Reputation: 3053
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 **
Upvotes: 10
Views: 5873
Reputation: 579
Creating a custom UIView that handles various gestures (tap, double tap, and long press) and then integrating it into SwiftUI using UIViewRepresentable is a great approach to achieve the level of control you're looking for, especially when the standard SwiftUI gestures are not providing the desired behavior. Here's a step-by-step guide to implementing this:
Step 1: Create a Custom UIView First, create a custom UIView subclass in your UIKit codebase that handles the desired gestures.
import UIKit
class CustomGestureView: UIView {
var onTap: (() -> Void)?
var onDoubleTap: (() -> Void)?
var onLongPressBegan: (() -> Void)?
var onLongPressEnded: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
setupGestures()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupGestures()
}
private func setupGestures() {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
self.addGestureRecognizer(tapGesture)
let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap))
doubleTapGesture.numberOfTapsRequired = 2
self.addGestureRecognizer(doubleTapGesture)
tapGesture.require(toFail: doubleTapGesture)
let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
self.addGestureRecognizer(longPressGesture)
}
@objc private func handleTap() {
onTap?()
}
@objc private func handleDoubleTap() {
onDoubleTap?()
}
@objc private func handleLongPress(gesture: UILongPressGestureRecognizer) {
if gesture.state == .began {
onLongPressBegan?()
}
if gesture.state == .ended {
onLongPressEnded?()
}
}
}
Step 2: Create a UIViewRepresentable Next, create a UIViewRepresentable in SwiftUI to integrate your custom view.
import SwiftUI
struct CustomGestureRepresentable: UIViewRepresentable {
var onTap: () -> Void
var onDoubleTap: () -> Void
var onLongPressBegan: () -> Void
var onLongPressEnded: () -> Void
func makeUIView(context: Context) -> CustomGestureView {
let view = CustomGestureView()
view.onTap = onTap
view.onDoubleTap = onDoubleTap
view.onLongPressBegan = onLongPressBegan
view.onLongPressEnded = onLongPressEnded
return view
}
func updateUIView(_ uiView: CustomGestureView, context: Context) {
// Update the view if needed
}
}
Step 3: Create a ViewModifier Create a ViewModifier that uses CustomGestureRepresentable.
import SwiftUI
struct CustomGestureModifier: ViewModifier {
var onTap: () -> Void
var onDoubleTap: () -> Void
var onLongPressBegan: (() -> Void)?
var onLongPressEnded: () -> Void
func body(content: Content) -> some View {
content
.overlay(
CustomGestureRepresentable(
onTap: onTap,
onDoubleTap: onDoubleTap,
onLongPressBegan: onLongPressBegan
onLongPressEnded: onLongPressEnded
)
)
}
}
extension View {
func customGestures(
onTap: @escaping () -> Void,
onDoubleTap: @escaping () -> Void,
onLongPressBegan: @escaping () -> Void,
onLongPressEnded: @escaping () -> Void
) -> some View {
self.modifier(CustomGestureModifier(
onTap: onTap,
onDoubleTap: onDoubleTap,
onLongPressBegan: onLongPressBegan,
onLongPressEnded: onLongPressEnded
))
}
}
and use like this
struct ContentView: View {
var body: some View {
CustomView()
.customGestures(
onTap: {
print("Tapped")
},
onDoubleTap: {
print("Double Tapped")
},
onLongPressBegan: {
print("Long Press Began")
},
onLongPressEnded: {
print("Long Press Ended")
}
)
}
}
Upvotes: 0
Reputation: 177
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.
Upvotes: 16
Reputation: 3053
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 firstLongPressGesture
hasn't ended yet.The
.second(Bool, Bool?)
case is when the firstLongPressGesture
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'sUpdate 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
}
})
Upvotes: 21
Reputation: 21
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.
Upvotes: 1
Reputation: 1802
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")
}
}
Upvotes: -1