Published on

Interactive animations in SwiftUI

Authors

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 too, 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 here and there for brevity.

Swiftui state property wrappers

Transactions

Most of the articles out there start by explaining explicit and implicit animations. But since both are backed by transactions, we'll start with them instead.

Transactions are contexts created on each state mutation. Every time the view updates due to a state update, a transaction is inherited from the binding that changed or the values set by withTransaction(_:_:)/withAnimation(_:_:). If neither the binding nor the mentioned methods set a specific transaction, one is created from scratch.

Note:

A transaction can have animation or not. If it doesn't, the update will be instant.

Also, an important aspect to keep in mind here is that withTransaction(_:_:) and withAnimation(_:_:) are setting the transaction for the state update triggered inside their closure:

var transaction = Transaction()
transaction.isContinuous = true
transaction.animation = .interpolatingSpring(stiffness: 30,
                                             damping: 20)
withTransaction(transaction) {
    // this transaction will affect views that
    // change when progress updates and no other
    progress = candidate
}

Explicit and implicit animations

In SwiftUI animations are just timing functions: they define the way the transform time (rotation, translation, morphing, etc) progresses compared to the wall time.

Example:

If you use .linear the wall time will run 1:1 to the progress time. For .easeIn, the progress time will start slow (e.g. presenting 30% of the transform while the wall time reached 75% of the duration) only to recover sharply in the end.

.animation(animation:value:), .animation(animation:) and withAnimation() are almost (.animation(animation:value:) also sets the transaction only if the value parameter changes) syntactic sugar for .transaction(transform:) and withTransaction().

// adding the `transaction` modifier to a view that
// updates when `progress` state changes
transaction({
    $0.animation = .linear
})

// is practically the same as adding the
// animation modifier
animation(.linear)

// using `withAnimation()`
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: withTransaction() is setting the transaction for the state update in its closure, while .animation(animation:) sets the transaction for all state updates affecting the view (while also passing it down to its subviews). You can see why this was a bad idea and Apple deprecated it in favor of .animation(animation:value:), which is similar, but limits setting the transaction only for updates that mutate the value parameter.

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 implicit/explicit animations and transitions. The good news is that animations can be interpolated, including when adding and removing views on screen. The bad news is that most of the time you'll need to create custom transitions in order to sync the interactive animation with the transition. I'll not focus heavily on this subject in this post, but here's a quick example of a transition that presents/dismisses the view from one of the screen's edges while being Animatable (which means, interactive-ready)

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)
    }
}
Subscribe and get articles like this in your email!

The interpolatingSpring animator

.interpolatingSpring is a special animator. Not only 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 even better: 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 form 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))
    }
}