Count Down Timer Animation SwiftUI

DevTechie Inc
Jun 24, 2022

Today we will build a count down timer to learn animation on trim modifier. Shapes have trim modifier and it is animatable so we will see it in action with an example. Here is what final countdown timer will look like:

We will start with couple of State properties to keep track of progress and countdown.

@State private var progress = 1.0
@State private var count = 10
We also need a timer which will publish update every one second:

let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
So far, this is what we have in our view:

struct CountDownProgress: View {
    @State private var progress = 1.0
    @State private var count = 10
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
    
    }
}
Next, we will add a GeometryReader, VStack and a Stack. On GeometryReader, we will also set preferredColorScheme to be dark and 50 points of padding.

struct CountDownProgress: View {
    @State private var progress = 1.0
    @State private var count = 10
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        GeometryReader { geo in
            VStack(spacing: 15) {
                ZStack {
                    
                    
                }
            }
        }
        .preferredColorScheme(.dark)
        .padding(50)
    }
}
We will add two circles inside ZStack. One will act like a background and other will become a progress circle with blur effect:

Circle()
    .fill(.white.opacity(0.03))
    .padding(-40)
Circle()
    .trim(from: 0, to: progress)
    .stroke(Color.pink, lineWidth: 10)
    .blur(radius: 15)
    .padding(-2)
Let’s add a few modifiers on ZStack to set padding, height, animation, overlay of Text view and an onReceive modifier which will observe timer’s published values.

struct CountDownProgress: View {
    @State private var progress = 1.0
    @State private var count = 10
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        GeometryReader { geo in
            VStack(spacing: 15) {
                ZStack {
                    // background circle
                    Circle()
                        .fill(.white.opacity(0.03))
                        .padding(-40)
                    Circle()
                        .trim(from: 0, to: progress)
                        .stroke(Color.pink, lineWidth: 10)
                        .blur(radius: 15)
                        .padding(-2)
                }
                .padding()
                .frame(height: geo.size.width)
                .animation(.spring(), value: progress)
                .overlay(
                    Text("\(count)")
                    .font(.largeTitle)
                )
                .onReceive(timer) { _ in
                    progress -= 0.1
                    count -= 1
                    if progress <= 0.0 || count <= 0 {
                        timer.upstream.connect().cancel()
                    }
                }
            }
        }
        .preferredColorScheme(.dark)
        .padding(50)
    }
}


Note: If you want to cancel timer publisher, simply use following line:timer.upstream.connect().cancel()

Build and run and your output should look like this:

Notice our progress is not starting from top but its starting from right hand side. Let’s fix that with a rotation modifier at ZStack.

struct CountDownProgress: View {
    @State private var progress = 1.0
    @State private var count = 10
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        GeometryReader { geo in
            VStack(spacing: 15) {
                ZStack {
                    Circle()
                        .fill(.white.opacity(0.03))
                        .padding(-40) 
                    Circle()
                        .trim(from: 0, to: progress)
                        .stroke(Color.pink, lineWidth: 10)
                        .blur(radius: 15)
                        .padding(-2)
                }
                .padding()
                .frame(height: geo.size.width)
                .rotationEffect(.degrees(-90))
                .animation(.spring(), value: progress)
                .overlay(
                    Text("\(count)")
                    .font(.largeTitle)
                )
                .onReceive(timer) { _ in
                    progress -= 0.1
                    count -= 1
                    if progress <= 0.0 || count <= 0 {
                        timer.upstream.connect().cancel()
                    }
                }
            }
        }
        .preferredColorScheme(.dark)
        .padding(50)
    }
}
Now, let’s add a circle for shadow effect:

struct CountDownProgress: View {
    @State private var progress = 1.0
    @State private var count = 10
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        GeometryReader { geo in
            VStack(spacing: 15) {
                ZStack {
                    Circle()
                        .fill(.white.opacity(0.03))
                        .padding(-40)

                    Circle()
                        .trim(from: 0, to: progress)
                        .stroke(Color.pink, lineWidth: 10)
                        .blur(radius: 15)
                        .padding(-2)
                    
                    Circle()
                        .trim(from: 0, to: 1)
                        .stroke(Color.pink, lineWidth: 10)
                        .blur(radius: 15)
                        .padding(-2)
                }
                .padding()
                .frame(height: geo.size.width)
                .rotationEffect(.degrees(-90))
                .animation(.spring(), value: progress)
                .overlay(
                    Text("\(count)")
                    .font(.largeTitle)
                )
                .onReceive(timer) { _ in
                    progress -= 0.1
                    count -= 1
                    if progress <= 0.0 || count <= 0 {
                        timer.upstream.connect().cancel()
                    }
                }
            }
        }
        .preferredColorScheme(.dark)
        .padding(50)
    }
}
Let’s put another circle at the top to hide blurred circle view partially:

struct CountDownProgress: View {
    @State private var progress = 1.0
    @State private var count = 10
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        GeometryReader { geo in
            VStack(spacing: 15) {
                ZStack {
                    Circle()
                        .fill(.white.opacity(0.03))
                        .padding(-40)
                    Circle()
                        .trim(from: 0, to: progress)
                        .stroke(Color.pink, lineWidth: 10)
                        .blur(radius: 15)
                        .padding(-2)
                    
                    Circle()
                        .trim(from: 0, to: 1)
                        .stroke(Color.pink, lineWidth: 10)
                        .blur(radius: 15)
                        .padding(-2)
                        
                    Circle()
                        .fill(.black)
                }
                .padding()
                .frame(height: geo.size.width)
                .rotationEffect(.degrees(-90))
                .animation(.spring(), value: progress)
                .overlay(
                    Text("\(count)")
                    .font(.largeTitle)
                )
                .onReceive(timer) { _ in
                    progress -= 0.1
                    count -= 1
                    if progress <= 0.0 || count <= 0 {
                        timer.upstream.connect().cancel()
                    }
                }
            }
        }
        .preferredColorScheme(.dark)
        .padding(50)
    }
}
Our progress circle is only a blur/shadow at this point. Let’s add another circle on top:

struct CountDownProgress: View {
    @State private var progress = 1.0
    @State private var count = 10
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        GeometryReader { geo in
            VStack(spacing: 15) {
                ZStack {
                    Circle()
                        .fill(.white.opacity(0.03))
                        .padding(-40)
                    Circle()
                        .trim(from: 0, to: progress)
                        .stroke(Color.pink, lineWidth: 10)
                        .blur(radius: 15)
                        .padding(-2)
                    
                    Circle()
                        .trim(from: 0, to: 1)
                        .stroke(Color.pink, lineWidth: 10)
                        .blur(radius: 15)
                        .padding(-2)Circle()
                        .fill(.black)Circle()
                        .trim(from: 0, to: progress)
                        .stroke(Color.orange.opacity(0.7), lineWidth: 10)
                }
                .padding()
                .frame(height: geo.size.width)
                .rotationEffect(.degrees(-90))
                .animation(.spring(), value: progress)
                .overlay(
                    Text("\(count)")
                    .font(.largeTitle)
                )
                .onReceive(timer) { _ in
                    progress -= 0.1
                    count -= 1
                    if progress <= 0.0 || count <= 0 {
                        timer.upstream.connect().cancel()
                    }
                }
            }
        }
        .preferredColorScheme(.dark)
        .padding(50)
    }
}
Our output should look like this:

Next, we will add a circular knob and (you guessed it right) this will be another circle 😃

Let’s create a computed view for this as the knob will need its own GeometryReader.

we will add following computed property.

private var knobView: some View {
        GeometryReader { knobGeo in
            Circle()
                .fill(Color.orange)
                .frame(width: 25, height: 25)
                .overlay(
                    Circle()
                        .fill(.white)
                        .padding(5)
                )
                .frame(width: knobGeo.size.width, height: knobGeo.size.height, alignment: .center)
               
        }
    }
We will add this knobView inside the ZStack as shown below:

struct CountDownProgress: View {
    @State private var progress = 1.0
    @State private var count = 10
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        GeometryReader { geo in
            VStack(spacing: 15) {
                ZStack {
                    Circle()
                        .fill(.white.opacity(0.03))
                        .padding(-40)

                    Circle()
                        .trim(from: 0, to: progress)
                        .stroke(Color.pink, lineWidth: 10)
                        .blur(radius: 15)
                        .padding(-2)
                    
                    Circle()
                        .trim(from: 0, to: 1)
                        .stroke(Color.pink, lineWidth: 10)
                        .blur(radius: 15)
                        .padding(-2)

                    Circle()
                        .fill(.black)

                     Circle()
                        .trim(from: 0, to: progress)
                        .stroke(Color.orange.opacity(0.7), lineWidth: 10)

                     knobView
                }
                .padding()
                .frame(height: geo.size.width)
                .rotationEffect(.degrees(-90))
                .animation(.spring(), value: progress)
                .overlay(
                    Text("\(count)")
                    .font(.largeTitle)
                )
                .onReceive(timer) { _ in
                    progress -= 0.1
                    count -= 1
                    if progress <= 0.0 || count <= 0 {
                        timer.upstream.connect().cancel()
                    }
                }
            }
        }
        .preferredColorScheme(.dark)
        .padding(50)
    }
    
    private var knobView: some View {
        GeometryReader { knobGeo in
            Circle()
                .fill(Color.orange)
                .frame(width: 25, height: 25)
                .overlay(
                    Circle()
                        .fill(.white)
                        .padding(5)
                )
                .frame(width: knobGeo.size.width, height: knobGeo.size.height, alignment: .center)
        }
    }
}
Notice that our knob is sitting at the center and is not moving like the final output. Well we can easily fix that.

First, we will apply and offset in x coordinate, which will be half of knobView’s height property.

.offset(x: knobGeo.size.height / 2)
Next, we will apply a rotationEffect where angle will be calculated based on the progress State property multiplied by 360 as shown below:

.rotationEffect(.degrees(progress * 360))
Our final view will look like this:

struct CountDownProgress: View {
    @State private var progress = 1.0
    @State private var count = 10
    let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    
    var body: some View {
        GeometryReader { geo in
            VStack(spacing: 15) {
                ZStack {
                    Circle()
                        .fill(.white.opacity(0.03))
                        .padding(-40)

                    Circle()
                        .trim(from: 0, to: progress)
                        .stroke(Color.pink, lineWidth: 10)
                        .blur(radius: 15)
                        .padding(-2)
                    
                    Circle()
                        .trim(from: 0, to: 1)
                        .stroke(Color.pink, lineWidth: 10)
                        .blur(radius: 15)
                        .padding(-2)
                    Circle()
                        .fill(.black)
                    Circle()
                        .trim(from: 0, to: progress)
                        .stroke(Color.orange.opacity(0.7), lineWidth: 10)

                    knobView
                }
                .padding()
                .frame(height: geo.size.width)
                .rotationEffect(.degrees(-90))
                .animation(.spring(), value: progress)
                .overlay(
                    Text("\(count)")
                    .font(.largeTitle)
                )
                .onReceive(timer) { _ in
                    progress -= 0.1
                    count -= 1
                    if progress <= 0.0 || count <= 0 {
                        timer.upstream.connect().cancel()
                    }
                }
            }
        }
        .preferredColorScheme(.dark)
        .padding(50)
    }
    
    private var knobView: some View {
        GeometryReader { knobGeo in
            Circle()
                .fill(Color.orange)
                .frame(width: 25, height: 25)
                .overlay(
                    Circle()
                        .fill(.white)
                        .padding(5)
                )
                .frame(width: knobGeo.size.width, height: knobGeo.size.height, alignment: .center)
                .offset(x: knobGeo.size.height / 2)
                .rotationEffect(.degrees(progress * 360))
        }
    }
}
And our final output will look like this:



With that we have reached the end of this article. Thank you once again for reading. Subscribe to our weekly newsletter at https://www.devtechie.com