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
- Design decisions and the alternatives
- The math lives in value types, the modifiers are thin
- A spring solved in closed form
- One Canvas and a seeded simulation for particles
- Reduce Motion inside every effect
- Narrower platforms on purpose
- What the history taught: a modifier that did nothing
- Usage
- Limits
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:
1public func body(content: Content) -> some View {2 // High-performance GeometryReader implementation3 content4 .overlay(5 GeometryReader { proxy in6 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:
1public func body(content: Content) -> some View {2 let multiplier = reduceMotion ? 0 : multiplier3 content.visualEffect { effect, proxy in4 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:
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:
1import SwiftUI2import SwiftUIAnimationMasterclass3 4struct LikeButton: View {5 @State private var likes = 06 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:
1struct PullUpPanel: View {2 @State private var detent = 13 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
ImageRendererand
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
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

