Viks Viks
Viks Viks

Reputation: 85

How to stop animation in the current state in SwiftUI

Hello i wrote a simple code where i created an animation with a Circle which changes its position. I want the animation to stop when i tap on that Circle, but it doesn't stop, no matter that i set the animation to nil. Maybe my approach is wrong in some way. I will be happy if someone helps.

struct ContentView: View {
    @State private var enabled = true
    @State private var offset  = 0.0
    
    var body: some View {

        Circle()
            .frame(width: 100, height: 100)
            .offset(y: CGFloat(self.offset))
            .onAppear {
                withAnimation(self.enabled ? .easeIn(duration: 5) : nil) {
                    self.offset = Double(-UIScreen.main.bounds.size.height + 300)
                }
            }
            .onTapGesture {
                self.enabled = false
            }

    }
}

Upvotes: 7

Views: 4904

Answers (3)

Abhishek
Abhishek

Reputation: 494

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

What do you have to change? make an offset as @Binding, provide from outside

How does it work? .id(animate) modifier here does not refresh the view but just replaces it with a new one, and since you have offset as binding property, replaced view will have the correct offset.

Again this might not be the best solution but it works for my case.

Upvotes: 2

cedricbahirwe
cedricbahirwe

Reputation: 1396

As @Schottky stated above, the offset value has already been set, SwiftUI is just animating the changes.

The problem is that when you tap the circle, onAppear is not called again which means enabled is not checked at all and even if it was it wouldn’t stop the offset from animating.

To solve this, we can introduce a Timer and do some small calculations, iOS comes with a built-in Timer class that lets us run code on a regular basis. So the code would look like this:

struct ContentView: View {
    @State private var offset  = 0.0
    // 1
    let timer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect()
    @State var animationDuration: Double = 5.0
    var body: some View {
        Circle()
            .frame(width: 100, height: 100)
            .offset(y: CGFloat(self.offset))
            .onTapGesture {
                // 2
                timer.upstream.connect().cancel()
            }
            .onReceive(timer) { _ in
                // 3
                animationDuration -= 0.1
                if animationDuration <= 0.0 {
                    timer.upstream.connect().cancel()
                } else {
                    withAnimation(.easeIn) {
                        self.offset += Double(-UIScreen.main.bounds.size.height + 300)/50
                    }
                }
            }
    }
}
  1. This creates a timer publisher which asks the timer to fire every 0.1 seconds. It says the timer should run on the main thread, It should run on the common run loop, which is the one you’ll want to use most of the time. (Run loops lets iOS handle running code while the user is actively doing something, such as scrolling in a list or tapping a button.)

  2. When you tap the circle, the timer is automatically cancelled.

  3. We need to catch the announcements (publications) by hand using a new modifier called onReceive(). This accepts a publisher as its first parameter (in this came it's our timer) and a function to run as its second, and it will make sure that function is called whenever the publisher sends its change notification (in this case, every 0.1 seconds).

After every 0.1 seconds, we reduce the duration of our animation, when the duration is over (duration = 0.0), we stop the timer otherwise we keep decreasing the offset.

Check out triggering-events-repeatedly-using-a-timer to learn more about Timer

Upvotes: 3

Schottky
Schottky

Reputation: 2024

What you need to understand is that only something that has already happened is being animated. It just takes longer to show. So in other words, when you set your offset to some value, it is going to animate towards that value, no matter what. What you, therefore, need to do is to set the offset to zero when tapping on the circle. The animation system is smart enough to deal with the 'conflicting' animations and cancel the previous one.

Also, a quick tip: You can do var offset: CGFloat = 0.0 so you don't have to cast it in the onAppear modifier

Upvotes: 1

Related Questions