diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..8f6a65ec
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,77 @@
+name: CI
+
+on:
+ push:
+ branches: [master, main, "feature/**", "release/**"]
+ pull_request:
+ branches: [master, main]
+
+concurrency:
+ group: ci-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build-test:
+ name: Build & Test (iOS Simulator)
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Select latest Xcode
+ uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: latest-stable
+
+ - name: Show toolchain
+ run: |
+ xcodebuild -version
+ swift --version
+
+ - name: Resolve packages
+ run: swift package resolve
+
+ - name: Build (iOS Simulator)
+ run: Scripts/build.sh
+
+ - name: Build (tvOS)
+ # Generic tvOS build (no simulator runtime required) so tvOS support — advertised in the
+ # podspec/README — is actually compiled in CI.
+ run: |
+ xcodebuild build \
+ -scheme XCoordinator \
+ -destination 'generic/platform=tvOS' \
+ -skipPackagePluginValidation \
+ CODE_SIGNING_ALLOWED=NO
+
+ - name: Test
+ run: Scripts/test.sh
+
+ pod-lint:
+ name: CocoaPods lint
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Select latest Xcode
+ uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: latest-stable
+
+ - name: Lint podspec
+ # iOS only: recent Xcode does not ship the tvOS simulator runtime, and the rest
+ # of CI (build/test/docs) is iOS-only as well.
+ run: pod lib lint --allow-warnings --fail-fast --platforms=ios
+
+ docs:
+ name: DocC build
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Select latest Xcode
+ uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: latest-stable
+
+ - name: Build documentation
+ run: Scripts/docs.sh
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 00000000..cf353e37
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,45 @@
+name: Publish documentation
+
+on:
+ push:
+ tags: ["*"]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Select latest Xcode
+ uses: maxim-lobanov/setup-xcode@v1
+ with:
+ xcode-version: latest-stable
+
+ - name: Build documentation
+ run: Scripts/docs.sh XCoordinator
+
+ - name: Upload artifact
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: ./Documentation
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index eeceeef6..5c59d728 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,8 @@
-# Mac OS X
-*.DS_Store
+# macOS
+**/.DS_Store
# Xcode
-.build
+.build/
*.pbxuser
*.mode1v3
*.mode2v3
@@ -12,17 +12,21 @@ project.xcworkspace/
xcuserdata/
Pods/*.xcodeproj/xcuserdata/
+# Swift Package Manager
+/.swiftpm/xcode/xcuserdata/
+/.swiftpm/xcode/package.xcworkspace/xcuserdata/
+/.swiftpm/configuration/
+Package.resolved
+
# Generated files
*.o
*.pyc
-# Docs
-docs/docsets/XCoordinator.tgz
-docs/undocumented.json
+# Documentation outputs
+/Documentation/
+*.doccarchive/
# Backup files
*~.nib
\#*#
.#*
-
-.swiftpm
diff --git a/.jazzy.yaml b/.jazzy.yaml
deleted file mode 100644
index a3a42f23..00000000
--- a/.jazzy.yaml
+++ /dev/null
@@ -1,8 +0,0 @@
-author: "Stefan Kofler & Paul Kraft"
-author_url: https://quickbirdstudios.com
-podspec: XCoordinator.podspec
-docset_icon: Images/logo-single.png
-github_url: https://github.com/quickbirdstudios/XCoordinator
-hide_documentation_coverage: true
-theme: fullwidth
-clean: true
diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..919434a6
--- /dev/null
+++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 3f4cf7c7..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-language: objective-c
-osx_image: xcode11
-
-install:
-# - gem update
-# - gem install jazzy
-# - brew update
-# - brew install sourcekitten
-script:
- - cd scripts
- - ./build.sh
-# - ./check_docs.sh
diff --git a/Package.resolved b/Package.resolved
deleted file mode 100644
index 4c541df3..00000000
--- a/Package.resolved
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "object": {
- "pins": [
- {
- "package": "RxSwift",
- "repositoryURL": "https://github.com/ReactiveX/RxSwift.git",
- "state": {
- "branch": null,
- "revision": "7c17a6ccca06b5c107cfa4284e634562ddaf5951",
- "version": "6.2.0"
- }
- }
- ]
- },
- "version": 1
-}
diff --git a/Package.swift b/Package.swift
index 9457fbd5..5d7fe3a9 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,42 +1,28 @@
-// swift-tools-version:5.1
-// The swift-tools-version declares the minimum version of Swift required to build this package.
+// swift-tools-version:5.9
import PackageDescription
let package = Package(
name: "XCoordinator",
- platforms: [.iOS(.v9), .tvOS(.v9)],
+ platforms: [.iOS(.v16), .tvOS(.v16)],
products: [
- // Products define the executables and libraries produced by a package, and make them visible to other packages.
.library(
name: "XCoordinator",
targets: ["XCoordinator"]),
.library(
name: "XCoordinatorRx",
targets: ["XCoordinatorRx"]),
- .library(
- name: "XCoordinatorCombine",
- targets: ["XCoordinatorCombine"]),
],
dependencies: [
- // Dependencies declare other packages that this package depends on.
- // .package(url: /* package url */, from: "1.0.0"),
- .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.0.0"),
+ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0"),
+ .package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.5.0"),
],
targets: [
- // Targets are the basic building blocks of a package. A target can define a module or a test suite.
- // Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "XCoordinator",
dependencies: []),
.target(
name: "XCoordinatorRx",
dependencies: ["XCoordinator", "RxSwift"]),
- .target(
- name: "XCoordinatorCombine",
- dependencies: ["XCoordinator"]),
- .testTarget(
- name: "XCoordinatorTests",
- dependencies: ["XCoordinator", "XCoordinatorRx"]),
]
)
diff --git a/README.md b/README.md
index 315ee43b..e8bc74d2 100644
--- a/README.md
+++ b/README.md
@@ -1,35 +1,62 @@
-
+
-# [](https://travis-ci.com/quickbirdstudios/XCoordinator) [](https://cocoapods.org/pods/XCoordinator) [](https://github.com/Carthage/Carthage) [](https://quickbirdstudios.github.io/XCoordinator) [](https://github.com/quickbirdstudios/XCoordinator) [](https://github.com/quickbirdstudios/XCoordinator/blob/master/LICENSE)
+# XCoordinator
-⚠️ We have recently released XCoordinator 2.0. Make sure to read [this section](#when-to-use-which-router-abstraction) before migrating. In general, please replace all `AnyRouter` by either `UnownedRouter` (in viewControllers, viewModels or references to parent coordinators) or `StrongRouter` in your `AppDelegate` or for references to child coordinators. In addition to that, the rootViewController is now injected into the initializer instead of being created in the `Coordinator.generateRootViewController` method.
+[](https://github.com/quickbirdstudios/XCoordinator/actions/workflows/ci.yml)
+[](https://swift.org)
+[](https://github.com/quickbirdstudios/XCoordinator)
+[](https://swift.org/package-manager/)
+[](https://cocoapods.org/pods/XCoordinator)
+[](LICENSE)
-“How does an app transition from one view controller to another?”.
-This question is common and puzzling regarding iOS development. There are many answers, as every architecture has different implementation variations. Some do it from within the implementation of a view controller, while some use a router/coordinator, an object connecting view models.
+**Type-safe, enum-driven navigation for UIKit and SwiftUI based on the Coordinator pattern.**
-To better answer the question, we are building **XCoordinator**, a navigation framework based on the **Coordinator** pattern.
-It's especially useful for implementing MVVM-C, Model-View-ViewModel-Coordinator:
+XCoordinator decouples navigation from view controllers and view models: you describe a flow as a `Route` enum, and a `Coordinator` decides which `Transition` to perform for each route. The result is reusable views, view models without navigation logic, and a single place to evolve the flow of your app.
-
-
-
+- 📚 **API reference** — [hosted DocC documentation](https://quickbirdstudios.github.io/XCoordinator/)
+- 🧪 **Example app** — [XCoordinator-Example](https://github.com/quickbirdstudios/XCoordinator-Example) — a complete MVVM-C app using XCoordinator
+- 🚀 **What's new in 3.0** — see [Migrating from 2.x to 3.0](#-migrating-from-2x-to-30) below
+
+## Table of contents
-## 🏃♂️Getting started
+- [Why XCoordinator](#-why-xcoordinator)
+- [Getting started](#%EF%B8%8F-getting-started)
+- [SwiftUI interop](#-swiftui-interop)
+- [Choosing a router reference](#-choosing-a-router-reference)
+- [Custom transitions](#-custom-transitions)
+- [Deep linking](#-deep-linking)
+- [RedirectionRouter](#-redirectionrouter)
+- [Combine and RxSwift](#-combine-and-rxswift)
+- [Migrating from 2.x to 3.0](#-migrating-from-2x-to-30)
+- [Installation](#-installation)
+- [Requirements](#-requirements)
+- [Contributing](#%EF%B8%8F-contributing)
-Create an enum with all of the navigation paths for a particular flow, i.e. a group of closely connected scenes. (It is up to you when to create a `Route/Coordinator`. As **our rule of thumb**, create a new `Route/Coordinator` whenever a new root view controller, e.g. a new `navigation controller` or a `tab bar controller`, is needed.).
+## 🤔 Why XCoordinator
+
+- **Type-safe routes** — enums give you autocompletion and compile-time errors instead of stringly-typed paths.
+- **One place for navigation** — view models trigger routes; the coordinator decides what each route does.
+- **UIKit and SwiftUI** — a single coordinator can mix `UIViewController` flows with SwiftUI views via `RoutingController`, `WrappedRouter`, and `@Routing`.
+- **Reusable** — coordinators, transitions, and animations compose; the same view model works inside different flows.
+- **Custom transitions and deep linking** built in — interactive transitions, presentation animations, and route chains across coordinator boundaries.
+
+
+
+
How Coordinator fits into MVVM
+
-Whereas the `Route` describes which routes can be triggered in a flow, the `Coordinator` is responsible for the preparation of transitions based on routes being triggered. We could, therefore, prepare multiple coordinators for the same route, which differ in which transitions are executed for each route.
+## 🏃♂️ Getting started
-In the following example, we create the `UserListRoute` enum to define triggers of a flow of our application. `UserListRoute` offers routes to open the home screen, display a list of users, to open a specific user and to log out. The `UserListCoordinator` is implemented to prepare transitions for the triggered routes. When a `UserListCoordinator` is shown, it triggers the `.home` route to display a `HomeViewController`.
+Define a `Route` enum and a `Coordinator` that prepares a transition for each case. Since 3.0 you can
+describe transitions with the **transition builder** ✨ — opt in by annotating your override with
+`@TransitionBuilder`:
```swift
enum UserListRoute: Route {
case home
- case users
case user(String)
- case registerUsersPeek(from: Container)
case logout
}
@@ -38,65 +65,68 @@ class UserListCoordinator: NavigationCoordinator {
super.init(initialRoute: .home)
}
+ @TransitionBuilder
override func prepareTransition(for route: UserListRoute) -> NavigationTransition {
switch route {
case .home:
- let viewController = HomeViewController.instantiateFromNib()
- let viewModel = HomeViewModelImpl(router: unownedRouter)
- viewController.bind(to: viewModel)
- return .push(viewController)
- case .users:
- let viewController = UsersViewController.instantiateFromNib()
- let viewModel = UsersViewModelImpl(router: unownedRouter)
- viewController.bind(to: viewModel)
- return .push(viewController, animation: .interactiveFade)
- case .user(let username):
- let coordinator = UserCoordinator(user: username)
- return .present(coordinator, animation: .default)
- case .registerUsersPeek(let source):
- return registerPeek(for: source, route: .users)
+ Transition.push(HomeViewController())
+ case .user(let name):
+ Transition.present(UserCoordinator(user: name), animation: .default)
case .logout:
- return .dismiss()
+ Transition.dismiss()
}
}
}
```
-Routes are triggered from within Coordinators or ViewModels. In the following, we describe how to trigger routes from within a ViewModel. The router of the current flow is injected into the ViewModel.
+> ✨ **New in 3.0 — the transition builder.** A `@resultBuilder` DSL lets you compose transitions
+> declaratively by listing the `Transition.…` factories (`.push`, `.present`, `.select`, …) — list
+> several in one block to chain them (like `.multiple`). It's opt-in and additive.
-```swift
-class HomeViewModel {
- let router: UnownedRouter
+
+Classic style (still supported, non-breaking)
- init(router: UnownedRouter) {
- self.router = router
- }
+The original style — a plain `prepareTransition(for:)` returning `Transition.…` factories — keeps working
+exactly as before. Just omit the `@TransitionBuilder` annotation:
- /* ... */
-
- func usersButtonPressed() {
- router.trigger(.users)
+```swift
+class UserListCoordinator: NavigationCoordinator {
+ override func prepareTransition(for route: UserListRoute) -> NavigationTransition {
+ switch route {
+ case .home: .push(HomeViewController())
+ case .user(let name): .present(UserCoordinator(user: name), animation: .default)
+ case .logout: .dismiss()
+ }
}
}
```
-### 🏗 Organizing an app's structure with XCoordinator
+The builder is a modern convenience, not a requirement — you opt into it per override by adding the attribute.
+
-In general, an app's structure is defined by nesting coordinators and view controllers. You can transition (i.e. `push`, `present`, `pop`, `dismiss`) to a different coordinator whenever your app changes to a different flow. Within a flow, we transition between viewControllers.
+Trigger routes from a view model that holds a typed router reference:
-Example: In `UserListCoordinator.prepareTransition(for:)` we change from the `UserListRoute` to the `UserRoute` whenever the `UserListRoute.user` route is triggered. By dismissing a viewController in `UserListRoute.logout`, we additionally switch back to the previous flow - in this case the `HomeRoute`.
+```swift
+class HomeViewModel {
+ unowned let router: any Router
-To achieve this behavior, every Coordinator has its own `rootViewController`. This would be a `UINavigationController` in the case of a `NavigationCoordinator`, a `UITabBarController` in the case of a `TabBarCoordinator`, etc. When transitioning to a Coordinator/Router, this `rootViewController` is used as the destination view controller.
+ init(router: any Router) {
+ self.router = router
+ }
-### 🏁 Using XCoordinator from App Launch
+ func userButtonPressed(name: String) {
+ router.trigger(.user(name))
+ }
+}
+```
-To use coordinators from the launch of the app, make sure to create the app's `window` programmatically in `AppDelegate.swift` (Don't forget to remove `Main Storyboard file base name` from `Info.plist`). Then, set the coordinator as the root of the `window`'s view hierarchy in the `AppDelegate.didFinishLaunching`. Make sure to hold a strong reference to your app's initial coordinator or a `strongRouter` reference.
+Bootstrap the initial coordinator from your app delegate or `@main` entry point — hold a strong `any Router` to keep it alive:
```swift
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
let window: UIWindow! = UIWindow()
- let router = AppCoordinator().strongRouter
+ let router: any Router = AppCoordinator()
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
router.setRoot(for: window)
@@ -105,277 +135,219 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
}
```
-## 🤸♂️ Extras
+### How transitions compose
-For more advanced use, XCoordinator offers many more customization options. We introduce custom animated transitions and deep linking. Furthermore, extensions for use in reactive programming with RxSwift/Combine and options to split up huge routes are described.
+An app's structure is defined by nesting coordinators. Whenever your app changes flow (a new navigation stack, a new tab bar) introduce a new coordinator. Each coordinator owns a `rootViewController` (a `UINavigationController`, `UITabBarController`, etc.) that becomes the destination when another coordinator pushes or presents it.
-### 🌗 Custom Transitions
+## 🚀 SwiftUI interop
-Custom animated transitions define presentation and dismissal animations. You can specify `Animation` objects in `prepareTransition(for:)` in your coordinator for several common transitions, such as `present`, `dismiss`, `push` and `pop`. Specifying no animation (`nil`) results in not overriding previously set animations. Use `Animation.default` to reset previously set animation to the default animations UIKit offers.
+XCoordinator integrates with SwiftUI in two directions.
-```swift
-class UsersCoordinator: NavigationCoordinator {
+**Embed a coordinator inside SwiftUI** with `WrappedRouter`. The closure builds the coordinator on first appearance and the instance is retained for the lifetime of the view:
- /* ... */
-
- override func prepareTransition(for route: UserRoute) -> NavigationTransition {
- switch route {
- case .user(let name):
- let animation = Animation(
- presentationAnimation: YourAwesomePresentationTransitionAnimation(),
- dismissalAnimation: YourAwesomeDismissalTransitionAnimation()
- )
- let viewController = UserViewController.instantiateFromNib()
- let viewModel = UserViewModelImpl(name: name, router: unownedRouter)
- viewController.bind(to: viewModel)
- return .push(viewController, animation: animation)
- /* ... */
+```swift
+struct ContentView: View {
+ var body: some View {
+ WrappedRouter {
+ UsersCoordinator()
}
}
}
```
-### 🛤 Deep Linking
-
-Deep Linking can be used to chain different routes together. In contrast to the `.multiple` transition, deep linking can identify routers based on previous transitions (e.g. when pushing or presenting a router), which enables chaining of routes of different types. Keep in mind, that you cannot access higher-level routers anymore once you trigger a route on a lower level of the router hierarchy.
+**Push or present a SwiftUI view from a UIKit coordinator** with `RoutingController`, a `UIHostingController` subclass that propagates the current `RoutingContext` into the SwiftUI environment:
```swift
-class AppCoordinator: NavigationCoordinator {
-
- /* ... */
-
- override func prepareTransition(for route: AppRoute) -> NavigationTransition {
+class UsersCoordinator: NavigationCoordinator {
+ override func prepareTransition(for route: UserRoute) -> NavigationTransition {
switch route {
- /* ... */
- case .deep:
- return deepLink(AppRoute.login, AppRoute.home, HomeRoute.news, HomeRoute.dismiss)
+ case .user(let name):
+ return .push(RoutingController { UserView(name: name) })
}
}
}
```
-⚠️ XCoordinator does not check at compile-time, whether a deep link can be executed. Rather it uses assertionFailures to inform about incorrect chaining at runtime, when it cannot find an appropriate router for a given route. Keep this in mind when changing the structure of your app.
-
-### 🚏 RedirectionRouter
-
-Let's assume, there is a route type called `HugeRoute` with more than 10 routes. To decrease coupling, `HugeRoute` needs to be split up into multiple route types. As you will discover, many routes in `HugeRoute` use transitions dependent on a specific rootViewController, such as `push`, `show`, `pop`, etc. If splitting up routes by introducing a new router/coordinator is not an option, XCoordinator has two solutions for you to solve such a case: `RedirectionRouter` or using multiple coordinators with the same rootViewController ([see this section for more information](#using-multiple-coordinators-with-the-same-rootviewcontroller)).
-
-A `RedirectionRouter` can be used to map a new route type onto a generalized `ParentRoute`. A `RedirectionRouter` is independent of the `TransitionType` of its parent router. You can use `RedirectionRouter.init(viewController:parent:map:)` or subclassing by overriding `mapToParentRoute(_:)` to create a `RedirectionRouter`.
-
-The following code example illustrates how a `RedirectionRouter` is initialized and used.
+**Trigger routes from inside a SwiftUI view** with `@Routing`:
```swift
-class ParentCoordinator: NavigationCoordinator {
- /* ... */
-
- override func prepareTransition(for route: ParentRoute) -> NavigationTransition {
- switch route {
- /* ... */
- case .child:
- let childCoordinator = ChildCoordinator(parent: unownedRouter)
- return .push(childCoordinator)
- }
- }
-}
+struct ChildView: View {
+ @Routing var usersRouter
-class ChildCoordinator: RedirectionRouter {
- init(parent: UnownedRouter) {
- let viewController = UIViewController()
- // this viewController is used when performing transitions with the Subcoordinator directly.
- super.init(viewController: viewController, parent: parent, map: nil)
- }
-
- /* ... */
-
- override func mapToParentRoute(for route: ChildRoute) -> ParentRoute {
- // you can map your ChildRoute enum to ParentRoute cases here that will get triggered on the parent router.
+ var body: some View {
+ Button("Open") {
+ usersRouter.trigger(.user("Bob"))
+ }
}
}
```
-### 🚏Using multiple coordinators with the same rootViewController
-
-With XCoordinator 2.0, we introduce the option to use different coordinators with the same rootViewController.
-Since you can specify the rootViewController in the initializer of a new coordinator, you can specify an existing coordinator's rootViewController as in the following:
+**Drive SwiftUI state changes from `prepareTransition(for:)`** without performing a UIKit transition — use `Transition.withAnimation` or `Transition.withTransaction`:
```swift
-class FirstCoordinator: NavigationCoordinator {
- /* ... */
-
- override func prepareTransition(for route: FirstRoute) -> NavigationTransition {
+class HomeCoordinator: TabBarCoordinator {
+ @Binding var selection: HomeTab
+
+ override func prepareTransition(for route: HomeRoute) -> TabBarTransition {
switch route {
- case .secondCoordinator:
- let secondCoordinator = SecondCoordinator(rootViewController: self.rootViewController)
- addChild(secondCoordinator)
- return .none()
- // you could also trigger a specific initial route at this point,
- // such as `.trigger(SecondRoute.initial, on: secondCoordinator)`
+ case .select(let tab):
+ return .withAnimation { selection = tab }
}
}
}
```
-We suggest to not use initial routes in the initializers of sibling coordinators, but instead using the transition option in the `FirstCoordinator` instead.
+For declarative triggering, `triggerOnAppear`, `triggerOnChange(of:)`, and `trigger(when:)` view modifiers fire routes through `@Routing` automatically.
-⚠️ If you perform transitions involving a sibling coordinator directly (e.g. pushing a sibling coordinator without overriding its `viewController` property), your app will most likely crash.
+## 🧭 Choosing a router reference
-### 🚀 RxSwift/Combine extensions
-
-Reactive programming can be very useful to keep the state of view and model consistent in a MVVM architecture. Instead of relying on the completion handler of the `trigger` method available in any `Router`, you can also use our RxSwift-extension. In the example application, we use Actions (from the [Action](https://github.com/RxSwiftCommunity/Action) framework) to trigger routes on certain UI events - e.g. to trigger `LoginRoute.home` in `LoginViewModel`, when the login button is tapped.
+Since 3.0, type erasure is provided by Swift's parameterized existential `any Router`. The previously dedicated `AnyRouter`, `StrongRouter`, `UnownedRouter`, and `WeakRouter` types are gone. Pick the ARC qualifier that matches the relationship:
```swift
-class LoginViewModelImpl: LoginViewModel, LoginViewModelInput, LoginViewModelOutput {
-
- private let router: UnownedRouter
+let strongRouter: any Router = ... // own the coordinator
+weak var weakRouter: (any Router)? = ... // sibling/parent reference
+unowned let unownedRouter: any Router = ... // child holding parent
+```
- private lazy var loginAction = CocoaAction { [unowned self] in
- return self.router.rx.trigger(.home)
- }
+- **strong** — the app delegate or whatever object owns the coordinator's lifetime; also used to hold child coordinators.
+- **weak** — view models or view controllers referring to a sibling or parent coordinator.
+- **unowned** — same use case as weak when the holder is guaranteed not to outlive the coordinator.
- /* ... */
-}
+## 🌗 Custom transitions
-```
-
-In addition to the above-mentioned approach, the reactive `trigger` extension can also be used to sequence different transitions by using the `flatMap` operator, as can be seen in the following:
+Pass a custom `Animation` to common transitions (`push`, `pop`, `present`, `dismiss`). Pass `nil` to keep the previously configured animation; pass `Animation.default` to reset to UIKit defaults.
```swift
-let doneWithBothTransitions =
- router.rx.trigger(.home)
- .flatMap { [unowned self] in self.router.rx.trigger(.news) }
- .map { true }
- .startWith(false)
+override func prepareTransition(for route: UserRoute) -> NavigationTransition {
+ switch route {
+ case .user(let name):
+ let animation = Animation(
+ presentationAnimation: YourAwesomePresentationTransitionAnimation(),
+ dismissalAnimation: YourAwesomeDismissalTransitionAnimation()
+ )
+ return .push(UserViewController(name: name), animation: animation)
+ }
+}
```
-When using `XCoordinator` with the `Combine` extensions, you can use `router.publishers.trigger` instead of `router.rx.trigger`.
+For interactive transitions driven by gesture recognizers, see `BaseCoordinator.registerInteractiveTransition(for:triggeredBy:handler:completion:)` and its progress-based overload.
-## 📚 Documentation & Example app
+## 🛤 Deep linking
-To get more information about XCoordinator, check out the [documentation](https://quickbirdeng.github.io/XCoordinator/).
-Additionally, this [repository](https://github.com/quickbirdstudios/XCoordinator-Example) serves as an example project using a MVVM architecture with XCoordinator.
+> [!IMPORTANT]
+> Deep links are not validated at compile time. If a router for one of the chained route types cannot be located at runtime, XCoordinator calls `assertionFailure`. Be careful when reshaping your coordinator hierarchy.
-For a MVC example app, have a look at [some presentations](https://github.com/quickbirdstudios/XCoordinator-Talks) we did about the Coordinator pattern and XCoordinator.
+Chain routes across coordinator boundaries using `deepLink(_:_:)`. The deep link walks the coordinator tree, switching to whichever router can handle the next route type:
-## 👨✈️ Why coordinators
-
-* **Separation of responsibilities** with the coordinator being the only component knowing anything related to the flow of your application.
-* **Reusable Views and ViewModels** because they do not contain any navigation logic.
-* **Less coupling between components**
-
-* **Changeable navigation**: Each coordinator is only responsible for one component and does not need to make assumptions about its parent. It can therefore be placed wherever we want to.
+```swift
+override func prepareTransition(for route: AppRoute) -> NavigationTransition {
+ switch route {
+ case .deep:
+ return deepLink(AppRoute.login, AppRoute.home, HomeRoute.news, HomeRoute.dismiss)
+ }
+}
+```
-> [The Coordinator](http://khanlou.com/2015/01/the-coordinator/) by **Soroush Khanlou**
+## 🚏 RedirectionRouter
+When a route enum has grown too large but you cannot introduce a new root view controller, `RedirectionRouter` lets you split a child route type onto a parent route type without owning its own transition type:
-## ⁉️ Why XCoordinator
+```swift
+class ChildCoordinator: RedirectionRouter {
+ init(parent: any Router) {
+ super.init(viewController: UIViewController(), parent: parent, map: nil)
+ }
-* Actual **navigation code is already written** and abstracted away.
-* Clear **separation of concerns**:
- - Coordinator: Coordinates routing of a set of routes.
- - Route: Describes navigation path.
- - Transition: Describe transition type and animation to new view.
-* **Reuse** coordinators, routers and transitions in different combinations.
-* Full support for **custom transitions/animations**.
-* Support for **embedding child views** / container views.
-* Generic `BasicCoordinator` classes suitable for many use cases and therefore **less** need to write your **own coordinators**.
-* Full **support** for your **own coordinator classes** conforming to our Coordinator protocol
- - You can also start with one of the following types to get a head start: `NavigationCoordinator`, `ViewCoordinator`, `TabBarCoordinator` and more.
-* Generic AnyRouter type erasure class encapsulates all types of coordinators and routers supporting the same set of routes. Therefore you can **easily replace coordinators**.
-* Use of enum for routes gives you **autocompletion** and **type safety** to perform only transition to routes supported by the coordinator.
+ override func mapToParentRoute(for route: ChildRoute) -> ParentRoute {
+ // map ChildRoute cases onto ParentRoute cases
+ }
+}
+```
-## 🔩 Components
+Alternatively, two sibling coordinators can share the same `rootViewController` by passing it into the second coordinator's initializer.
-### 🎢 Route
+## 🔀 Combine and RxSwift
-Describes possible navigation paths within a flow, a collection of closely related scenes.
+The Combine extensions are built into the main `XCoordinator` module. Use `router.publishers.trigger(_:)` to obtain a publisher for a triggered route. The publisher is **lazy** — the transition is performed when you subscribe, not when the publisher is created:
-### 👨✈️ Coordinator / Router
+```swift
+router.publishers.trigger(.home)
+ .sink { /* transition finished */ }
+```
-An object loading views and creating viewModels based on triggered routes. A Coordinator creates and performs transitions to these scenes based on the data transferred via the route. In contrast to the coordinator, a router can be seen as an abstraction from that concept limited to triggering routes. Often, a Router is used to abstract from a specific coordinator in ViewModels.
+For RxSwift, add the `XCoordinatorRx` product. The `router.rx.trigger(_:)` accessor returns an `Observable`:
-#### When to use which Router abstraction
+```swift
+router.rx.trigger(.home)
+ .flatMap { [unowned self] in self.router.rx.trigger(.news) }
+```
-You can create different router abstractions using the `unownedRouter`, `weakRouter` or `strongRouter` properties of your `Coordinator`.
-You can decide between the following router abstractions of your coordinator:
+## ⬆️ Migrating from 2.x to 3.0
-- **StrongRouter** holds a strong reference to the original coordinator. You can use this to hold child coordinators or to specify a certain router in the `AppDelegate`.
-- **WeakRouter** holds a weak reference to the original coordinator. You can use this to hold a coordinator in a viewController or viewModel. It can also be used to keep a reference to a sibling or parent coordinator.
-- **UnownedRouter** holds an unowned reference to the original coordinator. You can use this to hold a coordinator in a viewController or viewModel. It can also be used to keep a reference to a sibling or parent coordinator.
+3.0 removes the type-erased router/coordinator wrappers, replaces the `TransitionPerformer`/`TransitionProtocol` layer with the single generic `Transition`, and folds Combine into the main module. Most of the migration is mechanical:
-If you want to know more about the differences on how references can be held, have a look [here](https://docs.swift.org/swift-book/LanguageGuide/AutomaticReferenceCounting.html).
+| 2.x | 3.0 |
+| --- | --- |
+| `AnyRouter` | `any Router` |
+| `StrongRouter` | `any Router` |
+| `WeakRouter` | `weak var router: (any Router)?` |
+| `UnownedRouter` | `unowned let router: any Router` |
+| `WeakErased` / `UnownedErased` | use `weak`/`unowned` on an `any Router` directly |
+| `coordinator.unownedRouter` / `coordinator.weakRouter` | pass `self` directly or capture explicitly with `unowned`/`weak` |
+| `AnyCoordinator<…>` / `coordinator.anyCoordinator` | use the coordinator directly, or `any Router` where erasure is needed |
+| `TransitionPerformer`, `TransitionProtocol`, `AnyTransitionPerformer` | removed — coordinators now use `Transition` directly |
+| `Coordinator.TransitionType` associatedtype | removed — `prepareTransition(for:)` returns `Transition`; specify `RootViewController` instead of `TransitionType` |
+| `pod 'XCoordinator/Combine'` | the Combine extensions are bundled into `XCoordinator` |
-### 🌗 Transition
+### Other breaking changes
-Transitions describe the navigation from one view to another. Transitions are available based on the type of the root view controller in use. Example: Whereas `ViewTransition` only supports basic transitions that every root view controller supports, `NavigationTransition` adds navigation controller specific transitions.
+- **Minimum platforms raised to iOS 16 / tvOS 16** (from iOS 9 / tvOS 9), and the package now uses **swift-tools 5.9** (Xcode 15+).
+- **Combine publishers are now lazy.** `router.publishers.trigger(_:)` / `triggerPublisher(_:)` previously returned an eager `Future` that fired the transition immediately on creation; they now return a lazy `AnyPublisher` that performs the transition on **subscription**. Make sure you subscribe (e.g. `.sink`) to actually run the navigation.
-The available transition types include:
- - **present** presents a view controller on top of the view hierarchy - use **presentOnRoot** in case you want to present from the root view controller
- - **embed** embeds a view controller into a container view
- - **dismiss** dismisses the top most presented view controller - use **dismissToRoot** to call dismiss on the root view controller
- - **none** does nothing, may be used to ignore routes or for testing purposes
- - **push** pushes a view controller to the navigation stack (only in `NavigationTransition`)
- - **pop** pops the top view controller from the navigation stack (only in `NavigationTransition`)
- - **popToRoot** pops all the view controllers on the navigation stack except the root view controller (only in `NavigationTransition`)
-
- XCoordinator additionally supports common transitions for `UITabBarController`, `UISplitViewController` and `UIPageViewController` root view controllers.
+The SwiftUI interop layer (`RoutingController`, `WrappedRouter`, `@Routing`, `Transition.withAnimation`, …) is new in 3.0 — see [SwiftUI interop](#-swiftui-interop).
## 🛠 Installation
-#### CocoaPods
+### Swift Package Manager (recommended)
-To integrate XCoordinator into your Xcode project using CocoaPods, add this to your `Podfile`:
+Add the package to your `Package.swift`:
-```ruby
-pod 'XCoordinator', '~> 2.0'
+```swift
+.package(url: "https://github.com/quickbirdstudios/XCoordinator.git", from: "3.0.0")
```
-To use the RxSwift extensions, add this to your `Podfile`:
+Then add the product you need to your target's dependencies — either `XCoordinator` (core + Combine + SwiftUI) or `XCoordinatorRx` (adds RxSwift).
-```ruby
-pod 'XCoordinator/RxSwift', '~> 2.0'
-```
+In Xcode, use **File → Add Package Dependencies** and paste the same URL.
-To use the Combine extensions, add this to your `Podfile`:
+### CocoaPods
```ruby
-pod 'XCoordinator/Combine', '~> 2.0'
+pod 'XCoordinator', '~> 3.0'
```
-#### Carthage
-
-To integrate XCoordinator into your Xcode project using Carthage, add this to your `Cartfile`:
+For RxSwift bindings:
+```ruby
+pod 'XCoordinator/RxSwift', '~> 3.0'
```
-github "quickbirdstudios/XCoordinator" ~> 2.0
-```
-
-Then run `carthage update`.
-
-If this is your first time using Carthage in the project, you'll need to go through some additional steps as explained [over at Carthage](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application).
-
-#### Swift Package Manager
-
-See [this WWDC presentation](https://developer.apple.com/videos/play/wwdc2019/408/) about more information how to adopt Swift packages in your app.
-
-Specify `https://github.com/quickbirdstudios/XCoordinator.git` as the `XCoordinator` package link.
-You can then decide between three different frameworks, i.e. `XCoordinator`, `XCoordinatorRx` and `XCoordinatorCombine`.
-While `XCoordinator` contains the main framework, you can choose `XCoordinatorRx` or `XCoordinatorCombine` to get `RxSwift` or `Combine` extensions as well.
-
-#### Manually
-If you prefer not to use any of the dependency managers, you can integrate XCoordinator into your project manually, by downloading the source code and placing the files on your project directory.
+Combine is bundled into the main pod; no separate subspec is needed.
-## 👤 Author
-This framework is created with ❤️ by [QuickBird Studios](https://quickbirdstudios.com).
+## ✅ Requirements
-To get more information on XCoordinator check out [our blog post](https://quickbirdstudios.com/blog/ios-navigation-library-based-on-the-coordinator-pattern/).
+- iOS 16 / tvOS 16
+- Swift 5.9
+- Xcode 15
## ❤️ Contributing
-Open an issue if you need help, if you found a bug, or if you want to discuss a feature request. If you feel like having a chat about XCoordinator with the developers and other users, join our [Slack Workspace](https://join.slack.com/t/xcoordinator/shared_invite/enQtNDg4NDAxNTk1ODQ1LTkxYzE3MDM5ZGY1MTVmY2NhNjI0Y2JiYmQ5NTdjZDczZDRjZTg1ZmJlOTZmODYyYzMyYWQ0NzhlNGNkMGIzYjQ).
+- Open an [issue](https://github.com/quickbirdstudios/XCoordinator/issues) if you found a bug, want to discuss a feature request, or need help.
+- Use [GitHub Discussions](https://github.com/quickbirdstudios/XCoordinator/discussions) for usage questions.
+- Open a [pull request](https://github.com/quickbirdstudios/XCoordinator/pulls) if you want to contribute a change — please include tests where applicable.
-Open a PR if you want to make changes to XCoordinator.
+XCoordinator is created and maintained by [QuickBird Studios](https://quickbirdstudios.com).
## 📃 License
-XCoordinator is released under an MIT license. See [License.md](https://github.com/quickbirdstudios/XCoordinator/blob/master/LICENSE) for more information.
+XCoordinator is released under the MIT License. See [LICENSE](LICENSE) for more information.
diff --git a/Scripts/build.sh b/Scripts/build.sh
new file mode 100755
index 00000000..e31baba7
--- /dev/null
+++ b/Scripts/build.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+
+# Builds the XCoordinator package against the iOS Simulator SDK.
+# Works on both Apple Silicon and Intel Macs — the toolchain selects the appropriate arch.
+
+set -e -o pipefail
+
+cd "$(dirname "$0")/.."
+
+xcodebuild build \
+ -scheme XCoordinator \
+ -destination 'generic/platform=iOS Simulator' \
+ -skipPackagePluginValidation \
+ CODE_SIGNING_ALLOWED=NO
diff --git a/Scripts/docs.sh b/Scripts/docs.sh
new file mode 100755
index 00000000..09a1e78a
--- /dev/null
+++ b/Scripts/docs.sh
@@ -0,0 +1,47 @@
+#!/bin/sh
+
+# Generates static-hosting DocC output into ./Documentation.
+#
+# Builds the DocC archive against the iOS SDK via `xcodebuild docbuild` (the
+# package depends on UIKit, so a plain SwiftPM host build fails with
+# "no such module 'UIKit'"), then transforms it for static hosting.
+#
+# Pass an optional hosting base path as the first argument (used when publishing
+# to GitHub Pages):
+#
+# Scripts/docs.sh # local / CI validation build
+# Scripts/docs.sh XCoordinator # Pages build served under /XCoordinator
+
+set -e -o pipefail
+
+cd "$(dirname "$0")/.."
+
+DERIVED_DATA=".build/docs-derived-data"
+
+HOSTING_BASE_PATH_ARG=""
+if [ -n "$1" ]; then
+ HOSTING_BASE_PATH_ARG="--hosting-base-path $1"
+fi
+
+rm -rf "$DERIVED_DATA"
+
+xcodebuild docbuild \
+ -scheme XCoordinator \
+ -destination 'generic/platform=iOS' \
+ -derivedDataPath "$DERIVED_DATA" \
+ ONLY_ACTIVE_ARCH=YES \
+ CODE_SIGNING_ALLOWED=NO \
+ OTHER_DOCC_FLAGS="--warnings-as-errors" \
+ -quiet
+
+ARCHIVE=$(find "$DERIVED_DATA" -type d -name 'XCoordinator.doccarchive' | head -n 1)
+if [ -z "$ARCHIVE" ]; then
+ echo "error: could not find XCoordinator.doccarchive under $DERIVED_DATA" >&2
+ exit 1
+fi
+
+rm -rf ./Documentation
+
+"$(xcrun --find docc)" process-archive transform-for-static-hosting "$ARCHIVE" \
+ --output-path ./Documentation \
+ $HOSTING_BASE_PATH_ARG
diff --git a/Scripts/docs_preview.sh b/Scripts/docs_preview.sh
new file mode 100755
index 00000000..5d04ad7a
--- /dev/null
+++ b/Scripts/docs_preview.sh
@@ -0,0 +1,36 @@
+#!/bin/sh
+
+# Previews DocC documentation in a local web server.
+# Runs unchanged on Apple Silicon and Intel Macs.
+
+set -e -o pipefail
+
+cd "$(dirname "$0")/.."
+
+echo "1. Building documentation archive for iOS..."
+(./Scripts/docs.sh)
+
+# Locate the generated archive (docs.sh builds into .build/docs-derived-data)
+DOCC_ARCHIVE=$(find .build/docs-derived-data -type d -name "XCoordinator.doccarchive" | head -n 1)
+
+if [ -z "$DOCC_ARCHIVE" ]; then
+ echo "Error: Could not find the generated XCoordinator.doccarchive artifact."
+ exit 1
+fi
+
+echo "2. Transforming archive for local static web hosting..."
+STATIC_OUT=".build/Documentation/static"
+rm -rf "$STATIC_OUT"
+
+xcrun docc process-archive transform-for-static-hosting "$DOCC_ARCHIVE" \
+ --output-path "$STATIC_OUT"
+
+DOCC_URL=http://localhost:8000/documentation/xcoordinator
+
+echo "--------------------------------------------------------"
+echo "Documentation server running!"
+echo "$DOCC_URL"
+echo "--------------------------------------------------------"
+
+# 3. Serve the interactive documentation site
+python3 -m http.server --directory "$STATIC_OUT" 8000
diff --git a/Scripts/test.sh b/Scripts/test.sh
new file mode 100755
index 00000000..27249a6c
--- /dev/null
+++ b/Scripts/test.sh
@@ -0,0 +1,38 @@
+#!/bin/sh
+
+# Runs the XCoordinator tests on an iOS Simulator.
+#
+# The tests exercise real UIKit view-controller transitions, which only work when a
+# UIWindowScene exists — something a bare SwiftPM test bundle does not provide. They
+# therefore run through the TestHost application (TestHost/XCoordinatorTestHost.xcodeproj),
+# which references this package and hosts the tests in Tests/XCoordinatorTests.
+#
+# An available iPhone simulator is resolved at runtime so this works across Xcode
+# versions (which ship different device names) on both local machines and CI.
+
+set -e -o pipefail
+
+cd "$(dirname "$0")/.."
+
+DEVICE_ID=$(xcrun simctl list devices available -j | python3 -c '
+import json, sys
+devices = json.load(sys.stdin)["devices"]
+candidates = [
+ dev["udid"]
+ for runtime, devs in devices.items() if "iOS" in runtime
+ for dev in devs if "iPhone" in dev["name"]
+]
+print(candidates[0] if candidates else "")
+')
+
+if [ -z "$DEVICE_ID" ]; then
+ echo "error: no available iPhone simulator found" >&2
+ exit 1
+fi
+
+xcodebuild test \
+ -project TestHost/XCoordinatorTestHost.xcodeproj \
+ -scheme XCoordinatorTestHost \
+ -destination "id=$DEVICE_ID" \
+ -skipPackagePluginValidation \
+ CODE_SIGNING_ALLOWED=NO
diff --git a/Sources/XCoordinator/Animation.swift b/Sources/XCoordinator/Animations/Animation.swift
similarity index 100%
rename from Sources/XCoordinator/Animation.swift
rename to Sources/XCoordinator/Animations/Animation.swift
diff --git a/Sources/XCoordinator/GestureRecognizerTarget.swift b/Sources/XCoordinator/Animations/GestureRecognizerTarget.swift
similarity index 84%
rename from Sources/XCoordinator/GestureRecognizerTarget.swift
rename to Sources/XCoordinator/Animations/GestureRecognizerTarget.swift
index 69907be7..01c4e2bb 100755
--- a/Sources/XCoordinator/GestureRecognizerTarget.swift
+++ b/Sources/XCoordinator/Animations/GestureRecognizerTarget.swift
@@ -3,6 +3,7 @@
// XCoordinator
//
// Created by Paul Kraft on 19.12.18.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
//
import UIKit
@@ -23,8 +24,7 @@ internal class Target: GestureRecognizer
init(recognizer gestureRecognizer: GestureRecognizer, handler: @escaping (GestureRecognizer) -> Void) {
self.handler = handler
self.gestureRecognizer = gestureRecognizer
- // The method signature "handle(_ gestureRecognizer: UIGestureRecognizer)" is in conflict with validation Apple, use another name : "handleMyGesture"
- gestureRecognizer.addTarget(self, action: #selector(handleGesture(of: )))
+ gestureRecognizer.addTarget(self, action: #selector(handleGesture))
}
// MARK: Target actions
@@ -34,4 +34,5 @@ internal class Target: GestureRecognizer
guard let recognizer = gestureRecognizer as? GestureRecognizer else { return }
handler(recognizer)
}
+
}
diff --git a/Sources/XCoordinator/InteractiveTransitionAnimation.swift b/Sources/XCoordinator/Animations/InteractiveTransitionAnimation.swift
similarity index 100%
rename from Sources/XCoordinator/InteractiveTransitionAnimation.swift
rename to Sources/XCoordinator/Animations/InteractiveTransitionAnimation.swift
diff --git a/Sources/XCoordinator/InterruptibleTransitionAnimation.swift b/Sources/XCoordinator/Animations/InterruptibleTransitionAnimation.swift
similarity index 97%
rename from Sources/XCoordinator/InterruptibleTransitionAnimation.swift
rename to Sources/XCoordinator/Animations/InterruptibleTransitionAnimation.swift
index 4259744d..c5fd5f62 100755
--- a/Sources/XCoordinator/InterruptibleTransitionAnimation.swift
+++ b/Sources/XCoordinator/Animations/InterruptibleTransitionAnimation.swift
@@ -3,16 +3,15 @@
// XCoordinator
//
// Created by Paul Kraft on 24.12.18.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
//
import UIKit
///
/// Use InterruptibleTransitionAnimation to define interactive transitions based on the
-/// [UIViewPropertyAnimator](https://developer.apple.com/documentation/uikit/UIViewPropertyAnimator)
-/// APIs introduced in iOS 10.
+/// [UIViewPropertyAnimator](https://developer.apple.com/documentation/uikit/UIViewPropertyAnimator) APIs.
///
-@available(iOS 10.0, tvOS 10.0, *)
open class InterruptibleTransitionAnimation: InteractiveTransitionAnimation {
// MARK: Stored properties
diff --git a/Sources/XCoordinator/StaticTransitionAnimation.swift b/Sources/XCoordinator/Animations/StaticTransitionAnimation.swift
similarity index 94%
rename from Sources/XCoordinator/StaticTransitionAnimation.swift
rename to Sources/XCoordinator/Animations/StaticTransitionAnimation.swift
index 102edab0..8bad66c2 100755
--- a/Sources/XCoordinator/StaticTransitionAnimation.swift
+++ b/Sources/XCoordinator/Animations/StaticTransitionAnimation.swift
@@ -38,10 +38,8 @@ open class StaticTransitionAnimation: NSObject, TransitionAnimation {
///
/// - Parameters:
/// - duration: The total duration of the animation.
- /// - performAnimation: A closure performing the animation.
- /// - context:
- /// From the context, you can access source and destination views and
- /// viewControllers and the containerView.
+ /// - performAnimation: A closure performing the animation. From the closure's `context`,
+ /// you can access source and destination views and viewControllers and the containerView.
///
public init(duration: TimeInterval, performAnimation: @escaping (_ context: UIViewControllerContextTransitioning) -> Void) {
self.duration = duration
diff --git a/Sources/XCoordinator/TransitionAnimation.swift b/Sources/XCoordinator/Animations/TransitionAnimation.swift
similarity index 100%
rename from Sources/XCoordinator/TransitionAnimation.swift
rename to Sources/XCoordinator/Animations/TransitionAnimation.swift
diff --git a/Sources/XCoordinator/AnyCoordinator.swift b/Sources/XCoordinator/AnyCoordinator.swift
deleted file mode 100755
index 905ac917..00000000
--- a/Sources/XCoordinator/AnyCoordinator.swift
+++ /dev/null
@@ -1,115 +0,0 @@
-//
-// AnyCoordinator.swift
-// XCoordinator
-//
-// Created by Paul Kraft on 25.10.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-/// A type-erased Coordinator (`AnyCoordinator`) with a `UINavigationController` as rootViewController.
-public typealias AnyNavigationCoordinator = AnyCoordinator
-
-/// A type-erased Coordinator (`AnyCoordinator`) with a `UITabBarController` as rootViewController.
-public typealias AnyTabBarCoordinator = AnyCoordinator
-
-/// A type-erased Coordinator (`AnyCoordinator`) with a `UIViewController` as rootViewController.
-public typealias AnyViewCoordinator = AnyCoordinator
-
-///
-/// `AnyCoordinator` is a type-erased `Coordinator` (`RouteType` & `TransitionType`) and
-/// can be used as an abstraction from a specific coordinator class while still specifying
-/// TransitionType and RouteType.
-///
-/// - Note:
-/// If you do not want/need to specify TransitionType, you might want to look into the
-/// different router abstractions `StrongRouter`, `UnownedRouter` and `WeakRouter`.
-/// See `AnyTransitionPerformer` to further abstract from RouteType.
-///
-public class AnyCoordinator: Coordinator {
-
- // MARK: Stored properties
-
- private let _prepareTransition: (RouteType) -> TransitionType
- private let _viewController: () -> UIViewController?
- private let _rootViewController: () -> TransitionType.RootViewController
- private let _presented: (Presentable?) -> Void
- private let _setRoot: (UIWindow) -> Void
- private let _addChild: (Presentable) -> Void
- private let _removeChild: (Presentable) -> Void
- private let _removeChildrenIfNeeded: () -> Void
- private let _registerParent: (Presentable & AnyObject) -> Void
-
- // MARK: Initialization
-
- ///
- /// Creates a type-erased Coordinator for a specific coordinator.
- ///
- /// A strong reference to the source coordinator is kept.
- ///
- /// - Parameter coordinator:
- /// The source coordinator.
- ///
- public init(_ coordinator: C) where C.RouteType == RouteType, C.TransitionType == TransitionType {
- self._prepareTransition = coordinator.prepareTransition
- self._viewController = { coordinator.viewController }
- self._rootViewController = { coordinator.rootViewController }
- self._presented = coordinator.presented
- self._setRoot = coordinator.setRoot
- self._addChild = coordinator.addChild
- self._removeChild = coordinator.removeChild
- self._removeChildrenIfNeeded = coordinator.removeChildrenIfNeeded
- self._registerParent = coordinator.registerParent
- }
-
- // MARK: Computed properties
-
- public var rootViewController: TransitionType.RootViewController {
- _rootViewController()
- }
-
- public var viewController: UIViewController! {
- _viewController()
- }
-
- // MARK: Methods
-
- ///
- /// Prepare and return transitions for a given route.
- ///
- /// - Parameter route:
- /// The triggered route for which a transition is to be prepared.
- ///
- /// - Returns:
- /// The prepared transition.
- ///
- public func prepareTransition(for route: RouteType) -> TransitionType {
- _prepareTransition(route)
- }
-
- public func presented(from presentable: Presentable?) {
- _presented(presentable)
- }
-
- public func registerParent(_ presentable: Presentable & AnyObject) {
- _registerParent(presentable)
- }
-
- public func setRoot(for window: UIWindow) {
- _setRoot(window)
- }
-
- public func addChild(_ presentable: Presentable) {
- _addChild(presentable)
- }
-
- public func removeChild(_ presentable: Presentable) {
- _removeChild(presentable)
- }
-
- public func removeChildrenIfNeeded() {
- _removeChildrenIfNeeded()
- }
-
-}
diff --git a/Sources/XCoordinator/AnyTransitionPerformer.swift b/Sources/XCoordinator/AnyTransitionPerformer.swift
deleted file mode 100755
index 9a677a12..00000000
--- a/Sources/XCoordinator/AnyTransitionPerformer.swift
+++ /dev/null
@@ -1,58 +0,0 @@
-//
-// AnyTransitionPerformer.swift
-// XCoordinator
-//
-// Created by Paul Kraft on 13.09.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-///
-/// AnyTransitionPerformer can be used as an abstraction from a specific TransitionPerformer implementation
-/// without losing type information about its TransitionType.
-///
-/// This type abstraction can be especially helpful when performing transitions.
-/// AnyTransitionPerformer abstracts away any implementation specific details and reduces coordinators to the capabilities
-/// of the `TransitionPerformer` protocol.
-///
-public class AnyTransitionPerformer: TransitionPerformer {
-
- // MARK: Stored properties
-
- private var _viewController: () -> UIViewController?
- private var _rootViewController: () -> TransitionType.RootViewController
- private var _presented: (Presentable?) -> Void
- private var _perform: (TransitionType, TransitionOptions, PresentationHandler?) -> Void
-
- // MARK: Computed properties
-
- public var viewController: UIViewController! {
- _viewController()
- }
-
- public var rootViewController: TransitionType.RootViewController {
- _rootViewController()
- }
-
- // MARK: Methods
-
- public func presented(from presentable: Presentable?) {
- _presented(presentable)
- }
-
- public func performTransition(_ transition: TransitionType,
- with options: TransitionOptions,
- completion: PresentationHandler? = nil) {
- _perform(transition, options, completion)
- }
-
- // MARK: Initialization
-
- init(_ coordinator: T) where TransitionType == T.TransitionType {
- self._viewController = { coordinator.viewController }
- self._presented = coordinator.presented
- self._rootViewController = { coordinator.rootViewController }
- self._perform = coordinator.performTransition
- }
-}
diff --git a/Sources/XCoordinator/BasicCoordinator.swift b/Sources/XCoordinator/BasicCoordinator.swift
deleted file mode 100755
index 8c94cabc..00000000
--- a/Sources/XCoordinator/BasicCoordinator.swift
+++ /dev/null
@@ -1,105 +0,0 @@
-//
-// BasicCoordinator.swift
-// XCoordinator
-//
-// Created by Stefan Kofler on 05.05.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-/// A BasicCoordinator with a `UINavigationController` as its rootViewController.
-public typealias BasicNavigationCoordinator = BasicCoordinator
-
-/// A BasicCoordinator with a `UIViewController` as its rootViewController.
-public typealias BasicViewCoordinator = BasicCoordinator
-
-/// A BasicCoordinator with a `UITabBarController` as its rootViewController.
-public typealias BasicTabBarCoordinator = BasicCoordinator
-
-///
-/// BasicCoordinator is a coordinator class that can be used without subclassing.
-///
-/// Although subclassing of coordinators is encouraged for more complex cases, a `BasicCoordinator` can easily
-/// be created by only providing a `prepareTransition` closure, an `initialRoute` and an `initialLoadingType`.
-///
-open class BasicCoordinator: BaseCoordinator {
-
- // MARK: Nested types
-
- ///
- /// `InitialLoadingType` differentiates between different points in time when the initital route is to
- /// be triggered by the coordinator.
- ///
- public enum InitialLoadingType {
-
- /// The initial route is triggered before the coordinator is made visible (i.e. on initialization).
- case immediately
-
- /// The initial route is triggered after the coordinator is made visible.
- case presented
- }
-
- // MARK: Stored properties
-
- private let initialRoute: RouteType?
- private let initialLoadingType: InitialLoadingType
- private let prepareTransition: ((RouteType) -> TransitionType)?
-
- // MARK: Initialization
-
- ///
- /// Creates a BasicCoordinator.
- ///
- /// - Parameters:
- /// - initialRoute:
- /// If a route is specified, it is triggered depending on the initialLoadingType.
- /// - initialLoadingType:
- /// The initialLoadingType specifies when the initialRoute is triggered.
- /// - prepareTransition:
- /// A closure to define transitions based on triggered routes.
- /// Make sure to override `prepareTransition` by subclassing, if you specify `nil` here.
- ///
- /// - Seealso:
- /// See `InitialLoadingType` for more information.
- ///
- public init(rootViewController: RootViewController,
- initialRoute: RouteType? = nil,
- initialLoadingType: InitialLoadingType = .presented,
- prepareTransition: ((RouteType) -> TransitionType)?) {
- self.initialRoute = initialRoute
- self.initialLoadingType = initialLoadingType
- self.prepareTransition = prepareTransition
-
- if initialLoadingType == .immediately {
- super.init(rootViewController: rootViewController, initialRoute: initialRoute)
- } else {
- super.init(rootViewController: rootViewController, initialRoute: nil)
- }
- }
-
- // MARK: Open methods
-
- ///
- /// This method is called whenever the BasicCoordinator is shown to the user.
- ///
- /// If `initialLoadingType` has been specified as `presented` and an initialRoute is present,
- /// the route is triggered here.
- ///
- /// - Parameter presentable:
- /// The context in which this coordinator has been shown to the user.
- ///
- open override func presented(from presentable: Presentable?) {
- super.presented(from: presentable)
-
- if let initialRoute = initialRoute, initialLoadingType == .presented {
- trigger(initialRoute, with: TransitionOptions(animated: false), completion: nil)
- }
- }
-
- open override func prepareTransition(for route: RouteType) -> TransitionType {
- if let prepareTransition = prepareTransition {
- return prepareTransition(route)
- } else {
- fatalError("Either pass a \(#function) closure to the initializer or override this method.")
- }
- }
-}
diff --git a/Sources/XCoordinator/Combine/Router+Combine.swift b/Sources/XCoordinator/Combine/Router+Combine.swift
new file mode 100644
index 00000000..ed4f2c55
--- /dev/null
+++ b/Sources/XCoordinator/Combine/Router+Combine.swift
@@ -0,0 +1,122 @@
+//
+// Router+Combine.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 28.08.19.
+// Copyright © 2019 QuickBird Studios. All rights reserved.
+//
+
+#if canImport(Combine)
+
+import Combine
+
+///
+/// A namespace for Combine publishers exposed by a base value.
+///
+/// Routers expose this namespace via `router.publishers`, mirroring the `router.rx` namespace
+/// in `XCoordinatorRx`. Use the methods on this type — `trigger(_:with:)` and
+/// `contextTrigger(_:with:)` — to obtain publishers for route triggers.
+///
+@MainActor
+public struct PublisherExtension {
+
+ /// The underlying value (typically a `Router`) this namespace wraps.
+ public let base: Base
+}
+
+extension Router {
+
+ /// The Combine namespace for this router.
+ ///
+ /// Use `router.publishers.trigger(_:)` to obtain a publisher that performs the route's transition
+ /// when subscribed to and completes when the transition finishes.
+ public var publishers: PublisherExtension {
+ .init(base: self)
+ }
+
+ ///
+ /// Triggers a route and returns a publisher that completes when the transition finishes.
+ ///
+ /// The transition is performed on **subscription** (the returned publisher is lazy), so no
+ /// navigation happens until a subscriber attaches. Prefer the convenience accessor ``publishers``
+ /// for new code: `router.publishers.trigger(.home)`.
+ ///
+ /// - Parameters:
+ /// - route: The route to trigger.
+ /// - options: Transition options. Defaults to animated.
+ /// - Returns: A publisher that emits `()` and finishes once the transition completes.
+ ///
+ public func triggerPublisher(
+ _ route: RouteType,
+ with options: TransitionOptions = .init(animated: true)
+ ) -> AnyPublisher {
+ Deferred {
+ Future { completion in
+ self.trigger(route, with: options) {
+ completion(.success(()))
+ }
+ }
+ }
+ .eraseToAnyPublisher()
+ }
+
+ ///
+ /// Triggers a route and returns a publisher that emits the resulting transition context.
+ ///
+ /// The transition is performed on **subscription** (the returned publisher is lazy). Useful for
+ /// deep linking. Prefer ``publishers`` for new code: `router.publishers.contextTrigger(.home)`.
+ ///
+ /// - Parameters:
+ /// - route: The route to trigger.
+ /// - options: Transition options. Defaults to animated.
+ /// - Returns: A publisher that emits the transition context and finishes.
+ ///
+ public func contextTriggerPublisher(
+ _ route: RouteType,
+ with options: TransitionOptions = .init(animated: true)
+ ) -> AnyPublisher {
+ Deferred {
+ Future { completion in
+ self.contextTrigger(route, with: options) {
+ completion(.success($0))
+ }
+ }
+ }
+ .eraseToAnyPublisher()
+ }
+
+}
+
+extension PublisherExtension where Base: Router {
+
+ /// Triggers a route on the wrapped router and returns a publisher that completes when the transition finishes.
+ ///
+ /// The transition is performed on subscription (lazy).
+ ///
+ /// - Parameters:
+ /// - route: The route to trigger.
+ /// - options: Transition options. Defaults to animated.
+ public func trigger(
+ _ route: Base.RouteType,
+ with options: TransitionOptions = .init(animated: true)
+ ) -> AnyPublisher {
+ base.triggerPublisher(route, with: options)
+ }
+
+ /// Triggers a route on the wrapped router and returns a publisher emitting the resulting transition context.
+ ///
+ /// The transition is performed on subscription (lazy).
+ ///
+ /// - Parameters:
+ /// - route: The route to trigger.
+ /// - options: Transition options. Defaults to animated.
+ public func contextTrigger(
+ _ route: Base.RouteType,
+ with options: TransitionOptions = .init(animated: true)
+ ) -> AnyPublisher {
+ base.contextTriggerPublisher(route, with: options)
+ }
+
+}
+
+#endif
diff --git a/Sources/XCoordinator/Coordinator.swift b/Sources/XCoordinator/Coordinator.swift
deleted file mode 100755
index 59acc646..00000000
--- a/Sources/XCoordinator/Coordinator.swift
+++ /dev/null
@@ -1,142 +0,0 @@
-//
-// Coordinator.swift
-// XCoordinator
-//
-// Created by Stefan Kofler on 30.04.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-/// The completion handler for transitions.
-public typealias PresentationHandler = () -> Void
-
-/// The completion handler for transitions, which also provides the context information about the transition.
-public typealias ContextPresentationHandler = (TransitionContext) -> Void
-
-///
-/// Coordinator is the protocol every coordinator conforms to.
-///
-/// It requires an object to be able to trigger routes and perform transitions.
-/// This connection is created using the `prepareTransition(for:)` method.
-///
-public protocol Coordinator: Router, TransitionPerformer {
-
- ///
- /// This method prepares transitions for routes.
- /// It especially decides, which transitions are performed for the triggered routes.
- ///
- /// - Parameter route:
- /// The triggered route for which a transition is to be prepared.
- ///
- /// - Returns:
- /// The prepared transition.
- ///
- func prepareTransition(for route: RouteType) -> TransitionType
-
- ///
- /// This method adds a child to a coordinator's children.
- ///
- /// - Parameter presentable:
- /// The child to be added.
- ///
- func addChild(_ presentable: Presentable)
-
- ///
- /// This method removes a child to a coordinator's children.
- ///
- /// - Parameter presentable:
- /// The child to be removed.
- ///
- func removeChild(_ presentable: Presentable)
-
- /// This method removes all children that are no longer in the view hierarchy.
- func removeChildrenIfNeeded()
-}
-
-// MARK: - Typealiases
-
-extension Coordinator {
-
- /// Shortcut for Coordinator.TransitionType.RootViewController
- public typealias RootViewController = TransitionType.RootViewController
-}
-
-// MARK: - Presentable
-
-extension Coordinator {
-
- /// A Coordinator uses its rootViewController as viewController.
- public var viewController: UIViewController! {
- rootViewController
- }
-}
-
-extension Coordinator where Self: AnyObject {
-
- ///
- /// Creates a WeakRouter object from the given router to abstract from concrete implementations
- /// while maintaining information necessary to fulfill the Router protocol.
- /// The original router will be held weakly.
- ///
- public var weakRouter: WeakRouter {
- WeakRouter(self) { $0.strongRouter }
- }
-
- ///
- /// Creates an UnownedRouter object from the given router to abstract from concrete implementations
- /// while maintaining information necessary to fulfill the Router protocol.
- /// The original router will be held unowned.
- ///
-
- public var unownedRouter: UnownedRouter {
- UnownedRouter(self) { $0.strongRouter }
- }
-
-}
-
-// MARK: - Default implementations
-
-extension Coordinator where Self: AnyObject {
-
- /// Creates an AnyCoordinator based on the current coordinator.
- public var anyCoordinator: AnyCoordinator {
- AnyCoordinator(self)
- }
-
- public func presented(from presentable: Presentable?) {}
-
- public func childTransitionCompleted() {
- removeChildrenIfNeeded()
- }
-
- public func contextTrigger(_ route: RouteType,
- with options: TransitionOptions,
- completion: ContextPresentationHandler?) {
- let transition = prepareTransition(for: route)
- performTransition(transition, with: options) { completion?(transition) }
- }
-
- ///
- /// With `chain(routes:)` different routes can be chained together to form a combined transition.
- ///
- /// - Parameter routes:
- /// The routes to be chained.
- ///
- /// - Returns:
- /// A transition combining the transitions of the specified routes.
- ///
- public func chain(routes: [RouteType]) -> TransitionType {
- .multiple(routes.map(prepareTransition))
- }
-
- public func performTransition(_ transition: TransitionType,
- with options: TransitionOptions,
- completion: PresentationHandler? = nil) {
- transition.presentables.forEach(addChild)
- transition.perform(on: rootViewController, with: options) {
- completion?()
- self.removeChildrenIfNeeded()
- }
- }
-}
diff --git a/Sources/XCoordinator/CoordinatorPreviewingDelegateObject.swift b/Sources/XCoordinator/CoordinatorPreviewingDelegateObject.swift
deleted file mode 100755
index 68d7fe6d..00000000
--- a/Sources/XCoordinator/CoordinatorPreviewingDelegateObject.swift
+++ /dev/null
@@ -1,53 +0,0 @@
-//
-// CoordinatorPreviewingDelegateObject.swift
-// XCoordinator
-//
-// Created by Stefan Kofler on 19.07.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-internal class CoordinatorPreviewingDelegateObject:
-NSObject, UIViewControllerPreviewingDelegate {
-
- // MARK: Stored properties
-
- internal var context: UIViewControllerPreviewing?
-
- private weak var viewController: UIViewController?
-
- private let transition: () -> TransitionType
- private let rootViewController: TransitionType.RootViewController
- private let completion: PresentationHandler?
-
- // MARK: Initialization
-
- internal init(transition: @escaping () -> TransitionType,
- rootViewController: TransitionType.RootViewController,
- completion: PresentationHandler?) {
- self.transition = transition
- self.rootViewController = rootViewController
- self.completion = completion
- }
-
- // MARK: Methods
-
- internal func previewingContext(_ previewingContext: UIViewControllerPreviewing,
- viewControllerForLocation location: CGPoint) -> UIViewController? {
-
- if let viewController = viewController {
- return viewController
- }
-
- let presentable = transition().presentables.last
- presentable?.presented(from: nil)
- viewController = presentable?.viewController
- return viewController
- }
-
- internal func previewingContext(_ previewingContext: UIViewControllerPreviewing,
- commit viewControllerToCommit: UIViewController) {
- transition().perform(on: rootViewController, with: .default, completion: completion)
- }
-}
diff --git a/Sources/XCoordinator/BaseCoordinator.swift b/Sources/XCoordinator/Coordinators/BaseCoordinator.swift
similarity index 72%
rename from Sources/XCoordinator/BaseCoordinator.swift
rename to Sources/XCoordinator/Coordinators/BaseCoordinator.swift
index 1ecacdc3..1ae1b1ef 100755
--- a/Sources/XCoordinator/BaseCoordinator.swift
+++ b/Sources/XCoordinator/Coordinators/BaseCoordinator.swift
@@ -8,11 +8,6 @@
import UIKit
-extension BaseCoordinator {
- /// Shortcut for `BaseCoordinator.TransitionType.RootViewController`
- public typealias RootViewController = TransitionType.RootViewController
-}
-
///
/// BaseCoordinator can (and is encouraged to) be used as a superclass for any custom implementation of a coordinator.
///
@@ -20,7 +15,8 @@ extension BaseCoordinator {
/// `NavigationCoordinator`, `TabBarCoordinator`, `ViewCoordinator`, `SplitCoordinator`
/// and `PageCoordinator`.
///
-open class BaseCoordinator: Coordinator {
+@MainActor
+open class BaseCoordinator: Coordinator {
// MARK: Stored properties
@@ -32,12 +28,17 @@ open class BaseCoordinator
/// When performing a transition, children are automatically added and removed from this array
/// depending on whether they are in the view hierarchy.
///
- public private(set) var children = [Presentable]()
+ public private(set) var children = [any Presentable]()
// MARK: Computed properties
+ /// The root view controller of this coordinator's flow.
+ ///
+ /// Its concrete type is the coordinator's `RootViewController` — e.g. a `UINavigationController`
+ /// for a `NavigationCoordinator`. Transitions on this coordinator are performed against it.
public private(set) var rootViewController: RootViewController
-
+
+ /// The presentable view controller for this coordinator. Returns ``rootViewController`` by default.
open var viewController: UIViewController! {
rootViewController
}
@@ -45,10 +46,11 @@ open class BaseCoordinator
// MARK: Initialization
///
- /// This initializer trigger a route before the coordinator is made visible.
+ /// Creates a coordinator and optionally triggers a route before the coordinator is made visible.
///
- /// - Parameter initialRoute:
- /// If a route is specified, it is triggered before making the coordinator visible.
+ /// - Parameters:
+ /// - rootViewController: The root view controller for this coordinator's flow.
+ /// - initialRoute: A route to trigger before the coordinator becomes visible. Pass `nil` to skip.
///
public init(rootViewController: RootViewController, initialRoute: RouteType?) {
self.rootViewController = rootViewController
@@ -56,38 +58,63 @@ open class BaseCoordinator
}
///
- /// This initializer performs a transition before the coordinator is made visible.
+ /// Creates a coordinator and optionally performs a transition before the coordinator is made visible.
///
- /// - Parameter initialTransition:
- /// If a transition is specified, it is performed before making the coordinator visible.
+ /// - Parameters:
+ /// - rootViewController: The root view controller for this coordinator's flow.
+ /// - initialTransition: A transition to perform before the coordinator becomes visible. Pass `nil` to skip.
///
- public init(rootViewController: RootViewController, initialTransition: TransitionType?) {
+ public init(rootViewController: RootViewController, initialTransition: Transition?) {
self.rootViewController = rootViewController
initialTransition.map(performTransitionAfterWindowAppeared)
}
+ ///
+ /// Creates a coordinator and performs an initial transition — described with the transition builder —
+ /// before the coordinator is made visible.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The root view controller for this coordinator's flow.
+ /// - initialTransition: A transition-builder closure describing the transition to perform.
+ ///
+ public init(rootViewController: RootViewController,
+ @TransitionBuilder initialTransition: () -> Transition) {
+ self.rootViewController = rootViewController
+ performTransitionAfterWindowAppeared(initialTransition())
+ }
+
// MARK: Open methods
- open func presented(from presentable: Presentable?) {}
+ /// Returns this coordinator as a router for `route`, if it handles that route type.
+ ///
+ /// - Note: This matches only when `self` is a `BaseCoordinator` — i.e. the
+ /// same `RouteType` *and* the same `RootViewController`. It intentionally does not search child
+ /// coordinators; deep linking traverses the coordinator tree via the transition's `presentables`
+ /// (see `DeepLinking.swift`), and SwiftUI lookups register routers explicitly in the `RoutingContext`.
+ public func router(for route: R.Type) -> (any Router)? {
+ self as? BaseCoordinator
+ }
+
+ open func presented(from presentable: (any Presentable)?) {}
public func removeChildrenIfNeeded() {
children.removeAll { $0.canBeRemovedAsChild() }
removeParentChildren()
}
- public func addChild(_ presentable: Presentable) {
+ public func addChild(_ presentable: any Presentable) {
children.append(presentable)
presentable.registerParent(self)
}
- public func removeChild(_ presentable: Presentable) {
+ public func removeChild(_ presentable: any Presentable) {
children.removeAll { $0.viewController === presentable.viewController }
removeChildrenIfNeeded()
}
///
/// This method prepares transitions for routes.
- /// Override this method to define transitions for triggered routes.
+ /// Override this method to define transitions for triggered routes, using the transition builder DSL.
///
/// - Parameter route:
/// The triggered route for which a transition is to be prepared.
@@ -95,27 +122,27 @@ open class BaseCoordinator
/// - Returns:
/// The prepared transition.
///
- open func prepareTransition(for route: RouteType) -> TransitionType {
+ @TransitionBuilder
+ open func prepareTransition(for route: RouteType) -> Transition {
fatalError("Please override the \(#function) method.")
}
-
- public func registerParent(_ presentable: Presentable & AnyObject) {
+
+ public func registerParent(_ presentable: any Presentable & AnyObject) {
let previous = removeParentChildren
removeParentChildren = { [weak presentable] in
previous()
presentable?.childTransitionCompleted()
}
}
-
- @available(iOS, unavailable, message: "Please specify the rootViewController in the initializer of your coordinator instead.")
- open func generateRootViewController() -> RootViewController {
- .init()
- }
// MARK: Private methods
- private func performTransitionAfterWindowAppeared(_ transition: TransitionType) {
- guard !UIApplication.shared.windows.contains(where: { $0.isKeyWindow }) else {
+ private func performTransitionAfterWindowAppeared(_ transition: Transition) {
+ let hasKeyWindow = UIApplication.shared.connectedScenes
+ .compactMap { $0 as? UIWindowScene }
+ .flatMap(\.windows)
+ .contains(where: \.isKeyWindow)
+ guard !hasKeyWindow else {
return performTransition(transition, with: TransitionOptions(animated: false))
}
@@ -136,7 +163,7 @@ extension Presentable {
fileprivate func canBeRemovedAsChild() -> Bool {
guard !(self is UIViewController) else { return true }
- guard let viewController = viewController else { return true }
+ guard let viewController else { return true }
return !viewController.isInViewHierarchy
&& viewController.children.allSatisfy { $0.canBeRemovedAsChild() }
}
@@ -150,7 +177,7 @@ extension UIViewController {
|| presentingViewController != nil
|| presentedViewController != nil
|| parent != nil
- || view.window != nil
+ || viewIfLoaded?.window != nil
|| navigationController != nil
|| tabBarController != nil
|| splitViewController != nil
@@ -183,12 +210,10 @@ extension BaseCoordinator {
/// - recognizer:
/// The gesture recognizer to be used to update the interactive transition.
/// - handler:
- /// The handler to update the interaction controller of the animation generated by the given `transition` closure.
- /// - handlerRecognizer:
- /// The gestureRecognizer with which the handler has been registered.
- /// - transition:
- /// The closure to perform the transition. It returns the transition animation to control the interaction controller of.
- /// `TransitionAnimation.start()` is automatically called.
+ /// The handler to update the interaction controller of the animation generated by the transition closure.
+ /// It receives the gestureRecognizer with which the handler has been registered, and a closure to perform
+ /// the transition — which returns the transition animation to control the interaction controller of
+ /// (`TransitionAnimation.start()` is automatically called).
/// - completion:
/// The closure to be called whenever the transition completes.
/// Hint: Might be called multiple times but only once per performing the transition.
diff --git a/Sources/XCoordinator/Coordinators/BasicCoordinator.swift b/Sources/XCoordinator/Coordinators/BasicCoordinator.swift
new file mode 100755
index 00000000..3acd652e
--- /dev/null
+++ b/Sources/XCoordinator/Coordinators/BasicCoordinator.swift
@@ -0,0 +1,135 @@
+//
+// BasicCoordinator.swift
+// XCoordinator
+//
+// Created by Stefan Kofler on 05.05.18.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+import UIKit
+
+/// A BasicCoordinator with a `UINavigationController` as its rootViewController.
+public typealias BasicNavigationCoordinator = BasicCoordinator
+
+/// A BasicCoordinator with a `UIViewController` as its rootViewController.
+public typealias BasicViewCoordinator = BasicCoordinator
+
+/// A BasicCoordinator with a `UITabBarController` as its rootViewController.
+public typealias BasicTabBarCoordinator = BasicCoordinator
+
+///
+/// BasicCoordinator is a coordinator class that can be used without subclassing.
+///
+/// Although subclassing of coordinators is encouraged for more complex cases, a `BasicCoordinator` can easily
+/// be created by only providing a `prepare` closure, an `initialRoute` and an `initialLoadingType`.
+///
+open class BasicCoordinator: BaseCoordinator {
+
+ // MARK: Nested types
+
+ ///
+ /// `InitialLoadingType` differentiates between different points in time when the initital route is to
+ /// be triggered by the coordinator.
+ ///
+ public enum InitialLoadingType {
+
+ /// The initial route is triggered before the coordinator is made visible (i.e. on initialization).
+ case immediately
+
+ /// The initial route is triggered after the coordinator is made visible.
+ case presented
+ }
+
+ // MARK: Stored properties
+
+ private let initialRoute: RouteType?
+ private let initialLoadingType: InitialLoadingType
+ private let prepareClosure: ((RouteType) -> Transition)?
+
+ // MARK: Initialization
+
+ ///
+ /// Creates a BasicCoordinator whose transitions are defined inline with the transition builder.
+ ///
+ /// The `prepare` closure is a `@TransitionBuilder`, so its body lists the same `Transition.…`
+ /// factories as an overridden `prepareTransition(for:)`:
+ ///
+ /// ```swift
+ /// BasicNavigationCoordinator(rootViewController: .init(), initialRoute: .home) { route in
+ /// switch route {
+ /// case .home: Transition.show(HomeViewController())
+ /// case .detail: Transition.push(DetailViewController())
+ /// }
+ /// }
+ /// ```
+ ///
+ /// - Parameters:
+ /// - rootViewController: The view controller that hosts the coordinator's transitions.
+ /// - initialRoute: If specified, this route is triggered depending on `initialLoadingType`.
+ /// - initialLoadingType: Determines when `initialRoute` is triggered. See ``InitialLoadingType``.
+ /// - prepare: A transition-builder closure returning the transition for each triggered route.
+ ///
+ public init(rootViewController: RootViewController,
+ initialRoute: RouteType? = nil,
+ initialLoadingType: InitialLoadingType = .presented,
+ @TransitionBuilder prepare: @escaping (RouteType) -> Transition) {
+ self.initialRoute = initialRoute
+ self.initialLoadingType = initialLoadingType
+ self.prepareClosure = prepare
+
+ if initialLoadingType == .immediately {
+ super.init(rootViewController: rootViewController, initialRoute: initialRoute)
+ } else {
+ super.init(rootViewController: rootViewController, initialRoute: nil)
+ }
+ }
+
+ ///
+ /// Creates a BasicCoordinator that defines its transitions by overriding ``prepareTransition(for:)`` in a subclass.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The view controller that hosts the coordinator's transitions.
+ /// - initialRoute: If specified, this route is triggered depending on `initialLoadingType`.
+ /// - initialLoadingType: Determines when `initialRoute` is triggered. See ``InitialLoadingType``.
+ ///
+ public init(rootViewController: RootViewController,
+ initialRoute: RouteType? = nil,
+ initialLoadingType: InitialLoadingType = .presented) {
+ self.initialRoute = initialRoute
+ self.initialLoadingType = initialLoadingType
+ self.prepareClosure = nil
+
+ if initialLoadingType == .immediately {
+ super.init(rootViewController: rootViewController, initialRoute: initialRoute)
+ } else {
+ super.init(rootViewController: rootViewController, initialRoute: nil)
+ }
+ }
+
+ // MARK: Open methods
+
+ ///
+ /// This method is called whenever the BasicCoordinator is shown to the user.
+ ///
+ /// If `initialLoadingType` has been specified as `presented` and an initialRoute is present,
+ /// the route is triggered here.
+ ///
+ /// - Parameter presentable:
+ /// The context in which this coordinator has been shown to the user.
+ ///
+ open override func presented(from presentable: (any Presentable)?) {
+ super.presented(from: presentable)
+
+ if let initialRoute = initialRoute, initialLoadingType == .presented {
+ trigger(initialRoute, with: TransitionOptions(animated: false), completion: nil)
+ }
+ }
+
+ open override func prepareTransition(for route: RouteType) -> Transition {
+ if let prepareClosure = prepareClosure {
+ return prepareClosure(route)
+ } else {
+ fatalError("Either pass a `prepare` closure to the initializer or override this method.")
+ }
+ }
+}
diff --git a/Sources/XCoordinator/Coordinators/Coordinator.swift b/Sources/XCoordinator/Coordinators/Coordinator.swift
new file mode 100755
index 00000000..3baacb02
--- /dev/null
+++ b/Sources/XCoordinator/Coordinators/Coordinator.swift
@@ -0,0 +1,166 @@
+//
+// Coordinator.swift
+// XCoordinator
+//
+// Created by Stefan Kofler on 30.04.18.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+import UIKit
+
+/// The completion handler for transitions.
+public typealias PresentationHandler = () -> Void
+
+/// The completion handler for transitions, which also provides the context information about the transition.
+public typealias ContextPresentationHandler = (any TransitionContext) -> Void
+
+///
+/// Coordinator is the protocol every coordinator conforms to.
+///
+/// It owns a `rootViewController`, prepares a ``Transition`` for each triggered route via ``prepareTransition(for:)``,
+/// and performs those transitions. Every transition is a `Transition`; the concrete
+/// root-view-controller type (e.g. `UINavigationController`) determines which transitions are available.
+///
+@MainActor
+public protocol Coordinator: Router {
+
+ /// The type of the rootViewController on which transitions are performed.
+ associatedtype RootViewController: UIViewController
+
+ /// The rootViewController on which transitions are performed.
+ var rootViewController: RootViewController { get }
+
+ ///
+ /// This method prepares transitions for routes.
+ /// It especially decides which transition is performed for a triggered route.
+ ///
+ /// - Parameter route:
+ /// The triggered route for which a transition is to be prepared.
+ ///
+ /// - Returns:
+ /// The prepared transition.
+ ///
+ @TransitionBuilder
+ func prepareTransition(for route: RouteType) -> Transition
+
+ ///
+ /// Perform a transition.
+ ///
+ /// - Warning:
+ /// Do not use this method directly. Instead, trigger a route on your coordinator wherever possible.
+ ///
+ /// - Parameters:
+ /// - transition: The transition to be performed.
+ /// - options: The options on how to perform the transition, including the option to enable/disable animations.
+ /// - completion: The completion handler called once the transition has finished.
+ ///
+ func performTransition(_ transition: Transition,
+ with options: TransitionOptions,
+ completion: PresentationHandler?)
+
+ ///
+ /// The child coordinators currently in the view hierarchy.
+ /// They are added and removed automatically during transitions depending on whether they are in the
+ /// view hierarchy.
+ ///
+ var children: [any Presentable] { get }
+
+ ///
+ /// This method adds a child to a coordinator's children.
+ ///
+ /// - Parameter presentable:
+ /// The child to be added.
+ ///
+ func addChild(_ presentable: any Presentable)
+
+ ///
+ /// This method removes a child to a coordinator's children.
+ ///
+ /// - Parameter presentable:
+ /// The child to be removed.
+ ///
+ func removeChild(_ presentable: any Presentable)
+
+ /// This method removes all children that are no longer in the view hierarchy.
+ func removeChildrenIfNeeded()
+}
+
+// MARK: - Presentable
+
+extension Coordinator {
+
+ /// A Coordinator uses its rootViewController as viewController.
+ public var viewController: UIViewController! {
+ rootViewController
+ }
+}
+
+// MARK: - Default implementations
+
+extension Coordinator where Self: AnyObject {
+
+ public func presented(from presentable: (any Presentable)?) {}
+
+ public func childTransitionCompleted() {
+ removeChildrenIfNeeded()
+ }
+
+ public func contextTrigger(_ route: RouteType,
+ with options: TransitionOptions,
+ completion: ContextPresentationHandler?) {
+ let transition = prepareTransition(for: route)
+ performTransition(transition, with: options) { completion?(transition) }
+ }
+
+ ///
+ /// With `chain(routes:)` different routes can be chained together to form a combined transition.
+ ///
+ /// - Parameter routes:
+ /// The routes to be chained.
+ ///
+ /// - Returns:
+ /// A transition combining the transitions of the specified routes.
+ ///
+ public func chain(routes: [RouteType]) -> Transition {
+ .multiple(routes.map(prepareTransition))
+ }
+
+ public func performTransition(_ transition: Transition,
+ with options: TransitionOptions,
+ completion: PresentationHandler? = nil) {
+ #if canImport(SwiftUI)
+ for presentable in transition.presentables {
+ // The provider is usually the presentable's view controller (a `RoutingController`),
+ // not the presentable (a coordinator) itself — so check both.
+ if let provider = presentable as? RoutingContextProvider {
+ provider.routingContext.add(self)
+ } else if let viewController = presentable.viewController,
+ let provider = viewController as? RoutingContextProvider {
+ provider.routingContext.add(self)
+ }
+ }
+ #endif
+ transition.perform(on: rootViewController, with: options) { [self] in
+ removeChildrenIfNeeded()
+ transition.presentables.forEach(addChild)
+ completion?()
+ }
+ }
+
+ ///
+ /// Performs a transition described with the transition builder.
+ ///
+ /// - Warning:
+ /// Do not use this method directly. Instead, trigger a route on your coordinator wherever possible.
+ ///
+ /// - Parameters:
+ /// - options: The options on how to perform the transition. Defaults to animated.
+ /// - completion: The completion handler called once the transition has finished.
+ /// - transition: A transition-builder closure describing the transition to perform.
+ ///
+ public func performTransition(with options: TransitionOptions = TransitionOptions(animated: true),
+ completion: PresentationHandler? = nil,
+ @TransitionBuilder _ transition: () -> Transition) {
+ performTransition(transition(), with: options, completion: completion)
+ }
+}
diff --git a/Sources/XCoordinator/RedirectionRouter.swift b/Sources/XCoordinator/Coordinators/RedirectionRouter.swift
similarity index 87%
rename from Sources/XCoordinator/RedirectionRouter.swift
rename to Sources/XCoordinator/Coordinators/RedirectionRouter.swift
index 88cbf472..6284fb1d 100755
--- a/Sources/XCoordinator/RedirectionRouter.swift
+++ b/Sources/XCoordinator/Coordinators/RedirectionRouter.swift
@@ -24,7 +24,7 @@ open class RedirectionRouter: Router {
// MARK: Stored properties
/// A type-erased Router object of the parent router.
- public let parent: UnownedRouter
+ public unowned let parent: any Router
private let _map: ((RouteType) -> ParentRoute)?
@@ -43,8 +43,8 @@ open class RedirectionRouter: Router {
/// and an optional mapping.
///
/// - Note:
- /// Make sure to either override `mapToSuperRoute` or to specify a closure for the `map` parameter.
- /// If you override `mapToSuperRoute`, the `map` parameter is ignored.
+ /// Make sure to either override ``mapToParentRoute(_:)`` or to specify a closure for the `map` parameter.
+ /// If you override ``mapToParentRoute(_:)``, the `map` parameter is ignored.
///
/// - Parameters:
/// - viewController:
@@ -55,7 +55,7 @@ open class RedirectionRouter: Router {
/// A mapping from this RedirectionRouter's routes to the parent's routes.
///
public init(viewController: UIViewController,
- parent: UnownedRouter,
+ parent: any Router,
map: ((RouteType) -> ParentRoute)?) {
self.parent = parent
self._map = map
@@ -64,6 +64,10 @@ open class RedirectionRouter: Router {
// MARK: Methods
+ public func router(for route: R.Type) -> (any Router)? {
+ self as? RedirectionRouter
+ }
+
open func contextTrigger(_ route: RouteType,
with options: TransitionOptions,
completion: ContextPresentationHandler?) {
diff --git a/Sources/XCoordinator/Router.swift b/Sources/XCoordinator/Coordinators/Router.swift
similarity index 63%
rename from Sources/XCoordinator/Router.swift
rename to Sources/XCoordinator/Coordinators/Router.swift
index c29929d2..76c8f4c0 100755
--- a/Sources/XCoordinator/Router.swift
+++ b/Sources/XCoordinator/Coordinators/Router.swift
@@ -1,5 +1,5 @@
//
-// RouteTrigger.swift
+// Router.swift
// XCoordinator
//
// Created by Paul Kraft on 28.07.18.
@@ -9,15 +9,15 @@
import Foundation
///
-/// The Router protocol is used to abstract the transition-type specific characteristics of a Coordinator.
+/// The Router protocol abstracts a coordinator down to its route-triggering capability.
///
-/// A Router can trigger routes, which lead to transitions being executed. In constrast to the Coordinator protocol,
-/// the router does not specify a TransitionType and can therefore be used in the form of a
-/// `StrongRouter`, `UnownedRouter` or `WeakRouter` to reduce a coordinator's capabilities to
-/// the triggering of routes.
-/// This may especially be useful in viewModels when using them in different contexts.
+/// In contrast to ``Coordinator``, `Router` does not specify a `RootViewController` and can therefore be
+/// used as `any Router` to expose only the trigger surface to view models and views.
+/// Pair the existential with the ARC qualifier that matches the relationship — `unowned`/`weak` for
+/// child holding parent, `strong` for ownership.
///
-public protocol Router: Presentable {
+@MainActor
+public protocol Router: Presentable, AnyObject {
/// RouteType defines which routes can be triggered in a certain Router implementation.
associatedtype RouteType: Route
@@ -86,46 +86,14 @@ extension Router {
}
-extension Router {
-
- // MARK: Computed properties
-
- ///
- /// Creates a StrongRouter object from the given router to abstract from concrete implementations
- /// while maintaining information necessary to fulfill the Router protocol.
- /// The original router will be held strongly.
- ///
- public var strongRouter: StrongRouter {
- StrongRouter(self)
- }
-
- ///
- /// Returns a router for the specified route, if possible.
- ///
- /// - Parameter route:
- /// The route type to return a router for.
- ///
- /// - Returns:
- /// It returns the router's strongRouter,
- /// if it is compatible with the given route type,
- /// otherwise `nil`.
- ///
- public func router(for route: R) -> StrongRouter? {
- strongRouter as? StrongRouter
- }
-
-}
-
-#if swift(>=5.5.2)
-
-@available(iOS 13.0, tvOS 13.0, *)
extension Router {
///
/// Triggers the specified route with default transition options enabling the animation of the transition.
///
- /// - Parameters:
- /// - route: The route to be triggered.
+ /// Suspends until the underlying transition has completed (including any animations).
+ ///
+ /// - Parameter route: The route to be triggered.
///
@MainActor public func trigger(_ route: RouteType) async {
await trigger(route, with: .default)
@@ -134,6 +102,8 @@ extension Router {
///
/// Triggers the specified route by performing a transition.
///
+ /// Suspends until the underlying transition has completed (including any animations).
+ ///
/// - Parameters:
/// - route: The route to be triggered.
/// - options: Transition options for performing the transition, e.g. whether it should be animated.
@@ -143,30 +113,28 @@ extension Router {
}
///
- /// Triggers routes and returns context in completion-handler.
+ /// Triggers a route and returns the resulting transition context.
///
- /// Useful for deep linking. It is encouraged to use `trigger` instead, if the context is not needed.
+ /// Useful for deep linking. Prefer `trigger(_:with:)` if the context is not needed.
///
/// - Parameters:
/// - route: The route to be triggered.
- /// - options:
- /// Transition options configuring the execution of transitions, e.g. whether it should be animated.
- /// - completion:
- /// If present, this completion handler is executed once the transition is completed
- /// (including animations).
+ /// - options: Transition options configuring the execution of transitions, e.g. whether it should be animated.
///
- /// - Returns:
- /// The transition context of the performed transition(s).
- /// If the context is not needed, use `trigger` instead.
+ /// - Returns: The transition context of the performed transition(s).
///
- @MainActor public func contextTrigger(_ route: RouteType, with options: TransitionOptions) async -> TransitionContext {
+ @MainActor public func contextTrigger(_ route: RouteType, with options: TransitionOptions) async -> any TransitionContext {
await withCheckedContinuation { continuation in
+ // Some transitions (e.g. interactive ones, or custom `Transition.PerformClosure`s) may invoke
+ // their completion more than once. A checked continuation must be resumed exactly once, so we
+ // guard against the redundant calls to avoid a hard crash.
+ var resumed = false
contextTrigger(route, with: options) { context in
+ guard !resumed else { return }
+ resumed = true
continuation.resume(returning: context)
}
}
}
}
-
-#endif
diff --git a/Sources/XCoordinator/Extensions/NSObject+References.swift b/Sources/XCoordinator/Extensions/NSObject+References.swift
new file mode 100755
index 00000000..2f64b585
--- /dev/null
+++ b/Sources/XCoordinator/Extensions/NSObject+References.swift
@@ -0,0 +1,25 @@
+//
+// NSObject+References.swift
+// XCoordinator
+//
+// Created by Stefan Kofler on 19.07.18.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+import Foundation
+
+private var associatedObjectHandle: UInt8 = 0
+
+extension NSObject {
+
+ internal var strongReferences: [Any] {
+ get {
+ objc_getAssociatedObject(self, &associatedObjectHandle) as? [Any] ?? []
+ }
+ set {
+ objc_setAssociatedObject(self, &associatedObjectHandle, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
+ }
+ }
+
+}
+
diff --git a/Sources/XCoordinator/Container.swift b/Sources/XCoordinator/General/Container.swift
similarity index 100%
rename from Sources/XCoordinator/Container.swift
rename to Sources/XCoordinator/General/Container.swift
diff --git a/Sources/XCoordinator/DeepLinking.swift b/Sources/XCoordinator/General/DeepLinking.swift
similarity index 76%
rename from Sources/XCoordinator/DeepLinking.swift
rename to Sources/XCoordinator/General/DeepLinking.swift
index 5c356ffa..36d8b939 100755
--- a/Sources/XCoordinator/DeepLinking.swift
+++ b/Sources/XCoordinator/General/DeepLinking.swift
@@ -6,26 +6,7 @@
// Copyright © 2018 QuickBird Studios. All rights reserved.
//
-///
-/// `TransitionContext` provides context information about transitions.
-///
-/// It is especially useful for deep linking as XCoordinator can internally gather information about
-/// the presentables being pushed onto the view hierarchy.
-///
-public protocol TransitionContext {
-
- /// The presentables being shown to the user by the transition.
- var presentables: [Presentable] { get }
-
- ///
- /// The transition animation directly used in the transition, if applicable.
- ///
- /// - Note:
- /// Make sure to not return `nil`, if you want to use `BaseCoordinator.registerInteractiveTransition`
- /// to realize an interactive transition.
- ///
- var animation: TransitionAnimation? { get }
-}
+import UIKit
// MARK: - Coordinator + DeepLinking
@@ -46,15 +27,15 @@ extension Coordinator where Self: AnyObject {
/// Keep in mind that changes in the app's structure and changes of transitions
/// behind the given routes can lead to runtime errors and, therefore, crashes of your app.
///
- public func deepLink(_ route: RouteType, _ remainingRoutes: S)
- -> Transition where S.Element == Route, TransitionType == Transition {
+ public func deepLink(_ route: RouteType, _ remainingRoutes: S)
+ -> Transition where S.Element == Route {
.deepLink(with: self, route, array: Array(remainingRoutes))
}
///
/// Deep-Linking can be used to chain routes of different types together.
///
- /// - Parameters
+ /// - Parameters:
/// - route:
/// The first route in the chain.
/// It is given a special place because its exact type can be specified.
@@ -64,8 +45,8 @@ extension Coordinator where Self: AnyObject {
/// Keep in mind that changes in the app's structure and changes of transitions
/// behind the given routes can lead to runtime errors and, therefore, crashes of your app.
///
- public func deepLink(_ route: RouteType, _ remainingRoutes: Route...)
- -> Transition where TransitionType == Transition {
+ public func deepLink(_ route: RouteType, _ remainingRoutes: Route...)
+ -> Transition {
.deepLink(with: self, route, array: remainingRoutes)
}
}
@@ -93,9 +74,10 @@ extension Transition {
// MARK: - Route + DeepLink
extension Route {
- private func router(fromStack stack: inout [Presentable]) -> StrongRouter? {
+ @MainActor
+ private func router(fromStack stack: inout [Presentable]) -> (any Router)? {
while !stack.isEmpty {
- if let router = stack.last?.router(for: self) {
+ if let router = stack.last?.router(for: Self.self) {
return router
}
stack.removeLast()
@@ -103,6 +85,7 @@ extension Route {
return nil
}
+ @MainActor
fileprivate func trigger(on presentables: [Presentable],
remainingRoutes: ArraySlice,
with options: TransitionOptions,
diff --git a/Sources/XCoordinator/Presentable.swift b/Sources/XCoordinator/General/Presentable.swift
similarity index 75%
rename from Sources/XCoordinator/Presentable.swift
rename to Sources/XCoordinator/General/Presentable.swift
index 88012106..57bbf757 100755
--- a/Sources/XCoordinator/Presentable.swift
+++ b/Sources/XCoordinator/General/Presentable.swift
@@ -14,6 +14,7 @@ import UIKit
/// Therefore, it is useful for view controllers, coordinators and views.
/// Presentable is often used for transitions to allow for view controllers and coordinators to be transitioned to.
///
+@MainActor
public protocol Presentable {
///
@@ -33,7 +34,7 @@ public protocol Presentable {
/// - Parameter route:
/// The route to determine a router for.
///
- func router(for route: R) -> StrongRouter?
+ func router(for route: R.Type) -> (any Router)?
///
/// This method is called whenever a Presentable is shown to the user.
@@ -44,7 +45,7 @@ public protocol Presentable {
/// This could be a window, another viewController, a coordinator, etc.
/// `nil` is specified whenever a context cannot be easily determined.
///
- func presented(from presentable: Presentable?)
+ func presented(from presentable: (any Presentable)?)
///
/// This method is used to register a parent coordinator to a child coordinator.
@@ -52,7 +53,7 @@ public protocol Presentable {
/// - Note:
/// This method is used internally and should never be called directly.
///
- func registerParent(_ presentable: Presentable & AnyObject)
+ func registerParent(_ presentable: any Presentable & AnyObject)
///
/// This method gets called when the transition of a child coordinator is being reported to its parent.
@@ -76,22 +77,35 @@ public protocol Presentable {
extension Presentable {
- public func registerParent(_ presentable: Presentable & AnyObject) {}
+ public func registerParent(_ presentable: any Presentable & AnyObject) {}
public func childTransitionCompleted() {}
public func setRoot(for window: UIWindow) {
+ let previousRoot = window.rootViewController
window.rootViewController = viewController
window.makeKeyAndVisible()
presented(from: window)
- }
- public func router(for route: R) -> StrongRouter? {
- self as? StrongRouter
+ if let previousRoot {
+ previousRoot.removeFromParent()
+ previousRoot.dismiss(animated: false) {
+ previousRoot.viewIfLoaded?.removeFromSuperview()
+ }
+ }
}
- public func presented(from presentable: Presentable?) {}
+ public func presented(from presentable: (any Presentable)?) {}
+
}
-extension UIViewController: Presentable {}
-extension UIWindow: Presentable {}
+extension UIViewController: Presentable {
+ public func router(for route: R.Type) -> (any Router)? {
+ nil
+ }
+}
+extension UIWindow: Presentable {
+ public func router(for route: R.Type) -> (any Router)? {
+ nil
+ }
+}
diff --git a/Sources/XCoordinator/Route.swift b/Sources/XCoordinator/General/Route.swift
similarity index 100%
rename from Sources/XCoordinator/Route.swift
rename to Sources/XCoordinator/General/Route.swift
diff --git a/Sources/XCoordinator/NavigationAnimationDelegate.swift b/Sources/XCoordinator/Navigation/NavigationAnimationDelegate.swift
similarity index 98%
rename from Sources/XCoordinator/NavigationAnimationDelegate.swift
rename to Sources/XCoordinator/Navigation/NavigationAnimationDelegate.swift
index 29bf4c1e..b8f32193 100755
--- a/Sources/XCoordinator/NavigationAnimationDelegate.swift
+++ b/Sources/XCoordinator/Navigation/NavigationAnimationDelegate.swift
@@ -127,8 +127,8 @@ extension NavigationAnimationDelegate: UINavigationControllerDelegate {
///
/// - Parameters:
/// - navigationController: The delegate owner.
- /// - operation: The operation being executed. Possible values are push, pop or none.
/// - viewController: The target view controller.
+ /// - animated: Whether the transition was animated.
///
open func navigationController(_ navigationController: UINavigationController,
didShow viewController: UIViewController, animated: Bool) {
@@ -145,8 +145,8 @@ extension NavigationAnimationDelegate: UINavigationControllerDelegate {
///
/// - Parameters:
/// - navigationController: The delegate owner.
- /// - operation: The operation being executed. Possible values are push, pop or none.
/// - viewController: The view controller to be shown.
+ /// - animated: Whether the transition is animated.
///
open func navigationController(_ navigationController: UINavigationController,
willShow viewController: UIViewController,
diff --git a/Sources/XCoordinator/Navigation/NavigationCoordinator.swift b/Sources/XCoordinator/Navigation/NavigationCoordinator.swift
new file mode 100755
index 00000000..e91317d6
--- /dev/null
+++ b/Sources/XCoordinator/Navigation/NavigationCoordinator.swift
@@ -0,0 +1,111 @@
+//
+// NavigationCoordinator.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 29.07.18.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+import UIKit
+
+///
+/// NavigationCoordinator acts as a base class for custom coordinators with a `UINavigationController`
+/// as rootViewController.
+///
+/// NavigationCoordinator especially ensures that transition animations are called,
+/// which would not be the case when creating a `BaseCoordinator`.
+///
+open class NavigationCoordinator: BaseCoordinator {
+
+ // MARK: Stored properties
+
+ ///
+ /// The animation delegate controlling the rootViewController's transition animations.
+ /// It is installed as the navigation controller's `delegate` if no delegate was set earlier.
+ ///
+ /// - Note:
+ /// Use the ``delegate`` property to install your own delegate while keeping XCoordinator's
+ /// transition animations.
+ ///
+ public let animationDelegate = NavigationAnimationDelegate()
+ // swiftlint:disable:previous weak_delegate
+
+ // MARK: Computed properties
+
+ ///
+ /// A fallback delegate that receives navigation-controller events not consumed by XCoordinator,
+ /// and is used to drive transition animations when no animation is specified by the route.
+ ///
+ public var delegate: UINavigationControllerDelegate? {
+ get {
+ animationDelegate.delegate
+ }
+ set {
+ animationDelegate.delegate = newValue
+ }
+ }
+
+ // MARK: Initialization
+
+ ///
+ /// Creates a NavigationCoordinator and optionally triggers an initial route.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UINavigationController` to host transitions. Defaults to a fresh instance.
+ /// - initialRoute: A route to trigger once the coordinator is shown. Defaults to `nil`.
+ ///
+ public override init(rootViewController: RootViewController = .init(), initialRoute: RouteType? = nil) {
+ if rootViewController.delegate == nil {
+ rootViewController.delegate = animationDelegate
+ }
+ super.init(rootViewController: rootViewController, initialRoute: initialRoute)
+ animationDelegate.presentable = self
+ }
+
+ ///
+ /// Creates a NavigationCoordinator and pushes a presentable onto the navigation stack right away.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UINavigationController` to host transitions. Defaults to a fresh instance.
+ /// - root: The presentable to push as the initial view controller.
+ ///
+ public init(rootViewController: RootViewController = .init(), root: any Presentable) {
+ if rootViewController.delegate == nil {
+ rootViewController.delegate = animationDelegate
+ }
+ super.init(rootViewController: rootViewController, initialTransition: .push(root))
+ animationDelegate.presentable = self
+ }
+
+ ///
+ /// Creates a NavigationCoordinator and optionally performs an initial transition.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UINavigationController` to host transitions.
+ /// - initialTransition: A transition to perform once the coordinator is shown. Pass `nil` to skip.
+ ///
+ public override init(rootViewController: RootViewController, initialTransition: NavigationTransition?) {
+ if rootViewController.delegate == nil {
+ rootViewController.delegate = animationDelegate
+ }
+ super.init(rootViewController: rootViewController, initialTransition: initialTransition)
+ animationDelegate.presentable = self
+ }
+
+ ///
+ /// Creates a NavigationCoordinator and performs an initial transition described with the transition builder.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UINavigationController` to host transitions.
+ /// - initialTransition: A transition-builder closure describing the transition to perform.
+ ///
+ public override init(rootViewController: RootViewController,
+ @TransitionBuilder initialTransition: () -> NavigationTransition) {
+ if rootViewController.delegate == nil {
+ rootViewController.delegate = animationDelegate
+ }
+ super.init(rootViewController: rootViewController, initialTransition: initialTransition())
+ animationDelegate.presentable = self
+ }
+
+}
diff --git a/Sources/XCoordinator/NavigationTransition.swift b/Sources/XCoordinator/Navigation/NavigationTransition.swift
similarity index 94%
rename from Sources/XCoordinator/NavigationTransition.swift
rename to Sources/XCoordinator/Navigation/NavigationTransition.swift
index 90fc3331..9e55340a 100755
--- a/Sources/XCoordinator/NavigationTransition.swift
+++ b/Sources/XCoordinator/Navigation/NavigationTransition.swift
@@ -26,7 +26,7 @@ extension Transition where RootViewController: UINavigationController {
/// presentable before. You can use `Animation.default` to reset the previously set animations
/// on this presentable.
///
- public static func push(_ presentable: Presentable, animation: Animation? = nil) -> Transition {
+ public static func push(_ presentable: any Presentable, animation: Animation? = nil) -> Transition {
Transition(presentables: [presentable],
animationInUse: animation?.presentationAnimation
) { rootViewController, options, completion in
@@ -74,7 +74,7 @@ extension Transition where RootViewController: UINavigationController {
/// presentable before. You can use `Animation.default` to reset the previously set animations
/// on this presentable.
///
- public static func pop(to presentable: Presentable, animation: Animation? = nil) -> Transition {
+ public static func pop(to presentable: any Presentable, animation: Animation? = nil) -> Transition {
Transition(presentables: [presentable],
animationInUse: animation?.dismissalAnimation
) { rootViewController, options, completion in
@@ -118,7 +118,7 @@ extension Transition where RootViewController: UINavigationController {
/// here to leave animations as they were set for the presentables before. You can use
/// `Animation.default` to reset the previously set animations on all presentables.
///
- public static func set(_ presentables: [Presentable], animation: Animation? = nil) -> Transition {
+ public static func set(_ presentables: [any Presentable], animation: Animation? = nil) -> Transition {
Transition(presentables: presentables,
animationInUse: animation?.presentationAnimation
) { rootViewController, options, completion in
diff --git a/Sources/XCoordinator/UINavigationController+Transition.swift b/Sources/XCoordinator/Navigation/UINavigationController+Transition.swift
old mode 100755
new mode 100644
similarity index 95%
rename from Sources/XCoordinator/UINavigationController+Transition.swift
rename to Sources/XCoordinator/Navigation/UINavigationController+Transition.swift
index 83814232..068ac629
--- a/Sources/XCoordinator/UINavigationController+Transition.swift
+++ b/Sources/XCoordinator/Navigation/UINavigationController+Transition.swift
@@ -9,7 +9,7 @@
import UIKit
extension UINavigationController {
-
+
func push(_ viewController: UIViewController,
with options: TransitionOptions,
animation: Animation?,
@@ -25,14 +25,17 @@ extension UINavigationController {
To set another delegate of a rootViewController in a NavigationCoordinator, have a look at `NavigationCoordinator.delegate`.
""")
- CATransaction.begin()
- CATransaction.setCompletionBlock(completion)
-
autoreleasepool {
pushViewController(viewController, animated: options.animated)
}
- CATransaction.commit()
+ if let transitionCoordinator {
+ transitionCoordinator.animate(alongsideTransition: nil) { _ in
+ completion?()
+ }
+ } else {
+ completion?()
+ }
}
func pop(toRoot: Bool, with options: TransitionOptions, animation: Animation?, completion: PresentationHandler?) {
@@ -117,5 +120,5 @@ extension UINavigationController {
CATransaction.commit()
}
-
+
}
diff --git a/Sources/XCoordinator/NavigationCoordinator.swift b/Sources/XCoordinator/NavigationCoordinator.swift
deleted file mode 100755
index f9c8b1f1..00000000
--- a/Sources/XCoordinator/NavigationCoordinator.swift
+++ /dev/null
@@ -1,77 +0,0 @@
-//
-// NavigationCoordinator.swift
-// XCoordinator
-//
-// Created by Paul Kraft on 29.07.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-///
-/// NavigationCoordinator acts as a base class for custom coordinators with a `UINavigationController`
-/// as rootViewController.
-///
-/// NavigationCoordinator especially ensures that transition animations are called,
-/// which would not be the case when creating a `BaseCoordinator`.
-///
-open class NavigationCoordinator: BaseCoordinator {
-
- // MARK: Stored properties
-
- ///
- /// The animation delegate controlling the rootViewController's transition animations.
- /// This animation delegate is set to be the rootViewController's rootViewController, if you did not set one earlier.
- ///
- /// - Note:
- /// Use the `delegate` property to set a custom delegate and use transition animations provided by XCoordinator.
- ///
- public let animationDelegate = NavigationAnimationDelegate()
- // swiftlint:disable:previous weak_delegate
-
- // MARK: Computed properties
-
- ///
- /// This represents a fallback-delegate to be notified about navigation controller events.
- /// It is further used to call animation methods when no animation has been specified in the transition.
- ///
- public var delegate: UINavigationControllerDelegate? {
- get {
- animationDelegate.delegate
- }
- set {
- animationDelegate.delegate = newValue
- }
- }
-
- // MARK: Initialization
-
- ///
- /// Creates a NavigationCoordinator and optionally triggers an initial route.
- ///
- /// - Parameter initialRoute:
- /// The route to be triggered.
- ///
- public override init(rootViewController: RootViewController = .init(), initialRoute: RouteType? = nil) {
- if rootViewController.delegate == nil {
- rootViewController.delegate = animationDelegate
- }
- super.init(rootViewController: rootViewController, initialRoute: initialRoute)
- animationDelegate.presentable = self
- }
-
- ///
- /// Creates a NavigationCoordinator and pushes a presentable onto the navigation stack right away.
- ///
- /// - Parameter root:
- /// The presentable to be pushed.
- ///
- public init(rootViewController: RootViewController = .init(), root: Presentable) {
- if rootViewController.delegate == nil {
- rootViewController.delegate = animationDelegate
- }
- super.init(rootViewController: rootViewController, initialTransition: .push(root))
- animationDelegate.presentable = self
- }
-
-}
diff --git a/Sources/XCoordinator/Page/PageCoordinator.swift b/Sources/XCoordinator/Page/PageCoordinator.swift
new file mode 100755
index 00000000..1e86b90d
--- /dev/null
+++ b/Sources/XCoordinator/Page/PageCoordinator.swift
@@ -0,0 +1,144 @@
+//
+// PageCoordinator.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 30.07.18.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+import UIKit
+
+///
+/// PageCoordinator provides a base class for your custom coordinator with a `UIPageViewController` rootViewController.
+///
+/// - Note:
+/// PageCoordinator sets the dataSource of the rootViewController to reflect the parameters in the initializer.
+///
+open class PageCoordinator: BaseCoordinator {
+
+ // MARK: Stored properties
+
+ ///
+ /// The dataSource of the rootViewController.
+ ///
+ /// Feel free to change the pages at runtime. To reflect the changes in the rootViewController, perform a `set` transition as well.
+ ///
+ public let dataSource: UIPageViewControllerDataSource
+
+ // MARK: Initialization
+
+ // Note: PageCoordinator intentionally does NOT expose BaseCoordinator's bare
+ // `init(rootViewController:initialRoute:)` / `init(rootViewController:initialTransition:)` inits.
+ // A page view controller needs a `dataSource` to drive swipe navigation, and that `dataSource`
+ // is fixed at init — so every PageCoordinator must be built with `pages:` or `dataSource:` below.
+
+ ///
+ /// Creates a PageCoordinator with several sequential (potentially looping) pages.
+ ///
+ /// If neither `firstPage` nor `secondPage` is specified, the coordinator falls back to showing the
+ /// first one or two of `pages` (depending on whether the page view controller is double-sided).
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UIPageViewController` to host pages. Defaults to a fresh instance.
+ /// Note that you cannot change its transition style / navigation orientation / options after
+ /// initialization — use the convenience initializer to configure those up front.
+ /// - pages: The pages of the PageCoordinator. These can be changed later via ``dataSource``.
+ /// - loop: Whether the coordinator should loop when reaching the end or the beginning of `pages`.
+ /// - firstPage: The page to show on appearance. Must be an element of `pages`. If `nil`, falls back
+ /// to the first page (or first two for double-sided controllers).
+ /// - secondPage: The second page when the page view controller is double-sided. Optional.
+ /// - direction: Animation direction for the initial set transition. Ignored if no initial page is set.
+ ///
+ public init(rootViewController: RootViewController = .init(),
+ pages: [Presentable],
+ loop: Bool = false,
+ set firstPage: (any Presentable)? = nil,
+ _ secondPage: (any Presentable)? = nil,
+ direction: UIPageViewController.NavigationDirection = .forward) {
+ self.dataSource = PageCoordinatorDataSource(pages: pages.map { $0.viewController }, loop: loop)
+ rootViewController.dataSource = dataSource
+
+ let setInitialPages = [firstPage, secondPage].compactMap { $0 }
+ let initialPages = setInitialPages.isEmpty ? Array(pages.prefix(rootViewController.isDoubleSided ? 2 : 1)) : setInitialPages
+ guard let firstPage = initialPages.first else {
+ assertionFailure("Please provide a positive number of pages for use in \(String(describing: PageCoordinator.self))")
+ super.init(rootViewController: rootViewController, initialTransition: .initial(pages: pages))
+ return
+ }
+
+ super.init(rootViewController: rootViewController,
+ initialTransition: .multiple(.initial(pages: pages), .set(firstPage, initialPages.count > 1 ? initialPages[1] : nil, direction: direction)))
+ }
+
+ ///
+ /// Creates a PageCoordinator with a custom dataSource.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UIPageViewController` to host pages. Defaults to a fresh instance.
+ /// - dataSource: The dataSource to drive page navigation.
+ /// - firstPage: The page to show on appearance.
+ /// - secondPage: The second page when the page view controller is double-sided. Optional.
+ /// - direction: Animation direction for the initial set transition.
+ ///
+ public init(rootViewController: RootViewController = .init(),
+ dataSource: UIPageViewControllerDataSource,
+ set firstPage: any Presentable,
+ _ secondPage: (any Presentable)? = nil,
+ direction: UIPageViewController.NavigationDirection) {
+ self.dataSource = dataSource
+ rootViewController.dataSource = dataSource
+ super.init(rootViewController: rootViewController,
+ initialTransition: .set(firstPage, secondPage, direction: direction))
+ }
+
+ ///
+ /// Creates a PageCoordinator and its underlying `UIPageViewController` up front, letting you configure
+ /// the controller's transition style, orientation, double-sided mode, spine location, and inter-page spacing.
+ ///
+ /// - Parameters:
+ /// - transitionStyle: The style used to transition between pages.
+ /// - navigationOrientation: Horizontal or vertical page navigation.
+ /// - isDoubleSided: Whether the page view controller renders two pages at once.
+ /// - spineLocation: The spine location for double-sided controllers. Defaults to `.mid` when
+ /// `isDoubleSided` is true and `nil` otherwise.
+ /// - interPageSpacing: The spacing between adjacent pages.
+ /// - pages: The pages of the PageCoordinator.
+ /// - loop: Whether the coordinator should loop at the end and the beginning of `pages`.
+ /// - firstPage: The page to show on appearance. See ``init(rootViewController:pages:loop:set:_:direction:)``
+ /// for the fallback behaviour when `firstPage` is `nil`.
+ /// - secondPage: The second page when `isDoubleSided` is true. Optional.
+ /// - direction: Animation direction for the initial set transition.
+ public convenience init(
+ transitionStyle: UIPageViewController.TransitionStyle = .pageCurl,
+ navigationOrientation: UIPageViewController.NavigationOrientation = .horizontal,
+ isDoubleSided: Bool = false,
+ spineLocation: UIPageViewController.SpineLocation? = nil,
+ interPageSpacing: CGFloat? = nil,
+ pages: [any Presentable],
+ loop: Bool = false,
+ set firstPage: (any Presentable)? = nil,
+ _ secondPage: (any Presentable)? = nil,
+ direction: UIPageViewController.NavigationDirection = .forward
+ ) {
+ var options = [UIPageViewController.OptionsKey: Any]()
+ options[.spineLocation] = (spineLocation ?? (isDoubleSided ? .mid : nil))?.rawValue
+ options[.interPageSpacing] = interPageSpacing
+
+ let rootViewController = UIPageViewController(
+ transitionStyle: transitionStyle,
+ navigationOrientation: navigationOrientation,
+ options: options.isEmpty ? nil : options
+ )
+ rootViewController.isDoubleSided = isDoubleSided
+
+ self.init(
+ rootViewController: rootViewController,
+ pages: pages,
+ loop: loop,
+ set: firstPage,
+ secondPage,
+ direction: direction
+ )
+ }
+
+}
diff --git a/Sources/XCoordinator/PageCoordinatorDataSource.swift b/Sources/XCoordinator/Page/PageCoordinatorDataSource.swift
similarity index 100%
rename from Sources/XCoordinator/PageCoordinatorDataSource.swift
rename to Sources/XCoordinator/Page/PageCoordinatorDataSource.swift
diff --git a/Sources/XCoordinator/PageTransition.swift b/Sources/XCoordinator/Page/PageTransition.swift
similarity index 56%
rename from Sources/XCoordinator/PageTransition.swift
rename to Sources/XCoordinator/Page/PageTransition.swift
index e8b4c19b..6827ff61 100755
--- a/Sources/XCoordinator/PageTransition.swift
+++ b/Sources/XCoordinator/Page/PageTransition.swift
@@ -1,5 +1,5 @@
//
-// PageViewTransition.swift
+// PageTransition.swift
// XCoordinator
//
// Created by Paul Kraft on 29.07.18.
@@ -28,23 +28,35 @@ extension Transition where RootViewController: UIPageViewController {
/// - direction:
/// The direction in which the transition should be animated.
///
- public static func set(_ first: Presentable, _ second: Presentable? = nil,
+ public static func set(_ first: any Presentable, _ second: (any Presentable)? = nil,
direction: UIPageViewController.NavigationDirection) -> Transition {
let presentables = [first, second].compactMap { $0 }
- return Transition(presentables: presentables,
- animationInUse: nil
- ) { rootViewController, options, completion in
- rootViewController.set(presentables.map { $0.viewController },
- direction: direction,
- with: options
- ) {
+ return Transition(presentables: presentables, animationInUse: nil) { rootViewController, options, completion in
+ let viewControllers: [UIViewController] = presentables.map { $0.viewController }
+ rootViewController.isDoubleSided = viewControllers.count > 1
+
+ // `UIPageViewController.setViewControllers(_:direction:animated:completion:)` skips its completion
+ // block when asked to display the pages it is already showing (a long-standing UIKit quirk).
+ // `deepLink` chains the next route inside this completion, so short-circuit the no-op case and
+ // invoke the completion ourselves to keep chained transitions flowing. `presented(from:)` already
+ // fired when these pages were first set, so it is not repeated here.
+ guard rootViewController.viewControllers != viewControllers else {
+ completion?()
+ return
+ }
+
+ rootViewController.setViewControllers(
+ viewControllers,
+ direction: direction,
+ animated: options.animated
+ ) { _ in
presentables.forEach { $0.presented(from: rootViewController) }
completion?()
}
}
}
- static func initial(pages: [Presentable]) -> Transition {
+ static func initial(pages: [any Presentable]) -> Transition {
Transition(presentables: pages, animationInUse: nil) { rootViewController, _, completion in
CATransaction.begin()
CATransaction.setCompletionBlock {
@@ -54,4 +66,5 @@ extension Transition where RootViewController: UIPageViewController {
CATransaction.commit()
}
}
+
}
diff --git a/Sources/XCoordinator/PageCoordinator.swift b/Sources/XCoordinator/PageCoordinator.swift
deleted file mode 100755
index 4999f70a..00000000
--- a/Sources/XCoordinator/PageCoordinator.swift
+++ /dev/null
@@ -1,101 +0,0 @@
-//
-// PageCoordinator.swift
-// XCoordinator
-//
-// Created by Paul Kraft on 30.07.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-///
-/// PageCoordinator provides a base class for your custom coordinator with a `UIPageViewController` rootViewController.
-///
-/// - Note:
-/// PageCoordinator sets the dataSource of the rootViewController to reflect the parameters in the initializer.
-///
-open class PageCoordinator: BaseCoordinator {
-
- // MARK: Stored properties
-
- ///
- /// The dataSource of the rootViewController.
- ///
- /// Feel free to change the pages at runtime. To reflect the changes in the rootViewController, perform a `set` transition as well.
- ///
- public let dataSource: UIPageViewControllerDataSource
-
- // MARK: Initialization
-
- ///
- /// Creates a PageCoordinator with several sequential (potentially looping) pages.
- ///
- /// It further sets the current page of the rootViewController animated in the specified direction.
- ///
- /// - Note:
- /// If you need custom configuration of the rootViewController, modify the `configuration` parameter,
- /// since you cannot change this after the initialization.
- ///
- /// - Parameters:
- /// - pages:
- /// The pages of the PageCoordinator.
- /// These can be changed later, if necessary, using the `PageCoordinator.dataSource` property.
- /// - loop:
- /// Whether or not the PageCoordinator should loop when hitting the end or the beginning of the specified pages.
- /// - set:
- /// The presentable to be shown right from the start.
- /// This should be one of the elements of the specified pages.
- /// If not specified, no `set` transition is triggered, which results in the first page being shown.
- /// - direction:
- /// The direction in which the transition to set the specified first page (parameter `set`) should be animated in.
- /// If you specify `nil` for `set`, this parameter is ignored.
- /// - configuration:
- /// The configuration of the rootViewController. You cannot change this configuration later anymore (Limitation of UIKit).
- ///
- public init(rootViewController: RootViewController = .init(),
- pages: [Presentable],
- loop: Bool = false,
- set: Presentable? = nil,
- direction: UIPageViewController.NavigationDirection = .forward) {
- self.dataSource = PageCoordinatorDataSource(pages: pages.map { $0.viewController }, loop: loop)
- rootViewController.dataSource = dataSource
-
- guard let firstPage = set ?? pages.first else {
- assertionFailure("Please provide a positive number of pages for use in \(String(describing: PageCoordinator.self))")
- super.init(rootViewController: rootViewController, initialTransition: .initial(pages: pages))
- return
- }
-
- super.init(rootViewController: rootViewController,
- initialTransition: .multiple(.initial(pages: pages), .set(firstPage, direction: direction)))
- }
-
- ///
- /// Creates a PageCoordinator with a custom dataSource.
- /// It further sets the currently shown page and a direction for the animation of displaying it.
- /// If you need custom configuration of the rootViewController, modify the `configuration` parameter,
- /// since you cannot change this after the initialization.
- ///
- /// - Parameters:
- /// - dataSource:
- /// The dataSource of the PageCoordinator.
- /// - set:
- /// The presentable to be shown right from the start.
- /// This should be one of the elements of the specified pages.
- /// If not specified, no `set` transition is triggered, which results in the first page being shown.
- /// - direction:
- /// The direction in which the transition to set the specified first page (parameter `set`) should be animated in.
- /// If you specify `nil` for `set`, this parameter is ignored.
- /// - configuration:
- /// The configuration of the rootViewController. You cannot change this configuration later anymore (Limitation of UIKit).
- ///
- public init(rootViewController: RootViewController = .init(),
- dataSource: UIPageViewControllerDataSource,
- set: Presentable,
- direction: UIPageViewController.NavigationDirection) {
- self.dataSource = dataSource
- rootViewController.dataSource = dataSource
- super.init(rootViewController: rootViewController,
- initialTransition: .set(set, direction: direction))
- }
-}
diff --git a/Sources/XCoordinator/Split/SplitCoordinator.swift b/Sources/XCoordinator/Split/SplitCoordinator.swift
new file mode 100755
index 00000000..5a8c804f
--- /dev/null
+++ b/Sources/XCoordinator/Split/SplitCoordinator.swift
@@ -0,0 +1,82 @@
+//
+// SplitCoordinator.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 30.07.18.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+import UIKit
+
+///
+/// SplitCoordinator can be used as a basis for a coordinator with a rootViewController of type
+/// `UISplitViewController`.
+///
+/// You can use all `SplitTransitions` and get an initializer to set a master and
+/// (optional) detail presentable.
+///
+open class SplitCoordinator: BaseCoordinator {
+
+ // MARK: Initialization
+
+ ///
+ /// Creates a SplitCoordinator and optionally triggers an initial route.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UISplitViewController` to host transitions. Defaults to a fresh instance.
+ /// - initialRoute: A route to trigger once the coordinator is shown.
+ public override init(rootViewController: RootViewController = .init(), initialRoute: RouteType?) {
+ super.init(rootViewController: rootViewController, initialRoute: initialRoute)
+ }
+
+ ///
+ /// Creates a SplitCoordinator and optionally performs an initial transition.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UISplitViewController` to host transitions.
+ /// - initialTransition: A transition to perform once the coordinator is shown. Pass `nil` to skip.
+ public override init(rootViewController: RootViewController, initialTransition: SplitTransition?) {
+ super.init(rootViewController: rootViewController, initialTransition: initialTransition)
+ }
+
+ ///
+ /// Creates a SplitCoordinator and performs an initial transition described with the transition builder.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UISplitViewController` to host transitions.
+ /// - initialTransition: A transition-builder closure describing the transition to perform.
+ public override init(rootViewController: RootViewController,
+ @TransitionBuilder initialTransition: () -> SplitTransition) {
+ super.init(rootViewController: rootViewController, initialTransition: initialTransition())
+ }
+
+ ///
+ /// Creates a SplitCoordinator and sets the specified presentables as the split controller's view controllers.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UISplitViewController` to host transitions. Defaults to a fresh instance.
+ /// - primary: The presentable shown in the primary column.
+ /// - secondary: The presentable shown in the secondary (detail) column. Optional, because a small-screen
+ /// device may not want to show a detail right away.
+ /// - supplementary: The presentable shown in the supplementary column. Optional. When provided,
+ /// each column is set individually via the triple-column API, so `rootViewController` must be a
+ /// triple-column split controller (`UISplitViewController(style: .tripleColumn)`); otherwise the
+ /// supplementary column is ignored by UIKit.
+ ///
+ public init(rootViewController: RootViewController = .init(), primary: any Presentable, secondary: (any Presentable)?, supplementary: (any Presentable)? = nil) {
+ if let supplementary {
+ // Use the per-column API so the supplementary column is actually populated
+ // (assigning `viewControllers` only honors primary + secondary on a legacy split).
+ super.init(rootViewController: rootViewController,
+ initialTransition: .multiple(
+ .set(primary, for: .primary),
+ .set(secondary, for: .secondary),
+ .set(supplementary, for: .supplementary)
+ ))
+ } else {
+ super.init(rootViewController: rootViewController,
+ initialTransition: .set([primary, secondary].compactMap { $0 }))
+ }
+ }
+
+}
diff --git a/Sources/XCoordinator/Split/SplitTransition.swift b/Sources/XCoordinator/Split/SplitTransition.swift
new file mode 100755
index 00000000..6c2371cd
--- /dev/null
+++ b/Sources/XCoordinator/Split/SplitTransition.swift
@@ -0,0 +1,57 @@
+//
+// SplitTransition.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 10.01.19.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+import UIKit
+
+///
+/// SplitTransition offers different transitions common to a `UISplitViewController` rootViewController.
+///
+public typealias SplitTransition = Transition
+
+extension Transition where RootViewController: UISplitViewController {
+
+ ///
+ /// Replaces the split view controller's `viewControllers` with the given presentables.
+ ///
+ /// - Parameter presentables: The presentables that become the split controller's columns, in order.
+ public static func set(_ presentables: [any Presentable]) -> Transition {
+ Transition(presentables: presentables, animationInUse: nil) { rootViewController, _, completion in
+ CATransaction.begin()
+ CATransaction.setCompletionBlock {
+ presentables.forEach { $0.presented(from: rootViewController) }
+ completion?()
+ }
+ autoreleasepool {
+ rootViewController.viewControllers = presentables.map { $0.viewController }
+ }
+ CATransaction.commit()
+ }
+ }
+
+ ///
+ /// Sets a single presentable into the given `UISplitViewController.Column` (iOS 14+ triple-column API).
+ ///
+ /// - Parameters:
+ /// - presentable: The presentable for the column. Pass `nil` to clear the column.
+ /// - column: The column to set.
+ @available(iOS 14, *)
+ public static func set(_ presentable: (any Presentable)?, for column: UISplitViewController.Column) -> Transition {
+ Transition(presentables: [presentable].compactMap { $0 }, animationInUse: nil) { rootViewController, _, completion in
+ CATransaction.begin()
+ CATransaction.setCompletionBlock {
+ presentable?.presented(from: rootViewController)
+ completion?()
+ }
+ autoreleasepool {
+ rootViewController.setViewController(presentable?.viewController, for: column)
+ }
+ CATransaction.commit()
+ }
+ }
+
+}
diff --git a/Sources/XCoordinator/SplitCoordinator.swift b/Sources/XCoordinator/SplitCoordinator.swift
deleted file mode 100755
index dfe050c6..00000000
--- a/Sources/XCoordinator/SplitCoordinator.swift
+++ /dev/null
@@ -1,39 +0,0 @@
-//
-// SplitCoordinator.swift
-// XCoordinator
-//
-// Created by Paul Kraft on 30.07.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-///
-/// SplitCoordinator can be used as a basis for a coordinator with a rootViewController of type
-/// `UISplitViewController`.
-///
-/// You can use all `SplitTransitions` and get an initializer to set a master and
-/// (optional) detail presentable.
-///
-open class SplitCoordinator: BaseCoordinator {
-
- // MARK: Initialization
-
- public override init(rootViewController: RootViewController = .init(), initialRoute: RouteType?) {
- super.init(rootViewController: rootViewController, initialRoute: initialRoute)
- }
-
- ///
- /// Creates a SplitCoordinator and sets the specified presentables as the rootViewController's
- /// viewControllers.
- ///
- /// - Parameters:
- /// - master:
- /// The presentable to be shown as master in the `UISplitViewController`.
- /// - detail:
- /// The presentable to be shown as detail in the `UISplitViewController`. This is optional due to
- /// the fact that it might not be useful to have a detail page right away on a small-screen device.
- ///
- public init(rootViewController: RootViewController = .init(), master: Presentable, detail: Presentable?) {
- super.init(rootViewController: rootViewController,
- initialTransition: .set([master, detail].compactMap { $0 }))
- }
-}
diff --git a/Sources/XCoordinator/SplitTransition.swift b/Sources/XCoordinator/SplitTransition.swift
deleted file mode 100755
index 02018a38..00000000
--- a/Sources/XCoordinator/SplitTransition.swift
+++ /dev/null
@@ -1,32 +0,0 @@
-//
-// UISplitViewController+Transition.swift
-// XCoordinator
-//
-// Created by Paul Kraft on 10.01.19.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-///
-/// SplitTransition offers different transitions common to a `UISplitViewController` rootViewController.
-///
-public typealias SplitTransition = Transition
-
-extension Transition where RootViewController: UISplitViewController {
-
- public static func set(_ presentables: [Presentable]) -> Transition {
- Transition(presentables: presentables, animationInUse: nil) { rootViewController, _, completion in
- CATransaction.begin()
- CATransaction.setCompletionBlock {
- presentables.forEach { $0.presented(from: rootViewController) }
- completion?()
- }
- autoreleasepool {
- rootViewController.viewControllers = presentables.map { $0.viewController }
- }
- CATransaction.commit()
- }
- }
-
-}
diff --git a/Sources/XCoordinator/StrongRouter.swift b/Sources/XCoordinator/StrongRouter.swift
deleted file mode 100755
index b26cfa39..00000000
--- a/Sources/XCoordinator/StrongRouter.swift
+++ /dev/null
@@ -1,119 +0,0 @@
-//
-// StrongRouter.swift
-// XCoordinator
-//
-// Created by Paul Kraft on 28.07.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-///
-/// StrongRouter is a type-erasure of a given Router object and, therefore, can be used as an abstraction from a specific Router
-/// implementation without losing type information about its RouteType.
-///
-/// StrongRouter abstracts away any implementation specific details and
-/// essentially reduces them to properties specified in the `Router` protocol.
-///
-/// - Note:
-/// Do not hold a reference to any router from the view hierarchy.
-/// Use `UnownedRouter` or `WeakRouter` in your view controllers or view models instead.
-/// You can create them using the `Coordinator.unownedRouter` and `Coordinator.weakRouter` properties.
-///
-public final class StrongRouter: Router {
-
- // MARK: Stored properties
-
- private let _contextTrigger: (RouteType, TransitionOptions, ContextPresentationHandler?) -> Void
- private let _trigger: (RouteType, TransitionOptions, PresentationHandler?) -> Void
- private let _presented: (Presentable?) -> Void
- private let _viewController: () -> UIViewController?
- private let _setRoot: (UIWindow) -> Void
- private let _registerParent: (Presentable & AnyObject) -> Void
- private let _childTransitionCompleted: () -> Void
-
- // MARK: Initialization
-
- ///
- /// Creates a StrongRouter object from a given router.
- ///
- /// - Parameter router:
- /// The source router.
- ///
- public init(_ router: T) where T.RouteType == RouteType {
- _trigger = router.trigger
- _presented = router.presented
- _viewController = { router.viewController }
- _setRoot = router.setRoot
- _contextTrigger = router.contextTrigger
- _registerParent = router.registerParent
- _childTransitionCompleted = router.childTransitionCompleted
- }
-
- // MARK: Public methods
-
- ///
- /// Triggers routes and provides the transition context in the completion-handler.
- ///
- /// Useful for deep linking. It is encouraged to use `trigger` instead, if the context is not needed.
- ///
- /// - Parameters:
- /// - route: The route to be triggered.
- /// - options: Transition options configuring the execution of transitions, e.g. whether it should be animated.
- /// - completion:
- /// If present, this completion handler is executed once the transition is completed
- /// (including animations).
- /// If the context is not needed, use `trigger` instead.
- ///
- public func contextTrigger(_ route: RouteType,
- with options: TransitionOptions,
- completion: ContextPresentationHandler?) {
- _contextTrigger(route, options, completion)
- }
-
- ///
- /// Triggers the specified route by performing a transition.
- ///
- /// - Parameters:
- /// - route: The route to be triggered.
- /// - options: Transition options for performing the transition, e.g. whether it should be animated.
- /// - completion:
- /// If present, this completion handler is executed once the transition is completed
- /// (including animations).
- ///
- public func trigger(_ route: RouteType, with options: TransitionOptions, completion: PresentationHandler?) {
- _trigger(route, options, completion)
- }
-
- ///
- /// This method is called whenever a Presentable is shown to the user.
- /// It further provides information about the presentable responsible for the presenting.
- ///
- /// - Parameter presentable:
- /// The context in which the presentable is shown.
- /// This could be a window, another viewController, a coordinator, etc.
- /// `nil` is specified whenever a context cannot be easily determined.
- ///
- public func presented(from presentable: Presentable?) {
- _presented(presentable)
- }
-
- ///
- /// The viewController of the Presentable.
- ///
- /// In the case of a `UIViewController`, it returns itself.
- /// A coordinator returns its rootViewController.
- ///
- public var viewController: UIViewController! {
- _viewController()
- }
-
- public func registerParent(_ presentable: Presentable & AnyObject) {
- _registerParent(presentable)
- }
-
- public func childTransitionCompleted() {
- _childTransitionCompleted()
- }
-
-}
diff --git a/Sources/XCoordinator/SwiftUI/Representable.swift b/Sources/XCoordinator/SwiftUI/Representable.swift
new file mode 100644
index 00000000..850d10c2
--- /dev/null
+++ b/Sources/XCoordinator/SwiftUI/Representable.swift
@@ -0,0 +1,50 @@
+//
+// Representable.swift
+// XCoordinator
+//
+// Created by Paul Johannes Kraft (QB) on 15.05.25.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+#if canImport(SwiftUI)
+
+import SwiftUI
+
+internal struct Representable: UIViewControllerRepresentable {
+ // MARK: Stored Properties
+
+ private let create: () -> C
+ private let update: (UIViewController, Context) -> Void
+
+ // MARK: Initialization
+
+ internal init(
+ create: @escaping () -> C,
+ update: @escaping (UIViewController, Context) -> Void = { _, _ in }
+ ) {
+ self.create = create
+ self.update = update
+ }
+
+ // MARK: Methods
+
+ internal func makeCoordinator() -> C {
+ create()
+ }
+
+ internal func makeUIViewController(
+ context: Context
+ ) -> UIViewController {
+ context.coordinator.viewController
+ }
+
+ internal func updateUIViewController(
+ _ controller: UIViewController,
+ context: Context
+ ) {
+ update(controller, context)
+ }
+
+}
+
+#endif
diff --git a/Sources/XCoordinator/SwiftUI/RepresentableContext.swift b/Sources/XCoordinator/SwiftUI/RepresentableContext.swift
new file mode 100644
index 00000000..49dc025e
--- /dev/null
+++ b/Sources/XCoordinator/SwiftUI/RepresentableContext.swift
@@ -0,0 +1,59 @@
+//
+// RepresentableContext.swift
+// XCoordinator
+//
+// Created by Paul Johannes Kraft (QB) on 20.05.25.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+#if canImport(SwiftUI)
+
+import SwiftUI
+import UIKit
+
+///
+/// A common abstraction over the SwiftUI representable contexts.
+///
+/// Both `UIViewControllerRepresentableContext` and `UIViewRepresentableContext` conform to
+/// `RepresentableContext`, which lets callers handle either context type with the same code path.
+///
+@MainActor
+public protocol RepresentableContext {
+
+ /// The representable's coordinator instance.
+ associatedtype Coordinator = Void
+
+ /// The coordinator instance produced by `makeCoordinator()`.
+ var coordinator: Coordinator { get }
+
+ /// The current SwiftUI transaction associated with this update.
+ var transaction: Transaction { get }
+
+ /// The SwiftUI environment values at the point of this update.
+ var environment: EnvironmentValues { get }
+
+ ///
+ /// Runs the given changes inside a SwiftUI animation, calling the completion handler when it finishes.
+ ///
+ /// This bridges the iOS 18 `animate(changes:completion:)` API onto the representable contexts
+ /// so that callers can use it uniformly via the protocol.
+ ///
+ /// - Parameters:
+ /// - changes: The state mutations to animate.
+ /// - completion: A closure invoked once the animation has completed.
+ ///
+ @available(iOS 18.0, tvOS 18.0, visionOS 2.0, *)
+ @available(macOS, unavailable)
+ @available(watchOS, unavailable)
+ func animate(changes: () -> Void, completion: (() -> Void)?)
+}
+
+extension UIViewControllerRepresentableContext: RepresentableContext {
+ public typealias Coordinator = Representable.Coordinator
+}
+
+extension UIViewRepresentableContext: RepresentableContext {
+ public typealias Coordinator = Representable.Coordinator
+}
+
+#endif
diff --git a/Sources/XCoordinator/SwiftUI/Routing.swift b/Sources/XCoordinator/SwiftUI/Routing.swift
new file mode 100644
index 00000000..d51a8e55
--- /dev/null
+++ b/Sources/XCoordinator/SwiftUI/Routing.swift
@@ -0,0 +1,79 @@
+//
+// Routing.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 08.05.23.
+// Copyright © 2023 QuickBird Studios. All rights reserved.
+//
+
+#if canImport(SwiftUI)
+
+import SwiftUI
+
+///
+/// A property wrapper that resolves the nearest `Router` for a given `Route` type from the SwiftUI environment.
+///
+/// Use `@Routing` inside a SwiftUI view to access the router responsible for a particular flow.
+/// The wrapped value is non-optional — if no matching router is in scope, accessing it triggers a `fatalError`,
+/// because that is a programmer error rather than a runtime condition.
+///
+/// ```swift
+/// struct ChildView: View {
+/// @Routing var usersRouter
+///
+/// var body: some View {
+/// Button("Open") { usersRouter.trigger(.user("Bob")) }
+/// }
+/// }
+/// ```
+///
+/// The projected value exposes the full ``RoutingContext`` for advanced lookups via `$router[OtherRoute.self]`.
+///
+@MainActor
+@propertyWrapper
+public struct Routing: DynamicProperty {
+
+ // MARK: Stored Properties
+
+ @Environment(\.routingContext) private var routingContext
+
+ // MARK: Computed Properties
+
+ /// The router responsible for `RouteType` in the current environment.
+ ///
+ /// - Important: Triggers `fatalError` if no router for `RouteType` was registered upstream. Make sure
+ /// the view is hosted within a ``RoutingController``, ``WrappedRouter``, or a `View.router(_:)` modifier
+ /// that provides a matching router.
+ public var wrappedValue: any Router {
+ guard let router = routingContext[RouteType.self] else {
+ fatalError("""
+ The current environment does not contain a router with the route type of \"\(RouteType.self)\".
+ Please make sure to specify the correct route type when using this property wrapper.
+ """)
+ }
+ return router
+ }
+
+ /// The full ``RoutingContext``, allowing access to routers for other route types via subscript.
+ public var projectedValue: RoutingContext {
+ routingContext
+ }
+
+ // MARK: Initialization
+
+ /// Creates a property wrapper that resolves a router for the given route type.
+ public init(_ routeType: RouteType.Type = RouteType.self) {}
+
+ // MARK: Methods
+
+ /// Looks up a router for a different route type in the same environment.
+ ///
+ /// - Parameter for: The route type to search for.
+ /// - Returns: The router for the given route type, or `nil` if no router is registered upstream.
+ public func router(for: R.Type) -> (any Router)? {
+ routingContext[R.self]
+ }
+
+}
+
+#endif
diff --git a/Sources/XCoordinator/SwiftUI/RoutingContext.swift b/Sources/XCoordinator/SwiftUI/RoutingContext.swift
new file mode 100644
index 00000000..894e4914
--- /dev/null
+++ b/Sources/XCoordinator/SwiftUI/RoutingContext.swift
@@ -0,0 +1,135 @@
+//
+// RoutingContext.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 08.05.23.
+// Copyright © 2023 QuickBird Studios. All rights reserved.
+//
+
+#if canImport(SwiftUI)
+
+import SwiftUI
+
+///
+/// A registry of `Router` instances keyed by their `Route` type, propagated through the SwiftUI environment.
+///
+/// `RoutingContext` carries one router per route type so that `@Routing` can resolve the
+/// appropriate router for any flow currently in scope. It is propagated two ways simultaneously:
+///
+/// - **Down** through `EnvironmentValues.routingContext`, so descendants can read the available routers.
+/// - **Up** through a `SwiftUI.PreferenceKey`, so hosts (e.g. ``RoutingController``) can observe routers
+/// registered deeper in the view tree and merge them back into their own context.
+///
+public struct RoutingContext: Equatable {
+
+ // MARK: Static Functions
+
+ /// Two routing contexts are considered equal when they contain the same router instances
+ /// (compared by `ObjectIdentifier`) keyed by the same route types. Deallocated routers compare
+ /// as `nil`, so a context whose router has gone away is no longer equal to one that still holds it.
+ public static func == (lhs: RoutingContext, rhs: RoutingContext) -> Bool {
+ return lhs.routers.mapValues { $0.router.map(ObjectIdentifier.init) }
+ == rhs.routers.mapValues { $0.router.map(ObjectIdentifier.init) }
+ }
+
+ // MARK: Nested Types
+
+ /// Holds a router weakly so that a `RoutingContext` does not keep routers alive. Every router
+ /// registered here is owned elsewhere (a parent coordinator's `children`, `WrappedRouter.Holder`,
+ /// or a `RouterModifier`), so a strong reference would only create retain cycles.
+ private struct WeakRouter {
+ weak var router: (any Router)?
+ }
+
+ fileprivate enum EnvironmentKey: SwiftUI.EnvironmentKey {
+ static var defaultValue: RoutingContext { RoutingContext() }
+ }
+
+ fileprivate enum PreferenceKey: SwiftUI.PreferenceKey {
+ static var defaultValue: RoutingContext { RoutingContext() }
+
+ static func reduce(value: inout RoutingContext, nextValue: () -> RoutingContext) {
+ value.add(nextValue())
+ }
+ }
+
+ // MARK: Properties
+
+ private var routers = [ObjectIdentifier: WeakRouter]()
+
+ // MARK: Initialization
+
+ /// Creates an empty routing context.
+ public nonisolated init() {}
+
+ /// Creates a routing context pre-populated with the given routers.
+ ///
+ /// - Parameter routers: Routers to register. Each is keyed by its concrete `RouteType`.
+ public init(_ routers: [any Router] = []) {
+ for router in routers {
+ add(router)
+ }
+ }
+
+ // MARK: Subscripts
+
+ /// Reads or writes the router responsible for the given route type.
+ ///
+ /// Each router is stored keyed by its own `RouteType`, so casting the stored router to
+ /// `any Router` for that same key is equivalent to `router(for:)` — and avoids calling the
+ /// main-actor-isolated `router(for:)`, keeping this type free of actor isolation.
+ public subscript(_ routeType: R.Type) -> (any Router)? {
+ get { routers[ObjectIdentifier(routeType)]?.router as? any Router }
+ set {
+ if let newValue {
+ routers[ObjectIdentifier(routeType)] = WeakRouter(router: newValue)
+ } else {
+ routers[ObjectIdentifier(routeType)] = nil
+ }
+ }
+ }
+
+ // MARK: Methods
+
+ /// Registers a router under its declared `RouteType`. Replaces any existing router for that type.
+ public mutating func add(_ router: any Router) {
+ router.add(to: &self)
+ }
+
+ internal mutating func add(_ context: RoutingContext) {
+ for (key, value) in context.routers {
+ routers[key] = value
+ }
+ }
+
+}
+
+extension Router {
+ // `nonisolated` overrides the `@MainActor` isolation inherited from the `Router` protocol: this only
+ // stores a reference via the (nonisolated) subscript setter, so it needs no actor isolation and keeps
+ // `RoutingContext` free of `@MainActor`.
+ fileprivate nonisolated func add(to context: inout RoutingContext) {
+ context[RouteType.self] = self
+ }
+}
+
+extension View {
+ internal func onRoutingContextChanged(perform: @escaping (RoutingContext) -> Void) -> some View {
+ onPreferenceChange(RoutingContext.PreferenceKey.self) {
+ perform($0)
+ }
+ }
+
+ internal func routingContext(_ context: RoutingContext) -> some View {
+ preference(key: RoutingContext.PreferenceKey.self, value: context)
+ }
+}
+
+extension EnvironmentValues {
+ internal var routingContext: RoutingContext {
+ get { self[RoutingContext.EnvironmentKey.self] }
+ set { self[RoutingContext.EnvironmentKey.self] = newValue }
+ }
+}
+
+#endif
diff --git a/Sources/XCoordinator/SwiftUI/RoutingContextProvider.swift b/Sources/XCoordinator/SwiftUI/RoutingContextProvider.swift
new file mode 100644
index 00000000..4df2d884
--- /dev/null
+++ b/Sources/XCoordinator/SwiftUI/RoutingContextProvider.swift
@@ -0,0 +1,25 @@
+//
+// RoutingContextProvider.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 09.05.2025.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+#if canImport(SwiftUI)
+
+///
+/// A type that exposes a writable ``RoutingContext`` for downstream propagation through the SwiftUI environment.
+///
+/// Conforming types (such as ``RoutingController``) participate in the routing-context machinery used by
+/// `Coordinator.performTransition`: when a transition produces presentables that conform to this protocol,
+/// the coordinator registers itself in their `routingContext` so that descendant SwiftUI views can resolve
+/// the coordinator via `@Routing`.
+///
+public protocol RoutingContextProvider {
+
+ /// The routing context provided to the SwiftUI environment.
+ var routingContext: RoutingContext { get nonmutating set }
+}
+
+#endif
diff --git a/Sources/XCoordinator/SwiftUI/RoutingController.swift b/Sources/XCoordinator/SwiftUI/RoutingController.swift
new file mode 100644
index 00000000..7d7a6f8e
--- /dev/null
+++ b/Sources/XCoordinator/SwiftUI/RoutingController.swift
@@ -0,0 +1,144 @@
+//
+// RoutingController.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 08.05.23.
+// Copyright © 2023 QuickBird Studios. All rights reserved.
+//
+
+#if canImport(SwiftUI)
+
+import SwiftUI
+
+///
+/// An observable wrapper around a ``RoutingContext`` so that updates made after a
+/// ``RoutingController`` has been created (e.g. a coordinator registering itself during
+/// `performTransition`, or routers merged back up through a `PreferenceKey`) are re-injected
+/// into the hosted SwiftUI environment.
+///
+/// `RoutingContext` is a value type, so storing it in a plain property captures a snapshot.
+/// Routing every mutation through this reference type lets SwiftUI observe the change and
+/// re-evaluate the injecting view.
+///
+@MainActor
+internal final class RoutingContextBox: ObservableObject {
+ @Published var context: RoutingContext
+
+ init(_ context: RoutingContext) {
+ self.context = context
+ }
+}
+
+///
+/// A `UIHostingController` subclass that bridges a SwiftUI view tree into a UIKit coordinator flow.
+///
+/// `RoutingController` is the SwiftUI counterpart to ``ViewCoordinator``'s root view controller: it injects
+/// a ``RoutingContext`` into its hosted view's environment, and observes context updates flowing back up
+/// through a `PreferenceKey` so that descendant views can register additional routers.
+///
+/// Use it from `prepareTransition(for:)` to push or present SwiftUI content from a UIKit coordinator:
+///
+/// ```swift
+/// return .push(RoutingController { UserView(name: name) })
+/// ```
+///
+public class RoutingController: UIHostingController.InjectorView>, RoutingContextProvider {
+
+ // MARK: Nested Types
+
+ /// The internal SwiftUI wrapper view that injects the routing context and listens for downstream changes.
+ ///
+ /// This type is only public because it must appear in the `UIHostingController`'s generic parameter list.
+ /// Treat it as an implementation detail; do not construct or inspect it directly.
+ public struct InjectorView: View {
+
+ // MARK: Stored Properties
+
+ @ObservedObject private var box: RoutingContextBox
+ private let content: Content
+ private let onUpdate: (RoutingContext) -> Void
+
+ // MARK: Computed Properties
+
+ public var body: some View {
+ content
+ .environment(\.routingContext, box.context)
+ .onRoutingContextChanged(perform: onUpdate)
+ }
+
+ // MARK: Initialization
+
+ fileprivate init(
+ box: RoutingContextBox,
+ content: Content,
+ onUpdate: @escaping (RoutingContext) -> Void
+ ) {
+ self._box = ObservedObject(wrappedValue: box)
+ self.content = content
+ self.onUpdate = onUpdate
+ }
+
+ }
+
+ // MARK: Properties
+
+ private let box: RoutingContextBox
+
+ /// The routing context currently propagated into the hosted SwiftUI environment.
+ ///
+ /// Mutating it (e.g. via `routingContext.add(_:)`) re-injects the updated context into the
+ /// hosted SwiftUI environment, so descendant `@Routing` lookups resolve against the latest routers.
+ public var routingContext: RoutingContext {
+ get { box.context }
+ set { box.context = newValue }
+ }
+
+ // MARK: Initialization
+
+ ///
+ /// Creates a routing controller that hosts the given SwiftUI view.
+ ///
+ /// - Parameters:
+ /// - context: The initial routing context to inject into the environment. Defaults to an empty context;
+ /// the surrounding coordinator typically populates it via `performTransition`.
+ /// - rootView: The SwiftUI view to host.
+ ///
+ public init(
+ context: RoutingContext = .init(),
+ rootView: Content
+ ) {
+ let box = RoutingContextBox(context)
+ self.box = box
+ super.init(
+ rootView: InjectorView(
+ box: box,
+ content: rootView
+ ) { [box] updatedContext in
+ // Merge routers flowing UP via the PreferenceKey back into the injected context.
+ box.context.add(updatedContext)
+ }
+ )
+ }
+
+ ///
+ /// Creates a routing controller that hosts a SwiftUI view built with a `@ViewBuilder` closure.
+ ///
+ /// - Parameters:
+ /// - context: The initial routing context. Defaults to an empty context.
+ /// - rootView: The view-builder producing the hosted SwiftUI content.
+ ///
+ public convenience init(
+ context: RoutingContext = .init(),
+ @ViewBuilder rootView: () -> Content
+ ) {
+ self.init(context: context, rootView: rootView())
+ }
+
+ public required init?(coder aDecoder: NSCoder) {
+ self.box = RoutingContextBox(.init())
+ super.init(coder: aDecoder)
+ }
+
+}
+
+#endif
diff --git a/Sources/XCoordinator/SwiftUI/Transition+SwiftUI.swift b/Sources/XCoordinator/SwiftUI/Transition+SwiftUI.swift
new file mode 100644
index 00000000..22230bcd
--- /dev/null
+++ b/Sources/XCoordinator/SwiftUI/Transition+SwiftUI.swift
@@ -0,0 +1,102 @@
+//
+// Transition+SwiftUI.swift
+// XCoordinator
+//
+// Created by Paul Johannes Kraft (QB) on 12.05.25.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+#if canImport(SwiftUI)
+
+import SwiftUI
+
+extension Transition {
+
+ ///
+ /// Creates a transition that performs a SwiftUI state change instead of a UIKit transition.
+ ///
+ /// Use this in `prepareTransition(for:)` when a route should mutate SwiftUI state (e.g. a
+ /// `@Binding` stored on the coordinator) rather than push/present a view controller. The body
+ /// closure runs inside `SwiftUI.withAnimation`, and the transition's `TransitionOptions.animated`
+ /// flag is respected — when `false`, no animation is applied.
+ ///
+ /// On iOS 17+/tvOS 17+ the completion handler fires from SwiftUI's animation-completion callback;
+ /// on earlier OSes it is invoked synchronously after the state change.
+ ///
+ /// - Parameters:
+ /// - animation: The SwiftUI animation to use when `TransitionOptions.animated` is `true`.
+ /// Defaults to `.default`. Pass `nil` to apply no animation even when animations are enabled.
+ /// - body: The state mutations to perform.
+ /// - Returns: A transition with no presentables that drives SwiftUI animations.
+ ///
+ public static func withAnimation(
+ animation: SwiftUI.Animation? = .default,
+ _ body: @MainActor @escaping () -> Void
+ ) -> Transition {
+ return Transition(
+ presentables: [],
+ animationInUse: nil
+ ) { _, options, completion in
+ if #available(iOS 17, tvOS 17, *) {
+ SwiftUI.withAnimation(
+ options.animated ? animation : nil
+ ) {
+ body()
+ } completion: {
+ completion?()
+ }
+ } else {
+ SwiftUI.withAnimation(
+ options.animated ? animation : nil
+ ) {
+ body()
+ }
+ completion?()
+ }
+ }
+ }
+
+ ///
+ /// Creates a transition that performs a SwiftUI state change inside a given `Transaction`.
+ ///
+ /// This is the lower-level counterpart to ``withAnimation(animation:_:)`` — use it when you need
+ /// fine-grained control over the SwiftUI transaction (for example to set explicit
+ /// `Transaction.disablesAnimations`). The transaction's `disablesAnimations` flag is overwritten
+ /// to match `TransitionOptions.animated`.
+ ///
+ /// On iOS 17+/tvOS 17+ the completion handler fires from the transaction's animation-completion
+ /// callback; on earlier OSes it is invoked synchronously after the state change.
+ ///
+ /// - Parameters:
+ /// - transaction: An auto-closure producing the transaction to use. Re-evaluated each time
+ /// the transition is performed.
+ /// - body: The state mutations to perform inside the transaction.
+ /// - Returns: A transition with no presentables that wraps the body in `SwiftUI.withTransaction`.
+ ///
+ public static func withTransaction(
+ _ transaction: @autoclosure @escaping () -> Transaction,
+ body: @MainActor @escaping () -> Void
+ ) -> Transition {
+ return Transition(
+ presentables: [],
+ animationInUse: nil
+ ) { _, options, completion in
+ var transaction = transaction()
+ transaction.disablesAnimations = !options.animated
+ if #available(iOS 17, tvOS 17, *) {
+ transaction.addAnimationCompletion {
+ completion?()
+ }
+ }
+ SwiftUI.withTransaction(transaction) {
+ body()
+ }
+ if #unavailable(iOS 17, tvOS 17) {
+ completion?()
+ }
+ }
+ }
+
+}
+
+#endif
diff --git a/Sources/XCoordinator/SwiftUI/View+Router.swift b/Sources/XCoordinator/SwiftUI/View+Router.swift
new file mode 100644
index 00000000..9beb6228
--- /dev/null
+++ b/Sources/XCoordinator/SwiftUI/View+Router.swift
@@ -0,0 +1,77 @@
+//
+// View+Router.swift
+// XCoordinator
+//
+// Created by Paul Johannes Kraft (QB) on 12.05.25.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+#if canImport(SwiftUI)
+
+import SwiftUI
+
+private struct RouterModifier: ViewModifier {
+
+ // MARK: Properties
+
+ let router: (any Router)?
+
+ // MARK: Methods
+
+ func body(content: Content) -> some View {
+ content
+ .transformEnvironment(\EnvironmentValues.routingContext) { context in
+ context[RouteType.self] = router?.router(for: RouteType.self)
+ }
+ }
+
+}
+
+extension View {
+
+ ///
+ /// Wraps the view in a ``RedirectionRouter`` that maps a new child route type onto an existing parent router.
+ ///
+ /// Use this when a SwiftUI subtree should expose its own `Route` enum but ultimately delegate
+ /// transitions to a UIKit-backed parent coordinator. Triggering a `ChildRoute` from inside the view
+ /// (via `@Routing`) calls `map` to obtain a `ParentRoute` and triggers it on `parent`.
+ ///
+ /// - Parameters:
+ /// - routeType: The child route type. Defaults to inference from the closure signature.
+ /// - parent: The parent router that ultimately performs transitions.
+ /// - map: A closure mapping each `ChildRoute` to a `ParentRoute`.
+ /// - Returns: A view that exposes a ``RedirectionRouter`` for `ChildRoute` in its environment.
+ ///
+ public func redirect(
+ _ routeType: ChildRoute.Type = ChildRoute.self,
+ to parent: any Router,
+ map: @escaping (ChildRoute) -> ParentRoute
+ ) -> some View {
+ WrappedRouter {
+ let viewController = RoutingController(rootView: self)
+ let router = RedirectionRouter(
+ viewController: viewController,
+ parent: parent,
+ map: map
+ )
+ viewController.routingContext.add(router)
+ return router
+ }
+ }
+
+ ///
+ /// Registers (or overrides) the router for `RouteType` in this view's environment.
+ ///
+ /// Use this to inject a router into a SwiftUI subtree so that descendants can resolve it via
+ /// `@Routing`. Passing `nil` removes the router for `RouteType` from the environment.
+ ///
+ /// - Parameter router: The router to inject, or `nil` to remove it.
+ /// - Returns: A view whose environment contains the given router for `RouteType`.
+ ///
+ public func router(_ router: (any Router)?) -> some View {
+ modifier(RouterModifier(router: router))
+ }
+
+}
+
+#endif
diff --git a/Sources/XCoordinator/SwiftUI/View+Trigger.swift b/Sources/XCoordinator/SwiftUI/View+Trigger.swift
new file mode 100644
index 00000000..122363cc
--- /dev/null
+++ b/Sources/XCoordinator/SwiftUI/View+Trigger.swift
@@ -0,0 +1,150 @@
+//
+// View+Trigger.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 09.05.2025.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+#if canImport(SwiftUI)
+
+import SwiftUI
+
+private struct TriggerViewModifier: ViewModifier {
+
+ // MARK: Properties
+
+ let item: Item
+ let priority: TaskPriority
+ let skipFirst: Bool
+ let route: () -> RouteType?
+ let options: () -> TransitionOptions
+ let onCompleted: () async -> Void
+
+ @Routing private var router
+ @State private var isFirstCall = true
+
+ // MARK: Methods
+
+ func body(content: Content) -> some View {
+ content.task(id: item, priority: priority) {
+ let wasFirst = isFirstCall
+ isFirstCall = false
+ guard !(skipFirst && wasFirst) else {
+ return
+ }
+ guard let route = route() else {
+ return
+ }
+ await router.trigger(route, with: options())
+ await onCompleted()
+ }
+ }
+
+}
+
+extension View {
+
+ ///
+ /// Triggers the given route once when the view first appears.
+ ///
+ /// Resolves the router for `RouteType` via `@Routing` and fires the route from within a `.task`.
+ /// Returning `nil` from the `route` closure suppresses the trigger.
+ ///
+ /// - Parameters:
+ /// - priority: The task priority used to run the trigger. Defaults to `.userInitiated`.
+ /// - route: An auto-closure producing the route to trigger. Re-evaluated each time the task runs.
+ /// - options: An auto-closure producing the transition options. Defaults to `.init(animated: true)`.
+ /// - onCompleted: An async closure invoked after the transition completes.
+ /// - Returns: A view that triggers the route on first appearance.
+ ///
+ public func triggerOnAppear(
+ priority: TaskPriority = .userInitiated,
+ route: @autoclosure @escaping () -> RouteType?,
+ with options: @autoclosure @escaping () -> TransitionOptions = TransitionOptions(animated: true),
+ onCompleted: @escaping () async -> Void = {}
+ ) -> some View {
+ self.modifier(
+ TriggerViewModifier(
+ item: true,
+ priority: priority,
+ skipFirst: false,
+ route: route,
+ options: options,
+ onCompleted: onCompleted
+ )
+ )
+ }
+
+ ///
+ /// Triggers the given route whenever `item` changes, skipping the initial value.
+ ///
+ /// Useful for kicking off a navigation in response to a model change. The first invocation
+ /// (when the view appears with its initial `item`) is intentionally skipped to avoid firing
+ /// during the initial render.
+ ///
+ /// - Parameters:
+ /// - item: The value whose changes drive the trigger.
+ /// - priority: The task priority used to run the trigger. Defaults to `.userInitiated`.
+ /// - route: An auto-closure producing the route to trigger.
+ /// - options: An auto-closure producing the transition options. Defaults to `.init(animated: true)`.
+ /// - onCompleted: An async closure invoked after the transition completes.
+ /// - Returns: A view that triggers the route whenever `item` changes.
+ ///
+ public func triggerOnChange(
+ of item: Item,
+ priority: TaskPriority = .userInitiated,
+ route: @autoclosure @escaping () -> RouteType?,
+ with options: @autoclosure @escaping () -> TransitionOptions = TransitionOptions(animated: true),
+ onCompleted: @escaping () async -> Void = {}
+ ) -> some View {
+ self.modifier(
+ TriggerViewModifier(
+ item: item,
+ priority: priority,
+ skipFirst: true,
+ route: route,
+ options: options,
+ onCompleted: onCompleted
+ )
+ )
+ }
+
+ ///
+ /// Triggers the given route when `condition` becomes `true`.
+ ///
+ /// The route is only fired when `condition` transitions to `true`; setting it back to `false`
+ /// does not trigger another transition. Like ``triggerOnChange(of:priority:route:with:onCompleted:)``,
+ /// the initial value is skipped.
+ ///
+ /// - Parameters:
+ /// - condition: The boolean whose `true` transitions drive the trigger.
+ /// - priority: The task priority used to run the trigger. Defaults to `.userInitiated`.
+ /// - route: An auto-closure producing the route to trigger when `condition` is `true`.
+ /// - options: An auto-closure producing the transition options. Defaults to `.init(animated: true)`.
+ /// - onCompleted: An async closure invoked after the transition completes.
+ /// - Returns: A view that triggers the route whenever `condition` becomes `true`.
+ ///
+ public func trigger(
+ when condition: Bool,
+ priority: TaskPriority = .userInitiated,
+ route: @autoclosure @escaping () -> RouteType,
+ with options: @autoclosure @escaping () -> TransitionOptions = TransitionOptions(animated: true),
+ onCompleted: @escaping () async -> Void = {}
+ ) -> some View {
+ self.modifier(
+ TriggerViewModifier(
+ item: condition,
+ priority: priority,
+ skipFirst: true,
+ route: {
+ condition ? route() : nil
+ },
+ options: options,
+ onCompleted: onCompleted
+ )
+ )
+ }
+}
+
+#endif
diff --git a/Sources/XCoordinator/SwiftUI/WrappedRouter.swift b/Sources/XCoordinator/SwiftUI/WrappedRouter.swift
new file mode 100644
index 00000000..9ad6aa0d
--- /dev/null
+++ b/Sources/XCoordinator/SwiftUI/WrappedRouter.swift
@@ -0,0 +1,94 @@
+//
+// WrappedRouter.swift
+// XCoordinator
+//
+// Created by Paul Johannes Kraft (QB) on 20.05.25.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+#if canImport(SwiftUI)
+
+import SwiftUI
+
+///
+/// A SwiftUI view that embeds a UIKit-backed coordinator or router and exposes it via the routing environment.
+///
+/// Use `WrappedRouter` when you want to drive a coordinator-based flow from inside a SwiftUI hierarchy
+/// — for example, hosting an entire `NavigationCoordinator` inside a SwiftUI scene. The `create` closure
+/// is called once per view identity to instantiate the router; the resulting instance is retained for
+/// the lifetime of the view, and is registered in the surrounding `RoutingContext` so descendant SwiftUI
+/// views can resolve it via `@Routing`.
+///
+/// ```swift
+/// struct ContentView: View {
+/// var body: some View {
+/// WrappedRouter { UsersCoordinator() }
+/// }
+/// }
+/// ```
+///
+public struct WrappedRouter: View {
+
+ // MARK: Nested Types
+
+ /// Holds the lazily-created router and its routing context for the lifetime of the view.
+ ///
+ /// This is a reference type so the router is created exactly once and the context can be built
+ /// without mutating SwiftUI `@State` during a view update.
+ @MainActor
+ private final class Holder: ObservableObject {
+ private var router: RouterType?
+ private(set) var routingContext = RoutingContext()
+
+ func makeRouter(_ create: () -> RouterType) -> RouterType {
+ if let router {
+ return router
+ }
+ let router = create()
+ routingContext.add(router)
+ self.router = router
+ return router
+ }
+ }
+
+ // MARK: Stored Properties
+
+ @StateObject private var holder = Holder()
+ private let create: () -> RouterType
+ private let update: (UIViewController, any RepresentableContext) -> Void
+
+ // MARK: Computed Properties
+
+ public var body: some View {
+ let router = holder.makeRouter(create)
+ return Representable {
+ router
+ } update: {
+ update($0, $1)
+ }
+ .routingContext(holder.routingContext)
+ }
+
+ // MARK: Initialization
+
+ ///
+ /// Creates a wrapped router view.
+ ///
+ /// - Parameters:
+ /// - create: A closure that builds the router. Called once per view identity, on first appearance.
+ /// The returned router is retained for the lifetime of the view and registered in the
+ /// routing context propagated to descendants.
+ /// - update: A closure invoked on each SwiftUI update of the underlying representable. Use it
+ /// to forward SwiftUI state into the hosted UIKit view controller. Defaults to a no-op.
+ ///
+ public init(
+ create: @escaping () -> RouterType,
+ update: @escaping (UIViewController, any RepresentableContext) -> Void = { _, _ in }
+ ) {
+ self.create = create
+ self.update = update
+ }
+
+}
+
+#endif
diff --git a/Sources/XCoordinator/TabBarAnimationDelegate.swift b/Sources/XCoordinator/Tab/TabBarAnimationDelegate.swift
similarity index 97%
rename from Sources/XCoordinator/TabBarAnimationDelegate.swift
rename to Sources/XCoordinator/Tab/TabBarAnimationDelegate.swift
index 1f10049a..5c6dd598 100755
--- a/Sources/XCoordinator/TabBarAnimationDelegate.swift
+++ b/Sources/XCoordinator/Tab/TabBarAnimationDelegate.swift
@@ -128,6 +128,7 @@ extension TabBarAnimationDelegate: UITabBarControllerDelegate {
/// - Parameters:
/// - tabBarController: The delegate owner.
/// - viewControllers: The source viewControllers.
+ /// - changed: Whether the order of the viewControllers changed.
///
open func tabBarController(_ tabBarController: UITabBarController,
didEndCustomizing viewControllers: [UIViewController], changed: Bool) {
@@ -143,6 +144,7 @@ extension TabBarAnimationDelegate: UITabBarControllerDelegate {
/// - Parameters:
/// - tabBarController: The delegate owner.
/// - viewControllers: The source viewControllers.
+ /// - changed: Whether the order of the viewControllers changed.
///
open func tabBarController(_ tabBarController: UITabBarController,
willEndCustomizing viewControllers: [UIViewController], changed: Bool) {
diff --git a/Sources/XCoordinator/Tab/TabBarCoordinator.swift b/Sources/XCoordinator/Tab/TabBarCoordinator.swift
new file mode 100755
index 00000000..02053172
--- /dev/null
+++ b/Sources/XCoordinator/Tab/TabBarCoordinator.swift
@@ -0,0 +1,242 @@
+//
+// TabBarCoordinator.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 29.07.18.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+#if canImport(Combine) && canImport(SwiftUI)
+
+import Combine
+import SwiftUI
+
+#endif
+
+import UIKit
+
+///
+/// Use a TabBarCoordinator to coordinate a flow where a `UITabbarController` serves as a rootViewController.
+/// With a TabBarCoordinator, you get access to all tabbarController-related transitions.
+///
+open class TabBarCoordinator: BaseCoordinator {
+
+ // MARK: Stored properties
+
+ /// Internal animation delegate installed as the tab-bar controller's `delegate` when none was set.
+ /// External callers should install their own delegate via the public ``delegate`` property.
+ private let animationDelegate = TabBarAnimationDelegate()
+ // swiftlint:disable:previous weak_delegate
+
+ internal var strongReferences = [Any]()
+
+ // MARK: Computed properties
+
+ ///
+ /// Use this delegate to get informed about tabbarController-related notifications and delegate methods
+ /// specifying transition animations. The delegate is only referenced weakly.
+ ///
+ /// Set this delegate instead of overriding the delegate of the rootViewController
+ /// specified in the initializer, if possible, to allow for transition animations
+ /// to be executed as specified in the `prepareTransition(for:)` method.
+ ///
+ public var delegate: UITabBarControllerDelegate? {
+ get {
+ animationDelegate.delegate
+ }
+ set {
+ animationDelegate.delegate = newValue
+ }
+ }
+
+ // MARK: Initialization
+
+ ///
+ /// Creates a TabBarCoordinator and optionally triggers an initial route.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UITabBarController` to host transitions. Defaults to a fresh instance.
+ /// - initialRoute: A route to trigger once the coordinator is shown.
+ ///
+ public override init(rootViewController: RootViewController = .init(), initialRoute: RouteType?) {
+ if rootViewController.delegate == nil {
+ rootViewController.delegate = animationDelegate
+ }
+ super.init(rootViewController: rootViewController, initialRoute: initialRoute)
+ }
+
+ ///
+ /// Creates a TabBarCoordinator and optionally performs an initial transition.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UITabBarController` to host transitions.
+ /// - initialTransition: A transition to perform once the coordinator is shown. Pass `nil` to skip.
+ ///
+ public override init(rootViewController: RootViewController, initialTransition: TabBarTransition?) {
+ if rootViewController.delegate == nil {
+ rootViewController.delegate = animationDelegate
+ }
+ super.init(rootViewController: rootViewController, initialTransition: initialTransition)
+ }
+
+ ///
+ /// Creates a TabBarCoordinator and performs an initial transition described with the transition builder.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UITabBarController` to host transitions.
+ /// - initialTransition: A transition-builder closure describing the transition to perform.
+ ///
+ public override init(rootViewController: RootViewController,
+ @TransitionBuilder initialTransition: () -> TabBarTransition) {
+ if rootViewController.delegate == nil {
+ rootViewController.delegate = animationDelegate
+ }
+ super.init(rootViewController: rootViewController, initialTransition: initialTransition())
+ }
+
+ ///
+ /// Creates a TabBarCoordinator with a specified set of tabs.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UITabBarController` to host transitions. Defaults to a fresh instance.
+ /// - tabs: The presentables to use as tabs.
+ ///
+ public init(rootViewController: RootViewController = .init(), tabs: [Presentable]) {
+ if rootViewController.delegate == nil {
+ rootViewController.delegate = animationDelegate
+ }
+ super.init(rootViewController: rootViewController, initialTransition: .set(tabs))
+ }
+
+ ///
+ /// Creates a TabBarCoordinator with a specified set of tabs and selects a specific presentable.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UITabBarController` to host transitions. Defaults to a fresh instance.
+ /// - tabs: The presentables to use as tabs.
+ /// - select: The presentable to select before displaying. Must be one of `tabs`.
+ ///
+ public init(rootViewController: RootViewController = .init(), tabs: [Presentable], select: Presentable) {
+ if rootViewController.delegate == nil {
+ rootViewController.delegate = animationDelegate
+ }
+ super.init(rootViewController: rootViewController,
+ initialTransition: .multiple(.set(tabs), .select(select)))
+ }
+
+ ///
+ /// Creates a TabBarCoordinator with a specified set of tabs and selects a presentable at a given index.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The `UITabBarController` to host transitions. Defaults to a fresh instance.
+ /// - tabs: The presentables to use as tabs.
+ /// - select: The index of the tab to select before displaying.
+ ///
+ public init(rootViewController: RootViewController = .init(), tabs: [Presentable], select: Int) {
+ if rootViewController.delegate == nil {
+ rootViewController.delegate = animationDelegate
+ }
+ super.init(rootViewController: rootViewController,
+ initialTransition: .multiple(.set(tabs), .select(index: select)))
+ }
+
+ #if canImport(Combine) && canImport(SwiftUI)
+
+ ///
+ /// Creates a tab bar coordinator whose selection is driven by a SwiftUI `Binding`.
+ ///
+ /// The `selection` binding stays in sync with the tab bar's selected item: external changes to
+ /// the binding update the selected tab, and user-driven tab changes write back to the binding.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The tab bar controller. Defaults to a fresh instance.
+ /// - items: The data items to render as tabs.
+ /// - selection: A binding to the currently selected item.
+ /// - content: A closure that builds a view controller for each item.
+ ///
+ public init(
+ rootViewController: RootViewController = .init(),
+ items: Items,
+ selection: Binding,
+ content: (Items.Element) -> UIViewController
+ ) where Items.Index == Int, Items.Element: Equatable {
+ // Work against a 0-based array so the tab bar's 0-based selectedIndex always lines up
+ // with both `tabs` and the items (the source collection's indices may be offset, e.g. a slice).
+ let items = Array(items)
+ let tabs = items.map(content)
+ if rootViewController.delegate == nil {
+ rootViewController.delegate = animationDelegate
+ }
+
+ if let selectedOffset = items.firstIndex(of: selection.wrappedValue) {
+ super.init(rootViewController: rootViewController,
+ initialTransition: .multiple(.set(tabs), .select(tabs[selectedOffset])))
+ } else {
+ super.init(rootViewController: rootViewController, initialTransition: .set(tabs))
+ }
+
+ let cancellable = Publishers.Merge(
+ rootViewController
+ .publisher(for: \.selectedViewController, options: [.new])
+ .compactMap { [weak self] _ in self?.rootViewController.selectedIndex },
+ rootViewController
+ .publisher(for: \.selectedIndex, options: [.new])
+ )
+ .removeDuplicates()
+ .receive(on: DispatchQueue.main)
+ .sink { index in
+ guard items.indices.contains(index) else { return }
+ selection.wrappedValue = items[index]
+ }
+ strongReferences.append(cancellable)
+ }
+
+ ///
+ /// Creates a tab bar coordinator whose selection is a `CaseIterable & Equatable` enum.
+ ///
+ /// Convenience over ``init(rootViewController:items:selection:content:)`` for enum-typed
+ /// selections — the `items` are derived from `Item.allCases`.
+ ///
+ /// - Parameters:
+ /// - rootViewController: The tab bar controller. Defaults to a fresh instance.
+ /// - selection: A binding to the currently selected case.
+ /// - content: A closure that builds a view controller for each case.
+ ///
+ public init(
+ rootViewController: RootViewController = .init(),
+ selection: Binding- ,
+ content: (Item) -> UIViewController
+ ) where Item.AllCases.Index == Int {
+ // Work against a 0-based array so the tab bar's 0-based selectedIndex always lines up
+ // with both `tabs` and the cases (AllCases indices may be offset).
+ let cases = Array(Item.allCases)
+ let tabs = cases.map(content)
+ if rootViewController.delegate == nil {
+ rootViewController.delegate = animationDelegate
+ }
+
+ if let selectedOffset = cases.firstIndex(of: selection.wrappedValue) {
+ super.init(rootViewController: rootViewController,
+ initialTransition: .multiple(.set(tabs), .select(tabs[selectedOffset])))
+ } else {
+ super.init(rootViewController: rootViewController, initialTransition: .set(tabs))
+ }
+
+ let cancellable = Publishers.Merge(
+ rootViewController
+ .publisher(for: \.selectedViewController, options: [.new])
+ .compactMap { [weak self] _ in self?.rootViewController.selectedIndex },
+ rootViewController
+ .publisher(for: \.selectedIndex, options: [.new])
+ )
+ .removeDuplicates()
+ .receive(on: DispatchQueue.main)
+ .sink { index in
+ guard cases.indices.contains(index) else { return }
+ selection.wrappedValue = cases[index]
+ }
+ strongReferences.append(cancellable)
+ }
+ #endif
+
+}
diff --git a/Sources/XCoordinator/TabBarTransition.swift b/Sources/XCoordinator/Tab/TabBarTransition.swift
similarity index 94%
rename from Sources/XCoordinator/TabBarTransition.swift
rename to Sources/XCoordinator/Tab/TabBarTransition.swift
index deea83b0..4972f39c 100755
--- a/Sources/XCoordinator/TabBarTransition.swift
+++ b/Sources/XCoordinator/Tab/TabBarTransition.swift
@@ -26,7 +26,7 @@ extension Transition where RootViewController: UITabBarController {
/// - animation:
/// The animation to be used. If you specify `nil` here, the default animation by UIKit is used.
///
- public static func set(_ presentables: [Presentable], animation: Animation? = nil) -> Transition {
+ public static func set(_ presentables: [any Presentable], animation: Animation? = nil) -> Transition {
Transition(presentables: presentables,
animationInUse: animation?.presentationAnimation
) { rootViewController, options, completion in
@@ -53,7 +53,7 @@ extension Transition where RootViewController: UITabBarController {
/// - animation:
/// The animation to be used. If you specify `nil` here, the default animation by UIKit is used.
///
- public static func select(_ presentable: Presentable, animation: Animation? = nil) -> Transition {
+ public static func select(_ presentable: any Presentable, animation: Animation? = nil) -> Transition {
Transition(presentables: [presentable],
animationInUse: animation?.presentationAnimation
) { rootViewController, options, completion in
diff --git a/Sources/XCoordinator/UITabBarController+Transition.swift b/Sources/XCoordinator/Tab/UITabBarController+Transition.swift
old mode 100755
new mode 100644
similarity index 82%
rename from Sources/XCoordinator/UITabBarController+Transition.swift
rename to Sources/XCoordinator/Tab/UITabBarController+Transition.swift
index a918f5b4..810c149d
--- a/Sources/XCoordinator/UITabBarController+Transition.swift
+++ b/Sources/XCoordinator/Tab/UITabBarController+Transition.swift
@@ -9,7 +9,7 @@
import UIKit
extension UITabBarController {
-
+
func set(_ viewControllers: [UIViewController],
with options: TransitionOptions,
animation: Animation?,
@@ -19,7 +19,7 @@ extension UITabBarController {
viewControllers.first?.transitioningDelegate = animation
}
assert(animation == nil || animationDelegate != nil, """
- Animations do not work, if the navigation controller's delegate is not a NavigationAnimationDelegate.
+ Animations do not work, if the tab bar controller's delegate is not a TabBarAnimationDelegate.
This assertion might fail, if the rootViewController specified in the TabBarCoordinator's
initializer already had a delegate when initializing the TabBarCoordinator.
To set another delegate of a rootViewController in a TabBarCoordinator, have a look at `TabBarCoordinator.delegate`.
@@ -44,7 +44,7 @@ extension UITabBarController {
viewController.transitioningDelegate = animation
}
assert(animation == nil || animationDelegate != nil, """
- Animations do not work, if the navigation controller's delegate is not a NavigationAnimationDelegate.
+ Animations do not work, if the tab bar controller's delegate is not a TabBarAnimationDelegate.
This assertion might fail, if the rootViewController specified in the TabBarCoordinator's
initializer already had a delegate when initializing the TabBarCoordinator.
To set another delegate of a rootViewController in a TabBarCoordinator, have a look at `TabBarCoordinator.delegate`.
@@ -62,11 +62,19 @@ extension UITabBarController {
func select(index: Int, with options: TransitionOptions, animation: Animation?, completion: PresentationHandler?) {
+ guard index >= 0, index < (viewControllers?.count ?? 0) else {
+ assertionFailure("""
+ select(index:): index \(index) is out of bounds (\(viewControllers?.count ?? 0) tabs). Ignoring the transition.
+ """)
+ completion?()
+ return
+ }
+
if let animation = animation {
viewControllers?[index].transitioningDelegate = animation
}
assert(animation == nil || animationDelegate != nil, """
- Animations do not work, if the navigation controller's delegate is not a NavigationAnimationDelegate.
+ Animations do not work, if the tab bar controller's delegate is not a TabBarAnimationDelegate.
This assertion might fail, if the rootViewController specified in the TabBarCoordinator's
initializer already had a delegate when initializing the TabBarCoordinator.
To set another delegate of a rootViewController in a TabBarCoordinator, have a look at `TabBarCoordinator.delegate`.
@@ -81,5 +89,5 @@ extension UITabBarController {
CATransaction.commit()
}
-
+
}
diff --git a/Sources/XCoordinator/TabBarCoordinator.swift b/Sources/XCoordinator/TabBarCoordinator.swift
deleted file mode 100755
index d8ca8540..00000000
--- a/Sources/XCoordinator/TabBarCoordinator.swift
+++ /dev/null
@@ -1,102 +0,0 @@
-//
-// TabBarCoordinator.swift
-// XCoordinator
-//
-// Created by Paul Kraft on 29.07.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-///
-/// Use a TabBarCoordinator to coordinate a flow where a `UITabbarController` serves as a rootViewController.
-/// With a TabBarCoordinator, you get access to all tabbarController-related transitions.
-///
-open class TabBarCoordinator: BaseCoordinator {
-
- // MARK: Stored properties
-
- ///
- /// The animation delegate controlling the rootViewController's transition animations.
- /// This animation delegate is set to be the rootViewController's rootViewController, if you did not set one earlier.
- ///
- /// - Note:
- /// Use the `delegate` property to set a custom delegate and use transition animations provided by XCoordinator.
- ///
- private let animationDelegate = TabBarAnimationDelegate()
- // swiftlint:disable:previous weak_delegate
-
- // MARK: Computed properties
-
- ///
- /// Use this delegate to get informed about tabbarController-related notifications and delegate methods
- /// specifying transition animations. The delegate is only referenced weakly.
- ///
- /// Set this delegate instead of overriding the delegate of the rootViewController
- /// specified in the initializer, if possible, to allow for transition animations
- /// to be executed as specified in the `prepareTransition(for:)` method.
- ///
- public var delegate: UITabBarControllerDelegate? {
- get {
- animationDelegate.delegate
- }
- set {
- animationDelegate.delegate = newValue
- }
- }
-
- // MARK: Initialization
-
- public override init(rootViewController: RootViewController = .init(), initialRoute: RouteType?) {
- if rootViewController.delegate == nil {
- rootViewController.delegate = animationDelegate
- }
- super.init(rootViewController: rootViewController, initialRoute: initialRoute)
- }
-
- ///
- /// Creates a TabBarCoordinator with a specified set of tabs.
- ///
- /// - Parameter tabs:
- /// The presentables to be used as tabs.
- ///
- public init(rootViewController: RootViewController = .init(), tabs: [Presentable]) {
- if rootViewController.delegate == nil {
- rootViewController.delegate = animationDelegate
- }
- super.init(rootViewController: rootViewController, initialTransition: .set(tabs))
- }
-
- ///
- /// Creates a TabBarCoordinator with a specified set of tabs and selects a specific presentable.
- ///
- /// - Parameters:
- /// - tabs: The presentables to be used as tabs.
- /// - select:
- /// The presentable to be selected before displaying. Make sure, this presentable is one of the
- /// specified tabs in the other parameter.
- ///
- public init(rootViewController: RootViewController = .init(), tabs: [Presentable], select: Presentable) {
- if rootViewController.delegate == nil {
- rootViewController.delegate = animationDelegate
- }
- super.init(rootViewController: rootViewController,
- initialTransition: .multiple(.set(tabs), .select(select)))
- }
-
- ///
- /// Creates a TabBarCoordinator with a specified set of tabs and selects a presentable at a given index.
- ///
- /// - Parameters:
- /// - tabs: The presentables to be used as tabs.
- /// - select: The index of the presentable to be selected before displaying.
- ///
- public init(rootViewController: RootViewController = .init(), tabs: [Presentable], select: Int) {
- if rootViewController.delegate == nil {
- rootViewController.delegate = animationDelegate
- }
- super.init(rootViewController: rootViewController,
- initialTransition: .multiple(.set(tabs), .select(index: select)))
- }
-
-}
diff --git a/Sources/XCoordinator/TransitionPerformer.swift b/Sources/XCoordinator/TransitionPerformer.swift
deleted file mode 100755
index 262bbbf0..00000000
--- a/Sources/XCoordinator/TransitionPerformer.swift
+++ /dev/null
@@ -1,37 +0,0 @@
-//
-// TransitionPerformer.swift
-// XCoordinator
-//
-// Created by Paul Kraft on 13.09.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-///
-/// The TransitionPerformer protocol is used to abstract the route-type specific characteristics of a Coordinator.
-/// It keeps type information about its transition performing capabilities.
-///
-public protocol TransitionPerformer: Presentable {
-
- /// The type of transitions that can be executed on the rootViewController.
- associatedtype TransitionType: TransitionProtocol
-
- /// The rootViewController on which transitions are performed.
- var rootViewController: TransitionType.RootViewController { get }
-
- ///
- /// Perform a transition.
- ///
- /// - Warning:
- /// Do not use this method directly, but instead try to use the `trigger`
- /// method of your coordinator instead wherever possible.
- ///
- /// - Parameters:
- /// - transition: The transition to be performed.
- /// - options: The options on how to perform the transition, including the option to enable/disable animations.
- /// - completion: The completion handler called once a transition has finished.
- ///
- func performTransition(_ transition: TransitionType,
- with options: TransitionOptions,
- completion: PresentationHandler?)
-
-}
diff --git a/Sources/XCoordinator/TransitionProtocol.swift b/Sources/XCoordinator/TransitionProtocol.swift
deleted file mode 100755
index ce58d722..00000000
--- a/Sources/XCoordinator/TransitionProtocol.swift
+++ /dev/null
@@ -1,55 +0,0 @@
-//
-// TransitionProtocol.swift
-// XCoordinator
-//
-// Created by Paul Kraft on 13.09.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-///
-/// `TransitionProtocol` is used to abstract any concrete transition implementation.
-///
-/// `Transition` is provided as an easily-extensible default transition type implementation.
-///
-public protocol TransitionProtocol: TransitionContext {
-
- /// The type of the rootViewController that can execute the transition.
- associatedtype RootViewController: UIViewController
-
- ///
- /// Performs a transition on the given viewController.
- ///
- /// - Warning:
- /// Do not call this method directly. Instead use your coordinator's `performTransition` method or trigger
- /// a specified route (latter option is encouraged).
- ///
- func perform(on rootViewController: RootViewController,
- with options: TransitionOptions,
- completion: PresentationHandler?)
-
- // MARK: Always accessible transitions
-
- ///
- /// Creates a compound transition by chaining multiple transitions together.
- ///
- /// - Parameter transitions:
- /// The transitions to be chained to form a combined transition.
- ///
- static func multiple(_ transitions: [Self]) -> Self
-}
-
-extension TransitionProtocol {
-
- ///
- /// Creates a compound transition by chaining multiple transitions together.
- ///
- /// - Parameter transitions:
- /// The transitions to be chained to form a combined transition.
- ///
- public static func multiple(_ transitions: Self...) -> Self {
- multiple(transitions)
- }
-
-}
diff --git a/Sources/XCoordinator/Transition.swift b/Sources/XCoordinator/Transitions/Transition.swift
similarity index 93%
rename from Sources/XCoordinator/Transition.swift
rename to Sources/XCoordinator/Transitions/Transition.swift
index ec85b90f..606e1811 100755
--- a/Sources/XCoordinator/Transition.swift
+++ b/Sources/XCoordinator/Transitions/Transition.swift
@@ -9,7 +9,7 @@
import UIKit
///
-/// This struct represents the common implementation of the `TransitionProtocol`.
+/// This struct is the single transition type used by every coordinator.
/// It is used in every of the provided `BaseCoordinator` subclasses and provides all transitions implemented in XCoordinator.
///
/// `Transitions` are defined by a `Transition.Perform` closure.
@@ -23,7 +23,7 @@ import UIKit
/// Make sure to specify the `RootViewController` type of the `TransitionType` of your coordinator as precise as possible
/// to get all already available transitions.
///
-public struct Transition: TransitionProtocol {
+public struct Transition: TransitionContext {
// MARK: Typealias
@@ -45,7 +45,7 @@ public struct Transition: TransitionProtoc
// MARK: Stored properties
- private var _presentables: [Presentable]
+ private var _presentables: [any Presentable]
private var _animation: TransitionAnimation?
private var _perform: PerformClosure
@@ -55,7 +55,7 @@ public struct Transition: TransitionProtoc
/// The presentables this transition is putting into the view hierarchy. This is especially useful for
/// deep-linking.
///
- public var presentables: [Presentable] {
+ public var presentables: [any Presentable] {
_presentables
}
@@ -91,7 +91,7 @@ public struct Transition: TransitionProtoc
/// To create custom transitions, make sure to call the completion handler after all animations are done.
/// If applicable, make sure to use the TransitionOptions to, e.g., decide whether a transition should be animated or not.
///
- public init(presentables: [Presentable], animationInUse: TransitionAnimation?, perform: @escaping PerformClosure) {
+ public init(presentables: [any Presentable], animationInUse: TransitionAnimation?, perform: @escaping PerformClosure) {
self._presentables = presentables
self._animation = animationInUse
self._perform = perform
diff --git a/Sources/XCoordinator/Transitions/TransitionBuilder.swift b/Sources/XCoordinator/Transitions/TransitionBuilder.swift
new file mode 100644
index 00000000..9f12938d
--- /dev/null
+++ b/Sources/XCoordinator/Transitions/TransitionBuilder.swift
@@ -0,0 +1,73 @@
+//
+// TransitionBuilder.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 08.05.23.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+import UIKit
+
+///
+/// A result builder that assembles a single ``Transition`` from one or more `Transition` values.
+///
+/// Use it to describe a coordinator's transitions inline — e.g. in `prepareTransition(for:)` or a
+/// `BasicCoordinator`'s `prepare` closure — by listing the `Transition.…` factories that apply to the
+/// coordinator's root view controller:
+///
+/// ```swift
+/// override func prepareTransition(for route: AppRoute) -> NavigationTransition {
+/// switch route {
+/// case .home: Transition.push(HomeViewController())
+/// case .detail(let id): Transition.push(DetailViewController(id: id))
+/// case .ignored: Transition.none()
+/// }
+/// }
+/// ```
+///
+/// Multiple statements are chained with ``Transition/multiple(_:)-(Collection)`` and
+/// performed strictly in order. An empty builder block is a compile-time error — use ``Transition/none()``
+/// to express an intentional no-op.
+///
+@MainActor
+@resultBuilder
+public enum TransitionBuilder {
+
+ public static func buildExpression(_ expression: Transition) -> Transition {
+ expression
+ }
+
+ public static func buildExpression(_ expression: Never) -> Transition {}
+
+ public static func buildEither(first component: Transition) -> Transition {
+ component
+ }
+
+ public static func buildEither(second component: Transition) -> Transition {
+ component
+ }
+
+ public static func buildOptional(_ component: Transition?) -> Transition {
+ component ?? .none()
+ }
+
+ public static func buildLimitedAvailability(_ component: Transition) -> Transition {
+ component
+ }
+
+ public static func buildBlock(
+ _ first: Transition,
+ _ rest: Transition...
+ ) -> Transition {
+ rest.isEmpty ? first : .multiple([first] + rest)
+ }
+
+ public static func buildArray(_ components: [Transition]) -> Transition {
+ .multiple(components)
+ }
+
+ public static func buildFinalResult(_ component: Transition) -> Transition {
+ component
+ }
+
+}
diff --git a/Sources/XCoordinator/Transitions/TransitionContext.swift b/Sources/XCoordinator/Transitions/TransitionContext.swift
new file mode 100644
index 00000000..8fd32a40
--- /dev/null
+++ b/Sources/XCoordinator/Transitions/TransitionContext.swift
@@ -0,0 +1,22 @@
+//
+// TransitionContext.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 13.09.18.
+// Copyright © 2018 QuickBird Studios. All rights reserved.
+//
+
+///
+/// A non-generic view of a performed transition, used where the concrete root-view-controller type
+/// is not known — e.g. on `Router`, whose only knowledge is its `RouteType`.
+///
+/// The context-based `trigger` variants (`contextTrigger`, the async overload, and the Combine/RxSwift
+/// wrappers) hand back the performed transition as `any TransitionContext`. Deep linking
+/// (`General/DeepLinking.swift`) uses ``presentables`` to walk the resulting coordinator tree.
+///
+@MainActor
+public protocol TransitionContext {
+
+ /// The presentables introduced into the view hierarchy by the transition.
+ var presentables: [any Presentable] { get }
+}
diff --git a/Sources/XCoordinator/TransitionOptions.swift b/Sources/XCoordinator/Transitions/TransitionOptions.swift
similarity index 92%
rename from Sources/XCoordinator/TransitionOptions.swift
rename to Sources/XCoordinator/Transitions/TransitionOptions.swift
index b6d77a32..96b45efc 100755
--- a/Sources/XCoordinator/TransitionOptions.swift
+++ b/Sources/XCoordinator/Transitions/TransitionOptions.swift
@@ -39,7 +39,8 @@ public struct TransitionOptions {
// MARK: Static computed properties
- static var `default`: TransitionOptions {
+ /// The default transition options (animated).
+ public static var `default`: TransitionOptions {
TransitionOptions(animated: true)
}
diff --git a/Sources/XCoordinator/UIPageViewController+Transition.swift b/Sources/XCoordinator/UIPageViewController+Transition.swift
deleted file mode 100755
index 11b27969..00000000
--- a/Sources/XCoordinator/UIPageViewController+Transition.swift
+++ /dev/null
@@ -1,23 +0,0 @@
-//
-// UIPageViewController+Transition.swift
-// XCoordinator
-//
-// Created by Paul Kraft on 30.07.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-extension UIPageViewController {
- func set(_ viewControllers: [UIViewController],
- direction: UIPageViewController.NavigationDirection,
- with options: TransitionOptions,
- completion: PresentationHandler?) {
- setViewControllers(
- viewControllers,
- direction: direction,
- animated: options.animated,
- completion: { _ in completion?() }
- )
- }
-}
diff --git a/Sources/XCoordinator/UIView+Store.swift b/Sources/XCoordinator/UIView+Store.swift
deleted file mode 100755
index 58915aad..00000000
--- a/Sources/XCoordinator/UIView+Store.swift
+++ /dev/null
@@ -1,40 +0,0 @@
-//
-// UIView+Store.swift
-// XCoordinator
-//
-// Created by Stefan Kofler on 19.07.18.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-private var associatedObjectHandle: UInt8 = 0
-
-extension UIView {
-
- var strongReferences: [Any] {
- get {
- objc_getAssociatedObject(self, &associatedObjectHandle) as? [Any] ?? []
- }
- set {
- objc_setAssociatedObject(self, &associatedObjectHandle, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
- }
- }
-}
-
-extension UIView {
-
- @discardableResult
- func removePreviewingContext(for _: TransitionType.Type)
- -> UIViewControllerPreviewing? {
- guard let existingContextIndex = strongReferences
- .firstIndex(where: { $0 is CoordinatorPreviewingDelegateObject }),
- let contextDelegate = strongReferences
- .remove(at: existingContextIndex) as? CoordinatorPreviewingDelegateObject,
- let context = contextDelegate.context else {
- return nil
- }
- return context
- }
-
-}
diff --git a/Sources/XCoordinator/UnownedErased+Router.swift b/Sources/XCoordinator/UnownedErased+Router.swift
deleted file mode 100644
index f819d5e6..00000000
--- a/Sources/XCoordinator/UnownedErased+Router.swift
+++ /dev/null
@@ -1,62 +0,0 @@
-//
-// UnownedErased+Router.swift
-// XCoordinator
-//
-// Created by Paul Kraft on 02.09.19.
-// Copyright © 2018 QuickBird Studios. All rights reserved.
-//
-
-import UIKit
-
-///
-/// Please use `StrongRouter`, `WeakRouter` or `UnownedRouter` instead.
-///
-/// - Note:
-/// Use a `StrongRouter`, if you need to hold a router even
-/// when it is not in the view hierarchy.
-/// Use a `WeakRouter` or `UnownedRouter` when you are accessing
-/// any router from the view hierarchy.
-///
-@available(iOS, deprecated)
-public typealias AnyRouter = UnownedRouter
-
-///
-/// An `UnownedRouter` is an unowned version of a router object to be used in view controllers or view models.
-///
-/// - Note:
-/// Do not create an `UnownedRouter` from a `StrongRouter` since `StrongRouter` is only another wrapper
-/// and does not represent the might instantly
-///
-public typealias UnownedRouter = UnownedErased>
-
-extension UnownedErased: Presentable where Value: Presentable {
-
- public var viewController: UIViewController! {
- wrappedValue.viewController
- }
-
- public func childTransitionCompleted() {
- wrappedValue.childTransitionCompleted()
- }
-
- public func registerParent(_ presentable: Presentable & AnyObject) {
- wrappedValue.registerParent(presentable)
- }
-
- public func presented(from presentable: Presentable?) {
- wrappedValue.presented(from: presentable)
- }
-
- public func setRoot(for window: UIWindow) {
- wrappedValue.setRoot(for: window)
- }
-
-}
-
-extension UnownedErased: Router where Value: Router {
-
- public func contextTrigger(_ route: Value.RouteType, with options: TransitionOptions, completion: ContextPresentationHandler?) {
- wrappedValue.contextTrigger(route, with: options, completion: completion)
- }
-
-}
diff --git a/Sources/XCoordinator/UnownedErased.swift b/Sources/XCoordinator/UnownedErased.swift
deleted file mode 100644
index 1eb5216a..00000000
--- a/Sources/XCoordinator/UnownedErased.swift
+++ /dev/null
@@ -1,72 +0,0 @@
-//
-// File.swift
-//
-//
-// Created by Paul Kraft on 21.06.19.
-//
-
-import Foundation
-
-#if swift(>=5.1)
-
-///
-/// `UnownedErased` is a property wrapper to hold objects with an unowned reference when using type-erasure.
-///
-/// Create this wrapper using an initial value and a closure to create the type-erased object.
-/// Make sure to not create an `UnownedErased` wrapper for already type-erased objects,
-/// since their reference is most likely instantly lost.
-///
-@propertyWrapper
-public struct UnownedErased {
- private var _value: () -> Value
-
- /// The type-erased or otherwise mapped version of the value being held unowned.
- public var wrappedValue: Value {
- _value()
- }
-}
-
-#else
-
-///
-/// `UnownedErased` is a property wrapper to hold objects with an unowned reference when using type-erasure.
-///
-/// Create this wrapper using an initial value and a closure to create the type-erased object.
-/// Make sure to not create an `UnownedErased` wrapper for already type-erased objects,
-/// since their reference is most likely instantly lost.
-///
-public struct UnownedErased {
- private var _value: () -> Value
-
- /// The type-erased or otherwise mapped version of the value being held unowned.
- public var wrappedValue: Value {
- _value()
- }
-}
-
-#endif
-
-extension UnownedErased {
-
- ///
- /// Create an `UnownedErased` wrapper using an initial value and a closure to create the type-erased object.
- /// Make sure to not create an `UnownedErased` wrapper for already type-erased objects,
- /// since their reference is most likely instantly lost.
- ///
- public init(_ value: Erasable, erase: @escaping (Erasable) -> Value) {
- self._value = UnownedErased.createValueClosure(for: value, erase: erase)
- }
-
- ///
- /// Set a new value by providing a non-type-erased value and a closure to create the type-erased object.
- ///
- public mutating func set(_ value: Erasable, erase: @escaping (Erasable) -> Value) {
- self._value = UnownedErased.createValueClosure(for: value, erase: erase)
- }
-
- private static func createValueClosure(
- for value: Erasable,
- erase: @escaping (Erasable) -> Value) -> () -> Value {
- { [unowned value] in erase(value) }
- }
-}
diff --git a/Sources/XCoordinator/View/Coordinator+ContextMenu.swift b/Sources/XCoordinator/View/Coordinator+ContextMenu.swift
new file mode 100644
index 00000000..b1f4ec7a
--- /dev/null
+++ b/Sources/XCoordinator/View/Coordinator+ContextMenu.swift
@@ -0,0 +1,46 @@
+//
+// Coordinator+ContextMenu.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 13.02.20.
+// Copyright © 2020 QuickBird Studios. All rights reserved.
+//
+
+#if os(iOS)
+
+import UIKit
+
+extension Coordinator where Self: AnyObject {
+
+ ///
+ /// Creates a `UIContextMenuInteractionDelegate` that generates a preview from a given route and
+ /// performs that route when the preview is committed.
+ ///
+ /// The preview shown is the view controller produced by the route's transition. Committing the preview
+ /// triggers the route on this coordinator via ``performTransition(_:with:completion:)``, so any presented
+ /// presentables are correctly retained as children.
+ ///
+ /// - Parameters:
+ /// - route: The route to be triggered when the preview is committed.
+ /// - identifier: An optional identifier for the context menu configuration.
+ /// - menu: The menu to be shown alongside the preview.
+ /// - completion: A closure called once the route's transition completes.
+ ///
+ public func contextMenuInteractionDelegate(
+ for route: RouteType,
+ identifier: NSCopying? = nil,
+ menu: UIMenu? = nil,
+ completion: PresentationHandler? = nil
+ ) -> UIContextMenuInteractionDelegate {
+ CoordinatorContextMenuInteractionDelegate(
+ coordinator: self,
+ route: route,
+ identifier: identifier,
+ menu: menu,
+ completion: completion
+ )
+ }
+
+}
+
+#endif
diff --git a/Sources/XCoordinator/View/CoordinatorContextMenuInteractionDelegate.swift b/Sources/XCoordinator/View/CoordinatorContextMenuInteractionDelegate.swift
new file mode 100644
index 00000000..b9017535
--- /dev/null
+++ b/Sources/XCoordinator/View/CoordinatorContextMenuInteractionDelegate.swift
@@ -0,0 +1,95 @@
+//
+// CoordinatorContextMenuInteractionDelegate.swift
+// XCoordinator
+//
+// Created by Paul Kraft on 13.02.20.
+// Copyright © 2020 QuickBird Studios. All rights reserved.
+//
+
+#if os(iOS)
+
+import UIKit
+
+///
+/// A `UIContextMenuInteractionDelegate` that previews and triggers a coordinator route.
+///
+/// The preview is the view controller produced by the route's transition; committing the preview performs
+/// that route on the coordinator.
+///
+/// - Important:
+/// The route is performed via the coordinator's ``Coordinator/performTransition(_:with:completion:)``,
+/// **not** by running the transition directly. This is what keeps the presented presentables retained as
+/// children of the coordinator — performing the transition directly would bypass child management and the
+/// presented coordinator would be deallocated immediately (see `CoordinatorChildLifecycleTests`).
+///
+internal final class CoordinatorContextMenuInteractionDelegate: NSObject,
+ UIContextMenuInteractionDelegate {
+
+ // MARK: Stored properties
+
+ private let identifier: NSCopying?
+ private let route: C.RouteType
+ private let menu: UIMenu?
+ private weak var coordinator: C?
+ private let completion: PresentationHandler?
+
+ // MARK: Initialization
+
+ internal init(
+ coordinator: C,
+ route: C.RouteType,
+ identifier: NSCopying?,
+ menu: UIMenu?,
+ completion: PresentationHandler?
+ ) {
+ self.coordinator = coordinator
+ self.route = route
+ self.identifier = identifier
+ self.menu = menu
+ self.completion = completion
+ }
+
+ // MARK: Methods
+
+ internal func contextMenuInteraction(
+ _ interaction: UIContextMenuInteraction,
+ configurationForMenuAtLocation location: CGPoint
+ ) -> UIContextMenuConfiguration? {
+ UIContextMenuConfiguration(
+ identifier: identifier,
+ previewProvider: { [weak self] in
+ guard let self else { return nil }
+ return self.coordinator?.prepareTransition(for: self.route).presentables.last?.viewController
+ },
+ actionProvider: { [weak self] _ in
+ self?.menu
+ }
+ )
+ }
+
+ internal func contextMenuInteraction(
+ _ interaction: UIContextMenuInteraction,
+ willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration,
+ animator: UIContextMenuInteractionCommitAnimating
+ ) {
+ animator.addCompletion { [weak self] in
+ self?.performRoute()
+ }
+ }
+
+ /// Performs the configured route on the coordinator, keeping child management intact.
+ ///
+ /// Factored out of the delegate callback so it can be exercised directly in tests without a live
+ /// `UIContextMenuInteractionCommitAnimating`.
+ internal func performRoute() {
+ guard let coordinator else { return }
+ coordinator.performTransition(
+ coordinator.prepareTransition(for: route),
+ with: .default,
+ completion: completion
+ )
+ }
+
+}
+
+#endif
diff --git a/Sources/XCoordinator/Transition+Init.swift b/Sources/XCoordinator/View/Transition+Init.swift
similarity index 75%
rename from Sources/XCoordinator/Transition+Init.swift
rename to Sources/XCoordinator/View/Transition+Init.swift
index d73ae128..73234e9f 100755
--- a/Sources/XCoordinator/Transition+Init.swift
+++ b/Sources/XCoordinator/View/Transition+Init.swift
@@ -20,7 +20,7 @@ extension Transition {
/// - Parameter presentable:
/// The presentable to be shown as a primary view controller.
///
- public static func show(_ presentable: Presentable) -> Transition {
+ public static func show(_ presentable: any Presentable) -> Transition {
Transition(presentables: [presentable], animationInUse: nil) { rootViewController, options, completion in
rootViewController.show(
presentable.viewController,
@@ -42,7 +42,7 @@ extension Transition {
/// - Parameter presentable:
/// The presentable to be shown as a detail view controller.
///
- public static func showDetail(_ presentable: Presentable) -> Transition {
+ public static func showDetail(_ presentable: any Presentable) -> Transition {
Transition(presentables: [presentable], animationInUse: nil) { rootViewController, options, completion in
rootViewController.showDetail(
presentable.viewController,
@@ -67,7 +67,7 @@ extension Transition {
/// the current transitioningDelegate and `Animation.default` to reset the transitioningDelegate to use
/// the default UIKit animations.
///
- public static func presentOnRoot(_ presentable: Presentable, animation: Animation? = nil) -> Transition {
+ public static func presentOnRoot(_ presentable: any Presentable, animation: Animation? = nil) -> Transition {
Transition(presentables: [presentable],
animationInUse: animation?.presentationAnimation
) { rootViewController, options, completion in
@@ -93,7 +93,7 @@ extension Transition {
/// the current transitioningDelegate and `Animation.default` to reset the transitioningDelegate to use
/// the default UIKit animations.
///
- public static func present(_ presentable: Presentable, animation: Animation? = nil) -> Transition {
+ public static func present(_ presentable: any Presentable, animation: Animation? = nil) -> Transition {
Transition(presentables: [presentable],
animationInUse: animation?.presentationAnimation
) { rootViewController, options, completion in
@@ -115,7 +115,7 @@ extension Transition {
/// - presentable: The presentable to be embedded.
/// - container: The container to embed the presentable in.
///
- public static func embed(_ presentable: Presentable, in container: Container) -> Transition {
+ public static func embed(_ presentable: any Presentable, in container: any Container) -> Transition {
Transition(presentables: [presentable], animationInUse: nil) { rootViewController, options, completion in
rootViewController.embed(presentable.viewController,
in: container,
@@ -183,7 +183,20 @@ extension Transition {
/// - Parameter transitions:
/// The transitions to be chained to form the new transition.
///
- public static func multiple(_ transitions: C) -> Transition where C.Element == Transition {
+ public static func multiple(_ transitions: Transition...) -> Transition {
+ multiple(transitions)
+ }
+
+ ///
+ /// With this transition you can chain multiple transitions of the same type together.
+ ///
+ /// Each transition is performed strictly after the previous one has fully completed, since a
+ /// transition may fail if a prior one is still in progress.
+ ///
+ /// - Parameter transitions:
+ /// The transitions to be chained to form the new transition.
+ ///
+ public static func multiple(_ transitions: some Collection) -> Transition {
Transition(presentables: transitions.flatMap { $0.presentables },
animationInUse: transitions.compactMap { $0.animation }.last
) { rootViewController, options, completion in
@@ -227,7 +240,7 @@ extension Transition {
/// - route: The route to be triggered on the coordinator.
/// - router: The router to trigger the route on.
///
- public static func trigger(_ route: R.RouteType, on router: R) -> Transition {
+ public static func trigger(_ route: RouteType, on router: any Router) -> Transition {
Transition(presentables: [], animationInUse: nil) { _, options, completion in
router.trigger(route, with: options, completion: completion)
}
@@ -242,35 +255,57 @@ extension Transition {
/// - transition: The transition to be performed.
/// - viewController: The viewController to perform the transition on.
///
- public static func perform(_ transition: TransitionType,
- on viewController: TransitionType.RootViewController) -> Transition {
+ public static func perform(_ transition: Transition,
+ on viewController: OtherRoot) -> Transition {
Transition(presentables: transition.presentables, animationInUse: transition.animation) { _, options, completion in
transition.perform(on: viewController, with: options, completion: completion)
}
}
+ ///
+ /// Performs a transition — described with the transition builder — on a different viewController
+ /// than the coordinator's rootViewController.
+ ///
+ /// - Parameters:
+ /// - viewController: The viewController to perform the transition on.
+ /// - transition: A transition-builder closure describing the transition to perform.
+ ///
+ public static func perform(
+ on viewController: OtherRoot,
+ @TransitionBuilder