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