๐Ÿ”ฌ

SwiftUI macOS
Clunky UI Debugger

Diagnosis ยท Root Causes ยท Fix Patterns

Panel Jank Wrong Components Re-render Storms AppKit Bridging
// Step 0 โ€” Before touching code

Clunky UI: Diagnostic Framework

๐Ÿ‘๏ธ SYMPTOM CHECK
  • โ†’ Panel expands in steps, not fluidly?
  • โ†’ Cursor lags behind drag handle?
  • โ†’ Buttons feel delayed or unresponsive?
  • โ†’ UI freezes 1โ€“2s during interaction?
  • โ†’ Resize feels "sticky" near edges?
๐Ÿ” FIRST QUESTIONS
  • โ†’ Using SwiftUI, AppKit, or mixed?
  • โ†’ Is the panel in an HSplitView or HStack?
  • โ†’ Using onHover or DragGesture for tracking?
  • โ†’ Any @State in a ToolbarItem?
  • โ†’ How many views re-render on drag?
โšก FAST TRIAGE
  • โ†’ Run Instruments โ†’ SwiftUI template
  • โ†’ Look at View Body avg duration
  • โ†’ Check if parent views re-render on drag
  • โ†’ Profile on real hardware (not simulator)
๐Ÿง  ROOT CAUSE BUCKETS
  • โ†’ Wrong event source (onHover vs continuous)
  • โ†’ Wrong layout primitive (HStack vs HSplitView)
  • โ†’ Re-render storm during drag
  • โ†’ Wrong SwiftUI component (should be AppKit)
// Root Cause #1

Wrong Event Source: Cursor Tracking

โŒ ENTER/EXIT ONLY โ€” causes jumps
// Fires ONCE on enter, ONCE on exit // Cursor position: UNKNOWN between events .onHover { isHovering in if isHovering { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() } } // โš  Panel has NO idea where cursor is // โš  Resize only triggers at boundary
โœ… CONTINUOUS โ€” smooth tracking (macOS 13+)
.onContinuousHover { phase in switch phase { case .active(let location): // Called on EVERY pointer move cursorX = location.x isHovering = true NSCursor.resizeLeftRight.push() case .ended: isHovering = false NSCursor.pop() } } // โœ… Full position delta available
โšก AppKit alternative for even lower latency: NSTrackingArea + mouseDragged(_:) + mouseMoved(_:) โ€” zero SwiftUI overhead during drag
// Root Cause #2

DragGesture: Binding Size to Cursor Delta

โŒ Wrong: size snaps on gesture end only
// Width only updates AFTER drag ends // โš  Panel doesn't track cursor in real-time .gesture( DragGesture() .onEnded { val in panelWidth -= val.translation.width } ) .frame(width: panelWidth)
โœ… Correct: width updates on EVERY delta
@State private var panelWidth: CGFloat = 280 @State private var dragOffset: CGFloat = 0 .gesture( DragGesture(minimumDistance: 0) .onChanged { val in // Live update: subtract translation dragOffset = val.translation.width } .onEnded { val in panelWidth -= val.translation.width dragOffset = 0 } ) // Frame uses BOTH for smooth tracking .frame(width: panelWidth - dragOffset)
โš  Known SwiftUI bug: Any @State in a ToolbarItem causes drag degradation โ€” even if unrelated. Move toolbar @State to a separate observable or remove it.
// Root Cause #3

Layout Primitive Mismatch

HStack + frame()
Custom drag on Rectangle divider. Works but you reinvent resize logic, cursor tracking, min/max clamping, and animation. Prone to jank.
Manual work
HSplitView
AppKit-backed native divider. System handles resize cursor, drag tracking, and frame updates. Fluid by default. Use for persistent split layouts.
Native feel
NavigationSplitView
Best for sidebar + detail pattern. Gets hide/show sidebar button, proper selection states, and macOS window chrome for free. Preferred since macOS 13.
Most native
// If you need custom widths โ€” use HSplitView + constraints, not HStack + DragGesture HSplitView { SidebarView() .frame(minWidth: 200, idealWidth: 280, maxWidth: 400) ContentView() .frame(maxWidth: .infinity) } // NavigationSplitView for full Mac sidebar UX (macOS 13+) NavigationSplitView { SidebarView() } detail: { DetailView() }
// Root Cause #4

Re-render Storms During Drag

โŒ State too high โ€” every drag re-renders all children
// โš  panelWidth at ContentView level // โš  All children re-render on EVERY drag delta struct ContentView: View { @State var panelWidth: CGFloat = 280 var body: some View { HStack { ExpensiveListView() // re-renders! ExpensiveDetailView() // re-renders! ResizeHandle($panelWidth) } } }
โœ… Push state down โ€” only resize handle re-renders
// panelWidth owned by ResizablePanel struct ResizablePanel: View { @State private var width: CGFloat = 280 var body: some View { ZStack(alignment: .trailing) { PanelContent() // stable identity .frame(width: width) ResizeHandle($width) // only this re-renders } } } // PanelContent: conforming to Equatable // skips re-render when width changes
Equatable short-circuit: struct PanelContent: View, Equatable โ€” SwiftUI skips body recomputation entirely when props are equal. Critical for complex content inside resizable panels.
// Root Cause #5

Missing / Misplaced Animation

During drag: NO animation
While the user is actively dragging, remove any animation from frame changes. Animation during live drag fights the cursor and makes it feel laggy.
On snap/settle: YES animation
When drag ends, animate the panel snapping to its final clamped size. .animation(.spring(response:0.3), value: panelWidth) on the settled state feels native.
// Pattern: animate ONLY on drag end, not during drag .gesture( DragGesture(minimumDistance: 0) .onChanged { val in isDragging = true dragOffset = val.translation.width // no animation โ€” direct } .onEnded { val in isDragging = false // withAnimation only fires here withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { panelWidth = max(minW, min(maxW, panelWidth - val.translation.width)) dragOffset = 0 } } ) // Frame during drag: no animation modifier .frame(width: isDragging ? panelWidth - dragOffset : panelWidth)
// Root Cause #6 โ€” Component Selection

SwiftUI vs AppKit: Component Decision Map

Picker with 100+ items
SwiftUI Picker โ†’ 4-5s delay, beachball
โ†“ fix
NSViewRepresentable(NSPopUpButton)
Scrollable list (1000+ rows)
VStack in ScrollView โ†’ memory + jank
โ†“ fix
List{} or NSViewRepresentable(NSTableView)
Text input with autocomplete
SwiftUI TextField โ†’ limited delegate control
โ†“ fix
NSViewRepresentable(NSTextField)
Outline / tree view
SwiftUI List โ†’ broken disclosure arrows
โ†“ fix
NSViewRepresentable(NSOutlineView)
Inspector panel resize
.inspector modifier โ†’ crashes on resize (macOS 26 bug)
โ†“ fix
HSplitView or manual panel until fixed
Toolbar with @State
ToolbarItem + @State โ†’ drag degradation bug
โ†“ fix
Move state to @Observable or NSToolbar
// Instruments โ€” Profiling Protocol

Profiling the Jank: Instruments Workflow

1
โŒ˜+I โ†’ SwiftUI + Time Profiler
Select both templates. Profile on real hardware only. Simulator hides real bottlenecks.
2
Reproduce the drag jank
Record while dragging the panel handle 5-10 times. Include fast and slow drags.
3
View Body โ†’ Average Duration
Target: <8ms per body call. Anything over 16ms = visible dropped frame. Find the outlier views.
4
Check parent re-render cascade
If ContentView body fires on every drag delta โ†’ state is too high. Push down.
// Red flags in SwiftUI instrument
Large subtrees invalidating on drag
Root: @State too high or environment misuse
View identity resets repeatedly
Root: unstable IDs in ForEach, or .id() with UUID()
GeometryReader called repeatedly
Root: GeometryReader inside list rows or panel body
Only ResizeHandle body fires during drag
โœ… State is properly localized โ€” expected behavior
// AppKit Bridge Pattern

NSViewRepresentable: Minimal Correct Pattern

// Template: drop-in AppKit component with SwiftUI binding struct NativeTextField: NSViewRepresentable { @Binding var text: String func makeNSView(context: Context) -> NSTextField { let tf = NSTextField(string: text) tf.delegate = context.coordinator // required for delegate callbacks return tf } func updateNSView(_ nsView: NSTextField, context: Context) { // Only update if changed โ€” prevent feedback loops if nsView.stringValue != text { nsView.stringValue = text } } func makeCoordinator() -> Coordinator { Coordinator(parent: self) } class Coordinator: NSObject, NSTextFieldDelegate { var parent: NativeTextField init(parent: NativeTextField) { self.parent = parent } func controlTextDidChange(_ obj: Notification) { guard let tf = obj.object as? NSTextField else { return } parent.text = tf.stringValue // write back to SwiftUI } } }
NSView action button
Use NSViewControllerRepresentable, not NSViewRepresentable โ€” action targets need a VC
updateNSView guard
Always guard-check before setting values โ€” prevents binding feedback loops on every render
Coordinator lifetime
SwiftUI owns the Coordinator โ€” do not create it manually or store outside representable
// Root Cause #7 โ€” Identity

View Identity: The #1 Diffing Killer

โŒ Identity anti-patterns
// UUID() โ†’ new identity every render Text(title).id(UUID()) // Unstable ForEach IDs ForEach(items) { item in // no id: \.id RowView(item: item) } // ViewModel recreated on each render MyView(viewModel: ViewModel()) // โŒ // Conditional structure changes identity if loading { ProgressView() } else { ContentView() } // โŒ different trees
โœ… Stable identity patterns
// Stable, meaningful IDs ForEach(items, id: \.id) { item in RowView(item: item) } // ViewModel owned by SwiftUI lifecycle @StateObject var vm = ViewModel() // โœ… // Preserve identity with ZStack ZStack { ContentView() // always in tree if loading { ProgressView() } } // Equatable to skip diffing struct PanelContent: View, Equatable { ... }
Rule: Stable identity = stable performance. Every identity reset causes state loss, animation restart, and full layout recalculation. Profile identity in Instruments under "View Properties."
// Platform Caveats

Main Thread, Layout, and OS Caveats

Main thread blocking
Any I/O, decoding, or heavy computation in a body or drag handler blocks the main thread. Drag events queue up โ†’ burst on release = jumpy motion. Fix: Task { await ... } for all async work.
GeometryReader abuse
GeometryReader triggers layout measurement on every size change. Inside a resizable panel = layout thrash on every drag event. Prefer .containerRelativeFrame or intrinsic sizing.
SwiftUI Inspector API bug (macOS 26)
The .inspector modifier causes crashes and freezes when the user resizes with inspector open. Active bug as of macOS 26. Workaround: use HSplitView instead.
Environment global invalidation
Putting fast-changing values (cursor position, drag offset) into @EnvironmentObject invalidates the entire subtree on every change. Treat environment as configuration, not live state.
// โœ… Correct async pattern โ€” never block drag events .onChanged { val in dragOffset = val.translation.width // sync, cheap โ€” fine if shouldLoadPreview { Task { await loadPreviewAsync() } // off main thread } }
// Fix Checklist

Panel Jank Fix Checklist

โœ“ Use onContinuousHover, not onHover
โœ“ Bind frame to live dragOffset, not onEnded
โœ“ Use HSplitView or NavigationSplitView
โœ“ Push @State down to ResizeHandle only
โœ“ No animation during drag โ€” only on snap
โœ“ Conform heavy panel content to Equatable
โœ“ Use NSViewRepresentable for Picker 100+ items
โœ“ Stable IDs in ForEach (id: \.id, not UUID())
โœ“ Remove GeometryReader from resizable panels
โœ“ All async work off main thread (Task { await })