64

I have a swiftUI animation based on some state:

withAnimation(.linear(duration: 0.1)) {
    self.someState = newState
}

Is there any callback which is triggered when the above animation completes?

If there are any suggestions on how to accomplish an animation with a completion block in SwiftUI which are not withAnimation, I'm open to those as well.

I would like to know when the animation completes so I can do something else, for the purpose of this example, I just want to print to console when the animation completes.

4
  • 2
    Unfortunately at the moment SwiftUI doesn't provide you with a callback on the animation end, neither for the implicit animations nor for the explicit ones (as in your example). Commented Sep 4, 2019 at 7:27
  • 8
    mind boggling SwiftUI does not have this yet. Such an important part of an animation lifecycle. Anybody knows if there is anything new on this? Commented Jul 9, 2021 at 23:08
  • this tutorial works : avanderlee.com/swiftui/withanimation-completion-callback Commented Nov 3, 2022 at 7:25
  • 1
    Completion has been added in iOS 17 ;-) Commented Jun 30, 2023 at 23:10

10 Answers 10

49

iOS 17+

Starting from iOS 17 we can use the completion parameter:

withAnimation(.linear(duration: 0.1)) {
    self.someState = newState
} completion: {
    print("Animation finished")
}

iOS 13+

In earlier iOS versions there's no good solution to this problem.

However, if you can specify the duration of an Animation, you can use DispatchQueue.main.asyncAfter to trigger an action exactly when the animation finishes:

withAnimation(.linear(duration: 0.1)) {
    self.someState = newState
}

DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
    print("Animation finished")
}
Sign up to request clarification or add additional context in comments.

Comments

15

Now starting from Xcode 15.0 beta we have a completion callback

struct MainView: View {
    @State private var animate = false
    
    var body: some View {
        Text("Hello with xcode 15")
            .scaleEffect(value ? 2 : 1)
            .onTapGesture {
                withAnimation {
                    value.toggle()
                } completion: {
                    // To do
                    print("Animation have finished")
                }
            }
    }
}

2 Comments

will this only work on iOS 17 or it will support 16
@psdevss only in iOS 17.0+ and macOS 14.0+, see: withAnimation(:completionCriteria::completion:)
14

Here's a bit simplified and generalized version that could be used for any single value animations. This is based on some other examples I was able to find on the internet while waiting for Apple to provide a more convenient way:

struct AnimatableModifierDouble: AnimatableModifier {

    var targetValue: Double

    // SwiftUI gradually varies it from old value to the new value
    var animatableData: Double {
        didSet {
            checkIfFinished()
        }
    }

    var completion: () -> ()

    // Re-created every time the control argument changes
    init(bindedValue: Double, completion: @escaping () -> ()) {
        self.completion = completion

        // Set animatableData to the new value. But SwiftUI again directly
        // and gradually varies the value while the body
        // is being called to animate. Following line serves the purpose of
        // associating the extenal argument with the animatableData.
        self.animatableData = bindedValue
        targetValue = bindedValue
    }

    func checkIfFinished() -> () {
        //print("Current value: \(animatableData)")
        if (animatableData == targetValue) {
            //if animatableData.isEqual(to: targetValue) {
            DispatchQueue.main.async {
                self.completion()
            }
        }
    }

    // Called after each gradual change in animatableData to allow the
    // modifier to animate
    func body(content: Content) -> some View {
        // content is the view on which .modifier is applied
        content
        // We don't want the system also to
        // implicitly animate default system animatons it each time we set it. It will also cancel
        // out other implicit animations now present on the content.
            .animation(nil)
    }
}

And here's an example on how to use it with text opacity animation:

import SwiftUI

struct ContentView: View {

    // Need to create state property
    @State var textOpacity: Double = 0.0

    var body: some View {
        VStack {
            Text("Hello world!")
                .font(.largeTitle)

                 // Pass generic animatable modifier for animating double values
                .modifier(AnimatableModifierDouble(bindedValue: textOpacity) {

                    // Finished, hurray!
                    print("finished")

                    // Reset opacity so that you could tap the button and animate again
                    self.textOpacity = 0.0

                }).opacity(textOpacity) // bind text opacity to your state property

            Button(action: {
                withAnimation(.easeInOut(duration: 1.0)) {
                    self.textOpacity = 1.0 // Change your state property and trigger animation to start
                }
            }) {
                Text("Animate")
            }
        }
    }
}

struct HomeView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

1 Comment

Beautiful solution! Thank you! I had to make one change because .animation is deprecated so I was able to make it work by changing it to: content.animation(nil, value: targetValue)
8

On this blog this Guy Javier describes how to use GeometryEffect in order to have animation feedback, in his example he detects when the animation is at 50% so he can flip the view and make it looks like the view has 2 sides

here is the link to the full article with a lot of explanations: https://swiftui-lab.com/swiftui-animations-part2/

I will copy the relevant snippets here so the answer can still be relevant even if the link is not valid no more:

In this example @Binding var flipped: Bool becomes true when the angle is between 90 and 270 and then false.

struct FlipEffect: GeometryEffect {

    var animatableData: Double {
        get { angle }
        set { angle = newValue }
    }

    @Binding var flipped: Bool
    var angle: Double
    let axis: (x: CGFloat, y: CGFloat)

    func effectValue(size: CGSize) -> ProjectionTransform {

        // We schedule the change to be done after the view has finished drawing,
        // otherwise, we would receive a runtime error, indicating we are changing
        // the state while the view is being drawn.
        DispatchQueue.main.async {
            self.flipped = self.angle >= 90 && self.angle < 270
        }

        let a = CGFloat(Angle(degrees: angle).radians)

        var transform3d = CATransform3DIdentity;
        transform3d.m34 = -1/max(size.width, size.height)

        transform3d = CATransform3DRotate(transform3d, a, axis.x, axis.y, 0)
        transform3d = CATransform3DTranslate(transform3d, -size.width/2.0, -size.height/2.0, 0)

        let affineTransform = ProjectionTransform(CGAffineTransform(translationX: size.width/2.0, y: size.height / 2.0))

        return ProjectionTransform(transform3d).concatenating(affineTransform)
    }
}

You should be able to change the animation to whatever you want to achieve and then get the binding to change the state of the parent once it is done.

Comments

7

You need to use a custom modifier.

I have done an example to animate the offset in the X-axis with a completion block.

struct OffsetXEffectModifier: AnimatableModifier {

    var initialOffsetX: CGFloat
    var offsetX: CGFloat
    var onCompletion: (() -> Void)?

    init(offsetX: CGFloat, onCompletion: (() -> Void)? = nil) {
        self.initialOffsetX = offsetX
        self.offsetX = offsetX
        self.onCompletion = onCompletion
    }

    var animatableData: CGFloat {
        get { offsetX }
        set {
            offsetX = newValue
            checkIfFinished()
        }
    }

    func checkIfFinished() -> () {
        if let onCompletion = onCompletion, offsetX == initialOffsetX {
            DispatchQueue.main.async {
                onCompletion()
            }
        }
    }

    func body(content: Content) -> some View {
        content.offset(x: offsetX)
    }
}

struct OffsetXEffectModifier_Previews: PreviewProvider {
  static var previews: some View {
    ZStack {
      Text("Hello")
      .modifier(
        OffsetXEffectModifier(offsetX: 10, onCompletion: {
            print("Completed")
        })
      )
    }
    .frame(width: 100, height: 100, alignment: .bottomLeading)
    .previewLayout(.sizeThatFits)
  }
}

2 Comments

This is a clever solution. It could run into trouble with a spring animation though.
Sure it is not perfect, but I did it in the first versions of SwiftUI, it is possible that now it exists other better way.
2

You can try VDAnimation library

Animate(animationStore) {
    self.someState =~ newState
}
.duration(0.1)
.curve(.linear)
.start {
    ...
}

1 Comment

That's interesting but the documentation is really bad! And half of the thing mentioned there, like Animate, result in Cannot find 'Animate' in scope and alike. Same for AnimationStore. What is an AnimationStore?
1

This version of the modifier worked better for me than the one based on the didSet of animatableData.

My use case is animating a progress bar while the user keeps pressing a button and I want to know when the bar is full. For some reason the didSet block gets called more times than the onChange and the onFinish was being called again when the user released the button after the bar is full.

struct AnimationFinishedModifier: AnimatableModifier {
    private let targetValue: Double
    private let onFinish: @MainActor () -> Void

    // SwiftUI gradually changes this value as the animation happens
    var animatableData: Double

    init(
        animatedValue: Double, 
        targetValue: Double, 
        onFinish: @escaping @MainActor () -> Void
    ) {
        self.onFinish = onFinish
        self.animatableData = animatedValue
        self.targetValue = targetValue
    }


    func body(content: Content) -> some View {
        content
            .onChange(of: animatableData) { newValue in
                if newValue == targetValue {
                    onFinish()
                }
            }
    }
}

// Helper View extension
extension View {
    func onAnimationFinished(
        animatedValue: Double,
        targetValue: Double,
        onFinish: @escaping @MainActor () -> Void
    ) -> some View {
        return self.modifier(
            AnimationFinishedModifier(
                animatedValue: animatedValue,
                targetValue: targetValue,
                onFinish: { onFinish() }
            )
        )
    }
}

EDIT: One important remark for this modifier to work is that it needs to be placed before the animation modifier:

SomeView()
    // First add the animation finished listener
    .onAnimationFinished(
        animatedValue: animatedProgress,
        targetValue: 1.0
    ) { print("Finished!") }
    // Then add the animation
    .animation(
        .linear(duration: 1),
        value: animatedProgress
    )

Comments

0

just my two cents to show an iOS15 logic (hone can help others)

struct ContentView: View {
    private let ofsY = CGFloat(0)
    private let ofsX = CGFloat(0)
    @State private var isVertical = true
    @State private var animatableDeltaY: Double = 0
    @State private var animatableDeltaX: Double = 0

    var body: some View {
        VStack {
            Image(systemName: "globe")
                .font(.system(size: 100))
                .offset(x: ofsX+animatableDeltaX)
                .offset(y: ofsY+animatableDeltaY)
                .animation(.interpolatingSpring(mass: 1, stiffness: 350, damping: 5, initialVelocity: 10),
                           value: isVertical ? animatableDeltaY: animatableDeltaX)
            
            Spacer()
            
            Button("Vertical Bounce") {
                isVertical = true
                animatableDeltaY = 60
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    animatableDeltaY = 0
                }
            }
            
            Spacer()

            Button("Horizontal Bounce") {
                isVertical = false
                animatableDeltaX = 30
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
                    animatableDeltaX = 0
                }
            }
        }
    }
}

I do reset value with a "DispatchQueue.main.asyncAfter" (I have added two offset vars, can be removed, too)

Comments

0

Starting from iOS 17 completion block was added.

Usage

withAnimation(.easeInOut(duration: 3)) {
    presentation = .showing
} completion: {
    presentation = .showed
}

Full function signature

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public func withAnimation<Result>(_ animation: Animation? = .default, completionCriteria: AnimationCompletionCriteria = .logicallyComplete, _ body: () throws -> Result, completion: @escaping () -> Void) rethrows -> Result

Comments

0

Based @Aledis answer, I adding capacity determinate elapsedtime

struct AnimationFinishedModifier: AnimatableModifier {
    private let targetValue: Double
    private let onFinish: @MainActor (TimeInterval) -> Void 

    var animatableData: Double
    @State private var startTime: Date?

    init(
        animatedValue: Double,
        targetValue: Double,
        onFinish: @escaping @MainActor (TimeInterval) -> Void
    ) {
        self.onFinish = onFinish
        self.animatableData = animatedValue
        self.targetValue = targetValue
    }

    func body(content: Content) -> some View {
        content
            .onChange(of: animatableData) { oldValue, newValue in
                if startTime == nil {
                    startTime = Date()
                }

                if newValue == targetValue, let startTime = startTime {
                    let endTime = Date()
                    let elapsedTime = endTime.timeIntervalSince(startTime)
                    self.startTime = nil
                    onFinish(elapsedTime)
                }
            }
    }
}

// Helper View extension
extension View {
    func onAnimationFinished(
        animatedValue: Double,
        targetValue: Double,
        onFinish: @escaping @MainActor (TimeInterval) -> Void
    ) -> some View {
        return self.modifier(
            AnimationFinishedModifier(
                animatedValue: animatedValue,
                targetValue: targetValue,
                onFinish: onFinish
            )
        )
    }
}

struct AnimationFinished: View {
    @State private var animatedProgress: Double = 0.0
    @State private var isAnimating: Bool = false

    // Computed property to represent the boolean state
    private var isAnimated: Bool {
        return animatedProgress == 1.0
    }

    var body: some View {
        VStack {
            Text("Hello world")
                .offset(x: isAnimated ? 100 : 0)

            // First add the animation finished listener
                .onAnimationFinished(
                    animatedValue: animatedProgress,
                    targetValue: isAnimated ? 1.0 : 0.0, // Always check against the target value
                    onFinish: { elapsedTime in
                        print("Finished! Elapsed time: \(elapsedTime) seconds")
                        isAnimating = false
                    }
                )

            // Then add the animation
                .animation(
                    .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0),
                    value: animatedProgress
                )

            Button("Animate") {
                isAnimating = true
                animatedProgress = animatedProgress < 1.0 ? 1.0 : 0
            }
            .buttonStyle(.borderedProminent)
            .disabled(isAnimating)
        }
    }
}

#Preview {
    AnimationFinished()
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.