Mobile Dev

SwiftUI: Smooth Autoscroll Drag in ScrollView

Master smooth horizontal autoscroll in SwiftUI ScrollView during edge drags. Keep dragged items under finger using coordinate conversion, ScrollViewReader, and avoid CADisplayLink stutters for pixel-perfect alignment.

1 answer 1 view

SwiftUI: Smooth horizontal autoscroll while dragging near edges (keep dragged item under finger)

Context:

  • I have a horizontal ScrollView with multiple canvases side-by-side and support dragging a selected item across canvases.
  • I’m using a named coordinate space, a global overlay for the selection border, UIScrollView introspection, and a CADisplayLink-based tick to drive smooth, accelerating autoscroll by setting UIScrollView.contentOffset directly (animated: false).
  • Behavior: autoscroll mostly works but can stutter and the dragged item sometimes “slips” out from under the finger. A prior workaround was to modify the item’s local X position by the same dx used to scroll (i.e., mutating model state each tick), which looks correct visually but feels like the wrong approach.

Questions:

  1. What is the recommended pattern in SwiftUI to keep a dragged item visually under the finger while the scroll view is programmatically autoscrolling? Specifically, which approach is preferable and why:
  • Adjust the model by shifting the item’s local position by the scroll delta each tick, or
  • Compute the item’s position from the finger’s global location converted into content coordinates (e.g., using GeometryProxy + UIScrollView.contentOffset / named coordinate space) and update the model from that? Any trade-offs or race conditions to watch for?
  1. Is there a better way to drive smooth, accelerated autoscroll than manually stepping contentOffset with a CADisplayLink (for example: ScrollViewReader, synthesizing pan gestures on UIScrollView, different gesture composition, or another pattern)? What are best practices and pitfalls for avoiding stutter and keeping touch/visual alignment consistent?

In SwiftUI ScrollView setups with horizontal autoscroll during drags—like yours with canvases and overlaid selection borders—the best way to keep the dragged item pinned under your finger is by converting the gesture location from global to content coordinates using a named coordinate space and GeometryReader, then updating the model position directly from that. This beats tweaking the model with scroll deltas each tick because it avoids cumulative errors and jitter from state mutations syncing with UIScrollView offsets. For smoother autoscroll than CADisplayLink hacks, lean on ScrollViewReader’s proxy.scrollTo() combined with custom drag gestures and scrollTargetBehavior, dodging animation conflicts that cause slips.


Contents


Understanding Drag-to-Scroll Challenges in SwiftUI

Ever built a horizontal SwiftUI ScrollView packed with canvases, only to watch your dragged item lag behind your finger when autoscroll kicks in near the edges? You’re not alone. Native ScrollView handles pans great on iOS, but macOS drags (or custom cross-canvas drags) need extra love—especially with overlays and programmatic scrolling. The core issue? SwiftUI’s declarative nature fights against imperative offset tweaks from underlying UIScrollView.

Your setup sounds solid: named coordinate space for global tracking, CADisplayLink for 60fps ticks, and direct contentOffset sets. But stutters happen when view rebuilds clash with rapid model mutations, and “slips” emerge because finger position in screen space drifts from content space as the scrollview shifts. Think about it—without syncing global drag location to the scrolling content’s frame, your item visually jumps.

Communities like Reddit’s r/SwiftUI echo this: macOS ScrollView ignores drag-for-scroll out of the box, forcing custom gestures. And Stack Overflow threads highlight how even Lists stutter without careful proxy use.


Keeping the Dragged Item Under Your Finger

The goal: finger down, drag near edge, autoscroll accelerates, but item stays glued under touch. No slips. Here’s the preferable pattern—compute the item’s position dynamically from the drag gesture’s global location, converted to the ScrollView’s content coordinate space.

Why this over delta-adding? Deltas accumulate rounding errors over ticks, and mutating model state per frame triggers needless rebuilds. Instead:

  1. Attach a .coordinateSpace(name: "scrollContent") to your ScrollView.
  2. In your drag gesture’s onChanged, grab location(in: .named("scrollContent")).
  3. Use that directly (or clamped) to set your item’s model position.

This keeps visuals pixel-perfect because it’s derived from the actual touch, not a side-effect of scrolling. This Medium post on ScrollViewReader nails the proxy for scrolls, but pair it with GeometryProxy for location math during drags.

But what if the content scrolls mid-drag? The named space auto-adjusts for offsets—your converted location reflects the true content-relative spot.


Model Adjustment vs. Coordinate Conversion

Let’s break down your options head-to-head.

Option 1: Adjust model by scroll delta each tick.
Each CADisplayLink step: model.x += scrollDeltaX. Visually compensates, but…

  • Pros: Simple if you’re already ticking.
  • Cons: Race conditions galore—model updates lag behind gesture state, causing micro-jumps. Cumulative floats drift over long drags. Feels hacky, as you noted.

Option 2: Convert finger global to content coords (recommended).
let contentPos = dragGesture.location(in: .named("scrollContent"))model.position = contentPos.

  • Pros: Direct, no drift, handles any scroll (manual or auto) seamlessly. Pure SwiftUI, testable.
  • Cons: Needs GeometryReader or proxy for bounds clamping. Minor perf hit from conversions (negligible at 60fps).

Trade-offs? Coordinate conversion wins for reliability—avoids mutating during gestures, reducing SwiftUI diffing overhead. Pitfalls: Ensure your overlay (selection border) uses the same space, or it’ll desync. Race conditions? Minimal if you @State the position and update in gesture.onChanged only.

Stackademic’s drag-to-select grid does exactly this: tracks scrollViewFrame via GeometryReader and converts drag points for autoscroll + selection.


Driving Smooth Accelerated Autoscroll

CADisplayLink works, but it’s low-level boilerplate prone to battery drain and missed frames. Better: SwiftUI-native acceleration.

Core recipe:

  • Wrap in ScrollViewReader.
  • Custom DragGesture detects edge proximity (e.g., if location.x < 50 || > width - 50).
  • Compute velocity/acceleration from drag translation.
  • Use withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { proxy.scrollTo(id, anchor: .leading) } or step offsets smoothly.

For acceleration: Map edge distance to speed—closer to edge, faster scroll. Clamp max velocity to avoid overshoot.

Apple’s WWDC23 on beyond ScrollViews pushes scrollTargetBehavior(.viewAligned) for snapping, but add custom physics via scrollPosition(id:) binding for live control.

Best practice: Debounce ticks with Timer or Task { while edgeDrag { await scrollStep() } }—smoother than links on iOS 17+.


Common Pitfalls

Stutter? Blame view rebuilds—avoid @ObservedObject in ticking loops; use @State for drag state. Slips? Mismatched coordinate spaces. Here’s the hit list:

  • Animation conflicts: Chained withAnimation calls fight; use .animation(nil) during drags.
  • Rebuild loops: ForEach inside ScrollView? LazyVStack it.
  • macOS quirks: No native drag-scroll, so synthesize with MagnificationGesture or custom.
  • Overscroll: Bind scrollPosition and clamp programmatically.

This SO animation thread shows DispatchQueue.main.asyncAfter for chaining, but it jerks—prefer CADisplayLink only as fallback.

From experience, test on device: Simulators lag gestures badly.


Ditch the link for these:

  1. ScrollViewReader + Gesture synthesis: DragGesture(minimumDistance: 0) → compute delta → proxy.scrollBy(delta). (iOS 17+: scrollPosition binding.)
  2. ScrollableView wrapper: This Gist reads/writes offset sans GeometryReader lag.
  3. Custom physics: onChange(of: edgeProximity) { Task { await accelerateScroll() } }.
  4. UIKit interop (last resort): UIViewRepresentable<UIScrollView> with gesture passthrough.

Pitfalls: Proxy.scrollTo() snaps; use incremental IDs or offset binding for smooth. Consistency? Always convert locations in the content space.


Full Implementation Blueprint

Sketch for your canvases:

swift
struct CanvasScrollView: View {
 @State private var dragPos: CGPoint = .zero
 @State private var itemPos: CGPoint = .zero
 @State private var scrollID: String? = nil
 
 var body: some View {
 ScrollViewReader { proxy in
 ScrollView(.horizontal, showsIndicators: false) {
 LazyHStack {
 ForEach(canvases) { canvas in
 CanvasView(canvas: canvas)
 .id(canvas.id)
 .coordinateSpace(name: "content")
 }
 }
 .scrollTargetLayout()
 }
 .scrollPosition(id: $scrollID)
 .coordinateSpace(name: "global")
 .overlay(alignment: .topLeading) {
 Rectangle() // selection border
 .frame(width: 200, height: 200)
 .position(dragPos)
 .opacity(dragPos != .zero ? 1 : 0)
 }
 .gesture(
 DragGesture()
 .onChanged { value in
 dragPos = value.location(in: .named("global"))
 let contentLoc = value.location(in: .named("content"))
 itemPos = contentLoc // Pin to finger!
 
 // Edge autoscroll
 let screenW = UIScreen.main.bounds.width
 if value.location.x < 80 {
 scrollID = canvases.first?.id // Or compute
 proxy.scrollTo(scrollID, anchor: .leading)
 } else if value.location.x > screenW - 80 {
 // Accelerate right
 }
 }
 .onEnded { _ in dragPos = .zero }
 )
 }
 }
}

Tweak acceleration with velocity from value.translation.


Performance Tips for Production

  • LazyVStack/HStack: Only render visible canvases.
  • @State over @Binding for drag transients.
  • Profile with Instruments: Watch for GeometryReader overhead.
  • iOS 18+: scrollBehavior(.automatic) + physics.

Long drags? Throttle updates to 30fps if needed. Test edge cases: fast flings, interruptions.


Sources

  1. r/SwiftUI: Enable drag-scroll on macOS
  2. Auto-Scrolling with ScrollViewReader
  3. SwiftUI ScrollView animations and easing
  4. Drag to Select in Scrollable LazyVGrid
  5. Auto-scroll SwiftUI List
  6. WWDC23: Beyond Scroll Views
  7. ScrollableView Gist for offset control

Conclusion

Stick to finger-to-content coordinate conversion for rock-solid drag alignment in SwiftUI ScrollView—it’s precise, avoids hacks, and scales. Ditch CADisplayLink for ScrollViewReader proxies with edge-triggered tasks; you’ll get buttery acceleration without stutters if you sidestep rebuild pitfalls. Test iteratively on hardware, and your canvas-dragging will feel native. SwiftUI evolves fast—watch iOS 19 for even slicker scroll physics.

Authors
Verified by moderation
Moderation
SwiftUI: Smooth Autoscroll Drag in ScrollView