Skip to content

Commit

Permalink
Merge pull request #47 from li3zhen1/state_mixin
Browse files Browse the repository at this point in the history
[State Management] Add bidirection state management for running state and transformation.
  • Loading branch information
li3zhen1 authored Feb 23, 2024
2 parents a308de1 + 2374701 commit f89e012
Show file tree
Hide file tree
Showing 14 changed files with 298 additions and 118 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
B717595B2AFBFDBD000DF006 /* Lattice.swift in Sources */ = {isa = PBXBuildFile; fileRef = B717595A2AFBFDBD000DF006 /* Lattice.swift */; };
B762092F2B49FCD000476B93 /* MermaidVisualization.swift in Sources */ = {isa = PBXBuildFile; fileRef = B762092E2B49FCD000476B93 /* MermaidVisualization.swift */; };
B780DD7A2AF84ECB001C605F /* MyRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B780DD792AF84ECB001C605F /* MyRing.swift */; };
B79012AE2B88474F008F4C03 /* GraphStateToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79012AD2B88474F008F4C03 /* GraphStateToolbar.swift */; };
B7AFA55B2ADF4997009C7154 /* ForceDirectedGraphExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7AFA55A2ADF4997009C7154 /* ForceDirectedGraphExampleApp.swift */; };
B7AFA55D2ADF4997009C7154 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7AFA55C2ADF4997009C7154 /* ContentView.swift */; };
B7AFA55F2ADF4999009C7154 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B7AFA55E2ADF4999009C7154 /* Assets.xcassets */; };
Expand All @@ -26,6 +27,7 @@
B717595A2AFBFDBD000DF006 /* Lattice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lattice.swift; sourceTree = "<group>"; };
B762092E2B49FCD000476B93 /* MermaidVisualization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MermaidVisualization.swift; sourceTree = "<group>"; };
B780DD792AF84ECB001C605F /* MyRing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MyRing.swift; sourceTree = "<group>"; };
B79012AD2B88474F008F4C03 /* GraphStateToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphStateToolbar.swift; sourceTree = "<group>"; };
B7AFA5572ADF4997009C7154 /* ForceDirectedGraphExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ForceDirectedGraphExample.app; sourceTree = BUILT_PRODUCTS_DIR; };
B7AFA55A2ADF4997009C7154 /* ForceDirectedGraphExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceDirectedGraphExampleApp.swift; sourceTree = "<group>"; };
B7AFA55C2ADF4997009C7154 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -78,6 +80,7 @@
B71759582AFBFC4B000DF006 /* Miserables.swift */,
B717595A2AFBFDBD000DF006 /* Lattice.swift */,
B762092E2B49FCD000476B93 /* MermaidVisualization.swift */,
B79012AD2B88474F008F4C03 /* GraphStateToolbar.swift */,
);
path = ForceDirectedGraphExample;
sourceTree = "<group>";
Expand Down Expand Up @@ -168,6 +171,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B79012AE2B88474F008F4C03 /* GraphStateToolbar.swift in Sources */,
B717595B2AFBFDBD000DF006 /* Lattice.swift in Sources */,
B780DD7A2AF84ECB001C605F /* MyRing.swift in Sources */,
B7AFA55D2ADF4997009C7154 /* ContentView.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ extension ExampleKind {

struct ContentView: View {

@State var selection: ExampleKind? = .classicMiserable
@State var selection: ExampleKind? = .ring

var body: some View {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// GraphStateToolbar.swift
// ForceDirectedGraphExample
//
// Created by li3zhen1 on 2/22/24.
//

import Foundation
import SwiftUI
import Grape

struct GraphStateToggle: View {
@Bindable var graphStates: ForceDirectedGraphState
var body: some View {

Group {
Button {
graphStates.modelTransform.scaling(by: 0.9)
} label: {
Image(systemName: "minus")
}
Text(String(format:"Scale: %.2f", graphStates.modelTransform.scale))
.fontDesign(.monospaced)
Button {
graphStates.modelTransform.scaling(by: 1.1)
} label: {
Image(systemName: "plus")
}
}

Button {
graphStates.isRunning.toggle()
} label: {
Image(systemName: graphStates.isRunning ? "pause.fill" : "play.fill")
Text(graphStates.isRunning ? "Pause" : "Start")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ struct Lattice: View {

let width = 20
let edge: [(Int, Int)]
@State var isRunning = false

@State var graphStates = ForceDirectedGraphState(
initialIsRunning: true
)

init() {
var edge = [(Int, Int)]()
Expand All @@ -32,7 +35,7 @@ struct Lattice: View {

@inlinable
var body: some View {
ForceDirectedGraph($isRunning) {
ForceDirectedGraph(states: graphStates) {

Series(0..<(width*width)) { i in
let _i = Double(i / width) / Double(width)
Expand All @@ -54,12 +57,7 @@ struct Lattice: View {
ManyBodyForce(strength: -0.8)
}
.toolbar {
Button {
isRunning = !isRunning
} label: {
Image(systemName: isRunning ? "pause.fill" : "play.fill")
Text(isRunning ? "Pause" : "Start")
}
GraphStateToggle(graphStates: graphStates)
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ struct MermaidVisualization: View {
tappedNode = $0
}
.ignoresSafeArea()
#if !os(xrOS)
#if !os(visionOS)
.inspector(isPresented: .constant(true)) {
VStack {
Text("Tapped: \(tappedNode ?? "nil")")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,26 @@ struct MiserableGraph: View {

private let graphData = getData(miserables)

@State private var isRunning = false
@State private var opacity: Double = 0
@State private var inspectorPresented = false

@State private var modelTransform: ViewportTransform = .identity.scale(by: 2.0)

@State private var stateMixin = ForceDirectedGraphState(
initialIsRunning: false,
initialModelTransform: .identity.scale(by: 1.2)
)

@State private var opacity = 0.0

@ViewBuilder
func getLabel(_ text: String) -> some View {
Text(text)
.foregroundStyle(.background)
.font(.caption2)
.padding(.vertical, 2.0)
.padding(.horizontal, 6.0)
.background(alignment: .center) {
RoundedRectangle(cornerSize: .init(width: 12, height: 12))
.fill(.white)
.fill(.foreground)
.shadow(radius: 1.5, y: 1.0)
}
.padding()
Expand All @@ -38,29 +43,17 @@ struct MiserableGraph: View {
var body: some View {

ForceDirectedGraph(
$isRunning,
$modelTransform
states: stateMixin
) {

Series(graphData.nodes) { node in
NodeMark(id: node.id)
.symbol(.asterisk)
.symbolSize(radius: 9.0)
.stroke()
.foregroundStyle(
colors[node.group % colors.count]
.shadow(
.inner(
color: colors[node.group % colors.count].opacity(0.3),
radius: 3,
x: 0,
y: 1.5
)
)
)
.richLabel(node.id, offset: .zero) {
self.getLabel(node.id)
}
.symbol(.circle)
.symbolSize(radius: 8.0)
.stroke()
.richLabel(node.id, offset: .zero) {
self.getLabel(node.id)
}
}

Series(graphData.links) { l in
Expand All @@ -75,24 +68,49 @@ struct MiserableGraph: View {
stiffness: .weightedByDegree(k: { _, _ in 1.0})
)
}
.onNodeTapped { node in
inspectorPresented = true
}
.opacity(opacity)
.animation(.easeInOut, value: opacity)

.ignoresSafeArea()
.toolbar {
Text("\(modelTransform.scale)")
MiserableToolbarContent(stateMixin: stateMixin, opacity: $opacity)
}
}
}

struct MiserableToolbarContent: View {
@Bindable var stateMixin: ForceDirectedGraphState
@Binding var opacity: Double

var body: some View {
Group {
Button {
isRunning.toggle()
if opacity < 1 {
opacity = 1
}
stateMixin.modelTransform.scaling(by: 1.1)
} label: {
Image(systemName: isRunning ? "pause.fill" : "play.fill")
Text(isRunning ? "Pause" : "Start")
Image(systemName: "minus")
}
Button {
stateMixin.modelTransform.scaling(by: 1.1)
} label: {
Text(String(format:"Scale: %.2f", stateMixin.modelTransform.scale))
.fontDesign(.monospaced)
}
Button {
stateMixin.modelTransform.scaling(by: 0.9)
} label: {
Image(systemName: "plus")
}
}


Button {
stateMixin.isRunning.toggle()
if opacity < 1 {
opacity = 1
}
} label: {
Image(systemName: stateMixin.isRunning ? "pause.fill" : "play.fill")
Text(stateMixin.isRunning ? "Pause" : "Start")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,13 @@ import Grape
import SwiftUI
import ForceSimulation



struct MyRing: View {

@State var isRunning = false
@State var graphStates = ForceDirectedGraphState()

var body: some View {

ForceDirectedGraph($isRunning) {
ForceDirectedGraph(states: graphStates) {
Series(0..<20) { i in
NodeMark(id: 3 * i + 0)
.symbol(.circle)
Expand Down Expand Up @@ -51,12 +49,7 @@ struct MyRing: View {
CollideForce()
}
.toolbar {
Button {
isRunning = !isRunning
} label: {
Image(systemName: isRunning ? "pause.fill" : "play.fill")
Text(isRunning ? "Pause" : "Start")
}
GraphStateToggle(graphStates: graphStates)
}
}
}
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,18 @@ To use Grape in a [SwiftPM](https://swift.org/package-manager/) project, add thi

``` swift
dependencies: [
.package(url: "https://github.com/li3zhen1/Grape", from: "0.6.1")
.package(url: "https://github.com/li3zhen1/Grape", from: "0.7.0")
]
```

```swift
.product(name: "Grape", package: "Grape"),
```

> [!NOTE]
> The `Grape` module relies on [`Observation` framework](https://developer.apple.com/documentation/observation). For backdeployment, you may want to check some community shims like [`swift-perception`](https://github.com/pointfreeco/swift-perception).
> The `Grape` module may introduce breaking API changes in minor version changes before 1.0 release. The `ForceSimulation` module is stable in terms of public API now.
<br/>

<br/>
Expand All @@ -125,6 +129,7 @@ Grape ships 2 modules:
- The `Grape` module allows you to create force-directed graphs in SwiftUI Views.
- The `ForceSimulation` module is the underlying mechanism of `Grape`, and it helps you to create more complicated or customized force simulations. It also contains a `KDTree` data structure built with performance in mind, which can be useful for spatial partitioning tasks.


<br/>

### The `Grape` module
Expand All @@ -136,10 +141,13 @@ For detailed usage, please refer to [documentation](https://li3zhen1.github.io/G
import Grape

struct MyGraph: View {
@State var isRunning = true // start moving once appeared.

// States including running status, transformation, etc.
// Gives you a handle to control the states.
@State var graphStates = ForceDirectedGraphState()

var body: some View {
ForceDirectedGraph($isRunning) {
ForceDirectedGraph(states: graphStates) {

// Declare nodes and links like you would do in Swift Charts.
NodeMark(id: 0).foregroundStyle(.green)
Expand All @@ -160,6 +168,7 @@ struct MyGraph: View {
```



<br/>


Expand Down
1 change: 1 addition & 0 deletions Sources/ForceSimulation/Utils/SimulatableVector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ extension SIMD2: L2NormCalculatable where Scalar == Double {
}
}


extension SIMD3: L2NormCalculatable where Scalar == Float {
@inlinable
public func distanceSquared(to point: SIMD3<Scalar>) -> Scalar {
Expand Down
4 changes: 2 additions & 2 deletions Sources/Grape/Views/ForceDirectedGraph+Gesture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,10 @@ import SwiftUI
static var minimumScaleDelta: CGFloat { 0.001 }

@inlinable
static var minimumScale: CGFloat { 0.25 }
static var minimumScale: CGFloat { 1e-4 }

@inlinable
static var maximumScale: CGFloat { 4.0 }
static var maximumScale: CGFloat { .infinity }

@inlinable
static var magnificationDecay: CGFloat { 0.1 }
Expand Down
15 changes: 1 addition & 14 deletions Sources/Grape/Views/ForceDirectedGraph+View.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,8 @@ extension ForceDirectedGraph: View {
alpha: self.model.simulationContext.storage.kinetics.alpha
)
}
.onChange(
of: self.isRunning,
initial: false
) { oldValue, newValue in
guard oldValue != newValue else { return }
if newValue {
self.model.start()
} else {
self.model.stop()
}
}
.onAppear {
if self.isRunning {
self.model.start()
}
self.model.trackStateMixin()
}
}

Expand Down
Loading

0 comments on commit f89e012

Please sign in to comment.