ixany
ixany

Reputation: 6070

SwiftUI: Repeat animation forever with delay at the end, without autoreverse

I want to repeat an animation forever, without it to autoreverse, and with a delay/pause between the repetitions after the animation played.

I know there is a .delay() modifier, but it delays the beginning of the animation:

let ani = Animation.easeInOut.delay(1.0).repeatForever(autoreverses: false)

In addition with the .repeatForever modifier, after the animation played it immediately jumps back to its beginning. But I want the last keyframe to remain visible on the screen for a short amount of time.

I’ve also tried it the other way around, adding a .delay() after the .repeatForever modifier, but without success (delay has no effect).

let ani = Animation.easeInOut.repeatForever(autoreverses: false).delay(1.0)

How can I add a delay after the animation played?

Upvotes: 7

Views: 1601

Answers (1)

Benzy Neez
Benzy Neez

Reputation: 21720

iOS 17+

You might expect that a PhaseAnimator with 2 phases could be used for this:

  • phase 1 would perform the primary change, with animation
  • phase 2 would reset the change, using an animation with a duration of 0 that is performed after a delay.

Unfortunately, it seems that when a delay is added to an animation with a duration of 0, the delay doesn't take effect.

A 3-phase animation with a hold phase doesn't work either. It seems that a phase animator skips any phases that don't actually make any changes.

So using a phase animator doesn't work.


An alternative approach (one that does work) is to use a KeyframeAnimator instead. The animator just needs a single KeyframeTrack, with 3 keyframes to set / hold / reset the change:

struct ContentView: View {
    let circleSize: CGFloat = 100

    var body: some View {
        GeometryReader { proxy in
            let containerWidth = proxy.size.width
            let animationDuration = (containerWidth - circleSize) / 100.0
            Circle()
                .fill(.orange)
                .frame(width: circleSize, height: circleSize)
                .keyframeAnimator(initialValue: CGFloat.zero) { content, xOffset in
                    content
                        .offset(x: xOffset)
                } keyframes: { xOffset in
                    KeyframeTrack {
                        LinearKeyframe(containerWidth - circleSize, duration: animationDuration) // set
                        LinearKeyframe(containerWidth - circleSize, duration: 1) // hold
                        LinearKeyframe(0, duration: 0) // reset
                    }
                }
        }
        .frame(height: circleSize)
        .padding()
    }
}

Animation

Btw, if the reset would be performed with a very fast animation then a phase animator could in fact be used. The delay can be added to the animation used for phase 2. An example implementation of a 2-phase animation (without delay) can be found in this answer.


Earlier iOS versions

For earlier iOS versions (15+), a .task(id:) modifier can be used to implement the animation phases manually. The following example works the same as the version above that used a keyframe animator.

struct ContentView: View {
    let circleSize: CGFloat = 100
    @State private var xOffset = CGFloat.zero

    var body: some View {
        GeometryReader { proxy in
            let containerWidth = proxy.size.width
            let animationDuration = (containerWidth - circleSize) / 100.0
            Circle()
                .fill()
                .foregroundStyle(.orange)
                .frame(width: circleSize, height: circleSize)
                .offset(x: xOffset)
                .animation(.linear(duration: xOffset > 0 ? animationDuration : 0), value: xOffset)
                .task(id: xOffset) {
                    if xOffset > 0 {
                        try? await Task.sleep(for: .seconds(animationDuration + 1))
                    }
                    xOffset = xOffset > 0 ? 0 : containerWidth - circleSize
                }
        }
        .frame(height: circleSize)
        .padding()
    }
}

An alternative approach for earlier iOS versions would be to use an Animatable ViewModifier instead. The answer referenced above has an example that animates the reset phase at a different speed. The reset phase can be changed to a "hold" phase with a small adaption:

private var xOffset: CGFloat {
    let fraction: CGFloat = progress > rewindThreshold ? rewindThreshold : progress
    return (fraction / rewindThreshold) * maxOffset
}

Upvotes: 0

Related Questions