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
interpolatingSpring
animator 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.
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 inteactively 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)
)
}
}
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 the Hookes 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.
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))
}
}