Search code examples
iosanimationswiftuigesture

How to detect when SwiftUI View is being dragged over


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?


Solution

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