SwiftUI: Smooth horizontal autoscroll while dragging near edges (keep item under finger)
07:27 30 Dec 2025

I have a SwiftUI horizontal editor (multiple “canvases” laid out side‑by‑side in a ScrollView). When the user drags a selected object and reaches the left/right edge of the visible area, I want the ScrollView to autoscroll smoothly so the drag can continue across canvases. Two requirements:

  • Autoscroll smoothly with acceleration as the finger approaches the edge.
  • Keep the dragged item visually under the finger while autoscrolling.

I’m using a named coordinate space, a global overlay for the selection border, and UIScrollView introspection to control contentOffset. It mostly works, but the autoscroll can stutter and (depending on compensation logic) the dragged item can momentarily “slip” out from under the finger while autoscrolling.

Below is a minimal reproducible example showing my current approach. It uses a CADisplayLink‑based timer for smooth continuous autoscroll with acceleration. The missing bit is the best practice to keep the item’s position aligned with the finger while the scroll view moves.

import SwiftUI
import Combine
import UIKit
import UniformTypeIdentifiers

struct ContentView: View {
    
    @StateObject private var store = CanvasStore()
    @State private var canvasSize: CGSize = .init(width: 300, height: 400)
    @State private var dragStartItemPosition: CGPoint? = nil
    @State private var isEditing: Bool = false
    
    @State private var isCanvasEditing: Bool = false
    let targetHeight: CGFloat = 150
    @State private var draggedItem: CanvasData? = nil
    @State private var scrollView: UIScrollView? = nil
    @State private var autoScrollScheduled: Bool = false

    
    var body: some View {
        VStack {
            let targetPreviewWidth: CGFloat = 150

              // scale based on width
              let scale: CGFloat = isCanvasEditing ? (targetPreviewWidth / canvasSize.width) : 1
              let previewWidth  = canvasSize.width * scale
              let previewHeight = canvasSize.height * scale
            
            ScrollView(.horizontal, showsIndicators: false) {
                       ZStack {
                           LazyHStack(spacing: 1) {
                               ForEach(store.canvasesArray, id: \.id) { item in
                                   let i = store.canvasesArray.firstIndex(of: item) ?? 0
                                   cellView(for: item, index: i, scale: scale, previewWidth: previewWidth, previewHeight: previewHeight, isDragging: draggedItem?.id == item.id)
                               }
                           }

                           // Global overlay during drag using ObjectViewBorder.
                           if let selected = store.selectedItem,
                              let info = store.indexForItem(id: selected.id),
                              store.canvasFrames.indices.contains(info.canvasIndex) {
                               let frame = store.canvasFrames[info.canvasIndex]
                               ObjectViewBorder(selectedItem: selected)
                                   .offset(x: frame.minX, y: frame.minY)
                                   .transition(.opacity)
                           }
                       }
                       .coordinateSpace(name: "carousel")
                       .background(HorizontalScrollViewIntrospector(scrollView: $scrollView))
                       // container height adapts to scaled canvas + tools
                       .frame(
                           height: isCanvasEditing
                           ? (previewHeight + 60)   // 60 = approx tools height, tweak
                           : canvasSize.height
                       )
                       .contentShape(Rectangle())
                       .gesture(
                           dragGesture,
                           including: (store.selectedItemID != nil && !isEditing) ? .gesture : .none
                       )
                   }

                   .environmentObject(store)
   
        }
        .onChange(of: isCanvasEditing) { newValue in
            if !newValue {
                draggedItem = nil
            }
        }
        .frame(maxWidth: .infinity,maxHeight: .infinity)
      
        .navigationBarBackButtonHidden(true)
        .safeAreaInset(edge: .bottom) {
            ZStack {
                if isCanvasEditing {
                    HStack {

                        Button("Done") {
                            withAnimation(.spring(response: 0.3,
                                                  dampingFraction: 0.8)) {
                                isCanvasEditing = false
                            }
                        }
                    }
                    .frame(maxWidth: .infinity)
                    .background(.thinMaterial)
                    .transition(.move(edge: .bottom).combined(with: .opacity))
                    
                }else{
                    HStack {

                        Button("Edit") {
                            withAnimation(.spring(response: 0.3,
                                                  dampingFraction: 0.8)) {
                                isCanvasEditing = true
                                store.selectedItemID = nil
                            }
                        }
                    }
                    .frame(maxWidth: .infinity)
                    .background(.thinMaterial)
                    .transition(.move(edge: .bottom).combined(with: .opacity))
                }
                
            }
            .animation(.spring(response: 0.3, dampingFraction: 0.8),
                       value: isCanvasEditing)
        }
    }

    @ViewBuilder
    private func cellView(for item: CanvasData, index: Int,scale:CGFloat,previewWidth:CGFloat,previewHeight:CGFloat, isDragging: Bool) -> some View {
        VStack(spacing: 4) {
            
          let cell =   HStack(spacing: 1) {
              ZStack {
                  // full-size canvas content
                  canvasCell(index: index)
                      .frame(width: canvasSize.width,
                             height: canvasSize.height)
                      .scaleEffect(scale, anchor: .center)
              }
              .frame(width: previewWidth,
                     height: previewHeight)
              
              if index < store.canvasesArray.count - 1 {
                  Divider()
                      .frame(height: previewHeight)
              }
          }
            cell
            .scaleEffect(isDragging ? 1.06 : 1.0)
            .shadow(color: .black.opacity(isDragging ? 0.35 : 0.15),
                    radius: isDragging ? 12 : 4,
                    x: 0,
                    y: isDragging ? 8 : 2)
            .animation(.interpolatingSpring(stiffness: 260, damping: 16),
                       value: isDragging)
            
            if isCanvasEditing {
                canvasEditingTools(for: item,previewWidth: previewWidth,previewHeight: previewHeight)
            }
            
            Spacer(minLength: 0)
        }.onDrop(
            of: [UTType.text],
            delegate: CanvasDropDelegate(
                targetItem: item,
                items: $store.canvasesArray,
                draggedItem: $draggedItem
            )
        )
      
    }

    fileprivate func canvasEditingTools(
        for item: CanvasData,
        previewWidth: CGFloat,
        previewHeight: CGFloat
    ) -> some View {
        canvasEditingTools(for: item, previewWidth: previewWidth, previewHeight: previewHeight) {
            EmptyView()
        }
    }
    // Overload with optional preview via default EmptyView
    fileprivate func canvasEditingTools(
        for item: CanvasData,
        previewWidth: CGFloat,
        previewHeight: CGFloat,
        @ViewBuilder preview: () -> Preview = { EmptyView() }
    ) -> some View {
        
        return HStack(spacing: 12) {
            Image(systemName: "line.3.horizontal")
                .font(.title3)
                .foregroundColor(.black)
                .padding(.vertical, 4)
                .onDrag {
                    // Start drag only when this is pressed (still with system long-press)
                    
                    if draggedItem == nil{
                        self.draggedItem = item
                    }else{
                        self.draggedItem = nil
                    }
                    
                    return NSItemProvider(object: item.id.uuidString as NSString)
                } preview: {
                    // Drag preview
                    preview()
                        .frame(width: previewWidth, height: previewHeight)
                }

        }
        .transition(.move(edge: .bottom).combined(with: .opacity))
    }
    
    private var dragGesture: some Gesture {
        DragGesture(minimumDistance: 2)
            .onChanged { value in
                guard let selectedID = store.selectedItemID,
                      let (canvasIndex, itemIndex) = store.indexForItem(id: selectedID)
                else { return }
                
                let item = store.canvasesArray[canvasIndex].items[itemIndex]
                
                if dragStartItemPosition == nil {
                    dragStartItemPosition = item.position
                }
                guard let start = dragStartItemPosition else { return }
                
                let t = value.translation
                let newPos = CGPoint(
                    x: start.x + t.width,
                    y: start.y + t.height
                )
                
                store.isDraggingSelectedItem = true
                store.canvasesArray[canvasIndex].items[itemIndex].position = newPos

                // Auto-scroll when near edges
                autoScrollIfNeeded(globalPoint: globalCenter(for: store.canvasesArray[canvasIndex].items[itemIndex]))
            }
            .onEnded { _ in
                defer { dragStartItemPosition = nil }
                
                guard let selectedID = store.selectedItemID,
                      let (fromCanvas, itemIndex) = store.indexForItem(id: selectedID)
                else { return }
                
                let item = store.canvasesArray[fromCanvas].items[itemIndex]
                guard store.canvasFrames.indices.contains(fromCanvas) else { return }
                let canvasFrame = store.canvasFrames[fromCanvas]
                
                // convert local center to global center
                let globalCenter = CGPoint(
                    x: canvasFrame.minX + item.position.x,
                    y: canvasFrame.minY + item.position.y
                )
                
                store.finishDrag(
                    for: selectedID,
                    fromCanvas: fromCanvas,
                    globalCenter: globalCenter
                )
                store.isDraggingSelectedItem = false
                autoScrollScheduled = false
            }
    }
    @ViewBuilder
    private func canvasCell(index: Int) -> some View {
        VStack(spacing: 6) {
            CanvasView(canvasIndex: index)
                .frame(width: canvasSize.width, height: canvasSize.height)
        }
        
    }

    // MARK: - Helpers
    private func globalCenter(for item: CanvasItem) -> CGPoint {
        guard let info = store.indexForItem(id: item.id),
              store.canvasFrames.indices.contains(info.canvasIndex) else {
            return .zero
        }
        let frame = store.canvasFrames[info.canvasIndex]
        return CGPoint(x: frame.minX + item.position.x,
                       y: frame.minY + item.position.y)
    }

    // MARK: - Auto-scroll logic
    private func autoScrollIfNeeded(globalPoint: CGPoint) {
        guard let sv = scrollView else { return }

        // globalPoint is in the named "carousel" content coordinate space
        let inset: CGFloat = 80
        let minStep: CGFloat = 4
        let maxStep: CGFloat = 28

        let visibleMinX = sv.contentOffset.x
        let visibleMaxX = visibleMinX + sv.bounds.width

        let distLeft = globalPoint.x - visibleMinX
        let distRight = visibleMaxX - globalPoint.x

        var dx: CGFloat = 0
        var needs = false

        if distLeft < inset {
            let closeness = max(0, (inset - distLeft) / inset)           // 0..1
            let eased = closeness * closeness                             // quadratic
            let step = minStep + (maxStep - minStep) * eased
            dx = -step
            needs = true
        } else if distRight < inset {
            let closeness = max(0, (inset - distRight) / inset)
            let eased = closeness * closeness
            let step = minStep + (maxStep - minStep) * eased
            dx = step
            needs = true
        }

        if needs {
            let maxOffsetX = max(0, sv.contentSize.width - sv.bounds.width)
            var offset = sv.contentOffset
            offset.x = min(max(offset.x + dx, 0), maxOffsetX)
            if offset != sv.contentOffset {
                sv.setContentOffset(offset, animated: false)
            }

            // keep scrolling while hovering near edge
            if store.isDraggingSelectedItem && !autoScrollScheduled {
                autoScrollScheduled = true
                DispatchQueue.main.asyncAfter(deadline: .now() + 1.0/60.0) {
                    autoScrollScheduled = false
                    if store.isDraggingSelectedItem, let selected = store.selectedItem {
                        let center = globalCenter(for: selected)
                        autoScrollIfNeeded(globalPoint: center)
                    }
                }
            }
        }
    }
}

struct CanvasDropDelegate: DropDelegate {
    let targetItem: CanvasData
    @Binding var items: [CanvasData]
    @Binding var draggedItem: CanvasData?
    
    func dropEntered(info: DropInfo) {
        guard let draggedItem = draggedItem,
              draggedItem != targetItem,
              let fromIndex = items.firstIndex(of: draggedItem),
              let toIndex = items.firstIndex(of: targetItem) else {
            return
        }
        
        if items[toIndex] != draggedItem {
            withAnimation(.default) {
                items.move(fromOffsets: IndexSet(integer: fromIndex),
                           toOffset: toIndex > fromIndex ? toIndex + 1 : toIndex)
            }
        }
    }
    
    func performDrop(info: DropInfo) -> Bool {
        // drop finished → clear dragged state
        draggedItem = nil
        return true
    }
  
    func dropUpdated(info: DropInfo) -> DropProposal? {
        DropProposal(operation: .move)
    }
}
import SwiftUI

struct CanvasView: View {
    @EnvironmentObject var store: CanvasStore
    let canvasIndex: Int

    var body: some View {
        GeometryReader { geo in
            ZStack {
                // Tap empty space inside this canvas → unselect item
                Color.clear
                    .contentShape(Rectangle())
                    .onTapGesture {
                        store.selectedItemID = nil
                    }
                let items: [CanvasItem] = (store.canvasesArray.indices.contains(canvasIndex) ? store.canvasesArray[canvasIndex].items : [])
                ForEach(items) { item in
                    let isSelected = (store.selectedItemID == item.id)

                    Text(item.text)
                        .padding(8)
                        .position(item.position)
                        .onTapGesture {
                            store.selectedItemID = item.id
                        }
                }
              
            }
            .onAppear {
                if store.canvasFrames.indices.contains(canvasIndex) {
                    store.canvasFrames[canvasIndex] = geo.frame(in: .named("carousel"))
                }
            }
            .onChange(of: geo.frame(in: .named("carousel"))) { newFrame in
                if store.canvasFrames.indices.contains(canvasIndex) {
                    store.canvasFrames[canvasIndex] = newFrame
                }
            }
        }
    }
}
import Combine
import SwiftUI

@Observable
class CanvasItem: Identifiable, Codable {
    let id = UUID()
    var text: String
    var position: CGPoint       
    var rotation: Angle = .zero
    var width: CGFloat = 200.0
    var height: CGFloat = 50.0
    
    init(text: String = "test", position: CGPoint, rotation: Angle = .zero, width: CGFloat = 200.0, height: CGFloat = 50.0) {
        self.text = text
        self.position = position
        self.rotation = rotation
        self.width = width
        self.height = height
    }
}

// Optional but useful: Equatable for CanvasItem based on id
extension CanvasItem: Equatable {
    static func == (lhs: CanvasItem, rhs: CanvasItem) -> Bool {
        lhs.id == rhs.id
    }
}

@Observable
class CanvasData: Identifiable, Codable {
    let id = UUID()
    var items: [CanvasItem]
    init(items: [CanvasItem]) {
        self.items = items
    }
}

// Required: Equatable for CanvasData based on id
extension CanvasData: Equatable {
    static func == (lhs: CanvasData, rhs: CanvasData) -> Bool {
        lhs.id == rhs.id
    }
}

@MainActor
final class CanvasStore: ObservableObject {
    @Published var canvasesArray: [CanvasData] {
        didSet { ensureCanvasFramesCount() }
    }
    @Published var canvasFrames: [CGRect]          // global frames of each canvas
    @Published var selectedItemID: CanvasItem.ID?  // currently selected text
    @Published var isDraggingSelectedItem: Bool = false
    
    var selectedItem: CanvasItem? {
        guard let id = selectedItemID else { return nil }
        for canvas in canvasesArray {
            if let item = canvas.items.first(where: { $0.id == id }) {
                return item
            }
        }
        return nil
    }

    init() {
        let first = CanvasItem(text: "Drag me ➡️", position: CGPoint(x: 80, y: 80))
        let initialCanvases: [CanvasData] = [
            CanvasData(items: [first]),
            CanvasData(items: []),
            CanvasData(items: []),
            CanvasData(items: []),
            CanvasData(items: []),
            CanvasData(items: []),
            CanvasData(items: [])
        ]
        self.canvasesArray = initialCanvases
        self.canvasFrames = Array(repeating: .zero, count: initialCanvases.count)
        self.selectedItemID = nil
    }

    func indexForItem(id: CanvasItem.ID) -> (canvasIndex: Int, itemIndex: Int)? {
        for c in canvasesArray.indices {
            if let i = canvasesArray[c].items.firstIndex(where: { $0.id == id }) {
                return (c, i)
            }
        }
        return nil
    }
}

private extension CGRect {
    var area: CGFloat { width * height }
}

extension CanvasStore {
    private func ensureCanvasFramesCount() {
        let target = canvasesArray.count
        if canvasFrames.count == target { return }
        if canvasFrames.count < target {
            canvasFrames.append(contentsOf: Array(repeating: .zero, count: target - canvasFrames.count))
        } else {
            canvasFrames.removeLast(canvasFrames.count - target)
        }
    }

    func finishDrag(for id: CanvasItem.ID, fromCanvas: Int, globalCenter: CGPoint) {
        guard canvasesArray.indices.contains(fromCanvas),
              let itemIndex = canvasesArray[fromCanvas].items.firstIndex(where: { $0.id == id }) else { return }

        let item = canvasesArray[fromCanvas].items[itemIndex]
        let size = CGSize(width: item.width, height: item.height)
        guard size.width > 0, size.height > 0 else { return }

        let itemRect = CGRect(
            x: globalCenter.x - size.width / 2,
            y: globalCenter.y - size.height / 2,
            width: size.width,
            height: size.height
        )

        // Find which canvas has the largest overlap
        var bestCanvas: Int?
        var bestOverlap: CGFloat = 0

        for (index, frame) in canvasFrames.enumerated() {
            let intersection = frame.intersection(itemRect)
            let overlapArea = max(0, intersection.area)
            if overlapArea > bestOverlap {
                bestOverlap = overlapArea
                bestCanvas = index
            }
        }

        guard let targetIndex = bestCanvas, bestOverlap > 0 else { return }

        // Move only if >= half of the item is inside target canvas
        let halfArea = itemRect.area / 2
        guard bestOverlap >= halfArea else { return }

        if targetIndex == fromCanvas { return }

        // Move item to target canvas
        var movedItem = canvasesArray[fromCanvas].items.remove(at: itemIndex)

        guard canvasFrames.indices.contains(targetIndex) else { return }
        let targetFrame = canvasFrames[targetIndex]
        let localCenter = CGPoint(
            x: globalCenter.x - targetFrame.minX,
            y: globalCenter.y - targetFrame.minY
        )
        movedItem.position = localCenter

        canvasesArray[targetIndex].items.append(movedItem)
        selectedItemID = movedItem.id   // keep selection on the moved item
    }
}
struct ObjectViewBorder: View {
 
    @Bindable var selectedItem: CanvasItem
 
    @State private var lastDragValue: CGSize = .zero
    @State private var initialRotation: Angle = .zero
    @State private var initialAngle: Angle = .zero
    
    var body: some View {
        ZStack {
            // Base dashed border (doesn't block gestures)
            RoundedRectangle(cornerRadius: 5)
                .stroke(style: StrokeStyle(lineWidth: 1, dash: [5]))
                .frame(width: selectedItem.width + 20, height:  selectedItem.height + 20)
                .position(selectedItem.position)
                .allowsHitTesting(false)

        }
        .foregroundColor(.accentColor)
    }
}
import SwiftUI
import UIKit

struct HorizontalScrollViewIntrospector: UIViewRepresentable {
    @Binding var scrollView: UIScrollView?

    func makeUIView(context: Context) -> UIView {
        let view = UIView(frame: .zero)
        DispatchQueue.main.async { self.scrollView = findScrollView(from: view) }
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {
        DispatchQueue.main.async { self.scrollView = findScrollView(from: uiView) }
    }

    private func findScrollView(from view: UIView) -> UIScrollView? {
        var v: UIView? = view
        while let s = v?.superview {
            if let sv = s as? UIScrollView { return sv }
            v = s
        }
        return nil
    }
}

What I’ve tried

  • Named coordinate space for consistent geometry; computing visible range with contentOffset.
  • UIScrollView introspection; setting contentOffset directly (no animation).
  • Acceleration using a quadratic ease near edges.
  • A previous attempt adjusted the item’s local X by the same dx as the scroll step to keep it under the finger; it works visually, but I’m unsure if this is the right approach (it modifies model state during scroll).
  • I also tried a timer (asyncAfter) tick; CADisplayLink is smoother.

Questions

  • What’s the recommended pattern in SwiftUI to keep the dragged item visually under the finger while the scroll view is programmatically autoscrolling?
    • Should I adjust the item’s local position by the scroll delta (dx) each tick, or derive the item’s position directly from the finger’s global location converted into content coordinates (e.g., using GeometryProxy + UIScrollView contentOffset)?
  • Is there a better way to drive smooth autoscroll than manually stepping contentOffset (e.g., ScrollViewReader, UIScrollView pan programmatically, or a different gesture composition)?
ios swift swiftui uiscrollview gesture