SwiftUI animation and subsequent reverse animation to original state - ios

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()
}
}
}
}
}

Related

How to dynamically hide the status bar and the home indicator in SwiftUI?

I'm working on a fractal clock app that displays animated fractals based on clock hands in its main view. I want users of my app to be able to enter a fullscreen mode where all unnecessary UI is temporarily hidden and only the animation remains visible. The behavior I'm looking for is similar to Apple's Photos app where one can tap on the currently displayed image so that the navigation bar, the bottom bar, the status bar and the home indicator fade out until the image is tapped again.
Hiding the navigation bar and the status bar was as easy as finding the right view modifiers to pass the hiding condition to. But as far as I know it is currently not possible in SwiftUI to hide the home indicator without bringing in UIKit.
On Stack Overflow I found this solution by Casper Zandbergen for conditionally hiding the home indicator and adopted it for my project.
It works but sadly in comes with an unacceptable side effect: The main view now no longer extends under the status bar and the home indicator which has two implications:
When hiding the status bar with the relevant SwiftUI modifier the space for the main view grows by the height of the hidden status bar interrupting the display of the fractal animation.
In place of the hidden home indicator always remains a black bottom bar preventing the fullscreen presentation of the main view.
I hope somebody with decent UIKit experience can help me with this. Please keep in mind that I'm a beginner in SwiftUI and that I have basically no prior experience with UIKit. Thanks in advance!
import SwiftUI
struct ContentView: View {
#StateObject var settings = Settings()
#State private var showSettings = false
#State private var hideUI = false
var body: some View {
NavigationView {
GeometryReader { proxy in
let radius = 0.5 * min(proxy.size.width, proxy.size.height) - 20
FractalClockView(settings: settings, clockRadius: radius)
}
.ignoresSafeArea(.all)
.toolbar {
Button(
action: { showSettings.toggle() },
label: { Label("Settings", systemImage: "slider.horizontal.3") }
)
.popover(isPresented: $showSettings) { SettingsView(settings: settings) }
}
.navigationBarTitleDisplayMode(.inline)
.onTapGesture {
withAnimation { hideUI.toggle() }
}
.navigationBarHidden(hideUI)
.statusBar(hidden: hideUI)
.prefersHomeIndicatorAutoHidden(hideUI) // Code by Amzd
}
.navigationViewStyle(.stack)
}
}
I was able to solve the problem with the SwiftUI view not extending beyond the safe area insets for the status bar and the home indicator by completely switching to a storyboard based project template and embedding my views through a custom UIHostingController as described in this solution by Casper Zandbergen.
Before I was re-integrating the hosting controller into the SwiftUI view hierarchy by wrapping it with a UIViewRepresentable instance, which must have caused the complications in handling the safe area.
By managing the whole app through the custom UIHostingController subclass it was even easier to get the hiding of the home indicator working. As much as I love SwiftUI I had to realize that, with its current limitations, UIKit was the better option here.
Final code (optimized version of the solution linked above):
ViewController.swift
import SwiftUI
import UIKit
struct HideUIPreferenceKey: PreferenceKey {
static var defaultValue: Bool = false
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = nextValue() || value
}
}
extension View {
func userInterfaceHidden(_ value: Bool) -> some View {
preference(key: HideUIPreferenceKey.self, value: value)
}
}
class ViewController: UIHostingController<AnyView> {
init() {
weak var vc: ViewController? = nil
super.init(
rootView: AnyView(
ContentView()
.onPreferenceChange(HideUIPreferenceKey.self) {
vc?.userInterfaceHidden = $0
}
)
)
vc = self
}
#objc required dynamic init?(coder: NSCoder) {
weak var vc: ViewController? = nil
super.init(
coder: coder,
rootView: AnyView(
ContentView()
.onPreferenceChange(HideUIPreferenceKey.self) {
vc?.userInterfaceHidden = $0
}
)
)
vc = self
}
private var userInterfaceHidden = false {
didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() }
}
override var prefersStatusBarHidden: Bool {
userInterfaceHidden
}
override var prefersHomeIndicatorAutoHidden: Bool {
userInterfaceHidden
}
}

SwiftUI perform action after Spring() animation has completed

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.

Detect DragGesture cancelation in SwiftUI

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

What's the SwiftUI equivalent of touchesBegan(_:with:)

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

Activating an animation in SwiftUI automatically based on saved state

I'm trying to write a view that displays 3 buttons, I cannot get the animation to start on load.
When a button is tapped, I want it to animate until either:
it is tapped a second time
another of the 3 buttons is tapped
I have got the code working using a #Environment object to store the running state. It toggles between the 3 buttons nicely:
The code for this is here:
struct ContentView : View {
#EnvironmentObject var model : ModelClockToggle
var body: some View {
VStack {
ForEach(0...2) { timerButton in
ActivityBreak(myId: timerButton)
.padding()
}
}
}
}
import SwiftUI
struct ActivityBreak : View {
var myId: Int
#EnvironmentObject var model : ModelClockToggle
let anim1 = Animation.basic(duration: 1.0, curve: .easeInOut).repeatCount(Int.max)
let noAni = Animation.basic(duration: 0.2, curve: .easeInOut).repeatCount(0)
var body: some View {
return Circle()
.foregroundColor(.red)
.scaleEffect(self.model.amIRunning(clock: self.myId) ? 1.0 : 0.6)
.animation( self.model.amIRunning(clock: self.myId) ? anim1 : noAni )
.tapAction {
self.model.toggle(clock: self.myId)
}
}
}
For completeness, the model is:
import Foundation
import SwiftUI
import Combine
class ModelClockToggle: BindableObject {
let didChange = PassthroughSubject<ModelClockToggle, Never>()
private var clocksOn: [Bool] = [false,false,false]
init() {
clocksOn = []
clocksOn.append(UserDefaults.standard.bool(forKey: "toggle1"))
clocksOn.append(UserDefaults.standard.bool(forKey: "toggle2"))
clocksOn.append(UserDefaults.standard.bool(forKey: "toggle3"))
debugPrint(clocksOn)
}
func toggle(clock: Int) {
debugPrint(#function)
if clocksOn[clock] {
clocksOn[clock].toggle()
} else {
clocksOn = [false,false,false]
clocksOn[clock].toggle()
}
saveState()
didChange.send(self)
}
func amIRunning(clock: Int) -> Bool {
debugPrint(clocksOn)
return clocksOn[clock]
}
private func saveState() {
UserDefaults.standard.set(clocksOn[0], forKey: "toggle1")
UserDefaults.standard.set(clocksOn[1], forKey: "toggle2")
UserDefaults.standard.set(clocksOn[2], forKey: "toggle3")
}
}
How do I make the repeating animation start at load time based on the #Environment object I have passed into the View? Right now SwiftUI only seems to consider state change once the view is loaded.
I tried adding an .onAppear modifier, but that meant I had to use a different animator - which had very strange effects.
help gratefully received.
In your example, you are using an implicit animation. Those are animations that will look for changes on any animatable parameter such as size, position, opacity, color, etc. When SwiftUI detects any change, it will animate it.
In your specific case, Circles are normally scaled to 0.6 while not active, and 1.0 when active. Changes between inactive and active states, make your Circle to alter the scale, and this changes are animated in a loop.
However, your problem is that a Circle that is initially loaded at a 1.0 scale (because the model says it is active), will not detect a change: It starts at 1.0 and remains at 1.0. So there is nothing to animate.
In your comments you mention a solution, that involves having the model postpone loading the state of the Circle states. That way, your view is created first, then you ask the model to load states and then there is a change in your view that can be animated. That works, however, there is a problem with that.
You are making your model's behaviour dependent on the view. When it should really be the other way around. Suppose you have two instances of your view on the screen. Depending on timing, one will start fine, but the other will not.
The way to solve it, is making sure the entire logic is handle by the view itself. What you want to accomplish, is that your Circle always gets created with a scale of 0.6. Then, you check with the model to see if the Circel should be active. If so, you immediately change it to 1.0. This way you guarantee the view's animation.
Here is a possible solution, that uses a #State variable named booted to keep track of this. Your Circles will always be created with a scale of 0.6, but once the onAppear() method is call, the view will scale to 1.0 (if active), producing the corresponding animation.
struct ActivityBreak : View {
var myId: Int
#EnvironmentObject var model : ModelClockToggle
#State private var booted: Bool = false
// Beta 4
let anim1 = Animation.easeInOut(duration: 1.0).repeatCount(Int.max)
let noAni = Animation.easeInOut(duration: 0.2).repeatCount(0)
// Beta 3
// let anim1 = Animation.basic(duration: 1.0, curve: .easeInOut).repeatCount(Int.max)
// let noAni = Animation.basic(duration: 0.2, curve: .easeInOut).repeatCount(0)
var body: some View {
return Circle()
.foregroundColor(.red)
.scaleEffect(!booted ? 0.6 : self.model.amIRunning(clock: self.myId) ? 1.0 : 0.6)
.animation( self.model.amIRunning(clock: self.myId) ? anim1 : noAni )
.tapAction {
self.model.toggle(clock: self.myId)
}
.onAppear {
self.booted = true
}
}
}

Resources