# 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](https://gist.github.com/kmarcell/3f4c43323372fdd65ef334cae80a4eb9).

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.

```swift
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?

```swift
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!

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1713537125659/fe077ca3-3b05-4229-a2cf-67a3feb4cf05.gif align="center")

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: Double`
> 
> Can 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`.

```swift
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))
}
```

![Centred rounded circular loader](https://cdn.hashnode.com/res/hashnode/image/upload/v1714315451068/ad9438ea-8929-463d-a894-6af2121de469.gif align="center")

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`.

```swift
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?

```swift
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?

```swift
@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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1714316682132/02a97349-5a20-43ca-af64-b91c9d516603.gif align="center")

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?

```swift
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?

```swift
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](https://developer.apple.com/documentation/swiftui/controlling-the-timing-and-movements-of-your-animations#Gain-more-control-with-a-keyframe-animator) 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](https://developer.apple.com/documentation/swiftui/controlling-the-timing-and-movements-of-your-animations#Bounce-with-a-phase-animator) also says there's a [`PhaseAnimator`](https://developer.apple.com/documentation/swiftui/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`](https://developer.apple.com/documentation/swiftui/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?

```swift
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:)`](https://developer.apple.com/documentation/swiftui/view/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?

```swift
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 -&gt; ()' 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`](https://developer.apple.com/documentation/swiftui/withanimation(_:_:)) needs a closure as well as [`phaseAnimator(_:content:animation:)`](https://developer.apple.com/documentation/swiftui/view/phaseanimator(_:content:animation:)) which makes two and we only have one trailing closure.

Reading into the documentation, [`phaseAnimator(_:content:animation:)`](https://developer.apple.com/documentation/swiftui/view/phaseanimator(_:content:animation:)) has a `content` closure which has an input parameter of [`PlaceholderContentView`](https://developer.apple.com/documentation/swiftui/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:)`](https://developer.apple.com/documentation/swiftui/view/keyframeanimator(initialvalue:repeating:content:keyframes:)) where it has the same [`PlaceholderContentView`](https://developer.apple.com/documentation/swiftui/placeholdercontentview) which we can animate as we would any `View`, but not access the properties of `CircularLoaderView`.

Like this from Apple's examples:

```swift
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(_:)`](https://developer.apple.com/documentation/swiftui/view/offset(_:)) or [`scaleEffect(_:anchor:)`](https://developer.apple.com/documentation/swiftui/view/scaleeffect(_:anchor:)-pmi7), 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:)`](https://developer.apple.com/documentation/swiftui/shape/trim(from:to:)) is defined on protocol [`Shape`](https://developer.apple.com/documentation/swiftui/shape) not `View` which means the idea wouldn't work.

Another idea is that we can set the `autoreverses` parameter of [`repeatForever(autoreverses:)`](https://developer.apple.com/documentation/swiftui/animation/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

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1714322174656/5dc7d167-6546-488d-9e7f-af514aae731c.gif align="center")

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`](https://developer.apple.com/documentation/swiftui/view/phaseanimator(_:content:animation:)) and the SwiftUI capability of [`onReceive`](https://developer.apple.com/documentation/swiftui/view/onreceive(_:perform:)). 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`](https://developer.apple.com/documentation/foundation/timer) that emits every `animationDuration` and [`onReceive`](https://developer.apple.com/documentation/swiftui/view/onreceive(_:perform:)) will change the phase of the animation.

You can find the current version of the code on my [GitHub Gist](https://gist.github.com/kmarcell/3f4c43323372fdd65ef334cae80a4eb9).

```swift
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.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1714403318554/53ba6d5c-cddc-4f70-ad93-ca2a72b61cb8.gif align="center")

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](https://developer.apple.com/documentation/swiftui/controlling-the-timing-and-movements-of-your-animations#Gain-more-control-with-a-keyframe-animator).

Until next time...
