Skip to main content
Both Wave’s block-based API and SpringAnimator provide completion handlers that fire when animations finish or retarget to new values. Understanding these callbacks is essential for coordinating complex animations and UI state changes.

Block-based completion

The Wave.animate(withSpring:) method accepts an optional completion closure:
Wave.animate(
    withSpring: spring,
    animations: {
        view.animator.alpha = 0.0
    },
    completion: { finished, retargeted in
        if finished && !retargeted {
            view.removeFromSuperview()
        }
    }
)

Completion parameters

finished
Bool
Indicates whether the animation completed naturally (true) or is still in progress because it was retargeted (false).
retargeted
Bool
Indicates whether the animation’s target value changed mid-flight (true) or completed without interruption (false).

Understanding the flags

The two boolean parameters create four possible states:
finishedretargetedMeaning
truefalseAnimation completed normally
falsetrueAnimation was retargeted and continues
falsefalseAnimation was interrupted (rare)
truetrueNot used
The most common pattern is checking finished && !retargeted to detect clean completion.

Property-based completion

SpringAnimator uses a more expressive enum-based completion API:
let animator = SpringAnimator<CGFloat>(
    spring: spring,
    value: 0.0,
    target: 1.0
)

animator.completion = { event in
    switch event {
    case .finished(at: let finalValue):
        print("Completed at: \(finalValue)")
        
    case .retargeted(from: let oldTarget, to: let newTarget):
        print("Changed from \(oldTarget) to \(newTarget)")
    }
}

animator.start()

Completion events

Finished event

Fired when the animation naturally completes:
case .finished(at: let finalValue):
    // Animation reached its target
    // finalValue contains the final animated value
The associated value provides the final value at completion:
animator.completion = { event in
    switch event {
    case .finished(at: let position):
        print("View ended at: \(position)")
        self.cleanupAfterAnimation()
    case .retargeted:
        break
    }
}

Retargeted event

Fired when the target property changes while the animation is running:
case .retargeted(from: let oldTarget, to: let newTarget):
    // Animation was interrupted and redirected
    // oldTarget: the previous target value
    // newTarget: the new target value
Example usage:
animator.completion = { event in
    switch event {
    case .finished:
        break
    case .retargeted(from: let old, to: let new):
        print("Retargeted from \(old) to \(new)")
        self.updateTrackingUI(newTarget: new)
    }
}

When completions fire

Normal completion

Animation runs to completion without interruption:
Wave.animate(withSpring: spring) {
    view.animator.center = CGPoint(x: 200, y: 200)
} completion: { finished, retargeted in
    // After animation settles:
    // finished = true, retargeted = false
    print("Animation completed normally")
}

Retargeting

Target changes while animation is running:
// Start first animation
Wave.animate(withSpring: spring) {
    view.animator.center = CGPoint(x: 200, y: 200)
} completion: { finished, retargeted in
    // This fires with: finished = false, retargeted = true
    print("First animation retargeted")
}

// Before first animation finishes, start another
Wave.animate(withSpring: spring) {
    view.animator.center = CGPoint(x: 100, y: 100)
} completion: { finished, retargeted in
    // This fires with: finished = true, retargeted = false
    print("Second animation completed")
}

Manual stopping

With SpringAnimator, calling stop() triggers the completion handler:
let animator = SpringAnimator<CGFloat>(
    spring: spring,
    value: 0.0,
    target: 1.0
)

animator.completion = { event in
    switch event {
    case .finished(at: let value):
        print("Stopped at: \(value)")
    case .retargeted:
        break
    }
}

animator.start()

// Later...
animator.stop(immediately: true)  // Fires completion with .finished

Common patterns

Clean up after animation

Remove views or reset state only when animation completes normally:
Wave.animate(
    withSpring: spring,
    animations: {
        view.animator.alpha = 0.0
        view.animator.scale = CGPoint(x: 0.8, y: 0.8)
    },
    completion: { finished, retargeted in
        guard finished && !retargeted else { return }
        view.removeFromSuperview()
    }
)

Chain animations

Start a new animation when the previous one completes:
Wave.animate(
    withSpring: Spring(dampingRatio: 0.8, response: 0.5),
    animations: {
        view.animator.center = CGPoint(x: 200, y: 200)
    },
    completion: { finished, retargeted in
        guard finished && !retargeted else { return }
        
        // Start second animation
        Wave.animate(withSpring: spring) {
            view.animator.backgroundColor = .systemGreen
            view.layer.animator.cornerRadius = 25
        }
    }
)

Track retargeting count

Count how many times an animation retargets:
var retargetCount = 0

let animator = SpringAnimator<CGPoint>(
    spring: spring,
    value: view.center,
    target: destination
)

animator.completion = { event in
    switch event {
    case .finished:
        print("Completed after \(retargetCount) retargets")
        retargetCount = 0
        
    case .retargeted:
        retargetCount += 1
    }
}

Dismiss after animation

Dismiss a view controller when its exit animation completes:
Wave.animate(
    withSpring: Spring(dampingRatio: 0.9, response: 0.4),
    animations: {
        modalView.animator.origin = CGPoint(x: 0, y: view.bounds.height)
        modalView.animator.alpha = 0.0
    },
    completion: { [weak self] finished, retargeted in
        guard finished && !retargeted else { return }
        self?.dismiss(animated: false)
    }
)

State machine transitions

Update state only when animations complete:
enum SheetState {
    case collapsed
    case expanded
}

var currentState: SheetState = .collapsed

func expand() {
    sheetAnimator.target = 1.0
    sheetAnimator.completion = { [weak self] event in
        guard case .finished = event else { return }
        self?.currentState = .expanded
    }
    sheetAnimator.start()
}

Multiple property completions

When animating multiple properties in a block, the completion fires once when ALL properties finish:
Wave.animate(
    withSpring: spring,
    animations: {
        view.animator.center = newCenter
        view.animator.alpha = 0.5
        view.layer.animator.cornerRadius = 20
    },
    completion: { finished, retargeted in
        // Fires after ALL three properties settle
        print("All properties finished")
    }
)
If you need to track individual property completions, use separate SpringAnimator instances instead.

Completion guarantees

Always called

Completion handlers are guaranteed to be called eventually, whether the animation:
  • Completes normally
  • Gets retargeted
  • Gets stopped manually
  • Gets interrupted by animation mode changes

Main thread

Completion handlers always execute on the main thread:
animator.completion = { event in
    // Safe to update UI directly
    self.label.text = "Animation complete"
}

Weak references

Use [weak self] to avoid retain cycles:
Wave.animate(
    withSpring: spring,
    animations: {
        view.animator.alpha = 0.0
    },
    completion: { [weak self] finished, retargeted in
        guard let self = self else { return }
        self.handleCompletion()
    }
)

Real-world example

Here’s a complete modal presentation/dismissal implementation:
class ModalViewController: UIViewController {
    let modalView = UIView()
    let overlayView = UIView()
    
    let presentSpring = Spring(dampingRatio: 0.85, response: 0.5)
    let dismissSpring = Spring(dampingRatio: 0.9, response: 0.4)
    
    func present() {
        // Setup initial state
        modalView.frame = CGRect(
            x: 0,
            y: view.bounds.height,
            width: view.bounds.width,
            height: 400
        )
        overlayView.alpha = 0
        
        view.addSubview(overlayView)
        view.addSubview(modalView)
        
        // Animate in
        Wave.animate(
            withSpring: presentSpring,
            animations: { [self] in
                modalView.animator.origin = CGPoint(
                    x: 0,
                    y: view.bounds.height - 400
                )
                overlayView.animator.alpha = 0.4
            },
            completion: { [weak self] finished, retargeted in
                guard finished && !retargeted else { return }
                self?.didFinishPresenting()
            }
        )
    }
    
    func dismiss() {
        Wave.animate(
            withSpring: dismissSpring,
            animations: { [self] in
                modalView.animator.origin = CGPoint(
                    x: 0,
                    y: view.bounds.height
                )
                overlayView.animator.alpha = 0.0
            },
            completion: { [weak self] finished, retargeted in
                guard finished && !retargeted else { return }
                self?.modalView.removeFromSuperview()
                self?.overlayView.removeFromSuperview()
                self?.didFinishDismissing()
            }
        )
    }
    
    private func didFinishPresenting() {
        // Enable interaction, update state, etc.
        modalView.isUserInteractionEnabled = true
    }
    
    private func didFinishDismissing() {
        // Clean up, notify delegate, etc.
        dismiss(animated: false)
    }
}

Debugging completions

Add logging to understand when and why completions fire:
animator.completion = { event in
    switch event {
    case .finished(at: let value):
        print("βœ… Finished at: \(value)")
    case .retargeted(from: let old, to: let new):
        print("πŸ”„ Retargeted: \(old) β†’ \(new)")
    }
}
For block-based API:
Wave.animate(
    withSpring: spring,
    animations: { /*...*/ },
    completion: { finished, retargeted in
        let status = finished ? "βœ…" : "⏸️"
        let action = retargeted ? "retargeted" : "completed"
        print("\(status) Animation \(action)")
    }
)

Best practices

1

Check both flags

Always check both finished and retargeted flags in block-based completions to understand what happened.
2

Handle all event types

When using SpringAnimator, handle both .finished and .retargeted events in your switch statement.
3

Use weak references

Always use [weak self] in completion closures to prevent retain cycles.
4

Guard against retargeting

Use guard finished && !retargeted else { return } when you only want to run code on clean completion.
5

Don't assume immediate completion

Never assume completions fire immediately. Springs can take hundreds of milliseconds to settle.
6

Test edge cases

Test what happens when animations are interrupted, retargeted, or the view controller is dismissed mid-animation.

Next steps

Block-based animations

Learn the Wave.animate API

Property-based animations

Use SpringAnimator for more control

Gesture integration

Combine gestures with spring animations

Spring configuration

Fine-tune animation feel

Build docs developers (and LLMs) love