SwiftUI onDrag being called twice - ios

I'm implementing Drag & Drop using SwiftUI. The problem I'm facing is that onDrag is called twice: The first time is when long press gesture happens and the second time is after starting dragging/moving my finger (after the long press).
Also preview is displayed twice. First one on long press and the second one on actual drag. The second one looks like my view (created in makePreviewItem(...)). The first one is just a bit scaled item.
var body: some View {
ScrollView(.horizontal) {
LazyVGrid(columns: ...) {
ForEach(...) { item in
createItem(...)
.onTapGesture {
viewModel.didSelect(item)
}
.onDrag {
let haptic = UIImpactFeedbackGenerator(style: .medium)
haptic.prepare()
haptic.impactOccurred()
draggingItem = item
viewModel.didStartDragging(item)
return NSItemProvider(object: String(item.id) as NSString)
} preview: {
makePreviewItem(...)
}
.onDrop(of: [.text], delegate: DropRelocateDelegate(didFinishDragging: {
draggingItem = nil
viewModel.didFinishDragging()
}))
}
}
}
.frame(height: 100)
So I'd like to display my preview view (makePreviewItem(...)) immediately (when long press occurs) and I don't want onDrag to be called twice.

You can add a boolean. When a long press or drag is initiated then the boolean is set to true, and each gesture checks whether the boolean is true which does not run the code, once the drag or press has been completed, the boolean returns back to false. This stops the drag action being initiated twice.

Related

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

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?
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!

How to detect that a certain view is being displayed on screen in SwiftUI

I'm struggling with how to detect that a certain view is being displayed on the screen.
I have a notifications list in which new notification cells have blue backgrounds, and 2 seconds after you see the notification, the background changes to white, meaning you've seen that new notification, just like in Twitter's new notification screen.
First I thought about using the onAppear modifier to detect whether you've seen the new notification cell, but it gets triggered before the particular cell is being displayed on the screen.
So I thought up a workaround. When the notifications bottom tab is selected, i.e. when the user comes to the notifications view from another view, then 2 seconds later change the background of the new notification cell.
struct NotificationCell: View {
#Binding var tabSelection: TabBarItem
#Binding var notificationsDetail: NotificationsDetail
var body: some View {
HStack {
//Views inside
}
.background(notificationsDetail.notificationsIsAlreadyRead ? .white : .blue)
}
}
However, I don't know where to write the following code because init() or onAppear gets triggered only once at the beginning. I need to make sure I run the code when the tabSelection property changes.
if tabSelection == .notifications {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
notificationsDetail.notificationsIsAlreadyRead = true
}
}
I've also studied onChange modifier but since I'm not using Texts or TexFields I thought onChange might not fit as a solution here.
Maybe my workaround is not a good approach in the first place to achieving what I want to do.
Any advice would be highly appreciated.
Thanks in advance.
onChange is what would work here.
.onChange(of: tabSelection) { selection in
if selection == .notifications {
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
notificationsDetail.notificationsIsAlreadyRead = true
}
}
}

How do I stop a view from refreshing each time I return from a detail view in SwiftUI?

I have a view which displays a list of posts. I have implemented infinite scrolling, and it is functioning properly. however, there is one small problem I am running into, and attempts to solve it have me going round in circles.
Main view
struct PostsHomeView: View {
#EnvironmentObject var viewModel: ViewModel
#State var dataInitiallyFetched = false
var body: some View {
NavigationView {
VStack(alignment: .center, spacing: 0) {
if self.viewModel.posts.count > 0 {
PostsListView(posts: self.viewModel.posts,
isLoading: self.viewModel.canFetchMorePosts,
onScrolledAtBottom: self.viewModel.fetchMorePosts
)
} else {
VStack {
Text("You have no posts!")
}
}
}
.onAppear() {
if !self.dataInitiallyFetched {
self.viewModel.fetchMostRecentPosts()
self.dataInitiallyFetched = true
}
}
.navigationBarTitle("Posts", displayMode: .inline)
}
}
}
List view
struct PostsListView: View {
#EnvironmentObject var viewModel: ViewModel
let posts: [Post]
let isLoading: Bool
let onScrolledAtBottom: () -> Void
var body: some View {
List {
postsList
if isLoading {
loadingIndicator
}
}
}
private var postsList: some View {
ForEach(posts, id: \.self) { post in
PostsCellView(post: post)
.onAppear {
if self.posts.last == post {
self.onScrolledAtBottom()
}
}
}
.id(UUID())
}
}
Problem
Upon tapping one of the posts in the list, I am taken to a detail view. When I tap the nav bar's back button in order go back to the posts list, the whole view is reloaded and my post fetch methods are fired again.
In order to stop the fetch method that fetches most recent posts from firing, I have added a flag that I set to true after the initial load. This stops the fetch method that grabs the initial set of posts from firing when I go back and forth between the details view and posts home screen.
I have tried various things to stop the fetchMorePosts function from firing, but I keep going in circles. I added a guard statement to the top of the fetchMorePosts function in my view model. It checks to see if string is equal to "homeview", if not, then the fetch is not done. I set this string to "detailview" whenever the detail view is visited, then I reset it back to "homeview" in the guard statement.
guard self.lastView == "homeview" else {
self.lastView = "homeview"
return
}
This works to an extent, but I keep finding scenarios where it doesn't work as expected. There must be a straight-forward way to tell SwiftUI not to reload a view. The problem is the method sits in the onAppear closure which is vital for the infinite scrolling to work. I'm not using iOS 14 yet, so I can't use #StateObject.
Is there a way to tell SwiftUI not to fire onAppear everytime I return from a detail view?
Thanks in advance
The culprit was .id(UUID()). I removed it from my list and everything worked again.
Thanks Asperi. Your help is much appreciated.

SwiftUI tap gesture blocks item deleting action in List

So I have a view with List, also this view has side menu. I added tapGesture to my VStack to dismiss side menu when it's open, but then I face issue, tapGesture is blocking onDelete method of List. Any ideas how to fix that??
Here is code example:
VStack {
.....
List {
ForEach(){
//list elements here
}
.onDelete {
// delete action here
}
}
}
.onTapGesture {
// action here
}
Also, if while deleting I swipe once till the end, it's working. But if I swipe just a little and try to press Delete button nothing happens.
Replace your .onTapGesture with the simultaneousGesture modifier.
.simultaneousGesture(TapGesture().onEnded {
// action here
})

How to fire event handler when the user STOPS a Long Press Gesture in SwiftUI?

Based on the documentation, the .OnEnded event handler will fire when the LongPressGesture has been successfully detected. How can I fire an event when the user stops pressing after the gesture has been detected?
Here is an example:
User presses for e.g. 2 seconds.
** Something appears **
User releases after another 2 seconds
** That something disappears **
I managed to solve it, although if anyone has an easier solution I would gladly accept.
Basically I need to chain 2 LongPressGesture-s together.
The first one will take effect after a 2 second long press - this is when the something should appear.
The second one will take effect after Double.infinity time, meaning that it will never complete, so the user can press as long as they want. For this effect, we only care about the event when it is cancelled - meaning that the user stopped pressing.
#GestureState private var isPressingDown: Bool = false
[...]
aView.gesture(LongPressGesture(minimumDuration: 2.0)
.sequenced(before: LongPressGesture(minimumDuration: .infinity))
.updating($isPressingDown) { value, state, transaction in
switch value {
case .second(true, nil): //This means the first Gesture completed
state = true //Update the GestureState
default: break
}
})
.
[...]
something.opacity(isPressingDown ? 1 : 0)
When sequencing two LongPressGesture-s by calling the .sequenced(before:) method, you get a
SequenceGesture<LongPressGesture, LongPressGesture> as return value
which has a .first(Bool) and a .second(Bool, Bool?) case in its Value enum.
The .first(Bool) case is when the first LongPressGesture hasn't ended yet.
The .second(Bool, Bool?) case is when the first LongPressGesture has ended.
So when the SequenceGesture's value is .second(true, nil), that means the first Gesture has completed and the second is yet undefined - this is when that something should be shown - this is why we set the state variable to true inside that case (The state variable encapsulates the isPressingDown variable because it was given as first parameter to the .updating(_:body:) method).
And we don't have to do anything about setting the state back to false because when using the .updating(_:body:) method the state returns to its initial value - which was false - if the user cancels the Gesture. Which will result in the disappearance of "something". (Here cancelling means we lift our finger before the minimum required seconds for the Gesture to end - which is infinity seconds for the second gesture.)
So it is important to note that the .updating(_:body:) method's callback is not called when the Gesture is cancelled, as per this documentation's Update Transient UI State section.
EDIT 03/24/2021:
I ran into the problem of updating an #Published property of an #ObservedObject in my view. Since the .updating() method closure is not called when resetting the GestureState you need another way to reset the #Published property. The way to solve that issue is adding another View Modifier called .onChange(of:perform:):
Model.swift:
class Model: ObservableObject {
#Published isPressedDown: Bool = false
private var cancellableSet = Set<AnyCancellable>()
init() {
//Do Something with isPressedDown
$isPressedDown
.sink { ... }
.store(in: &cancellableSet)
}
}
View.swift:
#GestureState private var _isPressingDown: Bool = false
#ObservedObject var model: Model
[...]
aView.gesture(LongPressGesture(minimumDuration: 2.0)
.sequenced(before: LongPressGesture(minimumDuration: .infinity))
.updating($_isPressingDown) { value, state, transaction in
switch value {
case .second(true, nil): //This means the first Gesture completed
state = true //Update the GestureState
model.isPressedDown = true //Update the #ObservedObject property
default: break
}
})
.onChange(of: _isPressingDown) { value in
if !value {
model.isPressedDown = false //Reset the #ObservedObject property
}
})
It seems like there may be a more elegant solution here, although it's not apparent right away.
Instead of using a LongPressGesture, you can use a DragGesture with a minimum distance of 0
#State var pressing = false
Text("Press Me").gesture(
DragGesture(minimumDistance: 0)
.onChanged({ _ in
pressing = true
})
.onEnded({ _ in
doThingWhenReleased()
})
)
In this example, pressing will be true when you are pressed down, and will turn to false when it's released. The onEnded closure will be called when the item is released. Here is this feature bundled into a ViewModifier to use, although it may be better as a standalone Gesture() if you want it to have a similar api as other gestures:
struct PressAndReleaseModifier: ViewModifier {
#Binding var pressing: Bool
var onRelease: () -> Void
func body(content: Content) -> some View {
content
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged{ state in
pressing = true
}
.onEnded{ _ in
pressing = false
onRelease()
}
)
}
}
extension View {
func pressAndReleaseAction(pressing: Binding<Bool>, onRelease: #escaping (() -> Void)) -> some View {
modifier(PressAndReleaseModifier(pressing: pressing, onRelease: onRelease))
}
}
You can use it like this
struct SomeView: View {
#State var pressing
var body: some View {
Text(pressing ? "Press Me!" : "Pressed")
.pressAndReleaseAction(pressing: $pressing, onRelease: {
print("Released")
})
}
}
The only downside to this is that if the user drags outside of the button and releases, onRelease() is still fired. This could probably be updated inside the ViewModifier to get the expected outcome, where the user can drag outside to cancel the action, but you might have to use a GeometryReader.
I know it's kinda late, but I found another way to handle long press gesture. It also handle simple tap gesture.
Sorry for the bad writing you might encounter in this post.
It goes down in 2 parts:
First, we want to be able to detect touch down events on any view. For this we use a custom ViewModifier found this thread
Then, see the code below.
// 1.
#State var longPress = false
#State var longPressTimer: Timer?
[...]
// 2.
Button(action: {
if !longPress {
// 5.
}
// 6.
longPress = false
longPressTimer?.invalidate()
longPressTimer = nil
}) {
Color.clear
}
.onTouchDownGesture { // 3.
// 4.
longPressTimer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in
longPress = true
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
[...]
We will need a boolean to hold the long press state (pressed or not). And a Timer.
Add a standard SwiftUI Button. A Button action is only triggered when the button is released (touch up event), so very useful in our case.
Apply the custom touch down modifier to the button, we are now able to tell when the button is pressed & when it is released.
Init & fire the Timer at touch down. The time interval is the time you want to wait before recognising a touch down event as a long press event. When the timer ends, it means we are now in a "long press" state and we set longPress to true.
(optional) At button touch up, if the button was not tapped "long enough", we execute the work we want for the button default action, if desired.
Finally, still at touch up, cancel long press event & empty the timer.
With this method you achieve a long press like effect. My advice here is to listen to the didSet event on the #State var longPress: Bool, but what do I know ?
Also, I don't know if it's perf friendly, didn't check. But at least it works.
All improvements and ideas you guys might have are of course welcome.
Cheers.
Here is a solution if you want to fire action after release button
struct ContentView: View {
var body: some View {
Text("Tap me long")
.onLongPressGesture(minimumDuration: .infinity, maximumDistance: .infinity, pressing: handlePressing) {}
}
func handlePressing(_ isPressed: Bool) {
guard !isPressed else { return }
//MARK: - handle if unpressed
print("Unpressed")
}
}

Resources