All Articles
CategorySwiftUI
Reading Time
9 min read
Published
2026-10-15
Word Count
1,527words

Grab a coffee — this one is a deep dive!

SwiftUI-Animation-Masterclass 2.0: motion you can call, test and turn off

Summary

Why a SwiftUI animation package exposes its springs, decay and particles as plain value types, and what rebuilding it as 2.0.0 taught about tests.

  • Version 2.0.0 was released on 15 October 2026.
  • DampedSpring, Decay, GravityBounce, RubberBand, SnapPoints, ParticleSystem and ChangeEffect.state(at:) compute motion with no view code.
  • Add the package with Swift Package Manager: swift dependencies: [ ], targets: [ ] Then add a modifier to any view.
  • The 2.0.0 test suite has 73 Swift Testing tests in 13 suites; 70 run on a normal swift test and three that render README media are skipped unless an output folder is set.
SwiftUI-Animation-Masterclass 2.0: motion you can call, test and turn off

SwiftUI-Animation-Masterclass is a Swift package of motion for SwiftUI on iOS 17+ and macOS 14+:

physics-based springs, decay and bounces, drags that keep the speed of the finger, particle

effects, change effects and transitions. Version 2.0.0 was released on 15 October 2026. This

article covers the problem it tries to solve, the design decisions behind it, a bug that shaped

it and the limits it still has.

Table of Contents


The problem

SwiftUI's built-in animations answer one question well: "animate this change with this curve."

They do not expose the motion itself. A few common interactions need exactly that:

  • Where will a flick land? A pull-up panel has to pick a detent from the release position

_and_ the release speed, before any animation starts.

  • How do you hand the finger's speed to a spring? If the spring starts at rest after a fast

drag, the view visibly stops and starts again.

  • How do you test an effect? A shake or a bounce is hard to assert on when the only way to

see it is to run the app and watch.

The package is aimed at app developers who want one good effect on one screen without adopting

a framework. Everything it ships is a view modifier, an Animation, a Transition or a plain

value type, and it has no dependencies.

Design decisions and the alternatives

The math lives in value types, the modifiers are thin

DampedSpring, Decay, GravityBounce, RubberBand, SnapPoints, ParticleSystem and

ChangeEffect.state(at:) compute motion with no view code. The modifiers only read the

environment, keep gesture or animation state and apply the result. The alternative, putting the

logic inside each ViewModifier, is shorter to write but leaves nothing to unit-test without a

screen.

A spring solved in closed form

DampedSpring uses the analytic solution of the damped spring equation instead of stepping it

frame by frame. Position becomes a function of time, so any frame can be computed on its own,

whether for a test or for rendering a GIF. The cost is handling the underdamped, critically

damped and overdamped cases explicitly. To check that work, the tests do not compare against

numbers copied from the implementation: PhysicsTests integrates the same spring numerically

and compares the two.

The obvious alternative is SwiftUI's own .spring(...), and the package still ends there:

DampedSpring.animation(initialVelocity:) returns an ordinary Animation, and

DampedSpring.relativeVelocity(_:from:to:) converts a gesture velocity in points per second

into the relative velocity SwiftUI expects. Using .spring(...) alone, however, gives no access

to values such as a settling time.

One Canvas and a seeded simulation for particles

ParticleSystem is a struct with no timer of its own; it advances when update(by:in:) is

called. ParticleView drives it from a TimelineView and draws every particle into one

Canvas, and the clock pauses when nothing is alive. Randomness comes from a small SplitMix64

generator, so a seed fully determines the particles. That is what lets the tests and the README

animations produce the same frames on every run. The package does not include a performance

measurement on a device; one Canvas is a design choice, not a benchmark result.

Reduce Motion inside every effect

Each effect reads accessibilityReduceMotion from the environment itself: particles are not

drawn, change effects do not play, transitions fade, and drags settle with a short ease-out. The

alternative, asking every call site to check the setting, is easy to forget. The DocC article

"Respecting Reduce Motion" lists what each effect does, and it also says where this stops:

animations you start yourself with withAnimation are not changed for you.

Narrower platforms on purpose

The package requires Swift 6.2 tools, iOS 17 and macOS 14, and builds in the Swift 6 language

mode. visualEffect, keyframeAnimator and the iOS 17 Transition protocol need those

versions. Platforms that are not built and tested in CI are not listed in the manifest.

What the history taught: a modifier that did nothing

The repository was opened in August 2025. Its 1.0.0 release (February 2026) could not be built:

the manifest pointed at Sources/Components and Sources/Utilities, folders that were not in

the repository. In June 2026 most of the code was removed (15850b4, 5,991 lines deleted), and

what remained was a demo view and one new public API, scrollDriven(multiplier:).

Reading that modifier during the rebuild showed the problem. This is its body as of d62293e:

swift
1public func body(content: Content) -> some View {
2 // High-performance GeometryReader implementation
3 content
4 .overlay(
5 GeometryReader { proxy in
6 Color.clear.preference(key: ScrollOffsetKey.self, value: proxy.frame(in: .global).minY)
7 }
8 )
9}

multiplier was never read, and the measured frame went into a preference that nothing

observed. The API compiled, had documentation, and had no effect. Commit e08cc0a kept both

signatures and made the modifier do what its name says:

swift
1public func body(content: Content) -> some View {
2 let multiplier = reduceMotion ? 0 : multiplier
3 content.visualEffect { effect, proxy in
4 effect.offset(y: ScrollParallax.offset(minY: proxy.frame(in: .scrollView).minY, multiplier: multiplier))
5 }
6}

The math moved into ScrollParallax.offset(minY:multiplier:), a pure function that returns

-minY · multiplier and 0 for non-finite input, and ScrollTests checks it. The CHANGELOG

lists the change under "Changed", because existing callers now see their views move.

The same review found a quieter class of bug in the physics types. DampedSpring, Decay and

GravityBounce validated their constants in the initializer, but the constants are public

vars, so setting mass = 0 afterwards skipped the check and every method returned NaN.

6a44ac5 added the same precondition to each property's didSet. Then f0698d8 closed a second

hole: .infinity passes > 0, and an infinite stiffness produced ∞/∞. The checks now require

finite values, and the commit notes that all 11 new exit-test expectations fail without the fix.

A last detail is about the tests themselves. One test set drop.gravity = 2000 and then checked

drop.gravity == 2000, which only proves a stored property round-trips. 921732a replaced it

with a check that a 100-unit fall first lands after √(2·100/2000) seconds, so the test fails if

the new gravity never reaches the motion.

The lesson in all three cases is the same: a public API that compiles is not the same as one

that works, and a test is only useful if it fails when the behaviour breaks.

Usage

Add the package with Swift Package Manager:

swift
1dependencies: [
2 .package(url: "https://github.com/muhittincamdali/SwiftUI-Animation-Masterclass.git", from: "2.0.0")
3],
4targets: [
5 .target(name: "YourApp", dependencies: [
6 .product(name: "SwiftUIAnimationMasterclass", package: "SwiftUI-Animation-Masterclass")
7 ])
8]

Then add a modifier to any view. This like button jumps and sends up a burst of hearts on every

tap:

swift
1import SwiftUI
2import SwiftUIAnimationMasterclass
3 
4struct LikeButton: View {
5 @State private var likes = 0
6 
7 var body: some View {
8 Button("Like", systemImage: "heart.fill") { likes += 1 }
9 .buttonStyle(.borderedProminent)
10 .changeEffect(.jump(), value: likes)
11 .particleBurst(.hearts, trigger: likes)
12 }
13}

The pull-up panel from the problem section is a single modifier. A slow release returns to the

nearest offset, a flick carries on to the next one:

swift
1struct PullUpPanel: View {
2 @State private var detent = 1
3 
4 var body: some View {
5 RoundedRectangle(cornerRadius: 24)
6 .fill(.blue.gradient)
7 .frame(height: 400)
8 .overlay(alignment: .top) {
9 Capsule().fill(.white.opacity(0.6)).frame(width: 40, height: 5).padding(10)
10 }
11 .snapDrag(.vertical, offsets: [0, 260], selection: $detent)
12 }
13}

Both samples come from ReadmeSamples.swift, which the example app and the test target compile,

so the README cannot drift from the API without a build failing. To see every effect at once,

put AnimationShowcase() in a #Preview, or run the MotionGallery example app on an iPhone

simulator or on a Mac.

Limits

  • No device performance numbers. Particle counts are capped at 10,000 and simulation steps

at 1/60 s, but frame rate and CPU use have not been measured on hardware.

  • Rendering is checked, not snapshot-tested. Tests render views with ImageRenderer and

check coverage and pixels; there are no reference-image comparisons, and the visualEffect

wiring of scrollDriven is not covered by a UI test.

  • Crash checks run on macOS only. The exit tests that prove invalid constants stop the

process use a macOS-only Swift Testing feature.

  • Preconditions trap in release builds. Invalid configuration such as a zero mass stops the

app where it is written; values from the system, like gesture velocities, are clamped instead.

  • One scalar velocity. SwiftUI's spring takes a single initial velocity, so a 2D drag

projects its velocity onto the direction of travel.

  • Narrow platform range. iOS 17+, macOS 14+ and Xcode 26 or later.

The 2.0.0 test suite has 73 Swift Testing tests in 13 suites; 70 run on a normal swift test

and three that render README media are skipped unless an output folder is set. The roadmap in

the README lists a path-following animation and more transitions on the same Transition

protocol.

Source code: github.com/muhittincamdali/SwiftUI-Animation-Masterclass

Tags

#swiftui#animation#physics#particles#swift-package#accessibility
Muhittin Çamdalı

Muhittin Çamdalı

Senior Mobile Engineer

Senior Mobile Engineer with 12+ years of experience. I build mobile products with iOS, Flutter and React Native, working full-stack and AI-native.

iOS Development News

Weekly Swift tips, SwiftUI tricks and iOS best practices. No spam, only valuable content.

Your subscription starts when you open the link in the confirmation email and press “Confirm my subscription”. The newsletter keeps open/click statistics; you can unsubscribe anytime with one click. Privacy

Share