Skip to content

Force Directed Graph Node drag doesn't work on macOS 26 (Tahoe) - Canvas not redrawing on Observation changes #75

@spacecamp

Description

@spacecamp

Description

Dragging nodes does not visually update on macOS 26.1 (Tahoe beta). The same code works correctly on macOS 15.7 (Sequoia).

Environment

  • macOS version: 26.1 (Tahoe beta, build 25B5057f)
  • Xcode version: Version 26.0 (17A324)
  • Grape version: 1.1.0
  • Swift version: 6.2

Symptoms

  1. ✅ Drag gesture IS detected (verified via print statements - onDragChange fires, setNodeFixation is called)
  2. ✅ Node positions ARE being updated internally (alpha jumps to 0.5, continuous position logs show coordinates changing)
  3. ❌ Canvas does NOT redraw to show the new node positions
  4. The simulation runs initially on appear, but dragging nodes shows no visual movement

Console Output

When dragging a node, we can confirm the gesture and fixation are working:

Started drag on node 17
Alpha before: 0.29947800323308066
Alpha after setFixation: 0.5
Dragging node 17 to (340.98046875, 152.203125)
Dragging node 17 to (347.15625, 154.1875)
Dragging node 17 to (360.171875, 160.7734375)
... (continues with position updates)
Released node 17

But the node never visually moves on screen.

Root Cause Analysis

The issue appears to be a regression in macOS 26 where SwiftUI Canvas doesn't redraw when observing @Observable properties.

In ForceDirectedGraph+View.swift, the Canvas relies on this pattern to trigger redraws:

Canvas { context, size in
    let _ = model.currentFrame  // This observation should trigger redraws
    self.model.render(&context, size)
}

On macOS 15.7, accessing model.currentFrame causes Canvas to redraw when currentFrame changes. On macOS 26, this observation pattern appears to be broken - the Canvas never invalidates.

Minimal Reproduction

Using the MyRing example from the Grape repo:

import Foundation
import Grape
import SwiftUI
import ForceSimulation

struct MyRingTest: View {
    
    @State var graphStates = ForceDirectedGraphState(
        initialIsRunning: true,
        ticksOnAppear: .iteration(0)
    )
    
    static let strokeStyle = StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)
    
    var body: some View {
        
        ForceDirectedGraph(states: graphStates) {
            Series(0..<20) { i in
                
                NodeMark(id: 3 * i + 0)
                    .symbolSize(radius: 20.0)
                    .foregroundStyle(.green)
                    
                NodeMark(id: 3 * i + 1)
                    .symbol(.pentagon)
                    .symbolSize(radius: 24)
                    .foregroundStyle(.blue)
                
                NodeMark(id: 3 * i + 2)
                    .symbol(.circle)
                    .symbolSize(radius: 20.0)
                    .foregroundStyle(.yellow)
                
                LinkMark(from: 3 * i + 0, to: 3 * i + 1)
                LinkMark(from: 3 * i + 1, to: 3 * i + 2)
                LinkMark(from: 3 * i + 0, to: 3 * ((i + 1) % 20) + 0)
                LinkMark(from: 3 * i + 1, to: 3 * ((i + 1) % 20) + 1)
                LinkMark(from: 3 * i + 2, to: 3 * ((i + 1) % 20) + 2)
            }
            .stroke(.secondary, Self.strokeStyle)
            
        } force: {
            .manyBody(strength: -30)
            .link(
                originalLength: 50.0,
                stiffness: .weightedByDegree { _, _ in 1.0 }
            )
            .center()
        }
        .graphOverlay { proxy in
            Color.clear.contentShape(Rectangle())
                .gesture(
                    DragGesture(minimumDistance: 0, coordinateSpace: .local)
                        .onChanged { value in
                            if let nodeID = proxy.node(of: Int.self, at: value.startLocation) {
                                proxy.setNodeFixation(nodeID: nodeID, fixation: value.location, minimumAlpha: 0.8)
                                proxy.kineticAlpha = max(proxy.kineticAlpha, 0.5)
                            }
                        }
                        .onEnded { value in
                            if let nodeID = proxy.node(of: Int.self, at: value.startLocation) {
                                proxy.setNodeFixation(nodeID: nodeID, fixation: nil)
                            }
                        }
                )
                .withGraphMagnifyGesture(proxy)
        }
    }
}

Results:

  • ✅ Works on macOS 15.7 (Sequoia)
  • ❌ Fails on macOS 26.1 (Tahoe)

Attempted Workarounds

1. TimelineView wrapper

Wrapping Canvas in TimelineView(.animation(...)) to force continuous redraws:

TimelineView(.animation(paused: !model.stateMixinRef.isRunning)) { timeline in
    Canvas { context, size in
        let _ = model.currentFrame
        let _ = timeline.date  // Force dependency on timeline
        self.model.render(&context, size)
    }
}

Result: Did not resolve the issue.

2. Removing state-dependent styling

Ensured no conditional styling in ForceDirectedGraph content that could cause view recreation ("Graph state revived" messages).

Result: Eliminated revive spam but did not fix drag.

3. Manual setNodeFixation calls

Implemented custom drag gesture that directly calls proxy.setNodeFixation() and proxy.kineticAlpha = 0.5.

Result: Fixation IS being set (verified via logs), but Canvas doesn't redraw.

4. Local @State ownership

Moving ForceDirectedGraphState to be locally owned by the view containing ForceDirectedGraph.

Result: Did not help.

Possible Fixes

This may require Apple to fix the Canvas + Observation regression in macOS 26. Potential workarounds to explore:

  1. CADisplayLink - Manual frame updates outside of SwiftUI's observation system
  2. onChange with @State counter - Force view invalidation via explicit state mutation
  3. Metal/Core Graphics rendering - Bypass SwiftUI Canvas entirely
  4. File Apple Feedback - Report the SwiftUI Canvas + @observable regression

Additional Notes

The simulation DOES run and animate on initial appear - it's only the drag interaction that fails to trigger visual updates. This suggests the issue is specifically with how Canvas observes changes triggered by gesture handlers, not with the observation system in general.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions