8

I'm trying to make a view draggable and/or zoomable only within its clipping container view (otherwise it can run into and conflict with other views' gestures), but nothing I've tried so far keeps the gesture from extending outside the visible boundary of the container.

Here's a simplified demo of the behavior I don't want...

When the red Rectangle goes partially outside the green VStack area (clipped), it responds to drag gestures beyond the green area:
Drag not limited by container

import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    
    @State var position: CGPoint = CGPoint(x: 100, y: 150)
    @State var lastPosition: CGPoint = CGPoint(x: 100, y: 150)
    
    var body: some View {
        
        let drag = DragGesture()
        .onChanged {
            self.position = CGPoint(x: $0.translation.width + self.lastPosition.x, y: $0.translation.height + self.lastPosition.y)
        }
        .onEnded {_ in
            self.lastPosition = self.position
        }
        
        return VStack {
            Rectangle().foregroundColor(.red)
                .frame(width: 150, height: 150)
                .position(self.position)
                .gesture(drag)
                .clipped()
        }
        .background(Color.green)
        .frame(width: 200, height: 300)
        
    }
}

PlaygroundPage.current.setLiveView(ContentView())

How would you limit this gesture to only work inside the container (green area in the example above)?

UPDATE: @Asperi's solution to the above works well, but when I add a second draggable container next to the one above, I get a "dead area" in the first container inside which I can't drag (it appears to be where the second square would cover the first one if it were not clipped). The problem only happens to the original/left side, not to the new one. I think that has to do with it having higher priority since it is drawn second.

Here's an illustration of the new issue:

2 Containers create dragging dead zone

And here's the updated code:

struct ContentView: View {
    
    @State var position1: CGPoint = CGPoint(x: 100, y: 150)
    @State var lastPosition1: CGPoint = CGPoint(x: 100, y: 150)
    let dragArea1: CGRect = CGRect(x: 0, y: 0, width: 200, height: 300)
    
    @State var position2: CGPoint = CGPoint(x: 100, y: 150)
    @State var lastPosition2: CGPoint = CGPoint(x: 100, y: 150)
    let dragArea2: CGRect = CGRect(x: 0, y: 0, width: 200, height: 300)
    
    var body: some View {

        let drag1 = DragGesture(coordinateSpace: .named("dragArea1"))
        .onChanged {
            guard self.dragArea1.contains($0.startLocation) else { return }
            self.position1 = CGPoint(x: $0.translation.width + self.lastPosition1.x, y: $0.translation.height + self.lastPosition1.y)
        }
        .onEnded {_ in
            self.lastPosition1 = self.position1
        }
        
        let drag2 = DragGesture(coordinateSpace: .named("dragArea2"))
        .onChanged {
            guard self.dragArea2.contains($0.startLocation) else { return }
            self.position2 = CGPoint(x: $0.translation.width + self.lastPosition2.x, y: $0.translation.height + self.lastPosition2.y)
        }
        .onEnded {_ in
            self.lastPosition2 = self.position2
        }
        
        return HStack {
            VStack {
                Rectangle().foregroundColor(.red)
                    .frame(width: 150, height: 150)
                    .position(self.position1)
                    .gesture(drag1)
                    .clipped()
            }
            .background(Color.green)
            .frame(width: dragArea1.width, height: dragArea1.height)
            
            VStack {
                Rectangle().foregroundColor(.blue)
                .frame(width: 150, height: 150)
                .position(self.position2)
                .gesture(drag2)
                .clipped()
            }
            .background(Color.yellow)
            .frame(width: dragArea2.width, height: dragArea2.height)
        }
        
    }
}

Any ideas of how to keep dragging disabled outside any containers, as already achieved, but also allow dragging within the full bounds of each container regardless of what happens with others?

rliebert
  • 81
  • 1
  • 3

3 Answers3

5

Here is possible solution. The idea is to have drag coordinates in container coordinate space and ignore drag if start location is out of that named area.

Tested with Xcode 11.4 / iOS 13.4

demo

struct ContentView: View {

    @State var position: CGPoint = CGPoint(x: 100, y: 150)
    @State var lastPosition: CGPoint = CGPoint(x: 100, y: 150)

    var body: some View {
        let area = CGRect(x: 0, y: 0, width: 200, height: 300)

        let drag = DragGesture(coordinateSpace: .named("area"))
        .onChanged {
            guard area.contains($0.startLocation) else { return }
            self.position = CGPoint(x: $0.translation.width + self.lastPosition.x, y: $0.translation.height + self.lastPosition.y)
        }
        .onEnded {_ in
            self.lastPosition = self.position
        }

        return VStack {
            Rectangle().foregroundColor(.red)
                .frame(width: 150, height: 150)
                .position(self.position)
                .gesture(drag)
                .clipped()
        }
        .background(Color.green)
        .frame(width: area.size.width, height: area.size.height)
        .coordinateSpace(name: "area")

    }
}
Martijn Pieters
  • 1,048,767
  • 296
  • 4,058
  • 3,343
Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Thank you very much, @Asperi! That is a very elegant solution to the problem as posted, and it works great. I have updated the question with a follow-on issue that arises from putting two of these draggable areas next to each other. Please take a look and let me know if you have any good ideas to work around this. Much appreciated! – rliebert Jul 23 '20 at 18:38
  • OK, I worked out how to do the second part of this. I'll post my work as a separate answer. Thanks again, @Asperi! – rliebert Jul 23 '20 at 20:58
3

Two days I was looking for a solution to a similar problem @Asperi's solution helps, but it is not universal for 3 or more figures

My solution: I adding

.contentShape(Rectangle())

before

.gesture(DragGesture().onChanged {

this article helped me. https://www.hackingwithswift.com/quick-start/swiftui/how-to-control-the-tappable-area-of-a-view-using-contentshape

I hope it will be useful to someone.

Sample code:

var body: some View {
        VStack {
            Image("my image")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(200, 200)

                .clipShape(Rectangle())
                .contentShape(Rectangle())   // <== this code helped me

                .gesture(
                    DragGesture()
                        .onChanged {
                            //
                        }
                        .onEnded {_ in
                            //
                        }
                )
        }
}

For the example above, the code could be like this:

struct ContentView: View {

@State var position1: CGPoint = CGPoint(x: 100, y: 150)
@State var lastPosition1: CGPoint = CGPoint(x: 100, y: 150)
let dragArea1: CGSize = CGSize(width: 200, height: 300)

@State var position2: CGPoint = CGPoint(x: 100, y: 150)
@State var lastPosition2: CGPoint = CGPoint(x: 100, y: 150)
let dragArea2: CGSize = CGSize(width: 200, height: 300)

var body: some View {
    
let drag = DragGesture()
    .onChanged {
        if $0.startLocation.x <= self.dragArea1.width {
            self.position1 = CGPoint(x: $0.translation.width + self.lastPosition1.x, y: $0.translation.height + self.lastPosition1.y)
        } else {
            self.position2 = CGPoint(x: $0.translation.width + self.lastPosition2.x, y: $0.translation.height + self.lastPosition2.y)
        }
    }
    .onEnded {_ in
        self.lastPosition1 = self.position1
        self.lastPosition2 = self.position2
    }
    
    return HStack {
        VStack {
            Rectangle().foregroundColor(.red)
                .frame(width: 150, height: 150)
                .position(self.position1)
                .clipped()
        }
        .background(Color.green)
        .frame(width: dragArea1.width, height: dragArea1.height)
        
        VStack {
            Rectangle().foregroundColor(.blue)
            .frame(width: 150, height: 150)
            .position(self.position2)
            .clipped()
        }
        .background(Color.yellow)
        .frame(width: dragArea2.width, height: dragArea2.height)
    }
    .clipShape(Rectangle())     //<=== This
    .contentShape(Rectangle())  //<=== and this
    .gesture(drag)
} }
John
  • 31
  • 2
  • Your answer is unclear, reformat your post, there are lots of unnecessary spaces used in your code and unclosed brackets – GooDeeJAY Apr 10 '21 at 08:33
  • Please, reformat your code for a full working version. You can read [guidelines](https://stackoverflow.com/tour) and [how an answer](https://stackoverflow.com/help/how-to-answer) should look like. – Lew Winczynski Apr 10 '21 at 09:43
0

For the 2nd part (i.e. update to the original question), here's what I ended up with. Basically, I combined the two separate drag gestures into one gesture that covers the whole HStack, and then directed the gesture to the appropriate @State variable depending on where in the HStack it started.

Demo of the Result:

enter image description here

Code:

struct ContentView: View {
    
    @State var position1: CGPoint = CGPoint(x: 100, y: 150)
    @State var lastPosition1: CGPoint = CGPoint(x: 100, y: 150)
    let dragArea1: CGSize = CGSize(width: 200, height: 300)
    
    @State var position2: CGPoint = CGPoint(x: 100, y: 150)
    @State var lastPosition2: CGPoint = CGPoint(x: 100, y: 150)
    let dragArea2: CGSize = CGSize(width: 200, height: 300)
    
    var body: some View {

    let drag = DragGesture()
        .onChanged {
            guard $0.startLocation.y >= 0 && $0.startLocation.y <= self.dragArea1.height else { return }
            if $0.startLocation.x <= self.dragArea1.width {
                self.position1 = CGPoint(x: $0.translation.width + self.lastPosition1.x, y: $0.translation.height + self.lastPosition1.y)
            } else {
                self.position2 = CGPoint(x: $0.translation.width + self.lastPosition2.x, y: $0.translation.height + self.lastPosition2.y)
            }
        }
        .onEnded {_ in
            self.lastPosition1 = self.position1
            self.lastPosition2 = self.position2
        }
        
        return HStack {
            VStack {
                Rectangle().foregroundColor(.red)
                    .frame(width: 150, height: 150)
                    .position(self.position1)
                    .clipped()
            }
            .background(Color.green)
            .frame(width: dragArea1.width, height: dragArea1.height)
            
            VStack {
                Rectangle().foregroundColor(.blue)
                .frame(width: 150, height: 150)
                .position(self.position2)
                .clipped()
            }
            .background(Color.yellow)
            .frame(width: dragArea2.width, height: dragArea2.height)
        }
        .gesture(drag)
    }
}

Notes:

  1. As it is now, the gesture works anywhere in each container (i.e. green and yellow areas), even if you don't drag inside the red or blue square.
  2. This could probably be more versatile and/or give a bit more control if you put the whole gesture code into the view and wrapped it in a GeometryReader so you could reference the local bounds in context inside ".onChanged".
rliebert
  • 81
  • 1
  • 3
  • It's good that it works but it's pretty ugly because you cannot isolate the behaviour inside each component. I need a grid of these components, I don't want to manage them all in one superview. Each of them should contain it's own behaviour. – Alexander Ulitin Jan 16 '21 at 07:36