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)
        }
    }
}
How to drag drop to reorder items in a horizontal scroll view?
 
 
Q