SwiftUI: Cannot change view's size using gesture [duplicate] - ios

SwiftUI is in beta, so maybe this is a bug, but I've seen something like this working in others YouTube videos so perhaps it's not, the test is pretty simple. I'm creating a circle I can drag around on horizontally.
import SwiftUI
struct ContentView : View {
#State private var location = CGPoint.zero
var body: some View {
return Circle()
.fill(Color.blue)
.frame(width: 300, height: 300)
.gesture(
DragGesture(minimumDistance: 10)
.onChanged { value in
print("value.location")
print(value.location)
self.location = CGPoint(
x: value.location.x,
y: 0)
}
)
.offset(x: self.location.x, y: self.location.y)
}
}
#if DEBUG
struct ContentView_Previews : PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
This results in the behavior:
As far as I can tell, the value.location value in the DragGesture onChanged callback shouldn't be fluctuating between numbers like this. Looking at the logs the numbers are all over the place. Am I missing something?

DragGesture's default CoordinateSpace is .local, which is the coordinate space inside your Circle. What happens when you move the Circle? Since your finger doesn't move, the location of your finger in the Circle's geometry suddenly changes, which causes the Circle to move again. Repeat ad nauseum.
Try using CoordinateSpace.global:
DragGesture(minimumDistance: 10, coordinateSpace: .global)
You'll probably also want to use value.translation instead of value.location to avoid the initial jump when you put your finger down.

Related

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

SwiftUI drag gesture jump

Swift 5, iOS 13.x
I got this super simple app that lets me drag a view around by using the offset with SwiftUI, and when it stops I reset the offset and the position. Works well the first time, but each time I go back to drag it again it jumps when I release the button. How can I fix this ... I found something that mentioned insets? Is it related to insets?
Now I know this answers the question with some different code kontiki but I want to understand what is wrong with my solution.
struct ContentView: View {
#State var dragOffset = CGSize.zero
#State var position:CGPoint = CGPoint(x:0,y:0)
var body: some View {
VStack {
Circle()
.frame(width: 12, height: 128, alignment: .center)
}.offset(x: self.dragOffset.width, y: self.dragOffset.height)
.gesture(DragGesture(coordinateSpace: .global)
.onChanged({ ( value ) in
self.dragOffset = CGSize(width: value.translation.width, height: value.translation.height)
})
.onEnded { ( value ) in
self.dragOffset = .zero
self.position = value.location
}
).position(position)
}
}
My gut says that you are likely dealing with an issue that has to do with the SafeArea EdgeInsets. It's hard to tell. If you're interested, I did two videos on the drag gesture and the tap gesture to create views when you tap and to drag them if you tap on a previously generated view:
https://www.youtube.com/watch?v=x3mzLhPfLaE&list=PLEHYNiA7SPcP9zzQYHAN1T27w4cwG_f5i
https://www.youtube.com/watch?v=V0F5ZuJgzoY&list=PLEHYNiA7SPcP9zzQYHAN1T27w4cwG_f5i&index=2

SwiftUI: Error in dragging logic or is this a bug?

SwiftUI is in beta, so maybe this is a bug, but I've seen something like this working in others YouTube videos so perhaps it's not, the test is pretty simple. I'm creating a circle I can drag around on horizontally.
import SwiftUI
struct ContentView : View {
#State private var location = CGPoint.zero
var body: some View {
return Circle()
.fill(Color.blue)
.frame(width: 300, height: 300)
.gesture(
DragGesture(minimumDistance: 10)
.onChanged { value in
print("value.location")
print(value.location)
self.location = CGPoint(
x: value.location.x,
y: 0)
}
)
.offset(x: self.location.x, y: self.location.y)
}
}
#if DEBUG
struct ContentView_Previews : PreviewProvider {
static var previews: some View {
ContentView()
}
}
#endif
This results in the behavior:
As far as I can tell, the value.location value in the DragGesture onChanged callback shouldn't be fluctuating between numbers like this. Looking at the logs the numbers are all over the place. Am I missing something?
DragGesture's default CoordinateSpace is .local, which is the coordinate space inside your Circle. What happens when you move the Circle? Since your finger doesn't move, the location of your finger in the Circle's geometry suddenly changes, which causes the Circle to move again. Repeat ad nauseum.
Try using CoordinateSpace.global:
DragGesture(minimumDistance: 10, coordinateSpace: .global)
You'll probably also want to use value.translation instead of value.location to avoid the initial jump when you put your finger down.

Resources