So I have a Rectangle with an added DragGesture and want to track gesture start, change and ending. The issue is when I put another finger on the Rectangle while performing the gesture, the first gesture stop calling onChange handler and does not fire onEnded handler.
Also the handlers doesn't fire for that second finger.
But if I place third finger without removing previous two the handlers for that gesture start to fire (and so on with even presses cancel out the odd ones)
Is it a bug? Is there a way to detect that the first gesture was canceled?
Rectangle()
.fill(Color.purple)
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .local)
.onChanged() { event in
self.debugLabelText = "changed \(event)"
}
.onEnded() { event in
self.debugLabelText = "ended \(event)"
}
)
Thanks to #krjw for the hint with an even number of fingers
This appears to be a problem in the Gesture framework for attempting to detect a bunch of gestures even if we didn't specify that it should be listening for them.
As the documentation is infuriatingly sparse we can only really guess at what the intended behaviour and lifecycle here is meant to be (IMHO - this seems like a bug) - but it can be worked around.
Define a struct method like
func onDragEnded() {
// set state, process the last drag position we saw, etc
}
Then combine several gestures into one to cover the bases that we didn't specify
let drag = DragGesture(minimumDistance: 0)
.onChanged({ drag in
// Do stuff with the drag - maybe record what the value is in case things get lost later on
})
.onEnded({ drag in
self.onDragEnded()
})
let hackyPinch = MagnificationGesture(minimumScaleDelta: 0.0)
.onChanged({ delta in
self.onDragEnded()
})
.onEnded({ delta in
self.onDragEnded()
})
let hackyRotation = RotationGesture(minimumAngleDelta: Angle(degrees: 0.0))
.onChanged({ delta in
self.onDragEnded()
})
.onEnded({ delta in
self.onDragEnded()
})
let hackyPress = LongPressGesture(minimumDuration: 0.0, maximumDistance: 0.0)
.onChanged({ _ in
self.onDragEnded()
})
.onEnded({ delta in
self.onDragEnded()
})
let combinedGesture = drag
.simultaneously(with: hackyPinch)
.simultaneously(with: hackyRotation)
.exclusively(before: hackyPress)
/// The pinch and rotation may not be needed - in my case I don't but
/// obviously this might be very dependent on what you want to achieve
There might be a better combo for simultaneously and exclusively but for my use case at least (which is for something similar to a joystick) this seems like it is doing the job
There is also a GestureMask type that might have done the job but there is no documentation on how that works.
One solution is to use a #GestureState property that tracks if the drag is currently running. The state will be reset to false automatically when the gesture is cancelled.
struct DragSampleView: View {
#GestureState private var dragGestureActive: Bool = false
#State var dragOffset: CGSize = .zero
var draggingView: some View {
Text("DRAG ME").padding(50).background(.red)
}
var body: some View {
ZStack {
Color.blue.ignoresSafeArea()
draggingView
.offset(dragOffset)
.gesture(DragGesture()
.updating($dragGestureActive) { value, state, transaction in
state = true
}
.onChanged { value in
print("onChanged")
dragOffset = value.translation
}.onEnded { value in
print("onEnded")
dragOffset = .zero
})
.onChange(of: dragGestureActive) { newIsActiveValue in
if newIsActiveValue == false {
dragCancelled()
}
}
}
}
private func dragCancelled() {
print("dragCancelled")
dragOffset = .zero
}
}
struct DragV_PreviewProvider: PreviewProvider {
static var previews: some View {
DragSampleView()
}
}
See https://developer.apple.com/documentation/swiftui/draggesture/updating(_:body:)
Related
I am trying to make a button that controls a counter. If you tap it, the counter goes up by one. But if you tap and hold it, I want the counter to go up by one every n seconds while you are holding it and keep doing that until you let go.
If I use code like:
#GestureState var isDetectingLongPress = false
var plusLongPress: some Gesture {
LongPressGesture(minimumDuration: 1)
.updating($isDetectingLongPress) { currentstate, gestureState, _ in
gestureState = currentstate
}
.onEnded { finished in
print("LP: finished \(finished)")
}
}
Then isDetectingLongPress becomes true after one second and then immediately becomes false. And the print in onEnded is called after 1 second as well.
I want some way to keep calling code to continuously update a counter while the finger is pressing the view -- not just once after a long press is detected.
Use instead the following combination to track continuous pressing down
Tested with Xcode 11.4 / iOS 13.4
#GestureState var isLongPress = false // will be true till tap hold
var plusLongPress: some Gesture {
LongPressGesture(minimumDuration: 1).sequenced(before:
DragGesture(minimumDistance: 0, coordinateSpace:
.local)).updating($isLongPress) { value, state, transaction in
switch value {
case .second(true, nil):
state = true
// side effect here if needed
default:
break
}
}
}
DragGesture has onChanged and onEnded.
Just add this to your View.
#State private var isPaused = false
.gesture(
DragGesture(minimumDistance: 0)
.onChanged({ _ in
isPaused = true
})
.onEnded({ _ in
isPaused = false
})
)
You can add it as a simultaneousGesture to a Button.
You can use a ViewModifier to beautify it from this article.
I'm trying to figure out how to detect that an animation has completed in SwiftUI, to be specific: a Spring() animation. My first thought was to use a GeometryReader to detect when the Circle in the example below reaches the point of origin (offset = .zero), however there is one caveat to this approach: the Spring() animation goes a little bit beyond the point where it should end and then bounces back. So the "end of the animation" would be triggered before the animation has finished.
I did some research and found another approach : SwiftUI withAnimation completion callback. However, in this solution the offset of the animated object is compared to the point of origin so it's the same problem as described above.
I could use a timer but that wouldn't be an elegant solution since the duration of the Spring() animation dynamically changes depending from where it started, so that's not the way.
In the example below, I would like that the circle gets green after the animation has finished.
Is there a way to solve this issue? Thanks for helping!
struct ContentView: View {
#State var offset: CGSize = .zero
#State var animationRunning = false
var body: some View {
VStack {
Circle()
.foregroundColor(self.animationRunning ? .red : .green)
.frame(width: 200, height: 200)
.offset(self.offset)
.gesture(
DragGesture()
.onChanged{ gesture in
self.offset = gesture.translation
}
.onEnded{_ in
self.animationRunning = true
withAnimation(.spring()){
self.offset = .zero
}
})
Spacer()
}
}
}
Default animation duration (for those animations which do not have explicit duration parameter) is usually 0.25-0.35 (independently of where it is started & platform), so in your case it is completely safe (tested with Xcode 11.4 / iOS 13.4) to use the following approach:
withAnimation(.spring()){
self.offset = .zero
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.animationRunning = false
}
}
Note: you can tune that 0.5 delay, but the difference is not remarkable for human eye.
Intended Feature:
Tap to add circle
Press "next" to create new "frame"
Drag circle to new position
Press "back" to revert circle to previous position
Issue:
As shown above, at the last part when I tap "back", the circle stays at dragged position instead of being reverted as intended.
e.g. When I add a circle to (0,0), create a new frame, drag the circle to a new location (10, 10) and tap "Back", the console prints "Frame: 0, position (0,0)". And when I tap next, it prints "Frame: 1, position (10,10)". And tapping "Back" prints (0,0) again. But the Circle position does not update.
I have tried using a class for the DraggableCircleModel struct and used #Published on its position but that didn't seem to work as well.
I provided my classes below to give some more context. Also this is only my second time posting a question here so any advice to improve my question would be appreciated. Thanks a bunch!
Back and Next buttons
Button(action: {
self.viewModel.goTo(arrangementIndex: self.viewModel.currentIndex - 1)
}) { Text("Back") }
Button(action: {
self.viewModel.goTo(arrangementIndex: self.viewModel.currentIndex + 1)
}) { Text("Next")}
View used to present the circles:
struct DisplayView: View {
#ObservedObject var viewModel: ViewModel
var body: some View {
ZStack {
Rectangle()
.overlay(
TappableView { location in
self.viewModel.addCircle(at: location)
})
ForEach(self.viewModel.currentArrangement.circles, id: \.id) { circle in
return DraggableCircleView(viewModel: self.viewModel,
circleIndex: circle.circleIndex,
diameter: 50,
offset: circle.position)
}
}
}
}
Relevant parts of the DraggableCircle View
struct DraggableCircleView: View {
init(viewModel: ViewModel, circleIndex: Int, diameter: CGFloat, offset: CGPoint) {
// Initialize
...
_viewState = /*State<CGSize>*/.init(initialValue: CGSize(width: offset.x, height: offset.y))
// **Debugging print statement**
print("\(self.viewModel.currentCircles.forEach{ print("Frame: \(self.viewModel.currentIndex), position \($0.position)") }) \n")
}
var body: some View {
let minimumLongPressDuration = 0.0
let longPressDrag = LongPressGesture(minimumDuration: minimumLongPressDuration)
.sequenced(before: DragGesture())
.updating($dragState) { value, state, transaction in
// Update circle position during drag
...
}
.onEnded { value in
guard case .second(true, let drag?) = value else { return }
// get updated position of circle after drag
...
self.viewModel.setPositionOfCircle(at: self.circleIndex, to: circlePosition)
}
return Circle()
// Styling omitted
...
.position(
x: viewState.width + dragState.translation.width,
y: viewState.height + dragState.translation.height
)
.gesture(longPressDrag)
}
Solved it. The issue lies with the .position(...) modifier of the DraggableCircle View. Previously, it was only reflecting the state of the DraggableCircle but was not updating based on the underlying ViewModel and Model.
Changing it to:
.position(
x: self.viewModel.currentCircles[circleIndex].position.x + dragState.translation.width,
y: self.viewModel.currentCircles[circleIndex].position.y + dragState.translation.height)
did the trick. This is because the position of the DraggableCircle now reflects the underlying ViewModel instead of just the state of the DraggableCircle.
I'm using SwiftUI and I want to animate a view as soon as it appears (the explicit type of animation does not matter) for demo purposes in my app.
Let's say I just want to scale up my view and then scale it down to its original size again, I need to be able to animate the view to a new state and back to the original state right afterward.
Here's the sample code of what I've tried so far:
import SwiftUI
import Combine
struct ContentView: View {
#State private var shouldAnimate = false
private var scalingFactor: CGFloat = 2
var body: some View {
Text("hello world")
.scaleEffect(self.shouldAnimate ? self.scalingFactor : 1)
.onAppear {
let animation = Animation.spring().repeatCount(1, autoreverses: true)
withAnimation(animation) {
self.shouldAnimate.toggle()
}
}
}
Obviously this does not quite fulfill my requirements, because let animation = Animation.spring().repeatCount(1, autoreverses: true) only makes sure the animation (to the new state) is being repeated by using a smooth autoreverse = true setting, which still leads to a final state with the view being scaled to scalingFactor.
So neither can I find any property on the animation which lets my reverse my animation back to the original state automatically (without me having to interact with the view after the first animation), nor did I find anything on how to determine when the first animation has actually finished, in order to be able to trigger a new animation.
I find it pretty common practice to animate some View upon its appearance, e.g. just to showcase that this view can be interacted with, but ultimately not alter the state of the view. For example animate a bounce effect on a button, which in the end sets the button back to its original state. Of course I found several solutions suggesting to interact with the button to trigger a reverse animation back to its original state, but that's not what I'm looking for.
Here is a solution based on ReversingScale animatable modifier, from this my answer
Update: Xcode 13.4 / iOS 15.5
Complete test module is here
Tested with Xcode 11.4 / iOS 13.4
struct DemoReverseAnimation: View {
#State var scalingFactor: CGFloat = 1
var body: some View {
Text("hello world")
.modifier(ReversingScale(to: scalingFactor, onEnded: {
DispatchQueue.main.async {
self.scalingFactor = 1
}
}))
.animation(.default)
.onAppear {
self.scalingFactor = 2
}
}
}
Another approach which works if you define how long the animation should take:
struct ContentView: View {
#State private var shouldAnimate = false
private var scalingFactor: CGFloat = 2
var body: some View {
Text("hello world")
.scaleEffect(self.shouldAnimate ? self.scalingFactor : 1)
.onAppear {
let animation = Animation.easeInOut(duration: 2).repeatCount(1, autoreverses: true)
withAnimation(animation) {
self.shouldAnimate.toggle()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation(animation) {
self.shouldAnimate.toggle()
}
}
}
}
}
I want a timer to start running as soon as I touch the screen, and I when I lift my finger off the screen, the timer should stop.
I can't find anything, anywhere, remotely close to resembling/implementing this simple task.
Here is a (somewhat crude but functional) example.
Note that the gesture is attached to a view that is essentially defining your tap area (in this case the whole screen minus safe areas). It could easily be the Text scaled to match the screen as well but I used a ZStack to make it more clear.
I guess the most relevant part to your question is the onChanged/onEnded closures.
struct ContentView: View {
#State var counter = 0
#State var touching = false
let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
Color.white
Text("\(counter)")
}
.onReceive(timer) { input in
guard self.touching else { return }
self.counter += 1
}
.gesture(DragGesture(minimumDistance: 0).onChanged { _ in
self.touching = true
}.onEnded { _ in
self.touching = false
})
}
}