Skip to content

Multi-Destination Navigation & Modal Presentation#325

Draft
ScottPlease wants to merge 5 commits intopointfreeco:mainfrom
ScottPlease:SwapNavigationDestinations
Draft

Multi-Destination Navigation & Modal Presentation#325
ScottPlease wants to merge 5 commits intopointfreeco:mainfrom
ScottPlease:SwapNavigationDestinations

Conversation

@ScottPlease
Copy link

@ScottPlease ScottPlease commented Jan 22, 2026

Swift-Navigation Changes: Multi-Destination Navigation & Modal Presentation

Overview

We've extended swift-navigation with new APIs that coordinate multiple navigation destinations and modal presentations, solving a critical race condition problem that occurs when switching between destinations.

The Problem

When using multiple navigationDestination(item:) or present(item:) modifiers with mutually exclusive state (e.g., toggling between two optional bindings), UIKit's asynchronous dismiss/present operations can conflict. A dismiss operation might complete after a new present has started, causing the newly presented view controller to be incorrectly dismissed or resulting in inconsistent navigation stack states.

The Solution

We added three new APIs to UIViewController:

1. DestinationItem struct

A type-erased wrapper that encapsulates:

  • The binding identifier (key)
  • A closure to check if the item is presented (isPresented)
  • A factory to create the view controller (makeViewController)
  • A callback to clear the binding (clearBinding)

2. presents(_:) method

Coordinates modal presentations across multiple destinations by:

  • Tracking which destination is currently presented vs. which should be presented
  • Case 1 (Switching): When switching between destinations, it invalidates the old view controller's onDismiss callback (preventing double-dismissal), dismisses the old VC without animation, then presents the new VC
  • Case 2 (Normal present): Standard modal presentation when nothing is currently shown
  • Case 3 (Normal dismiss): Standard dismissal when the binding becomes nil

3. navigationDestinations(_:) method

Coordinates navigation pushes across multiple destinations by:

  • Using setViewControllers(_:animated:) instead of separate pop/push operations when switching destinations, which atomically updates the navigation stack
  • Handling the same three cases as presents(_:) but for navigation controller stacks
  • Replacing the last view controller in the stack when switching, avoiding race conditions

We also added a convenience overload navigationDestination(item1:content1:item2:content2:) for the common two-destination case.

How They Accomplish the Task

The key insight is coordination through observation: instead of having independent observers react to each binding separately, a single observer watches all destinations simultaneously and makes decisions based on the complete state. This prevents the race conditions inherent in independent, asynchronous operations.

For navigation, we use setViewControllers(_:) to perform atomic stack updates. For modals, we invalidate the old VC's dismiss callbacks before dismissing, ensuring cleanup logic doesn't fire at the wrong time.

What the Tests Prove

The memory management tests (testNavigationDestinations_ObservationDoesNotRetainModel and testPresents_ObservationDoesNotRetainModel) verify that:

  1. No Retain Cycles: The observation mechanism doesn't create strong reference cycles with the model
  2. Proper Cleanup: When the model goes out of scope, the weak reference becomes nil, proving the observation tokens and closures properly use weak self captures
  3. Multiple Bindings Work: Using multiple destination items from the same model doesn't cause unexpected retention

These tests use the @UIBindable macro with @Perceptible models (Swift's Observation framework) and assert that weakModel becomes nil after the scope exits, confirming proper memory management even with multiple concurrent observations.

Usage Examples

Navigation Destinations (TCA Integration)

// In your UIViewController
let item1 = $store.scope(state: \.destination?.feature1, action: \.destination.feature1)
let item2 = $store.scope(state: \.destination?.feature2, action: \.destination.feature2)

navigationDestinations([
    UIBindingIdentifier(item1): DestinationItem(
        item: item1,
        content: { store1 in ViewController1(store: store1.wrappedValue) }
    ),
    UIBindingIdentifier(item2): DestinationItem(
        item: item2,
        content: { store2 in ViewController2(store: store2.wrappedValue) }
    )
])

Modal Presentations (TCA Integration)

// In your UIViewController
let item1 = $store.scope(state: \.destination?.feature1, action: \.destination.feature1)
let item2 = $store.scope(state: \.destination?.feature2, action: \.destination.feature2)

presents([
    UIBindingIdentifier(item1): DestinationItem(
        item: item1,
        content: { store1 in ViewController1(store: store1.wrappedValue) }
    ),
    UIBindingIdentifier(item2): DestinationItem(
        item: item2,
        content: { store2 in ViewController2(store: store2.wrappedValue) }
    )
])

Simple Observation Example

// With plain @Perceptible models
@Perceptible
class MyModel {
    var detailA: DetailModel?
    var detailB: DetailModel?
}

@UIBindable var model = MyModel()

// Navigation
navigationDestinations([
    UIBindingIdentifier($model.detailA): DestinationItem(
        item: $model.detailA,
        content: { detailBinding in DetailViewController(detail: detailBinding.wrappedValue) }
    ),
    UIBindingIdentifier($model.detailB): DestinationItem(
        item: $model.detailB,
        content: { detailBinding in AlternateViewController(detail: detailBinding.wrappedValue) }
    )
])

Integration with The Composable Architecture

The API seamlessly integrates with The Composable Architecture's store scoping, allowing you to coordinate multiple enum-driven destinations without race conditions when switching between them. This is particularly useful when you have destination state like:

@Reducer
struct Feature {
    enum Destination {
        case feature1(Feature1.State)
        case feature2(Feature2.State)
    }
    
    struct State {
        @Presents var destination: Destination?
    }
}

Instead of using separate navigationDestination calls for each case (which can race), use navigationDestinations(_:) to coordinate them atomically.

@ScottPlease
Copy link
Author

ScreenRecording_01-22-2026.15-08-25_1.MP4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant