Published: Jul 30, 2024
Let’s build LinkedIn’s loading animation in <50 lines of SwiftUI.
Activity indicators are typically broken down into two main categories: determinate (for known progress) vs indeterminate (for unknown progress). For various psychological reasons, it’s often better to use a determinate indicator (even if it’s lying about its progress).
LinkedIn is using an indeterminate indicator though, so our code reflects the looping nature of this animation.
import SwiftUI struct LinkedInActivityIndicator: View { @State private var animating = false let cornerRadius: CGFloat var body: some View { GeometryReader { proxy in ZStack { Rectangle() .fill(.gray.secondary) Rectangle() .fill(.blue) .frame(width: proxy.size.width / 2) .clipShape(.rect(cornerRadius: cornerRadius)) .offset(x: animating ? proxy.size.width / 2 : -proxy.size.width / 2) .animation( .timingCurve(0.6, 0.0, 0.4, 1.0, duration: 0.85) .repeatForever(autoreverses: true), value: animating ) } .clipShape(.rect(cornerRadius: cornerRadius)) } .onAppear { animating = true } } } #Preview { VStack { LinkedInActivityIndicator(cornerRadius: 5) .frame(width: 100, height: 10) .padding(100) } }
#
Building the animationLooking at the animation, we can see there’s two main components: the gutter (the gray portion) and the fill (the blue portion).
One way to build this animation would be to draw the fill using a line, and then control the start/end positions of that line as two separate animations. This would get pretty gnarly pretty quick though, and there’s a much simpler way.
We’ll use a similar trick from the Threads seamless carousel effect to simplify our code: container clipping.
#
A simple approachTo understand this simpler approach, I’ve turned off container clipping in the solution. In this animation, you can see that the fill shape is actually a fixed width, and we’re simply moving it along the x axis using a repeating animation.
When we pair this with a container clip, the result looks like a fill that is expanding and squishing as it moves between the two edges. Magic!
#
Without clipping
#
With clipping
#
Building the layersWe start with a ZStack to overlay the fill on top of the gutter:
ZStack { Rectangle() .fill(.gray.secondary) Rectangle() .fill(.blue) .frame(width: proxy.size.width / 2) .clipShape(.rect(cornerRadius: cornerRadius))
Note that we avoid specifying any dimensions in the view implementation because we want to allow the view’s width/height to be set to arbitrary dimensions.
The ZStack is also where we apply our container clip.
#
Sizing the progress indicatorWe want our fill to be exactly one half the length of the gutter, so we use a GeometryReader to get the dimensions of the view:
GeometryReader { proxy in ... .frame(width: proxy.size.width / 2) }
We also use the GeometryReader to define the keyframes of our animation:
.offset(x: animating ? proxy.size.width / 2 : -proxy.size.width / 2)
For a simple two-frame animation like this we can make use of a single Boolean state value to define the keyframes. We alternate the pill’s x position between centering on the leading and trailing edges using .offset.
#
Tuning animation timingThe animation timing is important to tune.
We’re building a looping animation, so we want to ensure continuity in our timing function at the start and end so that the animation feels continuous.
To achieve this, we can use a custom timing curve where the curve approaches a velocity of 0 at the start and end of the curve: https://cubic-bezier.com/#.6,0,.4,1
.animation( .timingCurve(0.6, 0.0, 0.4, 1.0, duration: 0.85) .repeatForever(autoreverses: true), value: animating )
#
Kick-starting the animationAnd last, to start our animation we just toggle our animating state in an onAppear block:
.onAppear { animating = true }
This flips the state value, causing our view to change the offset to the leading edge and emit our looping, autoreversing animation as a result.
And that’s it!