← All articles

Interactive animations in SwiftUI

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
Sign up and be the first to know when we release new content!

Custom transitions

Careful:
That's transitions, not transactions.

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))
    }
}
Animation showing the transition in action
Animation showing the transition in action