Ability to be informed when .onDrag() ends without any movement (i.e. immediately and before any drop occurs)
Asked Answered
N

1

7

I’m using the .onDrag modifier to enable a drag/drop operation:

struct RootView: View {
    
    @State var dragging: Bool = false
    
    var body: some View {
        VStack {
            Color.red.cornerRadius(.some)
                .frame(width: 100, height: 100)
                .onDrag {
                    dragging = true
                    return NSItemProvider(object: NSString())
                }
        }
    }
}

As soon as a drag is invoked, I set the dragging flag to be true.

There’s no issue with performing a drop. However, if I invoked a drag but ended it without any movement, I’m not informed, i.e. dragging remains set to true.

I’ve tried adding a second gesture set to either highPriority or simultaneous of a drag gesture that ends without any perceived movement:

        .gesture(DragGesture(minimumDistance: 0)
            .onEnded({ value in
                if value.translation.height == 0 && value.translation.width == 0 {
                    self.dragging = false
                }
            })
        )

Regardless of whether I place it before/after the onDrag modifier, it stops the onDrag from triggering.

Is there any way to be able to tell when an onDrag ends without any movement, i.e. drag released immediately?

Nighttime answered 26/4, 2022 at 15:30 Comment(6)
I understand it is out of context but just curious, why would you need that dragging? What do you do on that flag triggered?Trump
Basically I need the UI to respond to the fact that a drag is active. For example, special UI appears as soon as drag is active, and disappears as soon as it becomes inactive. At the moment, I can only get this to occur if the user has moved the view. Sometimes they won’t because they didn’t intend to begin a drag, or they decided against.Nighttime
Well, you need UIKit UIDragInteractionDelegate, SwiftUI does not have yet such flexibility. Moreover, for now onDrag is called only once (item is cached until view rebuilt), so you might not get to dragging = true even if turn it to off for first time.Trump
Right, thanks for the clarification. Well, it’s good to know that it’s not me, it’s SwiftUI (: If you have any links to integrating UIDragInteractionDelegate for a SwiftUI view, it would be greatly appreciated.Nighttime
on the Gesture have .onChange state and we can use it for understand have offset or not for example we can see location.x and location.y it's our new position and have current position for item (piece or text, image what you need) I read more articles before use it, if you have time you can read this: Hacking swift Havinkg swift YoutubeReside
Check out my answer to: https://mcmap.net/q/1627491/-swiftui-notification-about-the-end-the-cancellation-of-a-drag-operationDivulge
N
2

I filed the inability to achieve the above in SwiftUI with Apple, and this is the response I got:

@GestureState is the way to clean up after cancelled gestures. onEnded() tells you when it completes. The combination of the two should let you implement the things described.

This wasn't helpful, at least based on my understanding on what was proposed. When I added the following:

@GestureState var tapGesture = false

And:

.onDrag {
   handler()
}
.simultaneousGesture(DragGesture()
   .updating($tapGesture) { _,_,_  in
       print("updating")
   }
   .onEnded { _ in
       print("ended")
   })

The print statements never occurred on a drag.

If however I did:

.onDrag {
     handler()
 }
 .simultaneousGesture(LongPressGesture()
     .updating($tapGesture) { _,_,_  in
         print("updating")
     }
     .onEnded { _ in
         print("ended")
     })

The long press gesture intercepts and stops the drag. Changing the minimum duration didn’t seem to make a difference. Nor the order in which the gesture modifiers are sequenced. Or if the additional “dummy” drag is set to be a high priority gesture.

So I ended up with a not-perfect creative workaround.

Firstly, to the root view I added a cancellation delegate:

struct RootView: View {
    
    static var cancellation = CancellationDelegate()

...

.onDrop(of: [UTType.text], delegate: Self.cancellation)

Its purpose is to ensure that the draggable view is immediately surrounded by a potential drop target. I have a view model for it that keeps a record for when a drop target has been entered.

    import SwiftUI
import UniformTypeIdentifiers
import ThirdSwiftLibs

struct CancellationDelegate: DropDelegate {

    var viewModel: CancellationViewModel = .init()
    
    // MARK: - Event handlers

    /// Dragged over a drop target
    func dropEntered(info: DropInfo) {
        viewModel.dropLastEntered = .now
    }
    
    /// Dragged away from a drop target
    func dropExited(info: DropInfo) {

    }
    
    /// Drag begins
    func dropUpdated(info: DropInfo) -> DropProposal? {
        DropProposal(operation: .move)
    }
  
    // MARK: - Mutators
    
    /// The dragged entity is dropped on a target
    /// - Returns: true if drop was successful
    func performDrop(info: DropInfo) -> Bool {
        SchedulerViewModel.shared.reset()
        return true
    }
    
    // MARK: - Checkers

    /// Checks whether the drag is considered valid based on
    /// the view it’s attached to
    /// - Returns: true if view is drag/droppable
    func validateDrop(info: DropInfo) -> Bool {
        /// Allow the drop to begin with any String set as the NSItemProvider
        return info.hasItemsConforming(to: [UTType.text])
    }
}

class CancellationViewModel {
    var dropLastEntered: Date?
}

In the card’s drag handler, I check after 3 seconds whether the card has moved. I can tell by referring to the cancellation’s dropLastEntered value. If it’s been less than 3 seconds, then it’s reasonable to assume it has moved and so there’s nothing I need to do. However if it’s been 3 or more, then it’s equally safe to assume that the drag was released at some point within the last 3 seconds, and so I should use this as a cue to reset the app state.

DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
    if let last = RootView.cancellation.viewModel.dropLastEntered {
        if last.seconds(from: .now) <= -3 {
            // reset UI because we last moved a card over the dummy drop target 3 or more seconds ago
        }
    }
    else {
        // reset UI because we’ve never moved any card over the dummy drop target
    }
}

The reason this isn’t perfect is because the UI reset doesn’t happen immediately. In tests, 3 seconds appears to be around when the system decides to de-activate the drag on the view due to no movement detected (the view no longer appears in the drag state the system originally activated). So, from this perspective 3 seconds is good. However, if I was to release the card myself after 1 second without any movement, then it will be another 2 seconds (3-1) before the UI realises it needs to reset due to the drag being de-activated.

Hope this helps. If someone has a better solution, I’m all ears!

Nighttime answered 28/9, 2022 at 11:16 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.