I have an app which has a parent view (graphView
, with a thick blue-border) and a child view (nodeView
, green box). Each has its own SwiftUI DragGesture handler. I want to be able to move both the green node and the blue-bordered graph with different fingers.
GOAL: Keep the “green node” under my finger as I actively drag the “blue-bordered graph.”
PROBLEM: Green node “ghosts”, i.e. seems to bounce around positions.
NOTE:
.simultaneousGesture
on nodeView would let me drag both the graph and the node at the same time with a single finger on the node. But I want to drag the graph and the node with two separate fingers: one on the node, the other on the graph..offset
on the ZStack
) is setup to mimic my app's actual setup.I'm also open to doing this in UIKit, if there's a better way. I suspect the problem is currently from competing 'firing cycles' on the graph vs node gesture handlers.
struct StackOverflowTestView: View {
@State var graphOffset: CGFloat = .zero
@State var graphPreviousOffset: CGFloat = .zero
@State var nodePosition: CGFloat = 200.0
@State var nodePreviousPosition: CGFloat = 200.0
@State var nodeIsDragged = false
@State var lastNodeTranslation: CGFloat = .zero
var graphDrag: some Gesture {
DragGesture()
.onChanged { graphDragValue in
graphOffset = graphPreviousOffset + graphDragValue.translation.height
// If we're simultaneously dragging the node,
// add inverse graph translation to node's position,
// so that node stays under our finger:
if nodeIsDragged {
nodePosition = nodePreviousPosition + lastNodeTranslation - graphDragValue.translation.height
}
}
.onEnded { _ in
graphPreviousOffset = graphOffset
}
}
var nodeDrag: some Gesture {
// .local: node translation is relative to graph's translation,
// e.g. graph +200, node -200 = node translation is -400
// .global: node translation affected by graph's translation
DragGesture(coordinateSpace: SwiftUI.CoordinateSpace.global)
.onChanged { nodeDragValue in
nodeIsDragged = true
lastNodeTranslation = nodeDragValue.translation.height
nodePosition = nodePreviousPosition + nodeDragValue.translation.height
}
.onEnded { _ in
nodeIsDragged = false
lastNodeTranslation = 0
nodePreviousPosition = nodePosition
}
}
var nodeView: some View {
Rectangle()
.fill(.green.opacity(0.9))
.frame(width: 100, height: 100) // NODE SIZE
.position(x: 500, y: nodePosition) // NODE POSITION
.gesture(nodeDrag)
}
var graphView: some View {
ZStack { nodeView }
.border(.blue, width: 32)
.offset(x: 0, y: graphOffset) // GRAPH OFFSET
.background(Rectangle()
.fill(.pink.opacity(0.1))
.frame(width: 1200, height: 800)
.gesture(graphDrag))
}
var body: some View {
graphView.border(.yellow)
}
}
You need to add inverse graph translation to node's position also inside the nodeDrag
-> DragGesture
-> .onChanged
. You are adding only inside graphDrag
.
Change nodeDrag
-> DragGesture
-> .onChanged
-> nodePosition = nodePreviousPosition + nodeDragValue.translation.height
to
nodePosition = nodePreviousPosition + nodeDragValue.translation.height - (graphOffset - graphPreviousOffset)
FYI -> I re-arrange the below equation to get a value for graphDragValue.translation.height
(graphOffset = graphPreviousOffset + graphDragValue.translation.height
).
If you want you can use a separate state var for that.
Explanation For The Ghost Behaviour
So this is what happens with your code.
When you are moving the graphView, inside graphDrag
-> .onChanged
you add inverse graph translation to node's position. Which is absolutely correct. So this keeps your nodeView in the correct (same) position.
But if your finger which is on the nodeView (green colour view) slightly changes nodeDrag
-> .onChange
method get calls. Inside that you are setting the nodePosition
a new value. Here you forget that ur graphView
has moved. So the nodeView moves to a position where it should be if the graphView hasn't moved.
Agin by grapDrag
-> onChanged
the nodeView get moves to the correct position.
So this keeps happening and nodeView keeps bouncing around positions. This creates the ghost behaviour.
If you keep your finger on the nodeView very Still (such that not making that onChange get calls) your code will work.
Changing the code as I mentioned above will solve the problem.