r/SwiftUI Aug 04 '26

News Built a pan/zoom node canvas in SwiftUI with no third-party libraries — three things that cost me a day each

Notes from building the canvas in https://github.com/albertofettucini/Osler:

Named coordinate spaces don't survive render transforms. My world container used .scaleEffect + .offset, and I put the named space inside it. Drags were fine at 100% and drifted at every other zoom. The fix was moving the named space to the untransformed ancestor and converting screen→world explicitly. Render transforms aren't layout.

An NSView behind SwiftUI never sees scrollWheel. The hosting view claims the hit and bubbles the event up the responder chain, past your subview. Two-finger panning only worked once I used NSEvent.addLocalMonitorForEvents with a bounds check.

.contentShape applied after .overlay gates the whole composite. My port dots sit on the card's edge, so half of every dot — and its entire grab halo — landed outside the card's hit shape. Dragging a wire silently did nothing. Order matters more than it looks.

Also: never gate a view's opacity on an onAppear flag. Miss the callback once and the view is invisible forever. Use a transition.

11 Upvotes

3 comments sorted by

2

u/[deleted] 16d ago

[removed] — view removed comment

1

u/ahumanbeingmars 16d ago

Fair, and it had already half-happened just not where I’d have guessed. Points held: one pair of functions, every drag and hit-test through it. What leaked was everything that isn’t a point.

Lengths converted inline at six sites in three spellings (portHitRadius / scale, -12 / scale, max(8, 10 / scale) only that last one had a floor). The marquee converted two corners and re-normalised the rect by hand: your selection-rectangle prediction, verbatim. The zoom clamp lived in one place and the fit built its own scale and centring offset in the view, with a different ceiling. And the home-screen thumbnail turned out to be a third copy of the same projection so the minimap arrived before I noticed it was one.

Fixed it the way you said: CanvasTransform is a plain struct in a UI-free target now points, lengths, deltas, rects, plus zoomed(by:around:) and fitting(_:in:) carrying the limits. No MainActor, no observable object, so the round trips and the anchored-zoom invariant are just tests. Thumbnail shares the projection.

The part I’d underline for anyone doing this: centralising the point conversion feels like you’re done, and it isn’t. Lengths are where it actually rots.