WWDC22 SALE: Save 50% on all my Swift books and bundles! >>

Animating

Forums > SwiftUI

I want the "Play/Pause" button to animate when I tap the "Reset" button.

struct timerView: View {
    let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()
    @State private var isButtonTapped: Bool = false
    @State private var currentTime: Float = 0
    @State private var animationAmount = 1.0
    @State private var isCountdownOverZero: Bool = false

    var body: some View {
        VStack {
            Spacer()

            Text("\(currentTime, specifier: "%.2f")")
                .bold()
                .font(.custom("timeFont", size: 80))
                .lineLimit(1)
                .onReceive(timer) { _ in
                    if isButtonTapped == true {
                        currentTime += 0.01
                        isCountdownOverZero = true
                    }
                }

            Spacer()

            HStack {
                if currentTime > 0 {
                    Button() {
                        currentTime = 0
                        isButtonTapped = false
                        isCountdownOverZero = false
                    } label: {
                        ZStack {
                            Rectangle()
                                .frame(width: 180, height: 150)
                                .foregroundColor(.mint)
                                .cornerRadius(20)

                            Label("Reset", systemImage: "arrow.uturn.left.circle")
                                .foregroundColor(.primary)
                                .font(.title)
                        }
                    }
                } else {
                    EmptyView()
                }

                Button() {
                    isButtonTapped.toggle()
                } label: {
                    ZStack {
                        Rectangle()
                            .frame(width: isButtonTapped || isCountdownOverZero ? 180 : 300, height: 150)
                            .foregroundColor(isButtonTapped ? .red : .green)
                            .cornerRadius(20)

                        Label(isButtonTapped ? "Pause" : "Play", systemImage: isButtonTapped ? "pause.circle" : "play.circle")
                            .foregroundColor(.primary)
                            .font(.title)
                    }
                }
            }
        }.padding(50)
    }
}

1      

Excellent!

This is a great effort. We can see your logic, and how you are thinking about this solution. Well done.

You ask about animation, but taking hammers and screwdrivers to address your question may not be the right answer for you. There are some other issues in your solution that, when addressed, make the animation part much simpler.

First, consider this variable isButtonTapped: Bool. This may be simple to understand what’s happening in your code, but in larger projects this will lead to much late night cursing. Frankly, this is not a good name for a variable. Which button? This also points to another design related issue, the STATE of your views.

Take a step back and think about the state of your views. When I drew your app on a blank sheet of paper, I considered you had two different states:

  1. The application is reset, or it is in the running state.
  2. The timer is running, or the timer is paused.

You can see how you can mix and match these two states:

  1. Application is reset and timer is not running.
  2. Application is running and timer is paused.
  3. Application is running and timer is running.
  4. Application is reset and timer is running. (This state does not exist in your solution.)

You are trying to ask your buttons to draw themselves based on one state named: isButtonTapped. But your application has three states.

Please consider removing isButtonTapped and focus on what your application is doing. Add two new states to your application:

@State private var timerIsPaused  = true
@State private var resetTimer     = false

This is much more “swifty” in that it allows you to be more declarative in your code. You can use these two states to TELL SWIFTUI how to look when either of these two states are true or false! Sweet!

Also, please consider adding these two Text() views to the top of your timerView’s VStack.

    Text("reset is: \(resetTimer ? "TRUE" : "FALSE" )")  // for debugging
    Text("paused? \(timerIsPaused ? "PAUSED" : "PLAYING")") // for debugging

These will help you understand the state each of your views is in. Then you can easily decide on the rules. You can think to yourself, when my application is running, and the timer is paused, I want this button to be RED, and the text to say Paused. Or you can say I want this button to HIDE and this button to be WIDER when the application state is RESET.

It’s easy for you to see the different states and make notes what you should see when the states change.

THIS leads to your question. HOW do you ask the views to animate? The answer is with the .animation()view modifier. Think about how SwiftUI can rapidly draw two versions of your views. One version shows two buttons side-by-side, another version shows one longer button. You just need to tell Swift to animate the views whenever one view changes to the other.

How do you know when this change will happen? It happens whenever your state variables change! And now you can clearly see them at the top of your VStack.

So when the resetTimer var changes from false to true, you want your buttons to animate. It’s that simple. Add this bit of code to your HStack:

.animation( .default, value: resetTimer) // animate this HStack whenever the resetTime variable changes value.

Full solution:

// How to animate buttons.
// For @Felix
// By: @Obelix at Hacking for Swift forums. 31 December 2021

// Paste this into Swift Playgrounds
import SwiftUI
import PlaygroundSupport

struct timerView: View {
    let timer = Timer.publish(every: 0.01, on: .main, in: .common).autoconnect()
    @State private var currentTime: Float = 0
    private var timerGreaterThanZero : Bool { currentTime > 0 }  // calculate this value!
    // ========= Application State
    @State private var timerIsPaused  = true  // State of your Timer
    @State private var resetTimer     = false // State of your Application

    var body: some View {
        VStack {
            Text("reset is: \(resetTimer ? "TRUE" : "FALSE" )")  // for debugging
            Text("paused? \(timerIsPaused ? "PAUSED" : "PLAYING")") // for debugging
            Spacer()
            Text("\(currentTime, specifier: "%.2f")")
                .bold()
                .font(.custom("timeFont", size: 80))
                .lineLimit(1)
                .onReceive(timer) { _ in
                    if !timerIsPaused { // increment ONLY when timer is running
                        currentTime += 0.01
                    }
                }
            Spacer()
            HStack {
                if timerGreaterThanZero {
                    Button() {
                        resetTheTimer() // Do this when user taps Reset button
                    } label: {
                        ZStack {
                            Rectangle()
                                .frame(width: 180, height: 150)
                                .foregroundColor(.mint)
                                .cornerRadius(20)
                            Label("Reset", systemImage: "arrow.uturn.left.circle")
                                .foregroundColor(.primary)
                                .font(.title)
                        }
                    }
                } else {
                    EmptyView()
                }
                Button() {
                    playOrPause() // DECLARE what this button does
                } label: {
                    ZStack {
                        Rectangle() // DECLARE what your views look like for each STATE it is in.
                            .frame(width: timerGreaterThanZero ? 180 : 300, height: 150)
                            .foregroundColor(timerIsPaused ? .green : .red)
                            .cornerRadius(20)
                        Label(timerIsPaused ? "Play" : "Pause", systemImage: timerIsPaused ? "play.circle" : "pause.circle")
                            .foregroundColor(.primary)
                            .font(.title)
                    }
                }
            }.animation( .default, value: resetTimer)  // ANIMATE this HStack when resetTime changes.
        }.padding(50)
    }

    private func resetTheTimer() {
        // put all your code for resetting the timer here.
        // 1. Set the counter to zero.
        currentTime = 0
        // 2. Tell all components to reset themselves
        resetTimer = true
    }

    private func playOrPause() {
        if resetTimer { resetTimer = false }
        // if playing, then pause. If paused, then play.
        timerIsPaused.toggle() // Play or Pause.
    }
}

PlaygroundPage.current.setLiveView(timerView())

2      

Other Notes:

Consider making your buttons DECLARE what you want them to do.

Easy! The reset button should stop the timer, and tell all views to reset themselves to their starting positions. So extract all that code into a view function and name it resetTheTimer(). Then your button is much simpler to manage.

Do the same with the Pause or Play button. Extract all the logic out of the Button code, and create a separate function to handle these tasks.

    private func resetTheTimer() {
        // put all your RULES for resetting the timer here.
        // 1. Set the counter to zero.
        currentTime = 0
        // 2. Tell all components to reset themselves
        resetTimer = true
    }

    private func playOrPause() {
        if resetTimer { resetTimer = false }  // if we're in the starting state, flip this flag.
        // if playing, then pause. If paused, then play.
        timerIsPaused.toggle() // Play or Pause.
    }

1      

Other notes:

In your code, isButtonTapped is a boolean that can only be TRUE or FALSE.

Avoid writing code like this:

if isButtonTapped == true {
// do something fun
}

Let your Swift code read like English.

if theUserTappedTheAboutButton {  
// don't write if theUserTappedTheAboutButton == TRUE
// do something fun
}

if playersScoreBeatTheWorldRecord {
// do something fun
}

1      

Other notes:

Let Swift worry about details.

You had a variable in your code named isCountdownOverZero.

@State private var isCountdownOverZero: Bool = false

This was frightenly messy because you had to manually think about the state of your appliction and the state of your timer and then set this flag to TRUE or FALSE based on your application's rules. You had to do this in a few places.

But why bother yourself with this detail? 100% chance you'll mess this up in some future application and we'll read about how you sent a Mars rover off course, or accidentally ordered 100 cups of coffee instead of 1.

You have a variable that holds the timer value named currentTime.

If you need a boolean to tell you if the timer is greater than zero, consider using a computed property.

   private var timerGreaterThanZero : Bool { currentTime > 0 }  // calculate this value!

This is way better! Now you are no longer in the business of trying to keep this variable in sync with your currentTime variable. You can delete all the code in your view where you set or reset this variable. Instead, it will always reflect the true state of your currentTime variable.

Ask for Feedback!

There are other refinements you could make. These are a few for you to consider. Keep asking for feedback, though! This is the value of working with a team of programmers. Code Reviews are a critical function of a programmer's environment.

1      

One more consideration.

I suggested your application has two states to track.

  1. Start state, or Running state.
  2. If running, is the timer counting or not counting.

Then the solution was created using BOOLEANS to track these two different state variables.

Maybe you can find an elegant solution using only ONE state variable to track all your application's states?

But instead of a BOOLEAN, you may have to consider using an enum to define your application's state.

Think about it and drop your thoughts here.

1      

Hey @Obelix,

First of all, I want to thank you for your super extensive answers and constructive criticism. I don't know if this is standard in this forum, but I appreciate it. To be honest, I didn't get any thought from you (14-year-old teaching myself Swift for 2 months yet). I try to dive deep into your responses and hope I get to understand every bit.

2      

Hacking with Swift is sponsored by Fernando Olivares

SPONSORED Fernando's book will guide you in fixing bugs in three real, open-source, downloadable apps from the App Store. Learn applied programming fundamentals by refactoring real code from published apps. Hacking with Swift readers get a $10 discount!

Read the book

Sponsor Hacking with Swift and reach the world's largest Swift community!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.