2

I'm trying to handle drag gestures entering into a SwiftUI view. However, DragGesture only fires (updates, changes) when the event originated in the view itself.

I could certainly think of various workarounds, yet I wanted to double-check if there is an easy/intended way to do this.

I'm looking for something similar to a UIKit touchDragEnter, or JavaScript ondragenter.

Geri Borbás
  • 15,810
  • 18
  • 109
  • 172
  • 3
    Probably you mean `dropEntered`... this should be helpful https://stackoverflow.com/a/63438481/12299030. As well as this one https://stackoverflow.com/a/59798786/12299030. – Asperi Sep 04 '21 at 13:06
  • Thanks for the pointers! I took a brief look at the docs earlier, however, it felt like somewhat an overhead considering that I don't want to drop anything in particular, only detect a "touch up" (that is not initiated in the view). Nevertheless, I'll give it a try, sounds (way) better than utilize `UIKit` only for gesture handling. – Geri Borbás Sep 04 '21 at 14:53
  • 1
    @GeriBorbás If it makes sense for what you are doing, a possible solution would be to overlay an invisible view with a drag gesture. You then track the coordinates, and if it's in a view's bounding box, then you can trigger whatever code you would like. – George Sep 04 '21 at 15:54
  • Thanks, I definitely consider that path. Basically, a view that handles every gesture, then dispatches it to appropriate places. – Geri Borbás Sep 04 '21 at 17:56
  • I'm also considering a `UIViewRepresentable`, then implement `UIKit` gesture recognizers. That way at least I could keep the logic local to the view. – Geri Borbás Sep 04 '21 at 17:58
  • @GeriBorbás @ me in the replies because otherwise I may miss them. However, without more information and a [mre] to get working, I can't really help any further. – George Sep 04 '21 at 18:32
  • @George Thanks, you already helped! I'll dive in and try out a couple of versions. Also, isolating to a prototype view makes a lot of sense. – Geri Borbás Sep 04 '21 at 19:13

1 Answers1

3

I ended up intercepting touches in an overlay view, then highlight items when touch location is contained in its bounds. I tried both onDrop and using UIKit gestures/actions, but none of them came without drawbacks. So I opted to implement George's comment above instead.

enter image description here

I incorporated a model object where I store the frames on every update, so highlight effect can adopt dynamically. It makes the solution animation proof, while picker also can be displayed anywhere on the screen.

import SwiftUI


class Model: ObservableObject {
    
    let coordinateSpace = "CoordinateSpace"
    
    @Published var isDragged = false
    @Published var highlightedNumber: Int? = nil
    @Published var selectedNumber: Int? = nil
    
    /// Frames for individual picker items (from their respective `GeometryReader`).
    private var framesForNumbers: [Int: CGRect] = [:]
    
    func update(frame: CGRect, for number: Int) {
        framesForNumbers[number] = frame
    }
    
    /// Updates `highlightedNumber` according drag location.
    func update(dragLocation: CGPoint) {
        
        // Lookup if any frame contains drag location.
        for (eachNumber, eachFrame) in framesForNumbers {
            if eachFrame.contains(dragLocation) {
                
                // Publish.
                self.highlightedNumber = eachNumber
                return
            }
        }
        
        // Reset otherwise.
        self.highlightedNumber = nil
    }
    
    /// Updates `highlightedNumber` and `selectedNumber` according drop location.
    func update(isDragged: Bool) {
        
        // Publish.
        self.isDragged = isDragged
        
        if isDragged == false,
           let highlightedNumber = self.highlightedNumber {
            
            // Publish.
            self.selectedNumber = highlightedNumber
            self.highlightedNumber = nil
        }
    }
}

struct ContentView: View {
    
    @StateObject var model = Model()
    
    var body: some View {
        ZStack {
            TouchesView(model: model, isDragged: $model.isDragged)
            CanvasView(number: $model.selectedNumber)
                .allowsHitTesting(false)
            PickerView(model: model, highlightedNumber: $model.highlightedNumber)
                .allowsHitTesting(false)
        }
        .ignoresSafeArea()
    }
}

/// Handles drag interactions and updates model accordingly.
struct TouchesView: View {
    
    var model: Model
    @Binding var isDragged: Bool
    
    var body: some View {
        Rectangle()
            .foregroundColor(isDragged ? .orange : .yellow)
            .coordinateSpace(name: model.coordinateSpace)
            .gesture(
                DragGesture(minimumDistance: 0)
                    .onChanged { value in
                        model.update(dragLocation: value.location)
                        model.update(isDragged: true)
                    }
                    .onEnded { state in
                        model.update(dragLocation: state.location)
                        model.update(isDragged: false)
                    }
                )
    }
}

/// Shows the selected number.
struct CanvasView: View {
    
    @Binding var number: Int?
    
    var body: some View {
        VStack {
            Text(number.string)
                .font(.system(size: 100, weight: .bold))
                .foregroundColor(.white)
                .offset(y: -50)
        }
    }
}

/// Displays a picker to select number items from.
struct PickerView: View {
    
    var model: Model
    @Binding var highlightedNumber: Int?
    
    var body: some View {
        HStack(spacing: 5) {
            PickerItemView(number: 1, model: model, highlightedNumber: $highlightedNumber)
            PickerItemView(number: 2, model: model, highlightedNumber: $highlightedNumber)
            PickerItemView(number: 3, model: model, highlightedNumber: $highlightedNumber)
        }
        .opacity(model.isDragged ? 1 : 0)
        .scaleEffect(model.isDragged ? 1 : 0.5, anchor: .top)
        .blur(radius: model.isDragged ? 0 : 10)
        .animation(.spring(response: 0.15, dampingFraction: 0.4, blendDuration: 0.5))
    }
}

/// Shows a number item (also highlights it when `highlightedNumber` matches).
struct PickerItemView: View {
    
    let number: Int
    var model: Model
    @Binding var highlightedNumber: Int?
    
    var body: some View {
        Text(String(number))
            .font(.system(size: 25, weight: .bold))
            .foregroundColor(isHighlighted ? .orange : .white)
            .frame(width: 50, height: 50)
            .background(isHighlighted ? Color.white : Color.orange)
            .cornerRadius(25)
            .overlay(
                RoundedRectangle(cornerRadius: 25)
                    .stroke(Color.white, lineWidth: 2)
            )
            .overlay(GeometryReader { geometry -> Color  in
                self.model.update(
                    frame: geometry.frame(in: .named(self.model.coordinateSpace)),
                    for: self.number
                )
                return Color.clear
            })
            .animation(.none)
    }
}

extension PickerItemView {
    
    var isHighlighted: Bool {
        self.highlightedNumber == self.number
    }
}

fileprivate extension Optional where Wrapped == Int {
    
    var string: String {
        if let number = self {
            return String(number)
        } else {
            return ""
        }
    }
}

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

Only thing I dislike about this is that this method is esentially the same I did 15 years ago in a Flash app. I was hoping to something less "manual".

Geri Borbás
  • 15,810
  • 18
  • 109
  • 172