SwiftUINavigationTransitions

repository·main·Indexed 22 days ago

https://github.com/davdroman/swiftui-navigation-transitions

A library for customizing push and pop transitions within SwiftUI's NavigationView (iOS 13+) and NavigationStack (iOS 16+). It provides a .customNavigationTransition modifier, built-in transitions like slide and fade, and a framework for creating custom animations using NavigationTransitionProtocol and AtomicTransition. The library also includes interactivity controls for back gestures, such as .edgePan and .contentPan.

Tokens
3.9K
Snippets
10
Records
13
Agent score
28%

What's inside SwiftUINavigationTransitions

  1. Use `NavigationTransitionProtocol` to define full navigation flows

    main

    NavigationTransitionProtocol is the primary interface for defining how a pair of views interact during navigation. It uses a result builder syntax to compose transitions for different lifecycle events.

    Commonly, you will implement this protocol by wrapping AtomicTransition types. For example, a Slide implementation might use MirrorPush to coordinate OnInsertion and OnRemoval behaviors for different axes.

    Example of a protocol implementation structure:

    public struct Slide: NavigationTransitionProtocol {
        private let axis: Axis
    
        public init(axis: Axis) {
            self.axis = axis
        }
    
        public var body: some NavigationTransitionProtocol {
            switch axis {
            case .horizontal:
                MirrorPush {
                    OnInsertion {
                        Move(edge: .trailing)
                    }
                    OnRemoval {
                        Move(edge: .leading)
                    }
                }
            case .vertical:
                MirrorPush {
                    OnInsertion {
                        Move(edge: .bottom)
                    }
                    OnRemoval {
                        Move(edge: .top)
                    }
                }
            }
        }
    }
  2. Implement custom transitions using `AtomicTransition`

    main

    For most use cases, the recommended way to build custom transitions is by implementing the AtomicTransition protocol. This allows you to define small, reusable building blocks that can be composed via NavigationTransitionProtocol.

    An AtomicTransition requires a transition handler that provides:

    • TransientView: An abstraction over the UIView being animated. You assign values for three stages: initial, animation, and completion.
    • TransitionOperation: An enum indicating if the operation is an .insertion or a .removal.
    • Container: A UIView representing the container where the transition occurs.

    This approach is highly reusable and prevents UI glitches by allowing the transition engine to merge states for properties affected by multiple atomic transitions.

  3. Understand the core abstractions for custom transitions

    main

    Building custom transitions in this library relies on a layered approach using two primary abstractions: NavigationTransitionProtocol and AtomicTransition.

    This is the high-level construct used to describe a full navigation event. It defines both the push and pop behaviors for both the origin and destination views. While you often interact with type-erased wrappers like CustomNavigationTransition (e.g., .slide), the actual logic resides in types conforming to NavigationTransitionProtocol.

    AtomicTransition

    An AtomicTransition is a lower-level building block inspired by SwiftUI's AnyTransition. Unlike the protocol above, an AtomicTransition applies to a single view and is agnostic of the navigation intent (it doesn't care if the view is being pushed or popped). It describes specific view changes for both insertion and removal operations.

    By composing multiple AtomicTransition instances (like Move) within a NavigationTransitionProtocol using result builder syntax, you can define complex navigation behaviors without writing explicit UIView animation code.

  4. Apply custom navigation transitions to NavigationView or NavigationStack

    main

    Use the .customNavigationTransition(_:) modifier to apply push and pop transitions to SwiftUI's first-party navigation components. This works with both NavigationView (iOS 13+) and NavigationStack (iOS 16+).

    For NavigationView, ensure you apply .navigationViewStyle(.stack) to ensure the transitions behave as expected.

    // iOS 16+
    NavigationStack {
      // ...
    }
    .customNavigationTransition(.slide)
    
    // iOS 13+
    NavigationView {
      // ...
    }
    .navigationViewStyle(.stack)
    .customNavigationTransition(.slide)
  5. Create custom transitions using NavigationTransitionProtocol

    main

    You can define complex, custom transitions by conforming to the NavigationTransitionProtocol. This allows you to specify different behaviors for insertion and removal using a MirrorPush structure and various modifiers like ZPosition, Rotate, Offset, Opacity, and Scale within OnInsertion and OnRemoval blocks.

    struct Swing: NavigationTransitionProtocol {
        var body: some NavigationTransitionProtocol {
            Slide(axis: .horizontal)
            MirrorPush {
                let angle = 70.0
                let offset = 150.0
                OnInsertion {
                    ZPosition(1)
                    Rotate(.degrees(-angle))
                    Offset(x: offset)
                    Opacity()
                    Scale(0.5)
                }
                OnRemoval {
                    Rotate(.degrees(angle))
                    Offset(x: -offset)
                }
            }
        }
    }
  6. Implement a holistic transition with `NavigationTransitionProtocol.transition(from:to:for:in:)`

    main

    If you only need a single custom transition and do not want to build atomic building blocks, you can implement the transition(from:to:for:in:) function directly in a type conforming to NavigationTransitionProtocol.

    This provides full context of the transition:

    • fromView and toView: TransientView instances for the origin and destination views.
    • TransitionOperation: Defines whether the operation is a .push or a .pop (unlike AtomicTransition, which uses insertion/removal).
    • Container: The UIView container where the views are added.

    Note: While easier for one-off transitions, modeling transitions via AtomicTransition is recommended for apps with multiple custom transitions to promote reusability.

    func transition(from fromView: TransientView, to toView: TransientView, for operation: TransitionOperation, in container: Container)
  7. Configure transition animations and combinations

    main

    The API allows you to apply standard SwiftUI-style animations to your transitions. You can also combine multiple transitions into a single effect using the .combined(with:) method.

    // Apply custom animation
    .customNavigationTransition(
        .fade(.in).animation(.easeInOut(duration: 0.3))
    )
    
    // Combine transitions
    .customNavigationTransition(
        .slide.combined(with: .fade(.in))
    )
    
    // Dynamic selection based on logic
    .customNavigationTransition(
        reduceMotion ? .fade(.in).animation(.linear) : .slide(.vertical)
    )
  8. Implement `AtomicTransition` for single-view changes

    main

    AtomicTransition is used to define how a single view behaves during an insertion or removal operation. It is the granular building block of the library.

    A typical implementation involves overriding the transition(_:for:in:) method to manipulate the translation properties of the view's state (initial, animation, or completion) based on the provided Edge and TransitionOperation.

    Example of a Move implementation:

    public struct Move: AtomicTransition {
        private let edge: Edge
    
        public init(edge: Edge) {
            self.edge = edge
        }
    
        public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) {
            switch (edge, operation) {
            case (.top, .insertion):
                view.initial.translation.dy = -container.frame.height
                view.animation.translation.dy = 0
            // ... other cases
            }
        }
    }
    public struct Move: AtomicTransition {
        private let edge: Edge
    
        public init(edge: Edge) {
            self.edge = edge
        }
    
        public func transition(_ view: TransientView, for operation: TransitionOperation, in container: Container) {
            switch (edge, operation) {
            case (.top, .insertion):
                view.initial.translation.dy = -container.frame.height
                view.animation.translation.dy = 0
    
            case (.leading, .insertion):
                view.initial.translation.dx = -container.frame.width
                view.animation.translation.dx = 0
    
            case (.trailing, .insertion):
                view.initial.translation.dx = container.frame.width
                view.animation.translation.dx = 0
    
            case (.bottom, .insertion):
                view.initial.translation.dy = container.frame.height
                view.animation.translation.dy = 0
    
            case (.top, .removal):
                view.animation.translation.dy = -container.frame.height
                view.completion.translation.dy = 0
    
            case (.leading, .removal):
                view.animation.translation.dx = -container.frame.width
                view.completion.translation.dx = 0
    
            case (.trailing, .removal):
                view.animation.translation.dx = container.frame.width
                view.completion.translation.dx = 0
    
            case (.bottom, .removal):
                view.animation.translation.dy = container.frame.height
                view.completion.translation.dy = 0
            }
        }
    }
  9. Combine existing transitions with `combined(with:)`

    main

    You can create a CustomNavigationTransition by combining two existing transitions using the .combined(with:) method.

    Warning: Combining high-level CustomNavigationTransition objects can lead to glitchy behavior if they attempt to override the same view properties. It is recommended to perform combinations at a lower level using NavigationTransitionProtocol and AtomicTransition instead.

    Combining transitions that affect different properties (e.g., .slide for movement and .fade(.in) for opacity) works well as they do not interfere with each other.

    .slide.combined(with: .fade(.in))
  10. Implement advanced UIKit-level transitions with `PrimitiveNavigationTransition`

    main

    For advanced use cases—such as migrating existing UIViewControllerAnimatedTransitioning implementations or requiring raw UIKit control—you can use PrimitiveNavigationTransition. This is the lowest level of the API and interacts directly with UIKit abstractions.

    To implement this, you must provide a transition handler that uses the provided Animator to set up animations and completion logic. This approach automatically handles interactive pops as long as you use the provided Animator.

    Warning: This level of granularity is rarely needed and should be avoided unless you specifically require manual control over view snapshotting, hierarchy setup, or animator configuration.

    @objc public protocol Animator {
        func addAnimations(_ animation: @escaping () -> Void)
        func addCompletion(_ completion: @escaping (UIViewAnimatingPosition) -> Void)
    }
    
    struct MyTransition: PrimitiveNavigationTransition {
        func transition(with animator: Animator, for operation: TransitionOperation, in context: Context) {
            // ...
        }
    }
  11. Configure transition interactivity and back gestures

    main

    You can control how users interact with the navigation (e.g., back swipes) using the interactivity parameter in .customNavigationTransition.

    Options include:

    • .disabled: Disables back swipes/gestures.
    • .edgePan: Standard edge-based pan gesture.
    • .contentPan: Enables full-screen pop gestures (similar to iOS 26 behavior) even on older versions like iOS 13.
    // Disable back swipes
    .customNavigationTransition(.slide, interactivity: .disabled)
    
    // Enable full-screen pop gestures (works on iOS 13-26)
    .customNavigationTransition(.slide, interactivity: .contentPan)
    
    // Use contentPan with the default system transition
    .customNavigationTransition(.default, interactivity: .contentPan)