SwiftUI DragGesture sets offset in infinite loop

Viewed 382

I want to drag one view within a ZStack. In this example code the Toolbar has a blue draggable area. It should move Toolbar around the screen, by setting the offset. Instead, it goes into an infinite loop. How can it be fixed to drag correctly?

It prints the following eternally:

changed (36.5, 27.0)
changed (0.0, 0.0)

extension CGPoint {
    func toSize() -> CGSize { .init(width:x, height:y) }
}

struct ContentView: View {
    @State var offset: CGSize = .zero
    var body: some View {
        ZStack(alignment: .topLeading) {
            Toolbar(dragOffset: $offset)
                .offset(offset)
            Text(verbatim: "Offset: \(offset)")
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
}

struct Toolbar: View {
    @Binding var dragOffset: CGSize
    var body: some View {
        HStack {
            Color.blue.frame(width: 40, height: 40)
                .gesture(drag)
            Color.green.frame(width: 40, height: 40)
            Color.red.frame(width: 40, height: 40)
        }
    }
    var drag: some Gesture {
        DragGesture()
            .onChanged { value in
                print("changed \(value.location)")
                dragOffset = value.location.toSize()
            }
            .onEnded { value in
                print("ended")
            }
    }
}

2 Answers

I needed to specify a CoordinateSpace, because the default is .local. Using .global works. A named space should also work.

It can also be helpful to record the initial offset in a @State variable and then set the new values using that starting value plus the drag's translation.

@State private var dragStartOffset: CGSize? = nil
...
DragGesture(coordinateSpace: .global)
   .onChanged { value in
       if dragStartOffset == nil {
           dragStartOffset = dragOffset
       }
       dragOffset = dragStartOffset! + value.translation
   }
   .onEnded { _ in 
       dragStartOffset = nil
   }

You should apply the offset before the gesture.

This ensures you don't create an infinite loop where you drag on the screen, the offset changes, which then changes the position of the view again. This means your gesture offset has now changed, hence creating an infinite loop.

Changes:

struct ContentView: View {
    @State var offset: CGSize = .zero
    var body: some View {
        ZStack(alignment: .topLeading) {
            Toolbar(dragOffset: $offset)
//                .offset(offset) // <- REMOVE THIS
            Text(verbatim: "Offset: \(offset)")
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
    }
}
struct Toolbar: View {
    @Binding var dragOffset: CGSize
    var body: some View {
        HStack {
            Color.blue.frame(width: 40, height: 40)
                .offset(dragOffset) // <- ADD THIS
                .gesture(drag)

            Color.green.frame(width: 40, height: 40)
                .offset(dragOffset) // <- ADD THIS

            Color.red.frame(width: 40, height: 40)
                .offset(dragOffset) // <- ADD THIS
        }
    }

    /* ... */
}
Related