{"id":48984109,"url":"https://github.com/1amageek/swift-flow","last_synced_at":"2026-04-18T12:06:11.419Z","repository":{"id":340977980,"uuid":"1168315527","full_name":"1amageek/swift-flow","owner":"1amageek","description":"A Canvas-based flow diagram library for SwiftUI (iOS / macOS)","archived":false,"fork":false,"pushed_at":"2026-04-18T11:33:48.000Z","size":869,"stargazers_count":5,"open_issues_count":0,"forks_count":1,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-04-18T11:43:08.931Z","etag":null,"topics":["canvas","flow-diagram","graph","ios","macos","swift","swiftui"],"latest_commit_sha":null,"homepage":null,"language":"Swift","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/1amageek.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-02-27T08:46:39.000Z","updated_at":"2026-04-18T11:33:49.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/1amageek/swift-flow","commit_stats":null,"previous_names":["1amageek/swift-flow"],"tags_count":8,"template":false,"template_full_name":null,"purl":"pkg:github/1amageek/swift-flow","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/1amageek%2Fswift-flow","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/1amageek%2Fswift-flow/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/1amageek%2Fswift-flow/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/1amageek%2Fswift-flow/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/1amageek","download_url":"https://codeload.github.com/1amageek/swift-flow/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/1amageek%2Fswift-flow/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31968002,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-18T00:39:45.007Z","status":"online","status_checked_at":"2026-04-18T02:00:07.018Z","response_time":103,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["canvas","flow-diagram","graph","ios","macos","swift","swiftui"],"created_at":"2026-04-18T12:06:05.999Z","updated_at":"2026-04-18T12:06:11.410Z","avatar_url":"https://github.com/1amageek.png","language":"Swift","funding_links":[],"categories":[],"sub_categories":[],"readme":"# SwiftFlow\n\nA Canvas-based flow diagram library for SwiftUI, supporting iOS and macOS.\n\n![SwiftFlow Screenshot](screenshot.png)\n\nEdges are batch-drawn via `GraphicsContext` for performance. Nodes are rendered as SwiftUI views via `resolveSymbol`, so you can use any SwiftUI view as a node.\n\n## Requirements\n\n- Swift 6.2+\n- iOS 26+ / macOS 26+\n\n## Installation\n\n```swift\ndependencies: [\n    .package(url: \"https://github.com/1amageek/swift-flow.git\", from: \"0.9.0\")\n]\n```\n\n## Quick Start\n\n```swift\nimport SwiftUI\nimport SwiftFlow\n\nstruct ContentView: View {\n    @State var store = FlowStore\u003cString\u003e(\n        nodes: [\n            FlowNode(id: \"a\", position: CGPoint(x: 50, y: 100), size: CGSize(width: 120, height: 50), data: \"Start\"),\n            FlowNode(id: \"b\", position: CGPoint(x: 250, y: 100), size: CGSize(width: 120, height: 50), data: \"End\"),\n        ],\n        edges: [\n            FlowEdge(id: \"e1\", sourceNodeID: \"a\", sourceHandleID: \"source\", targetNodeID: \"b\", targetHandleID: \"target\"),\n        ]\n    )\n\n    var body: some View {\n        FlowCanvas(store: store)\n    }\n}\n```\n\nThis renders two nodes connected by a bezier edge. Nodes are draggable, the canvas supports pan and zoom out of the box.\n\n## Core Concepts\n\n### FlowStore\n\n`FlowStore\u003cData\u003e` is the single source of truth. It is `@Observable` and `@MainActor`.\n\nThe generic parameter `Data` is the payload each node carries. It must conform to `Sendable \u0026 Hashable` (add `Codable` for serialization).\n\n```swift\n// Initialize with nodes and edges\nlet store = FlowStore\u003cString\u003e(\n    nodes: [node1, node2],\n    edges: [edge1],\n    configuration: FlowConfiguration(\n        defaultEdgePathType: .smoothStep,\n        snapToGrid: true,\n        gridSize: 20\n    )\n)\n```\n\n### FlowNode\n\nEach node has a position, size, and custom data payload.\n\n```swift\nFlowNode(\n    id: \"node-1\",\n    position: CGPoint(x: 100, y: 200),\n    size: CGSize(width: 150, height: 60),     // default: 150x60\n    data: \"My Node\",\n    isDraggable: true,                         // default: true\n    zIndex: 0,                                 // default: 0\n    handles: [                                 // default: target(top), source(bottom)\n        HandleDeclaration(id: \"in\", type: .target, position: .left),\n        HandleDeclaration(id: \"out\", type: .source, position: .right),\n    ]\n)\n```\n\n#### Handles\n\nHandles are connection points on a node. Each handle has an `id`, a `type` (.source or .target), and a `position` (.top, .bottom, .left, .right).\n\n- `.source` handles can connect **to** `.target` handles\n- `.target` handles can receive connections **from** `.source` handles\n\nDefault handles are `target` at top and `source` at bottom (vertical flow). Override for horizontal layouts:\n\n```swift\nlet horizontalHandles = [\n    HandleDeclaration(id: \"target\", type: .target, position: .left),\n    HandleDeclaration(id: \"source\", type: .source, position: .right),\n]\n```\n\nA node can have multiple handles:\n\n```swift\nlet multiHandles = [\n    HandleDeclaration(id: \"in\", type: .target, position: .left),\n    HandleDeclaration(id: \"out-yes\", type: .source, position: .right),\n    HandleDeclaration(id: \"out-no\", type: .source, position: .bottom),\n]\n```\n\n### FlowEdge\n\nEdges connect a source handle on one node to a target handle on another node.\n\n```swift\nFlowEdge(\n    id: \"edge-1\",\n    sourceNodeID: \"node-1\",\n    sourceHandleID: \"out\",        // matches HandleDeclaration.id on source node\n    targetNodeID: \"node-2\",\n    targetHandleID: \"in\",         // matches HandleDeclaration.id on target node\n    pathType: .bezier,            // .bezier | .straight | .smoothStep | .simpleBezier\n    label: \"Yes\"                  // optional label displayed on the edge\n)\n```\n\nEdge animation is managed separately via the store's `animatedEdgeIDs` side-table — see [Edge Animation](#edge-animation).\n\n### FlowCanvas\n\nThe main view. It accepts `@ViewBuilder` closures for customizing node and edge appearance.\n\n```swift\n// Default appearance\nFlowCanvas(store: store)\n\n// Custom nodes\nFlowCanvas(store: store) { node, context in\n    MyNodeView(node: node)\n}\n\n// Custom nodes + custom edges\nFlowCanvas(store: store) { node, context in\n    MyNodeView(node: node)\n} edgeContent: { edge, geometry in\n    geometry.path.stroke(edge.isSelected ? .blue : .gray, lineWidth: 2)\n}\n\n// Default nodes + custom edges\nFlowCanvas(store: store, edgeContent: { edge, geometry in\n    geometry.path.stroke(.red, lineWidth: 2)\n})\n```\n\n## Custom Node Views\n\nProvide a `@ViewBuilder` closure to `FlowCanvas` that returns any SwiftUI view for each node:\n\n```swift\nFlowCanvas(store: store) { node, context in\n    VStack(spacing: 4) {\n        Text(node.data.title)\n            .font(.caption.bold())\n        Text(node.data.status)\n            .font(.caption2)\n            .foregroundStyle(.secondary)\n    }\n    .padding(8)\n    .frame(width: node.size.width, height: node.size.height)\n    .background(.background, in: RoundedRectangle(cornerRadius: 8))\n    .overlay {\n        RoundedRectangle(cornerRadius: 8)\n            .strokeBorder(node.isSelected ? .blue : .gray.opacity(0.3))\n    }\n    .overlay {\n        ForEach(node.handles, id: \\.id) { handle in\n            FlowHandle(handle.id, type: handle.type, position: handle.position)\n                .frame(\n                    maxWidth: .infinity,\n                    maxHeight: .infinity,\n                    alignment: handleAlignment(handle.position)\n                )\n        }\n    }\n}\n```\n\nKey points for custom nodes:\n- Set `frame(width: node.size.width, height: node.size.height)` to match the node's declared size\n- Use `overlay` with `FlowHandle` views to render connection points at the node edges\n- Use `node.isSelected` to show selection state\n- Use `node.isHovered` to show hover state (mouse over on macOS, pointer hover on iOS)\n- Use `FlowHandle(id, type:, position:)` for each handle in `node.handles`\n\nYou can also switch between different node views based on the data:\n\n```swift\nFlowCanvas(store: store) { node, context in\n    switch node.data {\n    case .trigger: TriggerNodeView(node: node)\n    case .logic:   LogicNodeView(node: node)\n    case .output:  OutputNodeView(node: node)\n    }\n}\n```\n\n## Live Node Views\n\nThe default `Canvas` + `resolveSymbol` pipeline rasterizes each node every frame, which is great for pure SwiftUI content but falls apart for `UIViewRepresentable` / `NSViewRepresentable` subtrees — `WKWebView`, `MKMapView`, `AVPlayerView`, SceneKit / RealityKit hosts, etc. Their rendering loops, scroll views, decoders, and input handling require a real SwiftUI view in the tree; a one-shot rasterization leaves them blank, flickering, or frozen on the first frame.\n\n`LiveNode` is a container you declare once inside `nodeContent`. It transparently switches between a **cached snapshot** (drawn by `Canvas` when the node is inactive) and the **real live view** (hosted in a `ZStack` overlay above the `Canvas` when the node is active), from a single call site.\n\n### Basic Usage (SwiftUI-only)\n\n```swift\nFlowCanvas(store: store) { node, ctx in\n    let inset = FlowHandle.diameter / 2\n    LiveNode(node: node, context: ctx) {\n        TimelineView(.animation) { tl in\n            ClockFace(date: tl.date)\n        }\n    }\n    .frame(width: node.size.width, height: node.size.height)\n    .padding(inset)\n    .overlay { FlowNodeHandles(node: node, context: ctx) }\n}\n```\n\nThe library seeds a snapshot on first mount and re-captures on deactivation using `ImageRenderer` with the full `EnvironmentValues` inherited, so the rasterize path always has something to draw and colors / fonts stay consistent across the active ↔ inactive transition.\n\n`LiveNode` is a pure phase dispatcher — it applies no sizing, padding, clipping, or other styling. The caller composes all visual treatment (frame, corner radius, the handle-inset padding that keeps handles on the border from being clipped, background, overlays, etc.) with ordinary SwiftUI modifiers around `LiveNode`. Handle drawing is likewise the caller's responsibility: use `FlowNodeHandles(node:context:)` for the library default look, or compose `FlowHandle` views directly for fully custom handles.\n\n### Native Views (WKWebView / MKMapView / AVPlayerView)\n\nNative views can't be rendered off-screen by `ImageRenderer`. Use `.manual` capture and write snapshots yourself using the framework-native APIs (`WKWebView.takeSnapshot`, `MKMapSnapshotter`, `AVPlayerItemVideoOutput.copyPixelBuffer`):\n\n```swift\nFlowCanvas(store: store) { node, ctx in\n    let inset = FlowHandle.diameter / 2\n    LiveNode(node: node, context: ctx, capture: .manual) {\n        WebViewRepresentable(url: node.data.url)\n    } placeholder: {\n        ProgressView()\n    }\n    .frame(width: node.size.width, height: node.size.height)\n    .padding(inset)\n    .overlay { FlowNodeHandles(node: node, context: ctx) }\n}\n```\n\n```swift\n// App-layer snapshot write\nlet image = try await webView.takeSnapshot(configuration: WKSnapshotConfiguration())\nstore.setNodeSnapshot(\n    FlowNodeSnapshot(cgImage: image.cgImage!, scale: image.scale),\n    for: nodeID\n)\n```\n\n### Capture Cadence\n\n| `LiveNodeCapture` | Behavior |\n|---|---|\n| `.onDeactivation` *(default)* | Capture once on mount + on each active → inactive transition |\n| `.periodic(TimeInterval)` | Capture at the given interval while active; paused when inactive |\n| `.manual` | Library never captures — required for native views |\n\n### Activation\n\nBy default a node is active when it is selected or hovered. Override with `.liveNodeActivation`:\n\n```swift\n.liveNodeActivation { node, store in\n    guard store.connectionDraft == nil else { return false }\n    return store.selectedNodeIDs.contains(node.id) || store.hoveredNodeID == node.id\n}\n```\n\nThe overlay subtree stays mounted across activation toggles so `WKWebView` page state, scroll offset, JS execution, and player state all survive a deactivation — the overlay simply hides via opacity + hit-testing. Apps can pause their own internal loops while the node is hidden by reading the published `\\.isFlowNodeActive` environment value:\n\n```swift\nstruct WebViewRepresentable: UIViewRepresentable {\n    @Environment(\\.isFlowNodeActive) private var isActive\n    let url: URL\n\n    func makeUIView(context: Context) -\u003e WKWebView {\n        let view = WKWebView()\n        view.load(URLRequest(url: url))\n        return view\n    }\n\n    func updateUIView(_ view: WKWebView, context: Context) {\n        isActive ? view.resumeAllMediaPlayback() : view.pauseAllMediaPlayback()\n    }\n}\n```\n\n## Custom Edge Views\n\nProvide an `edgeContent` closure to render each edge as a SwiftUI view. The closure receives a `FlowEdge` and an `EdgeGeometry` with pre-computed path and position data in local coordinates.\n\n```swift\nFlowCanvas(store: store) { node, context in\n    DefaultNodeContent(node: node, context: context)\n} edgeContent: { edge, geometry in\n    geometry.path.stroke(\n        edge.isSelected ? Color.blue : Color.gray,\n        style: StrokeStyle(lineWidth: 2, lineCap: .round)\n    )\n    if let label = edge.label {\n        Text(label)\n            .font(.caption2)\n            .position(geometry.labelPosition)\n    }\n}\n```\n\n### EdgeGeometry\n\n`EdgeGeometry` provides all the information needed to render an edge. All coordinates are in the view's local coordinate system (bounds origin mapped to (0, 0)).\n\n| Property | Type | Description |\n|---|---|---|\n| `path` | `Path` | Pre-computed edge path |\n| `sourcePoint` | `CGPoint` | Source handle position |\n| `targetPoint` | `CGPoint` | Target handle position |\n| `sourcePosition` | `HandlePosition` | Source handle direction |\n| `targetPosition` | `HandlePosition` | Target handle direction |\n| `labelPosition` | `CGPoint` | Suggested label placement |\n| `labelAngle` | `Angle` | Suggested label rotation |\n| `bounds` | `CGRect` | Canvas-space bounding rect |\n\n### Performance Note\n\nWhen no `edgeContent` closure is provided, edges are batch-drawn via `GraphicsContext` (3 stroke calls for normal, selected, and animated edges). When custom edge content is provided, each edge is rendered as a Canvas symbol. Connection drafts (in-progress connections) always use the efficient `GraphicsContext` path.\n\n## Handling Connections\n\nWhen a user drags from a source handle to a target handle, `onConnect` is called. You must create the edge yourself:\n\n```swift\nstore.onConnect = { [weak store] proposal in\n    guard let store else { return }\n    let edge = FlowEdge(\n        id: UUID().uuidString,\n        sourceNodeID: proposal.sourceNodeID,\n        sourceHandleID: proposal.sourceHandleID,\n        targetNodeID: proposal.targetNodeID,\n        targetHandleID: proposal.targetHandleID\n    )\n    store.addEdge(edge)\n}\n```\n\n### Connection Validation\n\nBy default, self-loop connections (same source and target node) are rejected. Provide a custom validator for more rules:\n\n```swift\nstruct MyValidator: ConnectionValidating {\n    func validate(_ proposal: ConnectionProposal) -\u003e Bool {\n        // Reject self-loops\n        guard proposal.sourceNodeID != proposal.targetNodeID else { return false }\n        // Add custom rules here\n        return true\n    }\n}\n\nlet config = FlowConfiguration(connectionValidator: MyValidator())\nlet store = FlowStore\u003cString\u003e(configuration: config)\n```\n\n## Observing Changes\n\nReact to state changes via callbacks:\n\n```swift\nstore.onNodesChange = { changes in\n    for change in changes {\n        switch change {\n        case .add(let node):       print(\"Added: \\(node.id)\")\n        case .remove(let nodeID):  print(\"Removed: \\(nodeID)\")\n        case .position(let id, let pos): print(\"Moved \\(id) to \\(pos)\")\n        case .select(let id, let selected): print(\"\\(id) selected: \\(selected)\")\n        case .dimensions(let id, let size): print(\"\\(id) resized to \\(size)\")\n        case .replace(let node):   print(\"Replaced: \\(node.id)\")\n        }\n    }\n}\n\nstore.onEdgesChange = { changes in\n    for change in changes {\n        switch change {\n        case .add(let edge):       print(\"Connected: \\(edge.id)\")\n        case .remove(let edgeID):  print(\"Disconnected: \\(edgeID)\")\n        case .select(let id, let selected): print(\"\\(id) selected: \\(selected)\")\n        case .replace(let edge):   print(\"Updated: \\(edge.id)\")\n        }\n    }\n}\n```\n\n### Double-Tap\n\nRespond to double-tap (double-click) on nodes and edges:\n\n```swift\nstore.onNodeDoubleTap = { nodeID in\n    print(\"Double-tapped node: \\(nodeID)\")\n}\n\nstore.onEdgeDoubleTap = { edgeID in\n    print(\"Double-tapped edge: \\(edgeID)\")\n}\n```\n\nDouble-tap detection uses manual timing comparison instead of SwiftUI's `onTapGesture(count: 2)`, which would delay single-tap recognition by ~300ms. Single taps always fire immediately; a second tap within 300ms on the same target triggers the double-tap callback.\n\n## FlowConfiguration\n\nAll behavior is configurable:\n\n```swift\nFlowConfiguration(\n    defaultEdgePathType: .bezier,      // .bezier | .straight | .smoothStep | .simpleBezier\n    edgeStyle: EdgeStyle(\n        strokeColor: .gray,            // normal edge color\n        selectedStrokeColor: .blue,    // selected edge color\n        lineWidth: 1.5,               // normal width\n        selectedLineWidth: 2.5,       // selected width\n        dashPattern: [],              // empty = solid line, e.g. [5, 3]\n        animatedDashPattern: [5, 5]   // pattern for edges in animatedEdgeIDs\n    ),\n    backgroundStyle: BackgroundStyle(\n        pattern: .grid,                // .none | .grid | .dot\n        color: .gray.opacity(0.2),     // line/dot color\n        spacing: 20,                   // grid cell size in canvas points\n        lineWidth: 0.5,               // grid line width (grid pattern only)\n        dotRadius: 1.5                // dot radius (dot pattern only)\n    ),\n    snapToGrid: false,                 // snap node positions to grid\n    gridSize: 20,                      // grid cell size (when snapToGrid is true)\n    minZoom: 0.1,                      // minimum zoom level (clamped to \u003e= 0.01)\n    maxZoom: 4.0,                      // maximum zoom level (clamped to \u003e= minZoom)\n    connectionValidator: nil,          // custom ConnectionValidating, nil = DefaultConnectionValidator\n    panEnabled: true,                  // allow canvas panning\n    zoomEnabled: true,                 // allow canvas zooming\n    selectionEnabled: true,            // allow node/edge selection\n    multiSelectionEnabled: true        // allow multi-selection (Shift+drag on macOS, long-press+drag on iOS)\n)\n```\n\n## Store Operations\n\n### Node Operations\n\n```swift\nstore.addNode(node)                     // add a node\nstore.removeNode(\"node-1\")              // remove node and its connected edges\nstore.moveNode(\"node-1\", to: point)     // move node (respects snapToGrid)\nstore.updateNode(\"node-1\") { node in   // update any node property in-place\n    node.data.badge = \"New\"\n}\nstore.updateNodeSize(\"node-1\", size: size)  // resize node\n```\n\n### Edge Operations\n\n```swift\nstore.addEdge(edge)                     // add an edge (rejects duplicate IDs and dangling node references)\nstore.removeEdge(\"edge-1\")              // remove an edge\nstore.updateEdge(\"edge-1\") { edge in   // update structural properties (registers undo)\n    edge.pathType = .smoothStep\n    edge.label = \"Updated\"\n}\nstore.updateEdges { edge in                // batch update (single undo entry)\n    edge.pathType = .straight\n}\n```\n\n### Selection\n\n```swift\nstore.selectNode(\"node-1\")              // select (clears other selections)\nstore.selectNode(\"node-2\", exclusive: false)  // add to selection\nstore.deselectNode(\"node-1\")\nstore.selectEdge(\"edge-1\")\nstore.deselectEdge(\"edge-1\")\nstore.clearSelection()\n```\n\n### Drop Target State\n\n```swift\nstore.dropTargetNodeID                  // currently highlighted drop target node (nil if none)\nstore.dropTargetEdgeID                  // currently highlighted drop target edge (nil if none)\nstore.setDropTargetNode(\"node-1\")       // manually set drop target (usually managed by dropDestination)\nstore.setDropTargetEdge(\"edge-1\")       // manually set drop target edge\n```\n\n### Edge Animation\n\nAnimation state is managed as a store-level side-table (`animatedEdgeIDs`), separate from the `FlowEdge` struct. This follows the same pattern as `selectedEdgeIDs` — transient view state lives in the store, not on the model. Animated edges render with a moving dash pattern.\n\n```swift\nstore.setEdgeAnimated(\"edge-1\", true)       // mark a single edge as animated\nstore.setEdgeAnimated(\"edge-1\", false)      // stop animating a single edge\nstore.setAnimatedEdges([\"e1\", \"e2\"])        // replace the full animated set\nstore.setAnimatedEdges([])                  // stop all edge animations\nstore.animatedEdgeIDs                       // read current animated edge IDs\n```\n\nAnimation state does not participate in undo/redo and is cleared on `load()`.\n\n### Viewport\n\n```swift\nstore.pan(by: CGSize(width: 10, height: 0))  // pan canvas\nstore.zoom(by: 1.5, anchor: center)           // zoom around anchor point\nstore.fitToContent(canvasSize: size)           // fit all nodes in view\n```\n\n### Undo / Redo\n\nAssign an `UndoManager` to enable undo/redo for node add/remove, edge add/remove/update, node move, and selection deletion:\n\n```swift\nstore.undoManager = undoManager\n```\n\n### Queries\n\n```swift\nstore.edgesForNode(\"node-1\")            // all edges connected to node\nstore.nodeBounds()                      // bounding rect of all nodes\nstore.nodeLookup[\"node-1\"]              // O(1) node access by id\nstore.connectionLookup[\"node-1\"]        // O(1) edges for a node\nstore.selectedNodeIDs                   // currently selected node IDs\nstore.selectedEdgeIDs                   // currently selected edge IDs\nstore.animatedEdgeIDs                   // currently animated edge IDs\nstore.hoveredNodeID                     // currently hovered node ID (nil if none)\nstore.dropTargetNodeID                  // currently highlighted drop target node\nstore.dropTargetEdgeID                  // currently highlighted drop target edge\n```\n\n## Serialization\n\nExport and import the entire diagram as JSON (requires `Data: Codable`):\n\n```swift\n// Export\nlet document = store.export()\nlet jsonData = try document.encoded()\n\n// Import\nlet document = try FlowDocument\u003cString\u003e.decoded(from: jsonData)\nstore.load(document)\n```\n\n`FlowDocument` contains nodes, edges, and viewport state. Selection state is cleared on export.\n\n## Drop Destination\n\nEnable drag-and-drop onto the canvas, nodes, and edges using the `dropDestination(for:action:)` modifier.\n\n```swift\nFlowCanvas(store: store) { node, context in\n    MyNodeView(node: node)\n}\n.dropDestination(for: [UTType.json]) { phase in\n    switch phase {\n    case .updated(let providers, let location, let target):\n        // Called continuously during drag hover.\n        // `target` tells you what's under the cursor:\n        //   .canvas        — empty area\n        //   .node(nodeID)  — over a node\n        //   .edge(edgeID)  — over an edge\n        // Return true to accept (highlights the target), false to reject.\n        return true\n\n    case .performed(let providers, let location, let target):\n        // Drop occurred. Decode providers and act based on target.\n        for provider in providers {\n            provider.loadDataRepresentation(forTypeIdentifier: UTType.json.identifier) { data, error in\n                guard let data else { return }\n                // decode and handle...\n            }\n        }\n        return true\n\n    case .exited:\n        return false\n    }\n}\n```\n\n### DropPhase\n\n| Case | Parameters | Description |\n|---|---|---|\n| `.updated` | `[NSItemProvider], CGPoint, DropTarget` | Drag hovering — return `true` to highlight target |\n| `.performed` | `[NSItemProvider], CGPoint, DropTarget` | Drop completed — decode and apply |\n| `.exited` | — | Drag left the canvas |\n\n### DropTarget\n\n| Case | Value | Description |\n|---|---|---|\n| `.node` | `String` (node ID) | Cursor is over a node |\n| `.edge` | `String` (edge ID) | Cursor is over an edge |\n| `.canvas` | — | Cursor is over the background |\n\n### Drop Target Visual Feedback\n\nNodes and edges have an `isDropTarget: Bool` property that the library manages automatically based on the `Bool` you return from `.updated`. Use this in custom node views:\n\n```swift\nFlowCanvas(store: store) { node, context in\n    RoundedRectangle(cornerRadius: 8)\n        .fill(node.isDropTarget ? Color.accentColor.opacity(0.1) : Color(.systemBackground))\n        .overlay {\n            RoundedRectangle(cornerRadius: 8)\n                .strokeBorder(node.isDropTarget ? Color.accentColor : .gray, lineWidth: node.isDropTarget ? 2 : 0.5)\n        }\n        .scaleEffect(node.isDropTarget ? 1.04 : 1.0)\n        .animation(.spring(duration: 0.2), value: node.isDropTarget)\n}\n```\n\nDrop-target edges are drawn with an accent-colored stroke automatically by the built-in edge renderer. The store also exposes `dropTargetNodeID` and `dropTargetEdgeID` for reading the current target.\n\n## Accessory Views\n\nAttach floating views near selected nodes or edges. Accessory views appear/disappear with animation when selection changes.\n\n### Node Accessory\n\n```swift\nFlowCanvas(store: store) { node, context in\n    MyNodeView(node: node)\n}\n.nodeAccessory { node in\n    VStack {\n        Text(node.data.title).font(.headline)\n        Button(\"Delete\") { store.removeNode(node.id) }\n    }\n    .padding(8)\n    .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8))\n}\n```\n\nWith per-node placement:\n\n```swift\n.nodeAccessory(placement: { node in\n    node.data.category == \"trigger\" ? .bottom : .top\n}) { node in\n    MyAccessoryView(node: node)\n}\n```\n\n### Edge Accessory\n\n```swift\n.edgeAccessory { edge in\n    HStack {\n        Text(edge.label ?? \"Edge\")\n        Button(role: .destructive) { store.removeEdge(edge.id) } label: {\n            Image(systemName: \"xmark.circle.fill\")\n        }\n    }\n    .padding(6)\n    .background(.regularMaterial, in: Capsule())\n}\n```\n\n### Placement Options\n\n| `AccessoryPlacement` | Description |\n|---|---|\n| `.top` | Above the node/edge midpoint (default) |\n| `.bottom` | Below |\n| `.leading` | Left side |\n| `.trailing` | Right side |\n\nThe library flips placement automatically when the accessory would be clipped by the canvas edge.\n\n## Interaction Reference\n\n| Action | macOS | iOS |\n|---|---|---|\n| Drag node | Drag on node | Drag on node |\n| Pan canvas | Scroll / drag on empty area | Drag on empty area |\n| Zoom | Pinch trackpad / scroll+magnify | Pinch gesture |\n| Connect | Drag from handle to handle | Drag from handle to handle |\n| Select node/edge | Click | Tap |\n| Double-tap node/edge | Double-click | Double-tap |\n| Add to selection | Command + Click | Command + Tap |\n| Multi-select (rect) | Shift + drag rectangle | Long press + drag |\n| Hover | Mouse over node | Pointer hover |\n| Cursor feedback | Contextual (hand/crosshair/arrow) | N/A |\n| Drop onto canvas | Drag external item onto canvas | Drag external item onto canvas |\n\n## Architecture\n\n```\n┌─────────────────────────────────────────────┐\n│ FlowCanvas\u003cNodeData, NodeView\u003e              │\n│  ├─ Canvas + GraphicsContext (edges)        │\n│  ├─ resolveSymbol (nodes as SwiftUI Views)  │\n│  ├─ LiveNodeOverlay (ZStack, native views)  │\n│  ├─ @ViewBuilder nodeContent closure        │\n│  ├─ @ViewBuilder edgeContent closure (opt)  │\n│  └─ Gesture state machine                   │\n├─────────────────────────────────────────────┤\n│ FlowStore\u003cData\u003e  (@Observable, @MainActor)  │\n│  ├─ nodes: [FlowNode\u003cData\u003e]                │\n│  ├─ edges: [FlowEdge]                      │\n│  ├─ viewport: Viewport                      │\n│  ├─ selectedNodeIDs / selectedEdgeIDs      │\n│  ├─ animatedEdgeIDs (side-table)           │\n│  ├─ nodeSnapshots (rasterize cache)         │\n│  ├─ nodeLookup / connectionLookup (O(1))   │\n│  └─ hit testing, connection workflow        │\n├─────────────────────────────────────────────┤\n│ Protocols                                    │\n│  ├─ EdgePathCalculating (custom routing)    │\n│  └─ ConnectionValidating (connection rules) │\n└─────────────────────────────────────────────┘\n```\n\n## License\n\nMIT License. See [LICENSE](LICENSE) for details.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2F1amageek%2Fswift-flow","html_url":"https://awesome.ecosyste.ms/projects/github.com%2F1amageek%2Fswift-flow","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2F1amageek%2Fswift-flow/lists"}