SwiftUI DragGesture onEnded does not fire in some cases - ios

In my app I have a swipeable card view that can be dragged left or right.
When the user drags the card left or right, the card's horizontal offset is updated. When the user releases the drag, the card moves off the screen (either left or right, depending on the drag direction) or the card goes back to the initial position if the horizontal offset did not exceed the threshold.
It works well, but if the user touches the card view with another finger while dragging and then takes his/her fingers off screen, the card position freezes, and it doesn't either move off the screen or go back to the initial position. I debugged the code and it turns out that in that case the DragGesture().onEnded event does not fire.
I am looking for any hints on how I can detect this situation.
Here is the code:
If I had something like isTouchingScreen state, I would be able to solve this.
EDIT: Here is a minimal example where the problem manifests itself.
import SwiftUI
struct ComponentPlayground: View {
#State private var isDragging: Bool = false
#State private var horizontalOffset: CGFloat = .zero
var background: Color {
if abs(horizontalOffset) > 100 {
return horizontalOffset < 0 ? Color.red : Color.green
} else {
return Color.clear
}
}
var body: some View {
GeometryReader { geometry in
Color.white
.cornerRadius(15)
.shadow(color: Color.gray, radius: 5, x: 2, y: 2)
.overlay(background.cornerRadius(15))
.rotationEffect(.degrees(Double(horizontalOffset / 10)), anchor: .bottom)
.offset(x: horizontalOffset, y: 0)
.gesture(
DragGesture()
.onChanged { gesture in
self.isDragging = true
self.horizontalOffset = gesture.translation.width
}
.onEnded { gesture in
self.isDragging = false
if abs(horizontalOffset) > 100 {
withAnimation {
self.horizontalOffset *= 5
}
} else {
withAnimation {
self.horizontalOffset = .zero
}
}
}
)
.frame(width: 300, height: 500)
}
}
}
struct ComponentPlayground_Previews: PreviewProvider {
static var previews: some View {
VStack {
Spacer()
HStack(alignment: .center) {
ComponentPlayground()
}
.frame(width: 300, height: 500)
Spacer()
}
}
}

Since there is no update on the issue from Apple, I will share my workaround for this problem.
Workaround still does not provide drag functionality similar to UIPanGesture from UIKit, but still...
First I've created two GestureStates variables and keep updating them in my DragGesture:
#GestureState private var stateOffset: CGSize = CGSize.zero
#GestureState private var isGesturePressed: Bool = false
let dragGesture = DragGesture()
.updating($stateOffset) { value, gestureState, transaction in
gestureState = CGSize(width: value.translation.width, height: value.translation.height)
}
.updating($isGesturePressed) { value, gestureState, transaction in
gestureState = true
}
By updating "isGesturePressed"(by making it true while gesture is active) I will know if the gesture is actually pressed or not. And while it pressed, I do my animation code with stateOffset:
SomeView()
.offset(someCustomOffset)
.gesture(dragGesture)
.onChange(of: stateOffset) { _ in
if isGesturePressed {
withAnimation { Your Animation Code here
}
} else {
withAnimation { Your Animation Code here for when the gesture is no longer pressed. This is basically my .onEnd function now.
}
}
As I said above. It will not provide the usually UX (e.g. Twitter side menu), cause our animation just immeadiatly ends when another finger presses the screen during the on-going animation. But at least it is no longer stuck mid-way.
There are definitely more elegant and better written workarounds for this, but I hope it may help someone.

Related

How to force an explicit animation to occur in SwiftUI

I created this View modifier which animates a change in the offset of a view based on a boolean Binding and resets itself when the animation finishes (also resetting the boolean Binding).
struct OffsetViewModifier: AnimatableModifier {
let amount: Double // amount of offset
#Binding var animate: Bool // determines if the animation should begin
private(set) var pct = 0.0 // percentage of the animation complete
var animatableData: Double {
get { animate ? 1.0 : 0.0 }
set { pct = newValue }
}
func body(content: Content) -> some View {
content
.offset(x: amount * pct)
.onChange(of: pct) { newValue in
if newValue == 1.0 { // If newValue is 1.0 that means animation is complete
withAnimation(.none) { animate = false } // When animation completes, reset binding
// Since I don't want this to cause an animation, I wrap it with withAnimation(.none)
}
}
}
}
I used this new modifier like this:
VStack {
Text(tapped.description)
Circle()
.frame(width: 100, height: 100)
.onTapGesture {
tapped = true
}
.modifier(OffsetViewModifier(amount: 50, animate: $tapped))
.animation(Animation.linear(duration: 3), value: tapped)
}
However, the withAnimation(.none) didn't work, and this view still takes 3 seconds to reset.
How do I force the explicit animation to occur and not the .linear(duration: 3) one?
Thanks in advance.
If I understood this correctly, what you should do is wrap tapped within withAnimation because the .animation is modifying the whole view like this.
tapped is binded to animate, when you change animate's value in the modifier, you're changing the value of tapped, therefore, executing the linear animation
VStack {
Text(tapped.description)
Circle()
.frame(width: 100, height: 100)
.onTapGesture {
withAnimation(.linear(duration: 3)) {
tapped = true
}
}
.modifier(OffsetViewModifier(amount: 50, animate: $tapped))
}
My personal advice is to avoid using .animation() because like I said before it will animate the entire view and will cause problems like this one

How to animate view with velocity by DragGesture in SwiftUI?

Topic: I have DragGesture on my View and I want to change offset of the View by velocity value from DragGesture.
Details: I made custom sheet view and bind DragGesture to it. When user swipe down on the view it takes offset value from gesture value.translation.height. If swipe gesture is fast enough - view dissapear from screen by slide down animation.
Problem: Animation of disappearing has static speed value, but users swipe can be more faster or slower. As a result it looks like obstacle - view start moving with speed of drag gesture, but after gesture ended it change speed to value from animation.
Code of custom sheet view:
#State var offset = 0
#State var lastDragPosition: DragGesture.Value?
VStack {
Text("Sheet")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.offset(y: offset)
.gesture(
DragGesture()
.onChanged(onDragChanged(value:))
.onEnded(onDragEnded(value:))
)
.ignoresSafeArea()
On view drag changed:
func onDragChanged(value: DragGesture.Value) {
if value.translation.height > 0 {
// Move view down by finger dragging
offset = value.translation.height
}
// Save last position for calculating velocity of gesture
lastDragPosition = value
}
On view drag ended:
func onDragEnded(value: DragGesture.Value) {
let timeDiff = value.time.timeIntervalSince(lastDragPosition!.time)
let speed: CGFloat = .init(
value.translation.height - lastDragPosition!.translation.height
) / CGFloat(timeDiff)
// If velocity of drag gesture high enough...
if speed > 300 {
// ...animate it to disappear
withAnimation(.interactiveSpring(
response: 0.27, // This value is hard coded!
dampingFraction: 0.78,
blendDuration: 0.25
)) {
offset = 800
}
return
} else {
// Else return view offset to initial state (top of the screen)
withAnimation(.easeOut(duration: 0.28)) {
offset = 0
}
}
}
I believe it is some kind of math issue. But maybe there is SwiftUI native solution?

SwiftUI unexpected animations when toggling animations In .onAppear (using a GeometryReader's size)

I have a strange animation behavior in SwiftUI. I've tried to create a minimal view that demonstrates it below.
I want to animate in three circles with a fade and a scale effect (see column "What I Expect" below). However, the size of the circles depends on the width of the view, so I'm using a GeometryReader to get that.
I want to start the animation in .onAppear(perform:), but at the time that is called, the GeometryReader hasn't set the size property yet. What I end up with is the animation you see in "Unwanted Animation 1". This is due to the frames being animated from .zero to their correct sizes.
However, whenever I try to disable the animations for the frames by adding a .animation(nil, value: size) modifier, I get an extremely strange animation behavior (see "Unwanted Animation 2"). This I don't understand at all. It somehow adds a horizontal translation to the animation which makes it look even worse. Any ideas what's happening here and how to solve this?
Strangely, everything works fine if I use an explicit animation like this:
.onAppear {
withAnimation {
show.toggle()
}
}
But I want to understand what's going on here.
Thanks!
Update:
Would replacing .onAppear(perform:) with the following code be reasonable? This would trigger only once in the lifetime of the view, right when size changes from .zero to the correct value.
.onChange(of: size) { [size] newValue in
guard size == .zero else { return }
show.toggle()
}
What I Expect
Unwanted Animation 1
Unwanted Animation 2
import SwiftUI
struct TestView: View {
#State private var show = false
#State private var size: CGSize = .zero
var body: some View {
VStack {
circle
circle
circle
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
.background {
GeometryReader { proxy in
Color.clear.onAppear { size = proxy.size }
}
}
.onAppear { show.toggle() }
}
private var circle: some View {
Circle()
.frame(width: circleSize, height: circleSize)
.animation(nil, value: size) // This make the circles animate in from the side for some reason (see "Strange Animation 2")
.opacity(show ? 1 : 0)
.scaleEffect(show ? 1 : 2)
.animation(.easeInOut(duration: 1), value: show)
}
private var circleSize: Double {
size.width * 0.2 // Everything works fine if this is a constant
}
}
struct TestView_Previews: PreviewProvider {
static var previews: some View {
TestView()
}
}
The real size is known after first layout, but .onAppear is called before, so layout (including frame change, which is animatable) goes under animation.
To solve this we need to delay state change a bit (until first layout/render finished), like
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
show.toggle()
}
}
... and this is why withAnimation also works - it actually delays closure call to next cycle.
Tested with Xcode 13 / iOS 15

SwiftUI Drag to Dismiss ScrollView

I'd like to have a drag to dismiss scroll view in SwiftUI, where if you keep dragging when it's at the top of the content (offset 0), it will instead dismiss the view.
I'm working to implement this in SwiftUI and finding it to be rather difficult. It seems like I can either recognize the DragGesture or allowing scrolling, but not both.
I need to avoid using UIViewRepresentable and solve this using pure SwiftUI or get as close as possible. Otherwise it can make developing other parts of my app difficult.
Here's an example of the problem I'm running into:
import SwiftUI
struct DragToDismissScrollView: View {
enum SeenState {
case collapsed
case fullscreen
}
#GestureState var dragYOffset: CGFloat = 0
#State var scrollYOffset: CGFloat = 0
#State var seenState: SeenState = .collapsed
var body: some View {
GeometryReader { proxy in
ZStack {
Button {
seenState = .fullscreen
} label: {
Text("Show ScrollView")
}
/*
* Works like a regular ScrollView but provides updates on the current yOffset of the content.
* Can find code for OffsetAwareScrollView in link below.
* Left out of question for brevity.
* https://gist.github.com/robhasacamera/9b0f3e06dcf27b54962ff0e077249e0d
*/
OffsetAwareScrollView { offset in
self.scrollYOffset = offset
} content: {
ForEach(0 ... 100, id: \.self) { i in
Text("Item \(i)")
.frame(maxWidth: .infinity)
}
}
.background(Color.white)
// If left at the default minimumDistance gesture isn't recognized
.gesture(DragGesture(minimumDistance: 0)
.updating($dragYOffset) { value, gestureState, _ in
// Only want to start dismissing if at the top of the scrollview
guard scrollYOffset >= 0 else {
return
}
gestureState = value.translation.height
}
.onEnded { value in
if value.translation.height > proxy.frame(in: .local).size.height / 4 {
seenState = .collapsed
} else {
seenState = .fullscreen
}
})
.offset(y: offsetForProxy(proxy))
.animation(.spring())
}
}
}
func offsetForProxy(_ proxy: GeometryProxy) -> CGFloat {
switch seenState {
case .collapsed:
return proxy.frame(in: .local).size.height
case .fullscreen:
return max(dragYOffset, 0)
}
}
}
Note: I've tried a lot solutions for the past few days (none that have worked), including:
Adding a delay to the DragGesture using the method mentioned here: https://stackoverflow.com/a/59961959/898984
Adding an empty onTapGesture {} call before the DragGesture as mentioned here: https://stackoverflow.com/a/60015111/898984
Removing the gesture and using the offset provided from the OffsetAwareScrollView when it's > 0. This doesn't work because as the ScrollView is moving down the offset decreases as the OffsetAwareScrollView catches up to the content.

SwiftUI ScrollView with Tap and Drag gesture

I'm trying to implement a ScrollView with elements which can be tapped and dragged. It should work the following way:
The ScrollView should work normally, so swiping up/down should not interfere with the gestures.
Tapping an entry should run some code. It would be great if there would be a "tap indicator", so the user knows that the tap has been registered (What makes this difficult is that the tap indicator should be triggered on touch down, not on touch up, and should be active until the finger gets released).
Long pressing an entry should activate a drag gesture, so items can be moved around.
The code below covers all of those requirements (except the tap indicator). However, I'm not sure why it works, to be specific, why I need to use .highPriorityGesture and for example can't sequence the Tap Gesture and the DragGesture with .sequenced(before: ...) (that will block the scrolling).
Also, I'd like to be notified on a touch down event (not touch up, see 2.). I tried to use LongPressGesture() instead of TapGesture(), but that blocks the ScrollView scrolling as well and doesn't even trigger the DragGesture afterwards.
Does somebody know how this can be achieved? Or is this the limit of SwiftUI? And if so, would it be possible to port UIKit stuff over to achieve this (I already tried that, too, but was unsuccessful, the content of the ScrollView should also be dynamic so porting over the whole ScrollView might be difficult)?
Thanks for helping me out!
struct ContentView: View {
var body: some View {
ScrollView() {
ForEach(0..<5, id: \.self) { i in
ListElem()
.highPriorityGesture(TapGesture().onEnded({print("tapped!")}))
.frame(maxWidth: .infinity)
}
}
}
}
struct ListElem: View {
#GestureState var dragging = CGSize.zero
var body: some View {
Circle()
.frame(width: 100, height: 100)
.gesture(DragGesture(minimumDistance: 0, coordinateSpace: .global)
.updating($dragging, body: {t, state, _ in
state = t.translation
}))
.offset(dragging)
}
}
I tried a few option and I think a combination of sequenced and simultaneously allows two gestures to run the same time. To achieve a onTouchDown I used a DragGesture with minimum distance of 0.
struct ContentView: View {
var body: some View {
ScrollView() {
ForEach(0..<5, id: \.self) { i in
ListElem()
.frame(maxWidth: .infinity)
}
}
}
}
struct ListElem: View {
#State private var offset = CGSize.zero
#State private var isDragging = false
#GestureState var isTapping = false
var body: some View {
// Gets triggered immediately because a drag of 0 distance starts already when touching down.
let tapGesture = DragGesture(minimumDistance: 0)
.updating($isTapping) {_, isTapping, _ in
isTapping = true
}
// minimumDistance here is mainly relevant to change to red before the drag
let dragGesture = DragGesture(minimumDistance: 0)
.onChanged { offset = $0.translation }
.onEnded { _ in
withAnimation {
offset = .zero
isDragging = false
}
}
let pressGesture = LongPressGesture(minimumDuration: 1.0)
.onEnded { value in
withAnimation {
isDragging = true
}
}
// The dragGesture will wait until the pressGesture has triggered after minimumDuration 1.0 seconds.
let combined = pressGesture.sequenced(before: dragGesture)
// The new combined gesture is set to run together with the tapGesture.
let simultaneously = tapGesture.simultaneously(with: combined)
return Circle()
.overlay(isTapping ? Circle().stroke(Color.red, lineWidth: 5) : nil) //listening to the isTapping state
.frame(width: 100, height: 100)
.foregroundColor(isDragging ? Color.red : Color.black) // listening to the isDragging state.
.offset(offset)
.gesture(simultaneously)
}
}
For anyone interested here is a custom scroll view that will not be blocked by other gestures as mentioned in one of the comments. As this was not possible to be solved with the standard ScrollView.
OpenScrollView for SwiftUI on Github
Credit to
https://stackoverflow.com/a/59897987/12764795
http://developer.apple.com/documentation/swiftui/composing-swiftui-gestures
https://www.hackingwithswift.com/books/ios-swiftui/how-to-use-gestures-in-swiftui

Resources