Search code examples
iosswiftipadswiftuiipados

SwiftUI: drag parent and child view at same time but with separate fingers


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:

  1. .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.
  2. some of the UI (eg the .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)
    }
}

Solution

  • 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.