SwiftUI animations with GitHub Copilot

A SwiftUI animated loader written by AI using TDD
This time I'm focusing on writing a SwiftUI animation with the help of GitHub Copilot. I choose this task because I would need a lot of searching on the internet how to do this properly and I was wondering if I can leverage Copilot to suggest the code to me instead of me searching and copy-pasting it.
You can find the current version of the code on my GitHub Gist.
I chose to start as always TDD style writing a unit test and getting some basic implementation written by AI. I asked Copilot to create a basic code where the animation would start.
struct CircularLoaderView: View {
var body: some View {
Circle()
}
final class CircularLoaderViewTests: XCTestCase {
func testCircularLoaderInitialState() {
let testee = CircularLoaderView(
configuration: CircularLoaderView.Configuration(radius: 100.0, strokeWidth: 4.0, strokeColor: Color.accentColor)
)
XCTAssertEqual(testee.startPoint, Angle(degrees: 0))
XCTAssertEqual(testee.endPoint, Angle(degrees: 0))
}
}
Based on this unit test can you modify the CircularLoaderView SwiftUI View in this file to make this test pass by adding the missing parameters?
struct CircularLoaderView: View {
struct Configuration {
var radius: CGFloat
var strokeWidth: CGFloat
var strokeColor: Color
}
var configuration: Configuration
@State private var startPoint: Angle = .zero
@State private var endPoint: Angle = .zero
var body: some View {
Circle()
.trim(from: startPoint, to: endPoint)
.stroke(configuration.strokeColor, lineWidth: configuration.strokeWidth)
.rotationEffect(.degrees(-90))
.onAppear {
withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
startPoint = .degrees(0)
endPoint = .degrees(360)
}
}
}
}
Whoa. This is a lot more code than I anticipated I would get from the prompt above, but what does it do?
I had a compilation error Cannot convert value of type 'Angle' to expected argument type 'CGFloat' which I quickly fixed by changing .trim(from: startPoint, to: endPoint) and adding a devision as .trim(from: startPoint.degrees / 360.0, to: endPoint.degrees / 360.0). I changed also private to private(set) on startPoint and endPoint for the test to pass, then run the code!

That is a very good start indeed!
I would like to adjust the animation speed and the loader would need to be smaller and centred.
Can you extract the duration into a variable of the configuration?
var animationDuration: DoubleCan you make the ends of the stroke rounded?
.stroke(configuration.strokeColor, style: StrokeStyle(lineWidth: configuration.strokeWidth, lineCap: .round))
I've adjusted the test as well to have these new values passed to Configuration.
func testCircularLoaderInitialState() {
let testee = CircularLoaderView(
configuration: CircularLoaderView.Configuration(radius: 100.0, strokeWidth: 4.0, strokeColor: Color.blue, animationDuration: 2.0)
)
XCTAssertEqual(testee.startPoint, Angle(degrees: 0))
XCTAssertEqual(testee.endPoint, Angle(degrees: 0))
}

For the animation I would like to achieve I would like the circle to rotate with a different speed than the circle is stroked. This would make the animation have this parallax feel. I've modified the unit test too to have rotationDuration and rotationEffect.
func testCircularLoaderInitialState() {
let testee = CircularLoaderView(
configuration: CircularLoaderView.Configuration(radius: 100.0, strokeWidth: 4.0, strokeColor: Color.blue, animationDuration: 2.0, rotationDuration: 4.0)
)
XCTAssertEqual(testee.startPoint, Angle(degrees: 0))
XCTAssertEqual(testee.endPoint, Angle(degrees: 0))
XCTAssertEqual(testee.rotationEffect, Angle(degrees: 0))
}
Can you add a rotation animation where the whole Circle view would rotate clockwise with a rotationDuration variable added to Configuration?
var body: some View {
Circle()
.trim(from: startPoint.degrees / 360.0, to: endPoint.degrees / 360.0)
.stroke(configuration.strokeColor, style: StrokeStyle(lineWidth: configuration.strokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.rotationEffect(.degrees(360), anchor: .center) // Added rotation animation
.animation(.linear(duration: configuration.rotationDuration).repeatForever(autoreverses: false)) // Added rotation animation
.onAppear {
withAnimation(.linear(duration: configuration.animationDuration).repeatForever(autoreverses: false)) {
startPoint = .degrees(0)
endPoint = .degrees(360)
}
}
}
Almost, but I think we can do better with a second prompt.
Can you add a variable for the rotation effect and animate it in the onAppear using withAnimation?
@State private(set) var startPoint: Angle = .zero
@State private(set) var endPoint: Angle = .zero
@State private(set) var rotationEffect: Angle = .zero
var body: some View {
Circle()
.trim(from: startPoint.degrees / 360.0, to: endPoint.degrees / 360.0)
.stroke(configuration.strokeColor, style: StrokeStyle(lineWidth: configuration.strokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.rotationEffect(rotationEffect, anchor: .center)
.onAppear {
withAnimation(.linear(duration: configuration.animationDuration).repeatForever(autoreverses: false)) {
startPoint = .degrees(0)
endPoint = .degrees(360)
rotationEffect = .degrees(360)
}
}
}
I've also manually added .frame(width: configuration.radius / 2.0, height: configuration.radius / 2.0) just before the .onAppear to restrict the size of the view.

This looks almost what we would like to achieve, it doesn't yet utilise the rotationDuration variable we introduced. There is a slight jump between the animation repetitions too. This is caused by that how we stroke from 0 to 360 degrees and there's a point where the stroke suddenly disappears as the animation restarts. We could achieve a smoother look if the stroking would expand from 0 to 360 then the stroke would slowly shrink the startPoint from 0 to 360 to close the circle and give a smooth feel to the animation. To achieve this we require a two stage animation.
Can you change the linear animation to a key frame animation where in the first key frame we stroke from 0 to 360 than in the second key frame we stroke in reverse from 0 to 360 to shrink the stroking?
withAnimation(.keyframes(duration: configuration.animationDuration, repeatCount: .max)) {
Animation
.linear(duration: configuration.animationDuration)
.keyframeAnimation(
values: [
.init(strokeStart: 0, strokeEnd: 1),
.init(strokeStart: 1, strokeEnd: 0)
],
keyTimes: [0, 1],
timingFunctions: [.linear, .linear]
)
}
I've got 5 different compilation errors
Cannot infer contextual base in reference to member 'max'
Type 'Animation?' has no member 'keyframes'
Value of type 'Animation' has no member 'keyframeAnimation'
'Any' cannot be constructed because it has no accessible initializers
Reference to member 'linear' cannot be resolved without a contextual type
This will require me to look into the documentation or does it?
Xcode gives me the error Type 'Animation?' has no member 'keyframes' can you fix this?
withAnimation(.keyframedAnimation(
Animation.linear(duration: configuration.animationDuration),
values: [
.init(strokeStart: 0, strokeEnd: 1),
.init(strokeStart: 1, strokeEnd: 0)
],
keyTimes: [0, 1],
timingFunctions: [.linear, .linear]
)) {
startPoint = .degrees(0)
endPoint = .degrees(360)
}
- Type 'Animation?' has no member 'keyframedAnimation'
Okay, this is clearly not working, I need to look it up. Apple's documentation says "you can define complex, coordinated animations with complete control over timing and movement using a Keyframe Animator" which gives you more control, but also a warning that "SwiftUI calls the keyframe animator’s content closure on every frame of the animation, so avoid performing any expensive operations directly within it." Which means this is a per frame animation, which might be too much control for our case.
The documentation also says there's a PhaseAnimator that automatically advances through a set of given phases to create an animated transition.
Which sounds exactly what we want with our two phases of expansion and contraction.
I will reset the code and try to ask Copilot to use PhaseAnimator.
Can you change the linear animation to use PhaseAnimator having two phases, the first phase is expansion where we stroke the Circle from start 0 to end 360 and in the second shrinking the stroking animating the start to move to 360?
withAnimation(PhaseAnimator(phases: [
Phase(duration: configuration.animationDuration, animations: {
startPoint = .degrees(0)
endPoint = .degrees(360)
}),
Phase(duration: configuration.animationDuration, animations: {
startPoint = .degrees(360)
endPoint = .degrees(0)
})
])
Not exactly using phaseAnimator(_:content:animation:) but I haven't asked for that have I? I got a compilation error:
- Cannot find 'Phase' in scope
I see so PhaseAnimator<Phase, Content> generic would probably trick Copilot thinking it is a concrete type. I would like to try again:
Can you change the linear animation to use Animation method phaseAnimator with two phases, the first phase is expansion where we stroke the Circle from start 0 to end 360 and in the second shrinking the stroking animating the start to move to 360?
withAnimation(Animation.linear(duration: configuration.animationDuration)
.repeatForever(autoreverses: false)
.phaseAnimator(phases: [0.0, 1.0])) { phase in
startPoint = .degrees(0 + (360 * phase))
endPoint = .degrees(360 + (360 * phase))
}
More compiler errors:
Contextual closure type '() throws -> ()' expects 0 arguments, but 1 was used in closure body
Value of type 'Animation' has no member 'phaseAnimator'
It doesn't look correct from even a brief look as withAnimation needs a closure as well as phaseAnimator(_:content:animation:) which makes two and we only have one trailing closure.
Reading into the documentation, phaseAnimator(_:content:animation:) has a content closure which has an input parameter of PlaceholderContentView and it is not a transparent proxy. This means we wouldn't be able to modify content.startPoint in the animation closure as it's not of the type CircularLoaderView. The same applies to keyframeAnimator(initialValue:repeating:content:keyframes:) where it has the same PlaceholderContentView which we can animate as we would any View, but not access the properties of CircularLoaderView.
Like this from Apple's examples:
struct TwoPhaseAnimationView: View {
var emoji: String
var body: some View {
EmojiView(emoji: emoji)
.phaseAnimator([false, true]) { content, phase in
content.offset(y: phase ? -40.0 : 0.0)
}
}
}
We can use offset(_:) or scaleEffect(_:anchor:), but not modify variables of CircularLoaderView.
A different approach is needed. One idea would be to create our own 'effect' that applies to all View and we would modify the trimming of the path on the Circle, but trim(from:to:) is defined on protocol Shape not View which means the idea wouldn't work.
Another idea is that we can set the autoreverses parameter of repeatForever(autoreverses:) to true which would make the animation smooth, but it makes the stroking from 0 to 360 than back to 0, which is not what I am looking for.
It looks like this

The solution comes from a combination of AI suggested withAnimation using with the idea of values: [ .init(strokeStart: 0, strokeEnd: 1), .init(strokeStart: 1, strokeEnd: 0) ] and the phases that comes from phaseAnimator and the SwiftUI capability of onReceive. What we need to achieve is to apply changes periodically to the animation where we change it to a different phase. We'll combine Timer that emits every animationDuration and onReceive will change the phase of the animation.
You can find the current version of the code on my GitHub Gist.
var body: some View {
Circle()
.trim(from: points.first, to: points.second)
.stroke(configuration.strokeColor, style: StrokeStyle(lineWidth: configuration.strokeWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.rotationEffect(rotationEffect, anchor: .center)
.frame(width: configuration.radius / 2.0,
height: configuration.radius / 2.0)
.onReceive(Timer.publish(every: configuration.animationDuration, on: .main, in: .common).autoconnect()) { _ in
switch (points.first, points.second) {
case (0.0, 0.0):
animatePoints(AnimatablePair(0.0, 1.0))
case (1.0, 1.0):
points = AnimatablePair(0.0, 0.0)
animatePoints(AnimatablePair(0.0, 1.0))
case (_, _):
animatePoints(AnimatablePair(1.0, 1.0))
}
}
.onAppear {
withAnimation(.linear(duration: configuration.rotationDuration).repeatForever(autoreverses: false)) {
rotationEffect = .degrees(360)
}
}
}
func animatePoints(_ newValues: AnimatablePair<Double, Double>,
curve: (_ duration: TimeInterval) -> Animation = Animation.easeOut(duration: )) {
withAnimation(curve(configuration.animationDuration)) {
points = newValues
}
}
This achieves the desired look.

I'm glad I learned a lot about SwiftUI animations, I'm also quite pleased how well we can use Copilot for simple tasks. I would need to dig deeper into Copilot now I know how this animation can be achieved, how I could have got the answer from Copilot rather than Apple's documentation. So far it failed to provide me a useful suggestion that I could use to crack the problem I was facing.
If you have experience with Copilot or had faced similar challenges, please share it with me and the community, it would be a game changer if Copilot could suggest the snippets to me that I found on Apple documentation.
Until next time...





