How to drag drop to reorder items in a horizontal scroll view?

Hi,

I thought that drag drop reorder should be very easy with SwiftUI, but apparently I was wrong (unless I'm missing something). It seems to me that SwiftUI's drag-drop reorder is only easy for List, which supports .onMove modifier.

However, for UI like Grid, a horizontal ScrollView with items in a HStack, I don't see any easy approach to implement this. For example,

ScrollView(.horizontal) {
       HStack {
                ForEach(items) {
                       ItemView(item)
                }
       }
}

Does anyone know what's the best way to implement drag drop reorder for this horizontal scroll view?

The complex thing is to allow both scroll and move.

I do it by using longPress for move and normal press for scrolling.

Here is a code snippet:

struct ContentView: View {
    
    struct Item: Identifiable {
        let id = UUID()
        var pos: Int
        var value: String
        var color: Color
        var onMove: Bool = false
    }
    
    @State var items : [Item] = [
        Item(pos: 0, value: "A", color: .blue),
        Item(pos: 1, value: "B", color: .red),
        Item(pos: 2, value: "C", color: .blue),
        Item(pos: 3, value: "D", color: .red),
        Item(pos: 4, value: "E", color: .blue),
        Item(pos: 5, value: "F", color: .red),
        Item(pos: 6, value: "G", color: .blue),
        Item(pos: 7, value: "H", color: .red),
        Item(pos: 8, value: "I", color: .blue),
        Item(pos: 9, value: "J", color: .red),
        Item(pos: 10, value: "K", color: .blue)]

    @GestureState private var isDetectingLongPress = false
    @State private var completedLongPress = false
    @State private var activeLongPress = false

    var longPress: some Gesture {
        LongPressGesture(minimumDuration: 0.5) // LongPress to move, shortpress to scroll
            .updating($isDetectingLongPress) { currentState, gestureState, transaction in
                gestureState = currentState
                activeLongPress = true
            }
            .onEnded { finished in
                self.activeLongPress = !finished
            }
    }


    var body: some View {
        ScrollView(.horizontal) {
            HStack(spacing: 5) {
                ForEach(items) { item in
                    Rectangle()
                        .fill(item.onMove ? .green : item.color)
                        .frame(width:40, height:40)
                        .overlay {
                            Text("\(item.value)")
                        }
                        .gesture(
                            DragGesture(minimumDistance: 2)
                                .onChanged { _ in
                                    items[item.pos].onMove = true
                                }
                                .onEnded { value in
                                    let shift = Int(value.translation.width / 45)  // 40 width + 5 interspace
                                    let newPos = item.pos+shift
                                    if newPos >= 0 && newPos <= 7 {
                                        let element = items.remove(at: item.pos)
                                        items.insert(element, at: newPos)
                                        items[newPos].onMove = false
                                        for itemPos in 0...7 {
                                            items[itemPos].pos = itemPos
                                        }
                                    }
                                }
                        )
                        .gesture(longPress)
                }
            }
        }
        .scrollDisabled(activeLongPress)
    }
}

I've refined a little the demo code to better show how it works.

struct ContentView: View {
    
    struct Item: Identifiable {
        let id = UUID()
        var pos: Int
        var value: String
        var color: Color
        var onMove: Bool = false
    }
    
    @State var items : [Item] = [
        Item(pos: 0, value: "A", color: .blue),
        Item(pos: 1, value: "B", color: .red),
        Item(pos: 2, value: "C", color: .blue),
        Item(pos: 3, value: "D", color: .red),
        Item(pos: 4, value: "E", color: .blue),
        Item(pos: 5, value: "F", color: .red),
        Item(pos: 6, value: "G", color: .blue),
        Item(pos: 7, value: "H", color: .red),
        Item(pos: 8, value: "I", color: .blue),
        Item(pos: 9, value: "J", color: .red),
        Item(pos: 10, value: "K", color: .blue)
    ] // enough values to activate scroll

    @GestureState private var isDetectingLongPress = false
    @State private var completedLongPress = false
    @State private var activeLongPress = false
    @State private var itemOnMove = -1      // What item is being move ? -1 if none
    @State private var movingToPos = -1     // What position is it being move ? -1 if none

    var longPress: some Gesture {
        LongPressGesture(minimumDuration: 0.5) // LongPress to move, shortpress to scroll
            .updating($isDetectingLongPress) { currentState, gestureState, transaction in
                gestureState = currentState
                activeLongPress = true
            }
            .onEnded { finished in  // Only if there was no drag
                self.activeLongPress = !finished
            }
    }

    var msg : String {
        if itemOnMove >= 0 && itemOnMove <= 10 {
            if itemOnMove == movingToPos {
                return "Return to position \(movingToPos+1)"     // +1 to start at 1
            } else {
                return "\(items[itemOnMove].value) On move to position \(movingToPos+1)"
            }
        }
        return " "
    }

    var body: some View {
        VStack {
            Text("\(msg)")
            ScrollView(.horizontal) {
                HStack(spacing: 5) {
                    ForEach(items) { item in
                        Rectangle()
                            .fill(item.onMove ? .green : item.color)
                            .frame(width:40, height:40)
                            .border(Color.yellow, width: item.pos == movingToPos ? 3 : 0)
                            .overlay {
                                Text("\(item.value)")
                            }
                            .gesture(
                                DragGesture(minimumDistance: 20) // Need large enough to move to start drag ; otherwise, allow scroll
                                    .onChanged { value in
                                        items[item.pos].onMove = true
                                        itemOnMove = item.pos
                                        var shift = 0
                                        if value.translation.width > 0 {
                                            shift = Int(round(value.translation.width / 45))  // 40 width + 5 interspace
                                        } else { //  round on negative is too small
                                            shift = Int(value.translation.width / 45)  
                                        }
                                        movingToPos = item.pos + shift
                                    }
                                    .onEnded { value in
                                        // Need to drag beyond middle of next to effectively move
                                        var shift = 0
                                        if value.translation.width > 0 {
                                            shift = Int(round(value.translation.width / 45))
                                        } else { // le round du négatif est trop petit
                                            shift = Int(value.translation.width / 45)  
                                        }
                                        let newPos = item.pos + shift
                                        if newPos == item.pos { // No change
                                            items[item.pos].onMove = false
                                        } else if newPos >= 0 && newPos <= 10 {
                                            let element = items.remove(at: item.pos)
                                            items.insert(element, at: newPos)
                                            items[newPos].onMove = false
                                            for itemPos in 0...10 {
                                                items[itemPos].pos = itemPos
                                            }
                                        }
                                        itemOnMove = -1
                                        movingToPos = -1
                                    }
                            )
                            .gesture(longPress)
                    }
                }
            }
            .scrollDisabled(activeLongPress)
        }
    }
}

@fanwgwg Please file an enhancement report for the onMove functionality and post the Feedback ID number here once you do.

An easier alternative might be for you to use .onDrag and .onDrop modifier instead.

For example:

struct ContentView: View {
    @State private var items: [String] = (1...100).map { "Item \($0)" }
    @State private var draggedWord: String?

    var body: some View {
        ScrollView(.horizontal) {
            HStack {
                ForEach(items, id: \.self) { item in
                    Text(item)
                        .padding()
                        .background(Color.blue)
                        .foregroundColor(.white)
                        .cornerRadius(8)
                        .onDrag {
                            self.draggedWord = item
                            let provider = NSItemProvider(object: item as NSString)
                            return provider
                        }
                        .onDrop(
                            of: [.text],
                            delegate: DropViewDelegate(
                                destinationItem: item,
                                words: $items,
                                draggedItem: $draggedWord
                            )
                        )
                }
            }
            .padding()
        }
    }
}

struct DropViewDelegate: DropDelegate {
    let destinationItem: String
    @Binding var words: [String]
    @Binding var draggedItem: String?

    func dropUpdated(info: DropInfo) -> DropProposal? {
        return DropProposal(operation: .move)
    }

    func performDrop(info: DropInfo) -> Bool {
        draggedItem = nil
        return true
    }

    func dropEntered(info: DropInfo) {
        //  swap items if the dragged item exists and is different from the destination item
        guard let draggedItem = draggedItem else { return }

        if let fromIndex = words.firstIndex(of: draggedItem),
           let toIndex = words.firstIndex(of: destinationItem), fromIndex != toIndex {
            withAnimation {
                words.move(
                    fromOffsets: IndexSet([fromIndex]),
                    toOffset: toIndex > fromIndex ? (toIndex + 1) : toIndex
                )
            }
        }
    }
}

@DTS Engineer Submitted FB16390351.

While the current approach does partially work, it doesn't fully meet the desired behavior. Specifically, when dragging an item, I want the dragged item to become invisible in the ScrollView, as the drag preview already provides users with a visual representation. Unfortunately, using .onDrag and .onDrop identifiers makes this challenging because there’s no built-in mechanism to notify us when a drag operation is completed. Without this notification, we can't reliably restore the visibility of the dragged item in the ScrollView.

The DropDelegate exacerbates this limitation, as its performDropmethod is only invoked when the drop occurs inside the designated target. However, if the drop happens outside the target (e.g., in a completely unrelated view), the drop is treated as a no-op, and no callbacks are triggered. This leaves us unable to handle such scenarios effectively.

How to drag drop to reorder items in a horizontal scroll view?
 
 
Q