Skip to content

v2.0.0

Choose a tag to compare

@alexookah alexookah released this 23 Sep 22:24
· 2 commits to main since this release

Major release: split-view routing, bare presentations, in-place replace, zoom transitions. Breaking — see the migration notes below.

New

  • SplitRouter + SplitRoutingView — a router-driven NavigationSplitView. The split router is the detail column's Router (same verbs), sidebar is a full Router of its own, and the split router owns the layout state: columnVisibility, preferredCompactColumn, and the isCollapsed report for compact width.
  • Bare presentations — a route whose usesNavigationStack is false is presented without a RoutingView around it: split screens, views with their own NavigationStack, and UIKit controllers wrapped in a UIViewControllerRepresentable (share sheet, photo picker, QuickLook, calendar editor, composers). The default is true, 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 with dismiss().
  • Zoom transitions — transition: .zoom(sourceID:) on push, present, and presentSheet, with zoomSource(id:) on the view that opens the screen. The RoutingView supplies 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 a NavigationStack.
  • Replace-in-place — replace(last:with:) and replacePresented(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, matching SplitRoutingView's columns. The closure form stays for roots that need more than a route.
  • Root tracking — start(_:) records rootDestination; currentDestination, previousDestination and currentDestinationIs(_:) read the visible screen. presented and deepestRouter expose what a router shows and where .deepest lands.
  • 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.
  • hidesTabBar on Routable — RoutingView hides the tab bar while the top route asks for it and brings it back on pop.
  • hasNavigationStack — a router reports whether it drives a NavigationStack. Routers behind bare content report false, and push on 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:) and presentSheet(route:dismiss:). DismissButtonPresentationOptions describes a button that is shown: its position, and whether pushed views inside the modal get it too. "No button" is nil; showDismissButton and .hidden are gone. dismiss: is ignored for a route that uses no navigation stack.
  • Replacing follows the route — replace and replacePresented re-derive usesNavigationStack from 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:) is present(route:dismiss:target:); presentSheet(route:options:dismissOptions:target:) is presentSheet(route:dismiss:options:target:). "No button" is nil; showDismissButton, .sheetDismissOptions, and .fullScreenDismissOptions are gone.
  • presentingSheet and presentingFullScreenCover are PresentedRoute<Destination>? — the route plus its identity, dismiss options, sheet options, and transition — instead of Destination?. The router no longer stores dismissOptions, sheetPresentationOptions, or showDismissButtonOnPush.
  • routerFor(routeType:) is routerFor(routeType:toShow:); the child router is rebuilt when the presented route changes.
  • replaceLast(with:) is replace(with:), and replacing a presented modal now swaps its content in place.
  • Routable no longer requires Identifiable; the default id is gone. destination() is @MainActor.
  • RoutingView takes an optional dismissOptions: (default nil); the dismiss button no longer comes from the router.
  • View.if / View.ifLet are no longer public.

Migration from 1.x

  1. Replace dismissOptions: with dismiss:. .sheetDismissOptions is the default nil, .fullScreenDismissOptions is the default .visible, showDismissButton: false is nil.
  2. Rename replaceLast(with:) to replace(with:).
  3. Read presentingSheet?.route where you read presentingSheet before.
  4. Define id yourself if your code used route.id.
  5. Return false from usesNavigationStack on routes that build their own navigation container or wrap a UIKit controller with its own bar; they are presented without a RoutingView.
  6. 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 columnVisibility of .automatic starts the iPad sidebar hidden (measured); pass .all to show both columns from launch.
  • UIActivityViewController in 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.