Keyframe animations in SwiftUI

Photo by Linus Mimietz / Unsplash

SwiftUI is great. It is better year after year. Since iOS 15 is truly awesome but some of us have to work with the first version and support iOS 13.

Apple keeps adding new views modifiers to enable more creative solutions and designs but now and then I find SwiftUI lacking basic features that UIKit has since the beginning. One of these functionalities is keyframe animations.

I'll try to replicate the pulse animation, found here. As you can see, it almost looks like we can use autorepeat and autoreverse modifiers for achieving the goal. Almost. Since the timing of the two steps is different (70%, 30%) it is impossible. Using .delay(..) also doesn't solve all our problems, because we can add delay AFTER animation to wait with repetition. What a pickle.

After some time playing around, I figured out I can "hack" this! Let us start coding!

View hierarchy

First, we have to make our view, simple enough, two shapes in ZStack will do the trick:)

struct Pulse: View { 
	@State var shadowColor: SwiftUI.Color = .clear
	@State private var shadowScale: CGFloat = 0.45
	@State private var circleScale: CGFloat = 0.45
	var body: some View {
		ZStack {
			Circle()
				.foregroundColor(shadowColor)
				.scaleEffect(shadowScale)
			Circle()
				.foregroundColor(.blue)
				.scaleEffect(circleScale)   
		}
	}
}

Single animation

Secondly, I created one-time non-repeating animation of pulse using withAnimation {} blocks:

struct Pulse: View {
    // properties
    var body: some View {
        ZStack { .... }
        .onAppear { animate() } // 1
    }
    
    private func animate() {
        shadowColor = .blue.opacity(0.7)
        
        shadowScale = 0.45
        withAnimation(
            Animation
                .easeInOut(2.0 * 0.7)
        ) {
            circleScale = 0.5
            shadowScale = 1.0
        }
        withAnimation(
            Animation
                .easeInOut(duration: 2.0 * 0.3)
                .delay(2.0 * 0.7)
        ) {
            circleScale = 0.45
            shadowColor = .clear
        }
    }
}

All right, we added the onAppear { } block, triggering the animation.
The animation function sets initial values first without animation. After that, we start the first part of the pulse - a growing shadow with a slightly growing solid circle. Lastly, we trigger the second part with a delay of the duration of the first part, it shrinks the inner circle to the base value and makes the shadow transparent.

All right, the one-time pulse is done! But how make it repeat itself over and over again?

Repetition by timer

My solution? Use an auto-connecting timer! We can make a timer for our view that will trigger closure every time it fires. This will restart our animation from the beginning. All we need to do is add a couple of lines of code!

struct Pulse: View {
    // properties
    private let timer = Timer.publish(
        every: 2.0,
        on: .main,
        in: .common
    ).autoconnect()
    
    var body: some View {
        ZStack { .... }
        .onReceive(timer) { _ in
            animate()
    }
    
    private func animate() { ... }
}

First, we create an auto-connecting timer. It will fire every two seconds, this is how much we want our animation to last. On firing, the animate() function is called, jumpstarting animation from the top!

Since we are using the ZStack as the base building block, our animation can scale itself to any frame we want it to be. Also, you can improve it by adding duration and color as input parameters! Enjoy!

Do you know a better way how to do this? Tweet about it and mention my Twitter profile!

Artur Gruchała

Artur Gruchała

I started learning iOS development when Swift was introduced. Since then I've tried Xamarin, Flutter, and React Native. Nothing is better than native code:)
Poland