How does Apple make those fully interactive, smooth-looking animations between home screens? Probably not by using SwiftUI. 😜
Still, SwiftUI is capable of awesome interactive animations, almost matching UIKit's flexibility, but with way less complexity to manage. It does get a bit hairy when mixing them with explicit/implicit animations and state updates tho.
In this post we're going to explore:
- transactions
- explicit and implicit animations
- custom transitions
- the
interpolatingSpringanimator and the mass-spring-damper model
Then we'll put everything together and create a custom .sheet() component.
We'll be loosely following this example, simplifying things for brevity.
If you're looking for a gentler introduction on how animations work in SwiftUI, checkout my tweening animations article.
2025 update
iOS 17 introduced simpler spring syntax: .spring(duration:bounce:) replaces the physics-based mass/stiffness/damping parameters. iOS 26 adds the @Animatable macro, which eliminates manual animatableData boilerplate. The concepts in this article remain valid, but check the updated sections for modern alternatives.
Transactions
Most of the articles out there start by explaining explicit and implicit animations. However, since both are closely related by transactions, we'll explore them first instead.
Transactions can be thought of as contexts that track changes during state mutations. They operate independently of animations and are used whether the state change is animated or not. Each time the view updates due to a state mutation, a transaction is created. This typically occurs implicitly, but you can also configure and pass a transaction through a binding or when using withTransaction(_:_:) or withAnimation(_:_:).
Note:
A transaction can have animation or not. If it doesn't, the update will be instant.
var transaction = Transaction()
transaction.isContinuous = true
transaction.animation = .interpolatingSpring(stiffness: 30,
damping: 20)
withTransaction(transaction) {
progress = candidate
}
Explicit and implicit animations
In SwiftUI, there are two ways to animate views. One is by explicitly triggering an animation using withAnimation(), while the other employs declarative syntax, instructing the framework to animate all transactions triggered by changing a specific value. The latter is achieved via the .animation(animation, value:) modifier. We refer to them as explicit and implicit animations, respectively.
However, both are just distinct methods for configuring animations on the transaction of a state update and can be directly replicated using .transaction(transform:) and withTransaction()."
// both using the modifier...
SomeView()
.animation(.linear, value: progress)
// ...and using `withAnimation()` explicit call
withAnimation(.linear) {
progress = someValue
}
// is shorthand for
var transaction = Transaction()
transaction.animation = .linear
withTransaction(transaction) {
progress = someValue
}
Now the gap between implicit and explicit animations seems narrower. They're both ways to configure the transaction when one or multiple values change.
Join us
Custom transitions
Careful:
Animating something on-screen while changing its position/size/etc during a gesture update is trivial in SwiftUI. The harder part is to make it seamlessly work with other animations and more important, with transitions.
The good news is that this is possible, the bad news is that most of the time you'll need to create custom transitions to do so.
Here's a quick example of a modifier able to interactively present/dismiss the view by being Animatable. The modifier is then used to create the .screenOut transition.
struct ScreenOutModifier: ViewModifier, Animatable {
let edge: Edge
var progress: Double
@State private var rect: CGRect = .zero
var animatableData: Double {
get { progress }
set { progress = newValue }
}
func body(content: Content) -> some View {
content
.transformEffect(calculateAffineTransform())
.overlay {
GeometryReader { geo in
Color.clear
.preference(key: PresentedContentSizeKey.self,
value: geo.frame(in: .global))
}
.onPreferenceChange(PresentedContentSizeKey.self) {
rect = $0
}
}
}
private func calculateAffineTransform() -> CGAffineTransform {
let target: CGPoint
switch edge {
case .trailing:
target = CGPoint(x: UIScreen.main.bounds.maxX, y: 0)
case .leading:
target = CGPoint(x: -rect.maxX, y: 0)
case .top:
target = CGPoint(x: 0, y: -rect.maxY)
case .bottom:
target = CGPoint(x: 0, y: UIScreen.main.bounds.maxY)
}
return .init(translationX: target.x * progress,
y: target.y * progress)
}
}
extension AnyTransition {
static func screenOut(edge: Edge) -> AnyTransition {
AnyTransition.modifier(
active: ScreenOutModifier(edge: edge, progress: 1),
identity: ScreenOutModifier(edge: edge, progress: 0)
)
}
}
iOS 26: The @Animatable Macro
iOS 26 simplifies custom animatable modifiers with the @Animatable macro:
@Animatable
struct ScreenOutModifier: ViewModifier {
let edge: Edge
var progress: Double // Automatically tracked
@State private var rect: CGRect = .zero
// No manual animatableData needed
func body(content: Content) -> some View {
content
.transformEffect(calculateAffineTransform())
// ... rest of implementation
}
}
The macro automatically synthesizes animatableData for all animatable properties, eliminating the manual getter/setter boilerplate.
The interpolatingSpring animator
.interpolatingSpring is a special animator. Not only it has all the properties of a spring animator and but it also interpolates its values with other animators in the same transaction, resulting in smooth passings between them.
This allows us to have a seamless animation when leaving the interactive mode.
Normally, you'd have to calculate the acceleration and have a custom timing function for the explicit animation that dampers the interactive one. Or else, you'll get a "hiccup". This is not trivial to get right. Luckily with .interpolatingSpring, it's all handled using the mass-spring-damper model.
Spring animators are based on Hooke's law and the mass-spring-damper physics model. If you want to have a better intuition about its parameters I'd recommend Khan Academy's Hair simulation 101 (yeah, the same model is used for hair simulation)
When mixed with other interpolating spring animators on the same transaction, each animator will be replaced by their successor, preserving velocity from one to the next. This makes it perfect for continuous updates.
iOS 17+ Spring Syntax
iOS 17 introduced a more intuitive spring API based on duration and bounce:
// iOS 17+ preferred syntax
.spring(duration: 0.5, bounce: 0.3)
// Equivalent to the physics-based approach
.interpolatingSpring(stiffness: 30, damping: 20)
The duration controls perceived animation length, bounce controls overshoot (0 = no bounce, 1 = maximum). This is easier to tune than mass/stiffness/damping.
Putting everything together
With all the info from above we can move ahead and write the interactive sheet modifier:
private struct DynamicParameters: Equatable {
var translation: Double = 0
var delta: Double = 0
}
private struct InlineSheetModifier<C: View>: ViewModifier {
private let contentBuilder: () -> C
@Binding var isPresented: Bool
@State private var progress: Double = 0
@GestureState private var dynamics: DynamicParameters = .init()
init(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> C) {
self.contentBuilder = content
_isPresented = isPresented
}
func body(content: Content) -> some View {
content
.modifier(InlineSheetProgressModifier(progress: progress,
content: contentBuilder))
.onChange(of: isPresented) { value in
withAnimation(.easeInOut) {
progress = value ? 1 : 0
}
}
.onChange(of: dynamics) {
if $0.delta == 0 {
return
}
let candidate = progress - $0.delta / UIScreen.main.bounds.height
if candidate > 0, candidate < 1 {
var transaction = Transaction()
transaction.isContinuous = true
transaction.animation = .interpolatingSpring(stiffness: 30, damping: 20)
withTransaction(transaction) {
progress = candidate
}
}
}
.gesture(
DragGesture(minimumDistance: 0)
.updating($dynamics) { value, state, _ in
if state.translation > 0 {
state.delta = value.translation.height - state.translation
state.translation = value.translation.height
}
else {
state.translation = value.translation.height
}
}
.onEnded { _ in
withAnimation(.easeInOut(duration: 0.25)) {
if progress < 0.7 {
isPresented = false
progress = 0
}
else {
isPresented = true
progress = 1
}
}
}
)
}
}
extension View {
func inlineSheet<V: View>(isPresented: Binding<Bool>,
@ViewBuilder content: @escaping () -> V) -> some View
{
modifier(InlineSheetModifier(isPresented: isPresented, content: content))
}
}
