How to detect when SwiftUI View is being dragged over

Viewed 1415

I would like to trigger an animation when the user drags a finger over my view.

I can do something like

Image(…)
  .rotationEffect(…)
  .animation(self.isAnimating ? .spring : .default)
  .gesture(
    DragGesture(minimumDistance: 5, coordinateSpace: .global)
      .onChanged { value in
        self.isAnimating = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
          self.isAnimating = false
        }
      }
  )

However that captures only a drag event that started over the image. A drag event that started elsewhere and then travels over the image is ignored.

I can also detect the drag events in the parent view and calculate which of the child views is being dragged over – and that's fine. However, how do I tell the child view to animate then? Updating their properties causes a re-render which of course cancels the animation.

Passing ui data like this through a model seems like an anti pattern.

Any suggestions?

3 Answers

I replaced the Image of your example by a Grid of small CardViews. We will try to change the color of the cards that are "crossed" by the drag gesture.

We can use PreferenceKey to get all the CardViews bounds...

struct CardPreferenceData: Equatable {
    let index: Int
    let bounds: CGRect
}

struct CardPreferenceKey: PreferenceKey {
    typealias Value = [CardPreferenceData]
    
    static var defaultValue: [CardPreferenceData] = []
    
    static func reduce(value: inout [CardPreferenceData], nextValue: () -> [CardPreferenceData]) {
        value.append(contentsOf: nextValue())
    }
}

here :

struct CardView: View {
    let index: Int
    
    var body: some View {
        Text(index.description)
            .padding(10)
            .frame(width: 60)
            .overlay(RoundedRectangle(cornerRadius: 10).stroke())
            .background(
                GeometryReader { geometry in
                    Rectangle()
                        .fill(Color.clear)
                        .preference(key: CardPreferenceKey.self,
                                    value: [CardPreferenceData(index: self.index, bounds: geometry.frame(in: .named("GameSpace")))])
                }
            )
    }
}

In the ContentView now we can collect all preferences (bounds and index) of these cards and store them in an array :

.onPreferenceChange(CardPreferenceKey.self){ value in
            cardsData = value
        }

We can now compare the positions (the bounds) of these CardViews to the position of the drag gesture.


struct ContentView: View {
    let columns = Array(repeating: GridItem(.fixed(60), spacing: 40), count: 3)
    @State private var selectedCardsIndices: [Int] = []
    @State private var cardsData: [CardPreferenceData] = []
    var body: some View {
        LazyVGrid(columns: columns, content: {
            ForEach((1...12), id: \.self) { index in
                CardView(index: index)
                    .foregroundColor(selectedCardsIndices.contains(index) ? .red : .blue)
            }
        })
        .onPreferenceChange(CardPreferenceKey.self){ value in
            cardsData = value
        }
        .gesture(
            DragGesture()
                .onChanged {drag in
                    if let data = cardsData.first(where: {$0.bounds.contains(drag.location)}) {
                        selectedCardsIndices.append(data.index)
                    }
                }
        )
        .coordinateSpace(name: "GameSpace")
    }
}

Example

EDIT : The small "lag" at the start of the video does not occur with the canvas. Only on the simulator. I have not tested on a real device.

How about this way?

I set the drag over point at +30/-30 at both ends.

e.g. iPhone 12 width: 844, Endpoint: x: 30 and x: 814




struct ContentView: View {
    
    @State private var offsetX: CGFloat = 0
    @State private var offsetY: CGFloat = 0
    
    var body: some View {
        VStack {
            Circle()
                .frame(width: 100, height: 100)
                .offset(x: offsetX, y: offsetY)
                .gesture(DragGesture(minimumDistance: 5, coordinateSpace: .global)
                            .onChanged { value in
                                offsetX = value.translation.width
                                offsetY = value.translation.height
                                
                                print("x: \(value.location.x)")
                                print("y: \(value.location.y)")
                                
                                if value.location.x > UIScreen.main.bounds.width - 30 || value.location.x < 30 {
                                    print("End")
                                }
                                
                                if value.location.y > UIScreen.main.bounds.height - 30 || value.location.y < 30 {
                                    print("End")
                                }
                            }
                )
        }
    }
}



enter image description here

This worked for me:

                .gesture(
                    DragGesture()
                        .onChanged { drag in
                            print("drag: \(drag)")
                            userIsDraggingMap = true
                        }
                        .onEnded { drag in
                            userIsDraggingMap = false
                        }
                )
Related