Close Menu
  • Home
  • AI
  • Big Data
  • Cloud Computing
  • iOS Development
  • IoT
  • IT/ Cybersecurity
  • Tech
    • Nanotechnology
    • Green Technology
    • Apple
    • Software Development
    • Software Engineering

Subscribe to Updates

Get the latest technology news from Bigteetechhub about IT, Cybersecurity and Big Data.

    What's Hot

    This week in AI updates: GitHub Copilot SDK, Claude’s new constitution, and more (January 23, 2026)

    January 25, 2026

    Ambient-air power start-up secures £2m seed round funding

    January 25, 2026

    Today’s NYT Connections Hints, Answers for Jan. 25 #959

    January 25, 2026
    Facebook X (Twitter) Instagram
    Facebook X (Twitter) Instagram
    Big Tee Tech Hub
    • Home
    • AI
    • Big Data
    • Cloud Computing
    • iOS Development
    • IoT
    • IT/ Cybersecurity
    • Tech
      • Nanotechnology
      • Green Technology
      • Apple
      • Software Development
      • Software Engineering
    Big Tee Tech Hub
    Home»iOS Development»ios – Smooth horizontal autoscroll while dragging near edges (keep item under finger)
    iOS Development

    ios – Smooth horizontal autoscroll while dragging near edges (keep item under finger)

    big tee tech hubBy big tee tech hubDecember 30, 20250012 Mins Read
    Share Facebook Twitter Pinterest Copy Link LinkedIn Tumblr Email Telegram WhatsApp
    Follow Us
    Google News Flipboard
    ios – Smooth horizontal autoscroll while dragging near edges (keep item under finger)
    Share
    Facebook Twitter LinkedIn Pinterest Email Copy Link


    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
        }
    }
    

    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)?



    Source link

    autoscroll dragging edges finger horizontal iOS Item smooth
    Follow on Google News Follow on Flipboard
    Share. Facebook Twitter Pinterest LinkedIn Tumblr Email Copy Link
    tonirufai
    big tee tech hub
    • Website

    Related Posts

    ios – Why does my page scroll up when I tap on a button?

    January 25, 2026

    swift – iOS suspends app after BLE discovery even though I start Always-authorized location udpates

    January 24, 2026

    ios – ASAuthorizationControllerDelegate always returns .canceled for Face ID passcode fallback and failed attempts

    January 23, 2026
    Add A Comment
    Leave A Reply Cancel Reply

    Editors Picks

    This week in AI updates: GitHub Copilot SDK, Claude’s new constitution, and more (January 23, 2026)

    January 25, 2026

    Ambient-air power start-up secures £2m seed round funding

    January 25, 2026

    Today’s NYT Connections Hints, Answers for Jan. 25 #959

    January 25, 2026

    How Data-Driven Third-Party Logistics (3PL) Providers Are Transforming Modern Supply Chains

    January 25, 2026
    About Us
    About Us

    Welcome To big tee tech hub. Big tee tech hub is a Professional seo tools Platform. Here we will provide you only interesting content, which you will like very much. We’re dedicated to providing you the best of seo tools, with a focus on dependability and tools. We’re working to turn our passion for seo tools into a booming online website. We hope you enjoy our seo tools as much as we enjoy offering them to you.

    Don't Miss!

    This week in AI updates: GitHub Copilot SDK, Claude’s new constitution, and more (January 23, 2026)

    January 25, 2026

    Ambient-air power start-up secures £2m seed round funding

    January 25, 2026

    Subscribe to Updates

    Get the latest technology news from Bigteetechhub about IT, Cybersecurity and Big Data.

      • About Us
      • Contact Us
      • Disclaimer
      • Privacy Policy
      • Terms and Conditions
      © 2026 bigteetechhub.All Right Reserved

      Type above and press Enter to search. Press Esc to cancel.