Creating Custom Transitions

One of the most powerful features of SwiftUINavigationTransitions is its ability to define completely custom transitions using a declarative, composable API. This guide explains the core concepts and shows you how to build your own.

Core Concepts

The library's transition system is built on two key protocols: NavigationTransitionProtocol and AtomicTransition.

This is the high-level protocol that defines a complete push and pop transition between two views (a "from" view and a "to" view). Transitions like .slide are implementations of this protocol. You typically build a NavigationTransitionProtocol by composing smaller, single-purpose transitions.

AtomicTransition

This is the low-level building block. An AtomicTransition defines a single, isolated animation effect for a single view, specifying how it should behave on insertion and removal. Examples include Opacity, Scale, and Move. You combine these atomic transitions to create a NavigationTransitionProtocol.

Building a Custom Transition

Let's build the swing transition seen in the demo app. It slides the new view in, while the old view swings out with a rotation and offset.

We will define a new struct that conforms to NavigationTransitionProtocol.

import SwiftUINavigationTransitions
import SwiftUI

struct Swing: NavigationTransitionProtocol {
    var body: some NavigationTransitionProtocol {
        // The implementation will go here
    }
}

// It's good practice to add a static helper for convenience
extension AnyNavigationTransition {
    static var swing: Self {
        .init(Swing())
    }
}

Composing Atomic Transitions

The body of a NavigationTransitionProtocol uses a result builder to combine other transitions. The swing transition can be broken down into two parts:

  1. The standard Slide transition for the new view coming in.
  2. A custom "swing out" effect for the old view going out.

We can compose these like so:

struct Swing: NavigationTransitionProtocol {
    var body: some NavigationTransitionProtocol {
        // Apply a standard slide transition to both views.
        Slide(axis: .horizontal)

        // Define the custom "swing" effect.
        MirrorPush {
            let angle = 70.0
            let offset = 150.0

            // On insertion, the "to" view is pushed on top.
            OnInsertion {
                ZPosition(1)
                Rotate(.degrees(-angle))
                Offset(x: offset)
                Opacity()
                Scale(0.5)
            }

            // On removal, the "from" view swings out.
            OnRemoval {
                Rotate(.degrees(angle))
                Offset(x: -offset)
            }
        }
    }
}

Let's break down the MirrorPush block:

  • MirrorPush: This is a container that defines a transition for a push operation. The library automatically creates the inverse ("mirrored") transition for the pop operation. This saves you from having to define the pop animation separately.
  • OnInsertion: This block applies only to the view being inserted (the destination view in a push). We apply multiple atomic transitions:
    • ZPosition(1): Puts the new view on top.
    • Rotate, Offset, Scale, Opacity: These define the initial state of the incoming view (rotated, offset, scaled down, and transparent) before it animates to its final state.
  • OnRemoval: This block applies only to the view being removed (the source view in a push). It's simpler: it just rotates and offsets the view as it animates out.

By combining the base Slide with this custom MirrorPush block, we override parts of the slide behavior to create the unique swing effect, while keeping the core slide movement for the incoming view. This composability is what makes the system so flexible.