SwiftUI ScrollView with Tap and Drag gesture - ios

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

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

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

How to use DispatchQueue to make a view appear and disappear after some time?

I want to make a view appear, do something, then disappear with DispatchQueue. I want to toggle the showHand to show the hand then toggleHandAnimation to make the hand move left and right. After some time e.g. 5 seconds, I want that hand to disappear.
I have an implementation below which seems to be working on the build but it seems like there would be a better way. I have the code below.
What is the guidance on implementing views where you want to run multiple tasks at different points in time async?
import SwiftUI
struct ContentView: View {
#State var toggleHandAnimation: Bool = false
#State var showHand: Bool = true
var body: some View {
if showHand {
Image(systemName: "hand.draw")
.font(Font.system(size: 100))
.offset(x: toggleHandAnimation ? -40 : 0, y: 0)
.animation(Animation.easeInOut(duration: 0.6).repeatCount(5))
.onAppear {
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1, execute: {
toggleHandAnimation.toggle()
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5, execute: {
showHand.toggle()
})
})
}
}
}
}
I think this is what you are looking for:
struct ContentView: View {
#State var toggleHandAnimation: Bool = false
#State var showHand: Bool = true
var body: some View {
ZStack {
Color(uiColor: .systemBackground)
if showHand {
Image(systemName: "hand.draw")
.font(Font.system(size: 100))
.offset(x: toggleHandAnimation ? -40 : 0, y: 0)
.onAppear {
withAnimation(Animation.easeInOut(duration: 0.5).repeatCount(10)) {
toggleHandAnimation.toggle()
}
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5, execute: {
showHand.toggle()
})
}
}
}
}
}
A few things, you should read up on intrinsic vs. extrinsic animations. You were trying to use an intrinsic one, but for a situation like this, it should be extrinsic(i.e., withAnimation(). Also, .animation(_ animation:) has been deprecated because it does not work well. You should be using .animation(_ animation:, value:).
Also, I aligned the time so that the animation ends and the hand disappears. The nice thing with using the withAnimation() is that you can animate multiple things occur with the same animation.
As you can see, you didn't need to nest theDispatchQueues. You only needed the one to make the view disappear.
Lastly, I put this all in a ZStack and set a color on that, so that there was always a view to return. The reason is, this will crash if there is suddenly no view being returned. That is what would happen at the end of the animation with out the color. Obviously, you can have whatever view you want, that was just an example.

SwiftUI DragGesture onEnded does not fire in some cases

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.

SwiftUI TapGesture and LongPressGesture in ScrollView with tap indication not working

I'm struggling to implement TapGesture and LongPressGesture simultaneously in a ScrollView. Everything works fine with .onTapGesture and .onLongPressGesture, but I want that the opacity of the button gets reduced when the user taps on it, like a normal Button().
However, a Button() doesn't have an option to do something on a long press for whatever reason. So I tried to use .gesture(LongPressGesture() ... ). This approach works and shows the tap indication. Unfortunately, that doesn't work with a ScrollView: you can't scroll it anymore!
So I did some research and I found out that there has to be a TapGesture before the LongPressGesture so ScrollView works properly. That's the case indeed but then my LongPressGesture doesn't work anymore.
Hope somebody has a solution...
struct ContentView: View {
var body: some View {
ScrollView(.horizontal){
HStack{
ForEach(0..<5){ _ in
Button()
}
}
}
}
}
struct Button: View{
#GestureState var isDetectingLongPress = false
#State var completedLongPress = false
var body: some View{
Circle()
.foregroundColor(.red)
.frame(width: 100, height: 100)
.opacity(self.isDetectingLongPress ? 0 : 1)
// That works, but there is no indication for the user that the UI recognized the gesture
// .onTapGesture {
// print("Tapped!")
// }
// .onLongPressGesture(minimumDuration: 0.5){
// print("Long pressed!")
// }
// The approach (*) shows the press indication, but the ScrollView is stuck because there is no TapGesture
// If I add a dummy TapGesture, the LongPressGesture won't work anymore but now the ScrollView works as expected
//.onTapGesture {}
// (*)
.gesture(LongPressGesture()
.updating(self.$isDetectingLongPress) { currentstate, gestureState,
transaction in
gestureState = currentstate
}
.onEnded { finished in
self.completedLongPress = finished
}
)
}
}
I've tried many combinations of trying onTapGesture + LongPressGesture + custom timings and animations and many work /almost/ but leave minor annoyances. This is what I found that works perfectly. Tested on iOS 13.6.
With this solution your scroll view still scrolls, you get the button depression animation, long pressing on the button works too.
struct MainView: View {
...
Scrollview {
RowView().highPriorityGesture(TapGesture()
.onEnded { _ in
// The function you would expect to call in a button tap here.
})
}
}
struct RowView: View {
#State var longPress = false
var body: some View {
Button(action: {
if (self.longPress) {
self.longPress.toggle()
} else {
// Normal button code here
}
}) {
// Buttons LaF here
}
// RowView code here.
.simultaneousGesture(LongPressGesture(minimumDuration: 0.5)
.onEnded { _ in
self.longPress = true
})
}
}

Resources