I’m building a SwiftUI view with draggable items organized in a 2D array (array of arrays). I want to allow items to be moved between rows while dragging, specifically moving the item mid-gesture once the drag reaches a certain threshold. However, I’ve encountered an issue where the last item in each row stops responding to the DragGesture, particularly when attempting to move it to another row. This issue doesn’t occur for other items in the row, only the last one.
Additional Context
My actual app is much more complex, but I’ve created this minimal example where I’m able to replicate the issue. In the real application, I’m not using helper flags like doItOnce, and I’ve streamlined this code to illustrate the core problem. Interestingly, if I move items only within the same row (e.g., swapping positions), the gesture and movement work without any issues. This is all happening on iOS 18.1; I’m currently downloading other simulator versions to test on them as well. I can’t use .draggable because my app is more complex and requires that the gesture starts immediately without a long press on the view.
Issue Description
The code above works as expected for all items except the last item in each row. When I attempt to drag the last item in a row and cross the threshold, the drag gesture stops responding, and I can no longer move the item to another row. For other items, everything works fine, and they move correctly mid-gesture.
Question
Why does the DragGesture.onChanged stop responding for the last item in each row, and how can I enable smooth mid-gesture movement without the gesture dropping? Is there a recommended way to handle moving items between rows in a 2D array mid-gesture in SwiftUI?
Any suggestions or solutions would be greatly appreciated!
import SwiftUI
struct Item: Identifiable {
let text: String
let id = UUID()
}
struct DragBugView: View {
@State var array2d: [[Item]] = [
[.init(text: "1"), .init(text: "2"), .init(text: "3")],
[.init(text: "4"), .init(text: "5"), .init(text: "6")],
]
@State var doItOnce = true
@State var translationHeight: CGFloat = 0
var body: some View {
VStack {
Text("Dragging translation value: \n \(translationHeight)")
ForEach(array2d.indices, id: \.self) { i in
HStack {
ForEach(array2d[i].indices, id: \.self) { j in
Text(array2d[i][j].text)
.padding()
.background(Color.green)
.gesture(
DragGesture(coordinateSpace: .global)
.onChanged({ value in
translationHeight = value.translation.height
if value.translation.height > 200 && doItOnce {
let temp = array2d[i].remove(at: j)
array2d[0].insert(temp, at: 0)
doItOnce = false
}
})
.onEnded({ value in
doItOnce = true
translationHeight = 0
})
)
}
}
}
}
}
}
#Preview {
DragBugView()
}
This occurs because the view that is being dragged is removed from the view hierarchy.
ForEach
uses its id:
parameter (if it has one) to track when to add/remove views. The outer ForEach
creates two inner ForEach
es, one for each row. The inner ForEach
es uses array indices as their id
s. This is already a bad idea, given that you are going to re-order the inner arrays' elements. See also: SwiftUI: Why does ForEach need an ID?
Initially, the IDs for the two rows are:
row 1: 0, 1, 2
row 2: 0, 1, 2
The last item of the second row has 2 as its id. When you move it, the ids now become:
row 1: 0, 1, 2, 3
row 2: 0, 1
We see that the id corresponding to the last item in the second row (i.e. 2) has disappeared from the second row. As a result, ForEach
removes the view from the second row, cancelling the gesture. Then, the ForEach
for the first row adds a new view to itself.
If you move a non-last item, the same change happens, but that item's id will not be removed from the second row, so the gesture keeps going.
As a proof of concept, if you use only one ForEach
, and a more appropriate id, such as the id
of the Item
s, the gesture does not get cancelled.
VStack {
Text("Dragging translation value: \n \(translationHeight)")
LazyVGrid(columns: Array(repeating: .init(.flexible(minimum: 10, maximum: 50)), count: 3)) {
let allIndices = array2d.indices.flatMap { i in
array2d[i].indices.map { j in (i, j) }
}
let _ = print("updated")
let flattened = array2d.flatMap { $0 }
ForEach(Array(flattened.enumerated()), id: \.element.id) { (i, elem) in
Text(elem.text)
.padding()
.background(Color.green)
.gesture(
DragGesture(coordinateSpace: .global)
.onChanged({ value in
translationHeight = value.translation.height
if value.translation.height > 200 && doItOnce {
let (j, k) = allIndices[i]
let temp = array2d[j].remove(at: k)
array2d[0].insert(temp, at: 0)
doItOnce = false
}
})
.onEnded({ value in
doItOnce = true
translationHeight = 0
})
)
}
}
}