How to keep the animation running on TabView View change? - swift

I've recently found that when an animation is running forever and the View is switched through the TabView, the animation state is lost. While this is expected behaviour, I'd like to understand if there is a way to keep the animation running when back to the animated View, after the TabView View change?
Here's a quick example, showing the behaviour in SwiftUI:
import SwiftUI
class MyState: ObservableObject {
#Published var animate: Bool = false
}
struct ContentView: View {
var body: some View {
TabView {
AnimationView()
.tabItem {
Image(systemName: "eye")
}
Text("Some View")
.tabItem {
Image(systemName: "play")
}
}
}
}
struct AnimationView: View {
#EnvironmentObject var myState: MyState
var body: some View {
VStack {
Text(self.myState.animate ? "Animation running" : "Animation stopped")
.padding()
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 12.0))
.foregroundColor(Color.black)
.opacity(0.3)
Circle()
.trim(
from: 0,
to: self.myState.animate ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 12.0, lineCap: .round, lineJoin: .round)
)
.animation(
Animation
.linear(duration: self.myState.animate ? 1.0 : 0.0)
.repeatForever(autoreverses: false)
)
}.frame(width: 200, height: 200)
Button(action: {
self.myState.animate.toggle()
}) {
Text("Toggle animation")
.padding()
}
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.environmentObject(MyState())
}
}
Below you can see the behaviour:
What I'd expect to control, is get back to the View where the animation runs and see the state it'd be at the current time - this is, imagining that the animation is looping when switching Views and back to the View find it in the correct point in "running time" or "real-time".
Obs: The State is handled in MyState an EnvironmentObject and it's not local (#State) in the View

So I added an extra parameter to your MyState to store the current animation status when the user switches between tabs. Below is the updated code. I have also added comments in the code for better understanding.
class MyState: ObservableObject {
#Published var animate: Bool = false
var isAnimationActive = false // Store animate status when user switches between tabs
}
struct AnimationView: View {
#EnvironmentObject var myState: MyState
var body: some View {
VStack {
Text(self.myState.animate ? "Animation running" : "Animation stopped")
.padding()
ZStack {
Circle()
.stroke(style: StrokeStyle(lineWidth: 12.0))
.foregroundColor(Color.black)
.opacity(0.3)
Circle()
.trim(
from: 0,
to: self.myState.animate ? 1.0 : 0.0
)
.stroke(
style: StrokeStyle(lineWidth: 12.0, lineCap: .round, lineJoin: .round)
)
.animation(
Animation
.linear(duration: self.myState.animate ? 1.0 : 0.0)
.repeatForever(autoreverses: false)
)
}.frame(width: 200, height: 200)
Button(action: {
self.myState.animate.toggle()
}) {
Text("Toggle animation")
.padding()
}
}
.onAppear(perform: {
if self.myState.isAnimationActive {
/*switching the animation inside withAnimation block is important*/
withAnimation {
self.myState.animate = true
}
}
})
.onDisappear {
self.myState.isAnimationActive = self.myState.animate
/*The property needs to be set to false as SwiftUI compare the
views before re-rendering them*/
self.myState.animate = false
}
}
}

After some tests, the best solution I found this far is to follow a few rules I've stated below. Have in mind that this is only useful if you can track the exact animation position in time. So please check the process bellow:
1) Stop the animation .onDisappear
The reason is that state changes between views that have animated elements will cause unexpected behaviour, for example, the element positioning be completely off and so on.
self.myState.animate = false
2) Keep track of progress
My use-case is a long computation that starts from 0 and ends in X and MyState has methods that provide me with the current position in the computation. This value is then is calculated in the range* 0 to 1, so that it's possible to put it into the SwiftUI Animation.
3) Jumpstart to a specific time
This part is not supported yet in SwiftUI Animation, but let's say that you have an animation that takes 5 seconds, you're at 50% playtime, so you'd jump to 2.5 seconds where you'd want to start play.
An implementation for this, if you've experienced Javascript and way before, ActionScript is GSAP doc for seek (https://greensock.com/docs/v2/Core/Animation/seek())
4) Re-trigger the animation
We started by stopping the animation by changing the state, to make SwiftUI start the animation all we need to do at this stage is to change the state once again
self.myState.animate = true
There might be other ways to keep the animation running, but at the time of writing haven't found it documented, so there was a need to "start animation from any point".

Related

Can you control the progress of a SwiftUI animation

Is it possible to control the progress of withAnimation
Ideal API would look something like this
withAnimation(progress: percentDragged) { showSecondView.toggle() }
Here's a full code example of the above line in context.
The goal is to smoothly slide between different app states with a user controlled gesture.
struct ContentView: View {
#State var showSecondView = false
var body: some View {
VStack {
ZStack {
if !showSecondView {
Text("First view")
.padding()
.background(Color.blue)
.transition(.slide)
} else {
Text("Second view")
.padding()
.background(Color.red)
.transition(.slide)
}
}
}
.gesture(
DragGesture()
.onChanged { value in
let percentDragged = value.translation.width / UIScreen.main.bounds.width
// Here I want to gradually slide (interpolate) between the two states
// Ideal API would look like this:
withAnimation(progress: percentDragged) { showSecondView.toggle() }
}
)
}
}
Yes.. I know I can trigger an animation whenever I want (like this). but that's not what I'm looking for! I want to control the progress
if percentDragged > 0.5 {
withAnimation { showSecondView.toggle() }
}
Yes.. I also know I could manually store a #State var offset: Double and control my own slide.
This just gets nasty when my app state is tied to tons of different visual changes. Views appearing/disappearing, alignments shifting, etc.

SwiftUI Animation Bug inside Scroll View

I am relatively new to SwiftUI and I am trying to implement animated loader to my app. It works fine until I scroll my content inside scroll view down and then move back to trigger pull to refresh. Circles started to jump though they should only increase and decrease their size one by one.
My code is
struct ActivityIndicator: View {
var isShowing: Bool
#State private var shouldAnimate = false
init(isShowing: Bool) {
self.isShowing = isShowing
}
var body: some View {
HStack(alignment: .center) {
Circle()
.fill(Color.mainAccent)
.frame(width: 20, height: 20)
.scaleEffect(shouldAnimate ? 1.0 : 0.5)
.animation(Animation.easeInOut(duration: 0.5).repeatForever())
Circle()
.fill(Color.mainAccent)
.frame(width: 20, height: 20)
.scaleEffect(shouldAnimate ? 1.0 : 0.5)
.animation(Animation.easeInOut(duration: 0.5).repeatForever().delay(0.3))
Circle()
.fill(Color.mainAccent)
.frame(width: 20, height: 20)
.scaleEffect(shouldAnimate ? 1.0 : 0.5)
.animation(Animation.easeInOut(duration: 0.5).repeatForever().delay(0.6))
}
.opacity(self.isShowing ? 1 : 0)
.onAppear {
self.shouldAnimate = true
}
}
}
I have read few articles related to my case and it seems that I might had to use withAnimation (Explicit) instead .animation (Implicit) but I can't make it work properly.
Btw I connect my Activity Indicator to the scrollView using Loading View modifier and it looks like this
struct LoadingView: ViewModifier {
#Binding var isShowing: Bool
func body(content: Content) -> some View {
ZStack(alignment: .center) {
content
.disabled(self.isShowing)
.blur(radius: self.isShowing ? 3 : 0)
ActivityIndicator(isShowing: isShowing)
}
}
}
Any ideas and suggestions are appreciated I am really stuck. Thanks
Try to link all your animations to related state, like
var body: some View {
HStack(alignment: .center) {
Circle()
.fill(Color.mainAccent)
.frame(width: 20, height: 20)
.scaleEffect(shouldAnimate ? 1.0 : 0.5)
.animation(Animation.easeInOut(duration: 0.5).repeatForever(),
value: shouldAnimate) // << here !!
If you have any problems with the previous answer, try removing the .animation and replace it with withAnimation:
https://developer.apple.com/documentation/swiftui/withanimation(_:_:)
The advantage of that is that it does not affect other animations.

Can the changes of the ".disabled" modifier of SwiftUI be animated?

I think that the View.disabled(_:) modifier changes the .foregroundColor of a View (besides enabling / disabling interactions with it). Could the color change be animated?
I know it's pretty easy to animate say .opacity changes with implicit animations (e.g.: .animation(.default)). However the same won't work with .disabled.
Please consider the following code:
import SwiftUI
struct SomeButton: View {
#State var isDisabled = false
#State var isHidden = false
var body: some View {
VStack(spacing: 30) {
Spacer()
Button(action: {}) {
Image(systemName: "pencil.circle.fill")
.resizable()
}
.frame(width: 80, height: 80)
.disabled(isDisabled) // 👈🏻 Will not animate at all.
.opacity(isHidden ? 0 : 1) // 👈🏻 Will animate just fine.
.animation(.default) // 👈🏻 Set to use implicit animations.
VStack(spacing: 10) {
Button(action: {
self.isHidden.toggle()
}) {
Text("Hide")
}
Button(action: {
self.isDisabled.toggle()
}) {
Text("Disable")
}
}
Spacer()
}
}
}
// MARK: - Preview
#if DEBUG
struct SomeButton_Previews: PreviewProvider {
static var previews: some View {
SomeButton()
}
}
#endif
This produces the following result, which presents a smooth opacity transition:
... on the other hand the enabled / disabled color transition is binary. It doesn't look that good.
My current workaround is to change the opacity of a view based on its enabled/disabled state.
So for example if a view is disabled, the opacity is 0.6. If enabled, the opacity goes back to 1.0.
But it feels wrong. There must be another way.
The possible solution can be to combine with .colorMultipy, of course the disabled color should be experimentally fit, but this gives common effect animatable
.disabled(isDisabled)
.colorMultiply(isDisabled ? // 👈🏻 animatable
Color.gray /* to be selected to fit*/ : .white)
.animation(.default, value: isDisabled)

SwiftUI: Stop an Animation that Repeats Forever

I would like to have a 'badge' of sorts on the screen and when conditions are met, it will bounce from normal size to bigger and back to normal repeatedly until the conditions are no longer met. I cannot seem to get the badge to stop 'bouncing', though. Once it starts, it's unstoppable.
What I've tried:
I have tried using a few animations, but they can be classified as animations that use 'repeatForever' to achieve the desired effect and those that do not. For example:
Animation.default.repeatForever(autoreverses: true)
and
Animation.spring(response: 1, dampingFraction: 0, blendDuration: 1)(Setting damping to 0 makes it go forever)
followed by swapping it out with .animation(nil). Doesn't seem to work. Does anyone have any ideas? Thank you so very much ahead of time! Here is the code to reproduce it:
struct theProblem: View {
#State var active: Bool = false
var body: some View {
Circle()
.scaleEffect( active ? 1.08: 1)
.animation( active ? Animation.default.repeatForever(autoreverses: true): nil )
.frame(width: 100, height: 100)
.onTapGesture {
self.active = !self.active
}
}
}
I figured it out!
An animation using .repeatForever() will not stop if you replace the animation with nil. It WILL stop if you replace it with the same animation but without .repeatForever(). ( Or alternatively with any other animation that comes to a stop, so you could use a linear animation with a duration of 0 to get a IMMEDIATE stop)
In other words, this will NOT work: .animation(active ? Animation.default.repeatForever() : nil)
But this DOES work: .animation(active ? Animation.default.repeatForever() : Animation.default)
In order to make this more readable and easy to use, I put it into an extension that you can use like this: .animation(Animation.default.repeat(while: active))
Here is an interactive example using my extension you can use with live previews to test it out:
import SwiftUI
extension Animation {
func `repeat`(while expression: Bool, autoreverses: Bool = true) -> Animation {
if expression {
return self.repeatForever(autoreverses: autoreverses)
} else {
return self
}
}
}
struct TheSolution: View {
#State var active: Bool = false
var body: some View {
Circle()
.scaleEffect( active ? 1.08: 1)
.animation(Animation.default.repeat(while: active))
.frame(width: 100, height: 100)
.onTapGesture {
self.active.toggle()
}
}
}
struct TheSolution_Previews: PreviewProvider {
static var previews: some View {
TheSolution()
}
}
As far as I have been able to tell, once you assign the animation, it will not ever go away until your View comes to a complete stop. So if you have a .default animation that is set to repeat forever and auto reverse and then you assign a linear animation with a duration of 4, you will notice that the default repeating animation is still going, but it's movements are getting slower until it stops completely at the end of our 4 seconds. So we are animating our default animation to a stop through a linear animation.
How about using a Transaction
In the code below, I turn off or turn on the animation depending on the state of the active
Warning: Be sure to use withAnimation otherwise nothing will work
#State var active: Bool = false
var body: some View {
Circle()
.scaleEffect(active ? 1.08: 1)
.animation(Animation.default.repeatForever(autoreverses: true), value: active)
.frame(width: 100, height: 100)
.onTapGesture {
useTransaction()
}
}
func useTransaction() {
var transaction = Transaction()
transaction.disablesAnimations = active ? true : false
withTransaction(transaction) {
withAnimation {
active.toggle()
}
}
}
After going through many things, I found out something that works for me. At the least for the time being and till I have time to figure out a better way.
struct WiggleAnimation<Content: View>: View {
var content: Content
#Binding var animate: Bool
#State private var wave = true
var body: some View {
ZStack {
content
if animate {
Image(systemName: "minus.circle.fill")
.foregroundColor(Color(.systemGray))
.offset(x: -25, y: -25)
}
}
.id(animate) //THIS IS THE MAGIC
.onChange(of: animate) { newValue in
if newValue {
let baseAnimation = Animation.linear(duration: 0.15)
withAnimation(baseAnimation.repeatForever(autoreverses: true)) {
wave.toggle()
}
}
}
.rotationEffect(.degrees(animate ? (wave ? 2.5 : -2.5) : 0.0),
anchor: .center)
}
init(animate: Binding<Bool>,
#ViewBuilder content: #escaping () -> Content) {
self.content = content()
self._animate = animate
}
}
Use
#State private var editMode = false
WiggleAnimation(animate: $editMode) {
VStack {
Image(systemName: image)
.resizable()
.frame(width: UIScreen.screenWidth * 0.1,
height: UIScreen.screenWidth * 0.1)
.padding()
.foregroundColor(.white)
.background(.gray)
Text(text)
.multilineTextAlignment(.center)
.font(KMFont.tiny)
.foregroundColor(.black)
}
}
How does it work?
.id(animate) modifier here does not refresh the view but just replaces it with a new one, so it is back to its original state.
Again this might not be the best solution but it works for my case.
There is nothing wrong in your code, so I assume it is Apple's defect. It seems there are many with implicit animations (at least with Xcode 11.2). Anyway...
I recommend to consider alternate approach provided below that gives expected behaviour.
struct TestAnimationDeactivate: View {
#State var active: Bool = false
var body: some View {
VStack {
if active {
BlinkBadge()
} else {
Badge()
}
}
.frame(width: 100, height: 100)
.onTapGesture {
self.active.toggle()
}
}
}
struct Badge: View {
var body: some View {
Circle()
}
}
struct BlinkBadge: View {
#State private var animating = false
var body: some View {
Circle()
.scaleEffect(animating ? 1.08: 1)
.animation(Animation.default.repeatForever(autoreverses: true))
.onAppear {
self.animating = true
}
}
}
struct TestAnimationDeactivate_Previews: PreviewProvider {
static var previews: some View {
TestAnimationDeactivate()
}
}
Aspid comments on the accepted solution that an Xcode update broke it. I was struggling with a similar problem while playing around with an example from Hacking with Swift, and
.animation(active ? Animation.default.repeatForever() : Animation.default)
was not working for me either on Xcode 13.2.1. The solution I found was to encapsulate the animation in a custom ViewModifier. The code below illustrates this; the big button toggles between active and inactive animations.
`
struct ContentView: View {
#State private var animationAmount = 1.0
#State private var animationEnabled = false
var body: some View {
VStack {
Button("Tap Me") {
// We would like to stop the animation
animationEnabled.toggle()
animationAmount = animationEnabled ? 2 : 1
}
.onAppear {
animationAmount = 2
animationEnabled = true
}
.padding(50)
.background(.red)
.foregroundColor(.white)
.clipShape(Circle())
.overlay(
Circle()
.stroke(.red)
.scaleEffect(animationAmount)
.opacity(2 - animationAmount)
)
.modifier(AnimatedCircle(animationAmount: $animationAmount, animationEnabled: $animationEnabled))
}
}
}
struct AnimatedCircle: ViewModifier {
#Binding var animationAmount: Double
#Binding var animationEnabled: Bool
func body(content: Content) -> some View {
if animationEnabled {
return content.animation(.easeInOut(duration: 2).repeatForever(autoreverses: false),value: animationAmount)
}
else {
return content.animation(.easeInOut(duration: 0),value: animationAmount)
}
}
}
`
It may not be the best conceivable solution, but it works. I hope it helps somebody.

SwiftUI View struct without reloading

I would like to create a starry background view in SwiftUI that has its stars located randomly using Double.random(), but does not reinitialise them and move them when the parent view reloads its var body.
struct ContentView: View {
#State private var showButton = true
var body: some View {
ZStack {
BackgroundView()
if showButton {
Button("Tap me"){
self.showButton = false
}
}
}
}
}
I define my background view as such.
struct BackgroundView: View {
var body: some View {
ZStack {
GeometryReader { geometry in
Color.black
ForEach(0..<self.getStarAmount(using: geometry), id: \.self){ _ in
Star(using: geometry)
}
LinearGradient(gradient: Gradient(colors: [.purple, .clear]), startPoint: .bottom, endPoint: .top)
.opacity(0.7)
}
}
}
func getStarAmount(using geometry: GeometryProxy) -> Int {
return Int(geometry.size.width*geometry.size.height/100)
}
}
A Star is defined as
struct Star: View {
let pos: CGPoint
#State private var opacity = Double.random(in: 0.05..<0.4)
init(using geometry: GeometryProxy) {
self.pos = CGPoint(x: Double.random(in: 0..<Double(geometry.size.width)), y: Double.random(in: 0..<Double(geometry.size.height)))
}
var body: some View {
Circle()
.foregroundColor(.white)
.frame(width: 2, height: 2)
.scaleEffect(CGFloat(Double.random(in: 0.25...1)))
.position(pos)
.opacity(self.opacity)
.onAppear(){
withAnimation(Animation.linear(duration: 2).delay(Double.random(in: 0..<6)).repeatForever()){
self.opacity = self.opacity+0.5
}
}
}
}
As one can see, a Star heavily relies on random values, for both its animation (to create a 'random' twinkling effect) as well as its position. When the parent view of the BackgroundView, ContentView in this example, gets redrawn however, all Stars get reinitialised, their position values change and they move across the screen. How can this best be prevented?
I have tried several approaches to prevent the positions from being reinitialised. I can create a struct StarCollection as a static let of BackgroundView, but this is quite cumbersome. What is the best way to go about having a View dependent on random values (positions), only determine those positions once?
Furthermore, the rendering is quite slow. I have attempted to call .drawingGroup() on the ForEach, but this then seems to interfere with the animation's opacity interpolation. Is there any viable way to speed up the creation / re-rendering of a view with many Circle() elements?
The slowness coming out from the overcomplicated animations setting in onAppear, you only need the self.opacity state change to initiate the animation, so please move animation out and add to the shape directly.
Circle()
.foregroundColor(.white)
.frame(width: 2, height: 2)
.scaleEffect(CGFloat(Double.random(in: 0.25...1)))
.position(pos)
.opacity(self.opacity)
.animation(Animation.linear(duration: 0.2).delay(Double.random(in: 0..<6)).repeatForever())
.onAppear(){
// withAnimation{ //(Animation.linear(duration: 2).delay(Double.random(in: 0..<6)).repeatForever()){
self.opacity = self.opacity+0.5
// }
}