Creating a drag handle inside of a SwiftUI view (for draggable windows and such)

Viewed 769

I'm trying to learn SwiftUI, and I had a question about how to make a component that has a handle on it which you can use to drag around. There are several tutorials online about how to make a draggable component, but none of them exactly answer the question I have, so I thought I would seek the wisdom of you fine people.

Lets say you have a view that's like a window with a title bar. For simplicity's sake, lets make it like this:

struct WindowView: View {
    var body: some View {
        VStack(spacing:0) {
            Color.red
                .frame(height:25)
            Color.blue
        }
    }
}

I.e. the red part at the top is the title bar, and the main body of the component is the blue area. Now, this window view is contained inside another view, and you can drag it around. The way I've read it, you should do something like this (very simplified):

struct ContainerView: View {
    @State private var loc = CGPoint(x:150, y:150);
    
    var body: some View {
        ZStack {
            WindowView()
                .frame(width:100, height:100)
                .position(loc)
                .gesture(DragGesture()
                    .onChanged { value in
                        loc = value.location
                    }
                )
        }
    }
}

and that indeed lets you drag the component around (ignore for now that we're always dragging by the center of the image, it's not really the point):

window being dragged around

However, this is not what I want: I don't want you to be able to drag the component around by just dragging inside the window, I only want to drag it around by dragging the red title bar. But the red title-bar is hidden somewhere inside of WindowView. I don't want to move the @State variable containing the position to inside the WindowView, it seems to me much more logical to have that inside ContainerView. But then I need to somehow forward the gesture into the embedded title bar.

I imagine the best way would be for the ContainerView to look something like this:

struct ContainerView: View {
    @State private var loc = CGPoint(x:150, y:150);

    var body: some View {
        ZStack {
            WindowView()
                .frame(width:100, height:100)
                .position(loc)
                .titleBarGesture(DragGesture()
                    .onChanged { value in
                        loc = value.location
                    }
                )
        }
    }
}

but I don't know how you would implement that .titleBarGesture in the correct way (or if this is even the proper way to do it. should the gesture be an argument to the WindowView constructor?). Can anyone help me out, give me some pointers?

Thanks in advance!

3 Answers

You can just use .allowsHitTesting(false) on the blue view, which will ignore the touch gesture on that view. Hence, you can only drag on the red View and still have the DragGesture outside that view.

struct WindowView: View {
    var body: some View {
        VStack(spacing:0) {
            Color.red
                .frame(height:25)
            Color.blue
                .allowsHitTesting(false)
        }
    }
}

You can wrap the DragGesture into a ViewModifier:

struct MovableByBar: ViewModifier {
    static let barHeight: CGFloat = 14
    @State private var loc: CGPoint!
    @State private var transition: CGSize = .zero
    
    func body(content: Content) -> some View {
        if loc == nil {  // Get the original position
            content.padding(.top, MovableByBar.barHeight)
                .overlay {
                    GeometryReader { geo -> Color in
                        DispatchQueue.main.async {
                            let frame = geo.frame(in: .local)
                            
                            loc = CGPoint(x: frame.midX, y: frame.midY)
                        }
                        
                        return Color.clear
                    }
                }
        } else {
            VStack(spacing: 0) {
                Rectangle()
                    .fill(.secondary)
                    .frame(height: MovableByBar.barHeight)
                    .offset(x: transition.width, y: transition.height)
                    .gesture (
                        DragGesture()
                            .onChanged { value in
                                transition = value.translation
                            }
                            .onEnded { value in
                                loc.x += transition.width
                                loc.y += transition.height
                                transition = .zero
                            }
                    )
                
                content
                    .offset(x: transition.width,
                            y: transition.height)
            }
            .position(loc)
        }
    }
}

And use modifier like this:

WindowView()
    .modifier(MovableByBar())
    .frame(width: 80, height: 60)  // `frame()` after it

You can get smooth translation of the window using the offset from the drag, and then disable touch on the background element to prevent content from dragging.

Buttons still work in the content area.

Dragable windows

import SwiftUI

struct WindowBar: View {
    @Binding var location: CGPoint
    
    var body: some View {
        ZStack {
            Color.red
                .frame(height:25)
            Text(String(format: "%.1f: %.1f", location.x, location.y))
        }
    }
}

struct WindowContent: View {
    var body: some View {
        ZStack {
            Color.blue
                .allowsHitTesting(false)    // background prevents interaction
            Button("Press Me") {
                print("Tap")
            }
        }
    }
}

struct WindowView: View, Identifiable {
    @State var location: CGPoint // The views current center position
    let id = UUID()
    
    /// Keep track of total translation so that we don't jump on finger drag
    /// SwiftUI doesn't have an onBegin callback like UIKit's gestures
    @State private var startDragLocation = CGPoint.zero
    @State private var isBeginDrag = true
    
    init(location: CGPoint = .zero) {
        _location = .init(initialValue: location)
    }
    
    var body: some View {
        VStack(spacing:0) {
            WindowBar(location: $location)
            WindowContent()
        }
        .frame(width: 100, height: 100)
        .position(location)
        .gesture(DragGesture()
                    .onChanged({ value in
                        if isBeginDrag {
                            isBeginDrag = false
                            startDragLocation = location
                            
                        }
                        // In UIKit we can reset translation to zero, but we can't in SwiftUI
                        // So we do book keeping to track startLocation of gesture and adjust by
                        // total translation
                        location =  CGPoint(x: startDragLocation.x + value.translation.width,
                                            y: startDragLocation.y + value.translation.height)
                    })
                    .onEnded({ value in
                        isBeginDrag = true /// reset for next drag
                    }))
    }
}

struct ContainerView: View {
    
    @State private var windows = [
        WindowView(location: CGPoint(x: 50, y: 100)),
        WindowView(location: CGPoint(x: 190, y: 75)),
        WindowView(location: CGPoint(x: 250, y: 50))
    ]
    
    var body: some View {
        ZStack {
            ForEach(windows) { window in
                window
            }
        }
        .frame(width: 600, height: 480)
    }
}

struct ContentView: View {
    var body: some View {
        ContainerView()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}
Related