4

I’m building a horizontal “carousel” style view in SwiftUI where items can be reordered by dragging (similar to an editor timeline).

The basic idea:

Items are laid out in a horizontal track inside a ScrollView(.horizontal).

In Edit mode, you can drag an item horizontally.

While dragging, I update the array and recompute all item positions to live-reorder them.

The code below is a minimal example. It works functionally, but the drag does not feel smooth: the dragged item jitters and the other items jump around while I’m dragging.

My question is:

What changes should I make to this implementation so that the drag-to-reorder interaction feels smooth and continuous (no jitter) when dragging items horizontally?

auto-scrolling when dragging cells near the edges in a horizontally scrolling canvas

Here is the demo code:

struct ContentView: View {
    @State var isEditing: Bool = false
    var body: some View {
        VStack(alignment: .center, spacing: 30){
            Button( isEditing ? "Done" : "Edit", systemImage: isEditing ? "checkmark" : "slider.horizontal.3") {
                    isEditing.toggle()
            }
            .buttonStyle(.borderedProminent)
            HorizontalCanvasView(editMode: $isEditing)
        }
        .frame(maxHeight: .infinity)
        
    }
}
struct CanvasItem: Identifiable, Equatable {
    let id = UUID()
    var color: Color
    var size: CGSize = CGSize(width: 100, height: 100)
}
struct HorizontalCanvasView: View {
    @State private var items: [CanvasItem] = [
        CanvasItem(color: .blue),
        CanvasItem(color: .red),
        CanvasItem(color: .green)
    ]
    
    @Binding var editMode: Bool
    
    private let canvasHeight: CGFloat = 300
    private let itemSize = CGSize(width: 100, height: 100)
    private let itemSpacing: CGFloat = 10
    private let horizontalPadding: CGFloat = 200
    
    // Drag state
    @State private var draggedID: UUID? = nil
    @State private var dragOffsetX: CGFloat = 0          // visual offset for dragged item
    @State private var lastTranslationX: CGFloat = 0     // last gesture.translation.width
    
    private var cellWidth: CGFloat {
        itemSize.width + itemSpacing
    }
    
    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            ZStack(alignment: .topLeading) {
                Rectangle()
                    .fill(Color.gray.opacity(0.1))
                    .frame(height: canvasHeight)
                
                HStack(spacing: itemSpacing) {
                    ForEach(items) { item in
                        CanvasItemView(
                            item: item,
                            size: itemSize,
                            isDragging: draggedID == item.id,
                            editMode: editMode,
                            horizontalOffset: draggedID == item.id ? dragOffsetX : 0,
                            onDragChanged: { value in
                                handleDragChanged(for: item, value: value)
                            },
                            onDragEnded: { value in
                                handleDragEnded(for: item, value: value)
                            }
                        )
                    }
                }
                .padding(.horizontal, horizontalPadding)
                .frame(height: canvasHeight)
            }
        }
        .frame(height: canvasHeight)
        .onChange(of: editMode) { oldValue, newValue in
            if !newValue {
                // Reset drag state when leaving edit mode
                draggedID = nil
                dragOffsetX = 0
                lastTranslationX = 0
            }
        }
    }
    
    // MARK: - Drag logic (smooth, order-based)
    
    private func handleDragChanged(for item: CanvasItem, value: DragGesture.Value) {
        guard editMode else { return }
        
        // Start of drag
        if draggedID == nil {
            draggedID = item.id
            dragOffsetX = 0
            lastTranslationX = value.translation.width
            return
        }
        
        // Only track the currently dragged item
        guard draggedID == item.id,
              let currentIndex = items.firstIndex(where: { $0.id == item.id }) else { return }
        
        // Incremental delta instead of full translation
        let deltaX = value.translation.width - lastTranslationX
        lastTranslationX = value.translation.width
        dragOffsetX += deltaX
        
        // Move right
        if dragOffsetX > cellWidth / 2, currentIndex < items.count - 1 {
            let newIndex = currentIndex + 1
            withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
                items.move(fromOffsets: IndexSet(integer: currentIndex),
                           toOffset: newIndex + 1)
            }
            // Keep visual position continuous
            dragOffsetX -= cellWidth
        }
        // Move left
        else if dragOffsetX < -cellWidth / 2, currentIndex > 0 {
            let newIndex = currentIndex - 1
            withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
                items.move(fromOffsets: IndexSet(integer: currentIndex),
                           toOffset: newIndex)
            }
            dragOffsetX += cellWidth
        }
    }
    
    private func handleDragEnded(for item: CanvasItem, value: DragGesture.Value) {
        guard draggedID == item.id else { return }
        
        withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) {
            dragOffsetX = 0
        }
        draggedID = nil
        lastTranslationX = 0
    }
}

struct CanvasItemView: View {
    let item: CanvasItem
    let size: CGSize
    let isDragging: Bool
    let editMode: Bool
    let horizontalOffset: CGFloat
    let onDragChanged: (DragGesture.Value) -> Void
    let onDragEnded: (DragGesture.Value) -> Void
    
    var body: some View {
        ZStack {
            Rectangle()
                .fill(item.color.opacity(isDragging ? 0.7 : 0.8))
                .frame(width: size.width, height: size.height)
                .overlay(
                    Text("Item \(item.id.uuidString.prefix(4))")
                        .foregroundColor(.white)
                )
                .scaleEffect(isDragging ? 1.05 : 1.0)
                .shadow(color: .black.opacity(0.25),
                        radius: isDragging ? 10 : 0,
                        x: 0,
                        y: isDragging ? 6 : 0)
            
            if editMode {
                VStack {
                    Spacer()
                    Image(systemName: "line.3.horizontal")
                        .font(.title2)
                        .foregroundColor(.black)
                        .padding(.bottom, 8)
                }
                .frame(width: size.width)
            }
        }
        .offset(x: horizontalOffset, y: 0)
        .zIndex(isDragging ? 1 : 0)
        .gesture(
            editMode ?
            DragGesture()
                .onChanged(onDragChanged)
                .onEnded(onDragEnded)
            : nil
        )
    }
}

Any suggestions on how to structure the data / gestures so that dragging and reordering feels smooth?

1
  • @BenzyNeez can you please help? Commented Nov 18 at 5:42

1 Answer 1

4
+200

Reordering the array while dragging (items.move in handleDragChanged) will make the parent HStack/ScrollView re-layout and you get that “jitter / width changed” feeling, only perform the final reorder to the datasource when you are finishing the drag gesture!

Here is the complete code:


struct ContentView: View {
    @State var isEditing: Bool = false
    var body: some View {
        VStack(alignment: .center, spacing: 30){
            Button( isEditing ? "Done" : "Edit", systemImage: isEditing ? "checkmark" : "slider.horizontal.3") {
                    isEditing.toggle()
            }
            .buttonStyle(.borderedProminent)
            HorizontalCanvasView(editMode: $isEditing)
        }
        .frame(maxHeight: .infinity)
        
    }
}
struct CanvasItem: Identifiable, Equatable {
    let id = UUID()
    var color: Color
    var size: CGSize = CGSize(width: 100, height: 100)
}

struct HorizontalCanvasView: View {
    @State private var items: [CanvasItem] = [
        CanvasItem(color: .blue),
        CanvasItem(color: .red),
        CanvasItem(color: .green),
        CanvasItem(color: .pink)
    ]
    
    @Binding var editMode: Bool
    
    private let canvasHeight: CGFloat = 300
    private let itemSize = CGSize(width: 100, height: 100)
    private let itemSpacing: CGFloat = 10
    private let horizontalPadding: CGFloat = 200
    
    // Drag state
    @State private var draggedID: UUID? = nil
    @State private var dragOffsetX: CGFloat = 0         // actual finger offset
    @State private var dragStartIndex: Int? = nil       // where drag started
    @State private var dragCurrentIndex: Int? = nil     // which slot we’re hovering
    @State private var lastAutoScrollIndex: Int? = nil     // track last scroll
    
    private var cellWidth: CGFloat {
        itemSize.width + itemSpacing
    }
    
    var body: some View {
        GeometryReader { geo in // to read the view port width
            ScrollViewReader { proxy in
                ScrollView(.horizontal, showsIndicators: false) {
                    ZStack(alignment: .topLeading) {
                        Rectangle()
                            .fill(Color.gray.opacity(0.1))
                            .frame(height: canvasHeight)
                        
                        HStack(spacing: itemSpacing) {
                            ForEach(items) { item in
                                CanvasItemView(
                                    item: item,
                                    size: itemSize,
                                    isDragging: draggedID == item.id,
                                    editMode: editMode,
                                    horizontalOffset: xOffset(for: item),
                                    onDragChanged: { value in
                                        handleDragChanged(for: item,
                                                          value: value,
                                                          proxy: proxy,
                                                          viewPortWidth: geo.size.width
                                        )
                                    },
                                    onDragEnded: { value in
                                        handleDragEnded(for: item, value: value)
                                    }
                                )
                                .id(item.id) // needed so we can scrollTo a specific item
                            }
                        }
                        .padding(.horizontal, horizontalPadding)
                        .frame(height: canvasHeight)
                    }
                }
                .coordinateSpace(name: "canvasScroll")
                .frame(height: canvasHeight)
                .onChange(of: editMode) { oldValue, newValue in
                    if !newValue {
                        resetDragState()
                        dragOffsetX = 0
                    }
                }
            }
        }
    }
    
    private func handleDragChanged(for item: CanvasItem, value: DragGesture.Value, proxy: ScrollViewProxy, viewPortWidth: CGFloat) {
        guard editMode else { return }
        guard let index = items.firstIndex(where: { $0.id == item.id }) else { return }

        // Track start of drag
        if draggedID == nil {
            draggedID = item.id
            dragStartIndex = index
            dragCurrentIndex = index
        }
        
        guard draggedID == item.id, let startIndex = dragStartIndex else { return }
        
        
        if let currentIndex = dragCurrentIndex {
            
            let locationX = value.location.x // Finger's location in parent view
            dragOffsetX = value.translation.width
            let leadingZone: CGFloat = 80
            let trailingZone: CGFloat = viewPortWidth - 80
            
            if locationX < leadingZone {
                // Approaching the leading edge → scroll one item to the left
                let targetIndex = max(0, currentIndex - 1)
                if targetIndex != lastAutoScrollIndex, items.indices.contains(targetIndex) {
                    lastAutoScrollIndex = targetIndex
                    let id = items[targetIndex].id
                    withAnimation(.easeInOut(duration: 0.15)) {
                        // ScrollViewProxy does not have a scroll by offset API and our touch point of drag gesture will missalign with the dragging cell and finally the drag will interruped as we go out of screen safearea.
                        proxy.scrollTo(id, anchor: .leading)
                    }
                }
            } else if locationX > trailingZone {
                let targetIndex = min(items.count - 1, currentIndex + 1)
                if targetIndex != lastAutoScrollIndex, items.indices.contains(targetIndex) {
                    lastAutoScrollIndex = targetIndex
                    let id = items[targetIndex].id
                    withAnimation(.easeInOut(duration: 0.15)) {
                        // ScrollViewProxy does not have a scroll by offset API and our touch point of drag gesture will missalign with the dragging cell and finally the drag will interruped as we go out of screen safearea.
                        proxy.scrollTo(id, anchor: .trailing)
                    }
                }
            } else {
                // Finger is back in the "safe zone" → reset so we can scroll again later
                lastAutoScrollIndex = nil
            }
            
        }

        // How many cells did we “cross”? this is where we snap to the next index
        var proposedIndex = startIndex + Int(round(dragOffsetX / cellWidth))
        proposedIndex = max(0, min(items.count - 1, proposedIndex))

        // When we cross half a cell, update target index → other items animate
        if proposedIndex != dragCurrentIndex {
            withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
                debugPrint("Hovering on slot idx: \(proposedIndex)")
                dragCurrentIndex = proposedIndex
            }
        }
    }
    
    /// xOffsets during dragging process.
    private func xOffset(for item: CanvasItem) -> CGFloat {
        guard let draggedID,
              let start = dragStartIndex,
              let current = dragCurrentIndex,
              let index = items.firstIndex(where: { $0.id == item.id }) else {
            return 0
        }
        
        // The dragged item follows the finger
        if item.id == draggedID {
            return dragOffsetX
        }
        
        /*
         Example: Items are [A, B, C, D]
         You start dragging B (index 1) to the right(to [A,C,D,B]), and your hover index becomes 3 (over D).

         So:
             •    start == 1  (B)
             •    current == 3 (slot near D)
         So, A is untouched needs to return 0, C, D needs to have a -cellWidth offset to make room for the B's potiential new location
         */
       
        
        // Dragging to the right: items between start and current hovering shift left one cell
        if current > start {
            if index > start && index <= current {
                return -cellWidth
            }
        }
        /*
         Example: Items are [A, B, C, D]
         You start dragging D (index 3) to the right(to [A,D,B,C]), and your hover index becomes 1 (over B).

         So:
             •    start == 3  (D)
             •    current == 1 (slot near B)
         So, A is untouched needs to return 0, B, C needs to have a cellWidth offset to make room for the D's potiential new location
         */
        
        // Dragging to the left: items between current and start shift right one cell
        else if current < start {
            if index >= current && index < start {
                return cellWidth
            }
        }

        return 0
    }
    
    private func handleDragEnded(for item: CanvasItem, value: DragGesture.Value) {
        guard let draggedID,
              draggedID == item.id,
              let start = dragStartIndex,
              let current = dragCurrentIndex else {
            resetDragState()
            return
        }

        // Actually reorder the underlying data *once*
        if start != current {
            var newItems = items
            let moved = newItems.remove(at: start)
            newItems.insert(moved, at: current)
            items = newItems
        }

        dragOffsetX = 0   // dragged item snaps nicely into slot

        resetDragState()
    }
    
    private func resetDragState() {
        draggedID = nil
        dragStartIndex = nil
        dragCurrentIndex = nil
        lastAutoScrollIndex = nil
    }
}

struct CanvasItemView: View {
    let item: CanvasItem
    let size: CGSize
    let isDragging: Bool
    let editMode: Bool
    /// horizontalOffset is simply “how much this particular cell should be shifted horizontally (in points) from its normal position in the HStack.
    let horizontalOffset: CGFloat
    let onDragChanged: (DragGesture.Value) -> Void
    let onDragEnded: (DragGesture.Value) -> Void
    
    var body: some View {
        ZStack {
            Rectangle()
                .fill(item.color.opacity(isDragging ? 0.7 : 0.8))
                .frame(width: size.width, height: size.height)
                .overlay(
                    Text("Item \(item.id.uuidString.prefix(4))")
                        .foregroundColor(.white)
                )
                .scaleEffect(isDragging ? 1.05 : 1.0)
                .shadow(color: .black.opacity(0.25),
                        radius: isDragging ? 10 : 0,
                        x: 0,
                        y: isDragging ? 6 : 0)
            
            if editMode {
                VStack {
                    Spacer()
                    Image(systemName: "line.3.horizontal")
                        .font(.title2)
                        .foregroundColor(.black)
                        .padding(.bottom, 8)
                }
                .frame(width: size.width)
            }
        }
        .offset(x: horizontalOffset, y: 0)
        .zIndex(isDragging ? 1 : 0)
        .gesture(
            editMode ?
            DragGesture(coordinateSpace: .named("canvasScroll"))
                .onChanged(onDragChanged)
                .onEnded(onDragEnded)
            : nil
        )
    }
}



#Preview {
    ContentView()
}

And finally is this the smooth reordering that you wanted? Preview


Update 2, can we support auto-scrolling when dragging cells near the edges in a horizontally scrolling canvas? No, I tried many times and the scrollProxy does not have a scrollByOffset API.

Here is the code that i wrote:


if let currentIndex = dragCurrentIndex {
            
            let locationX = value.location.x // Finger's location in parent view
            dragOffsetX = value.translation.width
            let leadingZone: CGFloat = 80
            let trailingZone: CGFloat = viewPortWidth - 80
            
            if locationX < leadingZone {
                // Approaching the leading edge → scroll one item to the left
                let targetIndex = max(0, currentIndex - 1)
                if targetIndex != lastAutoScrollIndex, items.indices.contains(targetIndex) {
                    lastAutoScrollIndex = targetIndex
                    let id = items[targetIndex].id
                    withAnimation(.easeInOut(duration: 0.15)) {
                        // ScrollViewProxy does not have a scroll by offset API and our touch point of drag gesture will misalign with the dragging cell and finally the drag will interruped as the touch point go out of screen safearea
                        proxy.scrollTo(id, anchor: .leading)
                    }
                }
            } else if locationX > trailingZone {
                let targetIndex = min(items.count - 1, currentIndex + 1)
                if targetIndex != lastAutoScrollIndex, items.indices.contains(targetIndex) {
                    lastAutoScrollIndex = targetIndex
                    let id = items[targetIndex].id
                    withAnimation(.easeInOut(duration: 0.15)) {
                        // ScrollViewProxy does not have a scroll by offset API and our touch point of drag gesture will misalign with the dragging cell and finally the drag will interruped as the touch point go out of screen safearea.
                        proxy.scrollTo(id, anchor: .trailing)
                    }
                }
            } else {
                // Finger is back in the "safe zone" → reset so we can scroll again later
                lastAutoScrollIndex = nil
            }
            
        }

I tried to define a leading and trailing safe area, so that when our touch location reaches the safe area, we tell the scroll proxy to scroll to next/previous cell. But if we don't know how much the scroll view scrolled in this case, we can't give some compensation to the dragOffsetX, and as a result, the drag animation will interrupt as our touch point moves out of screen.


Finally, why don't we use the drag and drop that system offers? It uses less code since we don't need the offset calculation and interacts with the parent scroll view well when we do reorder.

//
//  ContentView.swift
//  draggy2
//
//  Created by 0x67 on 2025-11-19.
//

import SwiftUI
import UniformTypeIdentifiers

struct CanvasItem: Identifiable, Equatable, Hashable {
    let id = UUID()
    var color: Color
    var size: CGSize = CGSize(width: 100, height: 100)
}


struct ContentView: View {
    private let itemSpacing: CGFloat = 10
    private let cellWidth: CGFloat = 100
    
    @State private var draggedItem: CanvasItem? = nil
    
    @State private var items: [CanvasItem] = [
        CanvasItem(color: .blue),
        CanvasItem(color: .red),
        CanvasItem(color: .green),
        CanvasItem(color: .pink),
        CanvasItem(color: .cyan),
        CanvasItem(color: .yellow),
    ]
    
    @State private var isEditing: Bool = false
    
    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            LazyHStack(spacing: 10) {
                ForEach(items) { item in
                    CanvasItemView(item: item).geometryGroup()
                        .onDrag {
                            self.draggedItem = item
                            return NSItemProvider(object: item.id.uuidString as NSString)
                        } preview: {
                            // custom Any preview view you want here
                            CanvasItemView(item: item)
                        }
                        .onDrop(
                            of: [UTType.text],
                            delegate: CanvasDropDelegate(
                                targetItem: item,
                                items: $items,
                                draggedItem: $draggedItem
                            )
                        )
                }
            }.padding()
        }
    }
}


struct CanvasDropDelegate: DropDelegate {
    let targetItem: CanvasItem
    @Binding var items: [CanvasItem]
    @Binding var draggedItem: CanvasItem?
    
    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 {
        draggedItem = nil
        return true
    }
    
    func dropUpdated(info: DropInfo) -> DropProposal? {
        DropProposal(operation: .move)
    }
}


struct CanvasItemView: View {
    let item: CanvasItem
    
    var body: some View {
        Rectangle()
            .fill(item.color)
            .frame(width: item.size.width, height: item.size.height)
            .overlay(
                VStack {
                    Text("Item \(item.id.uuidString.prefix(4))")
                    Spacer()
                }.foregroundColor(.white).padding(.vertical)
            )
    }
}

#Preview {
    ContentView()
}

Drag And Drop

Sign up to request clarification or add additional context in comments.

6 Comments

auto-scrolling when dragging cells near the edges in a horizontally scrolling canvas
Let me take a look
Just checking in—any update on this issue?
Trying, but I have a feeling we can't do it in pure swiftUI, ScrollViewProxy lacks a core function like scrollByOffset.
Hector, did you read my post?
Glad we solved the problem, bro.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.