Repository navigation
v2.0.0
Major release: split-view routing, bare presentations, in-place replace, zoom transitions. Breaking — see the migration notes below.
New
SplitRouter+SplitRoutingView— a router-drivenNavigationSplitView. The split router is the detail column'sRouter(same verbs),sidebaris a fullRouterof its own, and the split router owns the layout state:columnVisibility,preferredCompactColumn, and theisCollapsedreport for compact width.- Bare presentations — a route whose
usesNavigationStackisfalseis presented without aRoutingViewaround it: split screens, views with their ownNavigationStack, and UIKit controllers wrapped in aUIViewControllerRepresentable(share sheet, photo picker, QuickLook, calendar editor, composers). The default istrue, the way most sheets want a bar and a few do not. Bare presentations still get a child router hosting their sheets and covers, so they can present on top of themselves and close withdismiss(). - Zoom transitions —
transition: .zoom(sourceID:)onpush,present, andpresentSheet, withzoomSource(id:)on the view that opens the screen. TheRoutingViewsupplies the namespace both ends share. Pushes, sheets, and covers, from iOS 18. .routerPresentations(_:)— hosts a router's sheets and covers on any view that already has navigation above it, without adding aNavigationStack.- Replace-in-place —
replace(last:with:)andreplacePresented(with:)swap a presented modal's content while keeping its identity, detents, and transition (no dismiss/re-present). The new route gets a fresh child stack. RoutingView(router, root:)— the common case as one initializer, matchingSplitRoutingView's columns. The closure form stays for roots that need more than a route.- Root tracking —
start(_:)recordsrootDestination;currentDestination,previousDestinationandcurrentDestinationIs(_:)read the visible screen.presentedanddeepestRouterexpose what a router shows and where.deepestlands. dismissSelfAndParent()— dismisses the presentation the current modal was opened from, taking the current modal with it. When the parent is the root there is nothing above, so the current presentation is dismissed instead. Lets a flow that ends a two-sheet stack (options → picker) and a flow that presents the picker directly share one dismiss call.SheetPresentationOptions.isInteractiveDismissDisabled— for forms that must close through their own controls.hidesTabBaronRoutable—RoutingViewhides the tab bar while the top route asks for it and brings it back on pop.hasNavigationStack— a router reports whether it drives aNavigationStack. Routers behind bare content reportfalse, andpushon such a router presents a sheet instead, so deep links landing on a bare modal still show their screen.
Changed
- Dismiss buttons —
present(route:dismiss:)andpresentSheet(route:dismiss:).DismissButtonPresentationOptionsdescribes a button that is shown: its position, and whether pushed views inside the modal get it too. "No button" isnil;showDismissButtonand.hiddenare gone.dismiss:is ignored for a route that uses no navigation stack. - Replacing follows the route —
replaceandreplacePresentedre-deriveusesNavigationStackfrom the new route, so a bare modal can be swapped for a stacked one and back. - Isolation —
destination()is@MainActor; the protocol itself stays nonisolated. Conformances need no annotation in any language mode or default isolation. Verified against Swift 5, Swift 6, and main-actor-default targets.
Breaking changes
present(route:dismissOptions:target:)ispresent(route:dismiss:target:);presentSheet(route:options:dismissOptions:target:)ispresentSheet(route:dismiss:options:target:). "No button" isnil;showDismissButton,.sheetDismissOptions, and.fullScreenDismissOptionsare gone.presentingSheetandpresentingFullScreenCoverarePresentedRoute<Destination>?— the route plus its identity, dismiss options, sheet options, and transition — instead ofDestination?. The router no longer storesdismissOptions,sheetPresentationOptions, orshowDismissButtonOnPush.routerFor(routeType:)isrouterFor(routeType:toShow:); the child router is rebuilt when the presented route changes.replaceLast(with:)isreplace(with:), and replacing a presented modal now swaps its content in place.Routableno longer requiresIdentifiable; the defaultidis gone.destination()is@MainActor.RoutingViewtakes an optionaldismissOptions:(defaultnil); the dismiss button no longer comes from the router.View.if/View.ifLetare no longer public.
Migration from 1.x
- Replace
dismissOptions:withdismiss:..sheetDismissOptionsis the defaultnil,.fullScreenDismissOptionsis the default.visible,showDismissButton: falseisnil. - Rename
replaceLast(with:)toreplace(with:). - Read
presentingSheet?.routewhere you readpresentingSheetbefore. - Define
idyourself if your code usedroute.id. - Return
falsefromusesNavigationStackon routes that build their own navigation container or wrap a UIKit controller with its own bar; they are presented without aRoutingView. RoutingView(router) { $0.start(.home) }still compiles;RoutingView(router, root: .home)is the shorter form.
Notes
- One modal shows per view-controller chain — stack modals with
target: .deepest. - A bound
columnVisibilityof.automaticstarts the iPad sidebar hidden (measured); pass.allto show both columns from launch. UIActivityViewControllerin a SwiftUI sheet opens at full height whatever the router does; pass detents, or present it from UIKit when the native share sheet behavior matters.- 87 tests, verified on iOS and macOS destinations.