├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Router │ ├── Misc │ ├── VoidObservableObject.swift │ └── unimplemented.swift │ ├── Presenter │ ├── AnyPresenter.swift │ ├── DestinationPresenter.swift │ ├── Environment+Presenter.swift │ ├── Presenter.swift │ └── SheetPresenter.swift │ ├── Route │ ├── AnyEnvironmentDependentRoute.swift │ ├── AnyRoute.swift │ ├── Route.swift │ ├── RouteViewIdentifier.swift │ ├── Routes.swift │ └── SimpleRoute.swift │ ├── Router │ ├── Environment+router.swift │ ├── Router.swift │ ├── RouterView.swift │ ├── UINavigationControllerRouter.swift │ └── UINavigationControllerRouterView.swift │ └── Views │ └── RouterLink.swift └── Tests └── RouterTests ├── RouterTests.swift └── XCTestManifests.swift /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push 5 | 6 | jobs: 7 | build: 8 | runs-on: macOS-latest 9 | 10 | strategy: 11 | matrix: 12 | destination: 13 | - generic/platform=iOS 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Build 18 | run: | 19 | xcodebuild build \ 20 | -scheme Router \ 21 | -destination "${{ matrix.destination }}" \ 22 | | xcpretty && exit ${PIPESTATUS[0]} 23 | - name: List Xcode & Swift versions 24 | if: ${{ failure() }} 25 | run: | 26 | swift --version 27 | xcodebuild -version 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 OWOW Projects B.V. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Router", 8 | platforms: [ 9 | .iOS(.v11) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, and make them visible to other packages. 13 | .library( 14 | name: "Router", 15 | targets: ["Router"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "Router", 26 | dependencies: []), 27 | .testTarget( 28 | name: "RouterTests", 29 | dependencies: ["Router"]), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 SwiftUI Router 2 | 3 | A SwiftUI package that provides routing functionality. Great for building (MVVM) SwiftUI apps where navigation is decoupled from the UI and view models. 4 | 5 | ## 🚲 Usage 6 | 7 | ### 🔀 Basic routing 8 | 9 | `Route` is a protocol. The package provides one basic implementation, `SimpleRoute`, which works for simple use cases. 10 | 11 | Define your routes by extending the `Routes` type. The most basic route looks like this: 12 | 13 | ```swift 14 | extension Routes { 15 | static let hello = SimpleRoute { Text("Hello! 👋") } 16 | } 17 | ``` 18 | 19 | To navigate to this route, you can use a `RouterLink` in any SwiftUI view contained in a `Router`: 20 | 21 | ```swift 22 | RouterLink(to: Routes.hello) { 23 | Text("Some link") 24 | } 25 | ``` 26 | 27 | ### 🔭 Routes and state 28 | 29 | A common SwiftUI pattern is to bind an `ObservableObject` to your view to serve as the view model. For example, you could have a view model like this: 30 | 31 | ```swift 32 | class EditNameViewModel: ObservableObject { 33 | @Published var name: String 34 | 35 | init(name: String = "") { 36 | self.name = name 37 | } 38 | } 39 | ``` 40 | 41 | And a view like this: 42 | 43 | ```swift 44 | struct EditNameView: View { 45 | @ObservedObject var viewModel: EditNameViewModel 46 | 47 | var body: some View { 48 | TextField("Name", text: $viewModel.name) 49 | } 50 | } 51 | ``` 52 | 53 | SwiftUI Router provides a mechanism to initialise your view state. Using `SimpleRoute`, it looks like this: 54 | 55 | ```swift 56 | extension Routes { 57 | static let editName = SimpleRoute(prepareState: { EditNameViewModel() }) { viewModel in 58 | EditNameView(viewModel: viewModel) 59 | } 60 | } 61 | ``` 62 | 63 | The `prepareState` closure runs once when navigating to the route. Afterwards, the return value is used to render the view. 64 | 65 | ### Parameterized and custom routes 66 | 67 | Some routes might need parameters to show correctly. To accept parameters, you can implement a custom route type. Let's expand on the name editing example above – say you want to pass a default name as route argument. 68 | 69 | A custom route implementation might look like this: 70 | 71 | ```swift 72 | struct EditNameRoute: IndependentRoute { 73 | var name: String 74 | 75 | func prepareState() -> EditNameViewModel { 76 | EditNameViewModel(name: emailAddress) 77 | } 78 | 79 | func body(state: EditNameViewModel) -> some View { 80 | EditNameView(viewModel: state) 81 | } 82 | } 83 | ``` 84 | 85 | Afterwards, you can add it as extension to your `Routes`: 86 | 87 | ```swift 88 | extension Routes { 89 | static func editName(name: String) -> EditNameRoute { 90 | EditNameRoute(name: name) 91 | } 92 | } 93 | ``` 94 | 95 | *Note that `IndependentRoute` is a specialized version of `Route` that doesn't depend on an environment object to prepare it's state.* 96 | 97 | ### Routers 98 | 99 | Before you are able to use your defined routes, you need to initialise a router. Because `Router` is a protocol, multiple implementations (including your own) are possible. SwiftUI Router provides the `UINavigationControllerRouter` implementation. 100 | 101 | ## Principles 102 | 103 | - A route defines how to perform set-up of the state (if needed) for a given view, and how the view is a product of that state 104 | - Navigation should be possible both programmatically (e.g. `router.navigate(...)`) and user-initiated (e.g. `RouterLink(to: ...)`) 105 | - The presentation of a route is decoupled 106 | -------------------------------------------------------------------------------- /Sources/Router/Misc/VoidObservableObject.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | /// An empty observable object, used to express that a given `Route` has no dependency on the environment. 4 | @available(iOS 13, macOS 10.15, *) 5 | public final class VoidObservableObject: ObservableObject { 6 | public init() {} 7 | } 8 | -------------------------------------------------------------------------------- /Sources/Router/Misc/unimplemented.swift: -------------------------------------------------------------------------------- 1 | @available(*, deprecated, message: "TODO") 2 | func unimplemented(function: StaticString = #function) -> Never { 3 | fatalError("\(function) is pending implementation") 4 | } 5 | -------------------------------------------------------------------------------- /Sources/Router/Presenter/AnyPresenter.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @available(iOS 13, macOS 10.15, *) 4 | public struct AnyPresenter: Presenter { 5 | private var _presentationMode: () -> RoutePresentationMode 6 | private var _body: (PresentationContext) -> AnyView 7 | 8 | public init(_ presenter: P) { 9 | self._presentationMode = { presenter.presentationMode } 10 | self._body = { AnyView(presenter.body(with: $0)) } 11 | } 12 | 13 | public func body(with context: PresentationContext) -> AnyView { 14 | _body(context) 15 | } 16 | 17 | public var presentationMode: RoutePresentationMode { _presentationMode() } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /Sources/Router/Presenter/DestinationPresenter.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A presenter that presents a destination only. 4 | @available(iOS 13, macOS 10.15, *) 5 | public struct DestinationPresenter: Presenter { 6 | public var presentationMode: RoutePresentationMode 7 | 8 | public init(presentationMode: RoutePresentationMode = .normal) { 9 | self.presentationMode = presentationMode 10 | } 11 | 12 | public func body(with context: PresentationContext) -> some View { 13 | context.destination 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Router/Presenter/Environment+Presenter.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @available(iOS 13, macOS 10.15, *) 4 | fileprivate struct PresenterEnvironmentKey: EnvironmentKey { 5 | typealias Value = AnyPresenter 6 | 7 | static let defaultValue = AnyPresenter(DestinationPresenter()) 8 | } 9 | 10 | @available(iOS 13, macOS 10.15, *) 11 | extension EnvironmentValues { 12 | var presenter: AnyPresenter { 13 | get { 14 | self[PresenterEnvironmentKey.self] 15 | } 16 | set { 17 | self[PresenterEnvironmentKey.self] = newValue 18 | } 19 | } 20 | } 21 | 22 | @available(iOS 13, macOS 10.15, *) 23 | extension View { 24 | public func routePresenter(_ presenter: P) -> some View { 25 | environment(\.presenter, AnyPresenter(presenter)) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Router/Presenter/Presenter.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @available(iOS 13, macOS 10.15, *) 4 | public protocol Presenter { 5 | associatedtype Body: View 6 | var presentationMode: RoutePresentationMode { get } 7 | 8 | func body(with context: PresentationContext) -> Body 9 | } 10 | 11 | public enum RoutePresentationMode { 12 | case normal 13 | case replaceParent 14 | case sibling 15 | } 16 | 17 | @available(iOS 13, macOS 10.15, *) 18 | public struct PresentationContext { 19 | private var _parent: AnyView 20 | private var _destination: AnyView 21 | private var _makeDestinationRouter: (PresentationContext) -> AnyView 22 | 23 | public typealias RouterViewFactory = (PresentationContext) -> AnyView 24 | 25 | public init(parent: Parent, destination: Destination, isPresented: Binding, makeRouter: @escaping RouterViewFactory) { 26 | self._parent = AnyView(parent) 27 | self._destination = AnyView(destination) 28 | self._isPresented = isPresented 29 | _makeDestinationRouter = { `self` in 30 | return makeRouter(self) 31 | } 32 | } 33 | 34 | init(parent: AnyView, destination: AnyView, isPresented: Binding, makeRouter: @escaping RouterViewFactory) { 35 | self._parent = parent 36 | self._destination = destination 37 | self._isPresented = isPresented 38 | _makeDestinationRouter = { `self` in 39 | return makeRouter(self) 40 | } 41 | } 42 | 43 | public var parent: some View { _parent } 44 | public var destination: some View { _destination } 45 | @Binding public var isPresented: Bool 46 | 47 | public func makeDestinationRouter() -> some View { 48 | _makeDestinationRouter(self) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Router/Presenter/SheetPresenter.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A presenter that presents content as a sheet. 4 | @available(iOS 13, macOS 10.15, *) 5 | public struct SheetPresenter: Presenter { 6 | public let presentationMode: RoutePresentationMode = .sibling 7 | 8 | let providesRouter: Bool 9 | 10 | public init(providesRouter: Bool = true) { 11 | self.providesRouter = providesRouter 12 | } 13 | 14 | @ViewBuilder 15 | public func body(with context: PresentationContext) -> some View { 16 | context.parent 17 | .sheet(isPresented: context.$isPresented) { 18 | if providesRouter { 19 | context.makeDestinationRouter() 20 | } else { 21 | context.destination 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Router/Route/AnyEnvironmentDependentRoute.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | 4 | /// A type-erased environment dependent route. 5 | @available(iOS 13, macOS 10.15, *) 6 | public struct AnyEnvironmentDependentRoute: EnvironmentDependentRoute { 7 | private var _prepareState: (EnvironmentObjectDependency) -> Any 8 | private var _body: (State) -> AnyView 9 | 10 | public typealias State = Any 11 | 12 | /// Create an instance that type-erases `route`. 13 | public init(_ route: R) where R: EnvironmentDependentRoute, R.EnvironmentObjectDependency == EnvironmentObjectDependency { 14 | self._prepareState = { route.prepareState(environmentObject: $0) } 15 | self._body = { 16 | guard let state = $0 as? R.State else { 17 | fatalError("internal inconsistency: AnyRoute body called with mismatching state argument") 18 | } 19 | 20 | return AnyView(route.body(state: state)) 21 | } 22 | } 23 | 24 | /// Create an instance that type-erases `route`. 25 | /// 26 | /// This initializer variant supports type-erasing a route that isn't dependent on the environment to one that is. 27 | public init(_ route: R) where R: Route { 28 | self._prepareState = { _ in route.prepareState(environmentObject: VoidObservableObject()) } 29 | self._body = Self.makeBody(route: route) 30 | } 31 | 32 | private static func makeBody(route: R) -> ((State) -> AnyView) where R: Route { 33 | return { 34 | guard let state = $0 as? R.State else { 35 | fatalError("internal inconsistency: AnyRoute body called with mismatching state argument") 36 | } 37 | 38 | return AnyView(route.body(state: state)) 39 | } 40 | } 41 | 42 | public func prepareState(environmentObject: EnvironmentObjectDependency) -> State { 43 | _prepareState(environmentObject) 44 | } 45 | 46 | public func body(state: State) -> some View { 47 | _body(state) 48 | } 49 | } 50 | 51 | @available(iOS 13, macOS 10.15, *) 52 | extension AnyEnvironmentDependentRoute where EnvironmentObjectDependency == VoidObservableObject { 53 | /// Create an instance that type-erases `route`. 54 | /// 55 | /// This initializer variant supports type-erasing a route that isn't dependent on the environment to one that is. 56 | public init(_ route: R) where R: Route { 57 | self._prepareState = { _ in route.prepareState(environmentObject: VoidObservableObject()) } 58 | self._body = Self.makeBody(route: route) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/Router/Route/AnyRoute.swift: -------------------------------------------------------------------------------- 1 | /// A type-erased route. 2 | @available(iOS 13, macOS 10.15, *) 3 | public typealias AnyRoute = AnyEnvironmentDependentRoute 4 | 5 | @available(iOS 13, macOS 10.15, *) 6 | extension AnyRoute: Route {} 7 | -------------------------------------------------------------------------------- /Sources/Router/Route/Route.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A route represents a navigatable destination. It contains everything a `Router` needs to present a destination on the screen. 4 | @available(iOS 13, macOS 10.15, *) 5 | public protocol EnvironmentDependentRoute { 6 | /// The `State` type of the route. The body (view) is defined as a product of the state. 7 | associatedtype State 8 | 9 | /// The body type of the Route – a SwiftUI view. 10 | associatedtype Body: View 11 | 12 | /// A route may depend on an EnvironmentObject in the environment. If a route doesn't depend on an environment, it can implement the `IndependentRoute` protocol instead of the `Route` protocol. 13 | associatedtype EnvironmentObjectDependency: ObservableObject 14 | 15 | /// Runs once when navigating to a route. 16 | func prepareState(environmentObject: EnvironmentObjectDependency) -> State 17 | 18 | /// The body of the Route, defined as a product of the `State`. 19 | func body(state: State) -> Body 20 | } 21 | 22 | @available(iOS 13, macOS 10.15, *) 23 | public extension EnvironmentDependentRoute where State == Void { 24 | func prepareState(environmentObject: EnvironmentObjectDependency) -> State { 25 | () 26 | } 27 | } 28 | 29 | @available(iOS 13, macOS 10.15, *) 30 | public protocol Route: EnvironmentDependentRoute where Self.EnvironmentObjectDependency == VoidObservableObject {} 31 | -------------------------------------------------------------------------------- /Sources/Router/Route/RouteViewIdentifier.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// When a route is presented, the presenting router is responsible for assigning it a `RouteViewIdentifier`. 4 | /// You need a route view identifier to refer to routed views after their initial presentation. 5 | /// 6 | /// You can read the route view identifier of the current route using the environment modifier. 7 | /// 8 | /// `@Environment(\.routeViewId) var routeViewIdentifier` 9 | public struct RouteViewIdentifier: Hashable { 10 | public static let none = RouteViewIdentifier(id: 0) 11 | private static var id = 1 12 | let id: Int 13 | 14 | private init(id: Int) { 15 | self.id = id 16 | } 17 | 18 | /// Generates a new route view identifier. 19 | public init() { 20 | self.id = Self.id 21 | Self.id += 1 22 | } 23 | } 24 | 25 | // MARK: - Environment 26 | 27 | @available(iOS 13, macOS 10.15, *) 28 | fileprivate struct RouteViewIdentifierKey: EnvironmentKey { 29 | typealias Value = RouteViewIdentifier 30 | 31 | static var defaultValue: RouteViewIdentifier = .none 32 | } 33 | 34 | @available(iOS 13, macOS 10.15, *) 35 | public extension EnvironmentValues { 36 | var routeViewId: RouteViewIdentifier { 37 | get { 38 | self[RouteViewIdentifierKey.self] 39 | } 40 | set { 41 | self[RouteViewIdentifierKey.self] = newValue 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Router/Route/Routes.swift: -------------------------------------------------------------------------------- 1 | /// Routes can be declared in extensions to this type. 2 | /// 3 | /// For example: 4 | /// 5 | /// ``` 6 | /// extension Routes { 7 | /// static let hello = SimpleRoute { Text("Hello! 👋") } 8 | /// } 9 | /// ``` 10 | public struct Routes {} 11 | -------------------------------------------------------------------------------- /Sources/Router/Route/SimpleRoute.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @available(iOS 13, macOS 10.15, *) 4 | public struct SimpleRoute: EnvironmentDependentRoute where Body: View, EnvironmentObjectDependency: ObservableObject { 5 | @usableFromInline 6 | var _prepareState: (EnvironmentObjectDependency) -> State 7 | 8 | @usableFromInline 9 | var _body: (State) -> Body 10 | 11 | @inlinable 12 | public init(dependency: EnvironmentObjectDependency.Type = EnvironmentObjectDependency.self, prepareState: @escaping (EnvironmentObjectDependency) -> State, @ViewBuilder body: @escaping (State) -> Body) { 13 | _prepareState = prepareState 14 | _body = body 15 | } 16 | 17 | @inlinable 18 | public init(prepareState: @escaping () -> State, @ViewBuilder body: @escaping (State) -> Body) where EnvironmentObjectDependency == VoidObservableObject { 19 | _prepareState = { _ in prepareState() } 20 | _body = body 21 | } 22 | 23 | @inlinable 24 | public init(@ViewBuilder body: @escaping () -> Body) where State == Void, EnvironmentObjectDependency == VoidObservableObject { 25 | _prepareState = { _ in () } 26 | _body = { _ in body() } 27 | } 28 | 29 | @inlinable 30 | public func prepareState(environmentObject: EnvironmentObjectDependency) -> State { 31 | _prepareState(environmentObject) 32 | } 33 | 34 | @inlinable 35 | public func body(state: State) -> Body { 36 | _body(state) 37 | } 38 | } 39 | 40 | @available(iOS 13, macOS 10.15, *) 41 | extension SimpleRoute: Route where EnvironmentObjectDependency == VoidObservableObject {} 42 | -------------------------------------------------------------------------------- /Sources/Router/Router/Environment+router.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @available(iOS 13, macOS 10.15, *) 4 | fileprivate struct RouterKey: EnvironmentKey { 5 | typealias Value = Router? 6 | 7 | static var defaultValue: Router? = nil 8 | } 9 | 10 | @available(iOS 13, macOS 10.15, *) 11 | public extension EnvironmentValues { 12 | var router: Router? { 13 | get { 14 | self[RouterKey.self] 15 | } 16 | set { 17 | self[RouterKey.self] = newValue 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Router/Router/Router.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A router is responsible for presenting routes. 4 | /// 5 | /// As part of this responsibility, it also has the following responsibilities: 6 | /// - For all presented views, the router generates a `RouteViewIdentifier` 7 | /// - The router prepares the environment for the presented views 8 | /// 9 | /// As part of the environment preparation, the router should provide the following data to all presented routes: 10 | /// - The router itself 11 | /// - The `VoidObservableObject` instance 12 | /// - The environment object dependency of the presented route 13 | /// - The `RouteViewIdentifier` of the presented route 14 | @available(iOS 13, macOS 10.15, *) 15 | public protocol Router { 16 | 17 | // MARK: - Navigation 18 | 19 | /// Programatically navigate to the specified route. 20 | /// 21 | /// - Note: The preferred way to initiate navigation is by using `RouterLink` instead of this method. 22 | /// 23 | /// - Parameters: 24 | /// - target: The route to navigate to. 25 | /// - environmentObject: If the state preparation of the route depends on an environment object, the environment object. 26 | /// - presenter: The presenter to use when navigating. 27 | /// - source: The identifier of the view that initiated the navigation. 28 | @discardableResult 29 | func navigate( 30 | to target: Target, 31 | _ environmentObject: Target.EnvironmentObjectDependency, 32 | using presenter: ThePresenter, 33 | source: RouteViewIdentifier? 34 | ) -> RouteViewIdentifier where Target: EnvironmentDependentRoute, ThePresenter: Presenter 35 | 36 | /// Replaces the root view of the router with the given `target` route, replacing all current views. 37 | @discardableResult 38 | func replaceRoot( 39 | with target: Target, 40 | _ environmentObject: Target.EnvironmentObjectDependency, 41 | using presenter: ThePresenter 42 | ) -> RouteViewIdentifier where Target: EnvironmentDependentRoute, ThePresenter: Presenter 43 | 44 | // MARK: - Dismissal 45 | 46 | /// Dismiss up to, but not including, the route matching `id`. 47 | /// 48 | /// The actual dismissal behavior can differ between router implementations. 49 | func dismissUpTo(routeMatchesId id: RouteViewIdentifier) 50 | 51 | /// Dismiss all routes up to, and including, the route matching `id`. 52 | /// 53 | /// The actual dismissal behavior can differ between router implemetations. 54 | func dismissUpToIncluding(routeMatchingId id: RouteViewIdentifier) 55 | 56 | /// Dismiss all up to, but not including, the root route 57 | func dismissToRoot() 58 | 59 | // MARK: - Querying the router 60 | 61 | /// Returns `true` if the router is currently presenting a route matching `id`. 62 | func isPresenting(routeMatchingId id: RouteViewIdentifier) -> Bool 63 | 64 | } 65 | 66 | @available(iOS 13, macOS 10.15, *) 67 | public extension Router { 68 | 69 | // MARK: - Convenience navigation methods 70 | 71 | /// A variation on `navigate(to:_:using:source:)` without a source. 72 | @discardableResult 73 | func navigate( 74 | to target: Target, 75 | _ environmentObject: Target.EnvironmentObjectDependency, 76 | using presenter: ThePresenter 77 | ) -> RouteViewIdentifier where Target: EnvironmentDependentRoute, ThePresenter: Presenter { 78 | navigate(to: target, environmentObject, using: presenter, source: nil) 79 | } 80 | 81 | /// A variation on `navigate(to:_:using:source:)` that uses `DestinationPresenter`. 82 | @discardableResult 83 | func navigate( 84 | to target: Target, 85 | _ environmentObject: Target.EnvironmentObjectDependency, 86 | source: RouteViewIdentifier? = nil 87 | ) -> RouteViewIdentifier where Target: EnvironmentDependentRoute { 88 | navigate(to: target, environmentObject, using: DestinationPresenter(), source: source) 89 | } 90 | 91 | /// A variation on `navigate(to:_:using:source:)` without an EnvironmentObject dependency. 92 | @discardableResult 93 | func navigate( 94 | to target: Target, 95 | using presenter: ThePresenter, 96 | source: RouteViewIdentifier? = nil 97 | ) -> RouteViewIdentifier where Target: Route, ThePresenter: Presenter { 98 | navigate(to: target, VoidObservableObject(), using: presenter, source: source) 99 | } 100 | 101 | /// A variation on `navigate(to:_:using:source:)` without an EnvironmentObject dependency that uses `DestinationPresenter`. 102 | @discardableResult 103 | func navigate( 104 | to target: Target, 105 | source: RouteViewIdentifier? = nil 106 | ) -> RouteViewIdentifier where Target: Route { 107 | navigate(to: target, VoidObservableObject(), source: source) 108 | } 109 | 110 | // MARK: - Convenience root replacement functions 111 | 112 | /// A variation of `replaceRoot(with:_:using:)` that uses `DestinationPresenter`. 113 | @discardableResult 114 | func replaceRoot( 115 | with target: Target, 116 | _ environmentObject: Target.EnvironmentObjectDependency 117 | ) -> RouteViewIdentifier where Target: EnvironmentDependentRoute { 118 | replaceRoot(with: target, environmentObject, using: DestinationPresenter()) 119 | } 120 | 121 | /// A variation of `replaceRoot(with:_:using:)` without an EnvironmentObject dependency. 122 | @discardableResult 123 | func replaceRoot( 124 | with target: Target, 125 | using presenter: ThePresenter 126 | ) -> RouteViewIdentifier where Target: Route, ThePresenter: Presenter { 127 | replaceRoot(with: target, VoidObservableObject(), using: presenter) 128 | } 129 | 130 | /// A variation of `replaceRoot(witH:_:using:)` without an EnvironmentObject dependency that uses `DestinationPresenter`. 131 | @discardableResult 132 | func replaceRoot( 133 | with target: Target 134 | ) -> RouteViewIdentifier where Target: Route { 135 | replaceRoot(with: target, VoidObservableObject(), using: DestinationPresenter()) 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /Sources/Router/Router/RouterView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @available(iOS 13, *) 4 | public struct RouterView: View { 5 | @State var router: UINavigationControllerRouter 6 | 7 | public init(root: RootRoute) { 8 | self._router = State(wrappedValue: UINavigationControllerRouter(root: root)) 9 | } 10 | 11 | public init(root: RootRoute, dependency: RootRoute.EnvironmentObjectDependency) { 12 | self._router = State(wrappedValue: UINavigationControllerRouter(root: root, dependency)) 13 | } 14 | 15 | public var body: some View { 16 | UINavigationControllerRouterView(router: router) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Router/Router/UINavigationControllerRouter.swift: -------------------------------------------------------------------------------- 1 | #if canImport(UIKit) 2 | import UIKit 3 | import SwiftUI 4 | import Combine 5 | 6 | @available(iOS 13, macOS 10.15, *) 7 | final class RouteHost: Hashable { 8 | 9 | // MARK: State 10 | 11 | private let root: AnyView 12 | weak var hostingController: UIHostingController? 13 | 14 | func root(sibling: Sibling) -> some View { 15 | self.root 16 | .overlay(AnyView(sibling)) 17 | } 18 | 19 | // MARK: Init 20 | 21 | init(hostingController: UIHostingController) { 22 | self.root = hostingController.rootView 23 | self.hostingController = hostingController 24 | 25 | // Doing this ensures that the root view is of a stable type and structure, preventing 26 | // SwiftUI from resetting everything when presenting a sibling view. 27 | hostingController.rootView = AnyView(self.root(sibling: EmptyView())) 28 | } 29 | 30 | // MARK: Equatable / hashable 31 | 32 | static func == (lhs: RouteHost, rhs: RouteHost) -> Bool { 33 | lhs === rhs 34 | } 35 | 36 | func hash(into hasher: inout Hasher) { 37 | ObjectIdentifier(self).hash(into: &hasher) 38 | } 39 | } 40 | 41 | @available(iOS 13, macOS 10.15, *) 42 | extension Dictionary where Value == RouteHost { 43 | mutating func garbageCollect() { 44 | self = self.filter { $0.value.hostingController != nil } 45 | } 46 | } 47 | 48 | /// A `Router` implementation that pushes routed views onto a `UINavigationController`. 49 | @available(iOS 13, *) 50 | open class UINavigationControllerRouter: Router { 51 | public let navigationController: UINavigationController 52 | let parentRouter: (Router, PresentationContext)? 53 | 54 | private var routeHosts: [RouteViewIdentifier: RouteHost] = [:] 55 | 56 | /// A reference to the presenter view models of presented child routes. Used for dismissal support. 57 | private var presenterViewModels: [RouteViewIdentifier: PresenterViewModel] = [:] 58 | 59 | /// 🗑 Combine cancellables. 60 | private var cancellables = Set() 61 | 62 | /// 🌷 63 | /// - Parameter navigationController: The navigation controller to use for routing. 64 | public init(navigationController: UINavigationController = UINavigationController()) { 65 | self.navigationController = navigationController 66 | self.parentRouter = nil 67 | } 68 | 69 | public init(navigationController: UINavigationController = UINavigationController(), root: Root, _ environmentObject: Root.EnvironmentObjectDependency, parent: (Router, PresentationContext)? = nil) where Root: EnvironmentDependentRoute { 70 | self.navigationController = navigationController 71 | self.parentRouter = parent 72 | navigate(to: root, environmentObject, using: DestinationPresenter()) 73 | } 74 | 75 | public init(navigationController: UINavigationController = UINavigationController(), root: Root) where Root: Route { 76 | self.navigationController = navigationController 77 | self.parentRouter = nil 78 | navigate(to: root, .init(), using: DestinationPresenter()) 79 | } 80 | 81 | // MARK: Root view replacement 82 | 83 | open func replaceRoot( 84 | with target: Target, 85 | _ environmentObject: Target.EnvironmentObjectDependency, 86 | using presenter: ThePresenter 87 | ) -> RouteViewIdentifier where Target : EnvironmentDependentRoute, ThePresenter : Presenter { 88 | if navigationController.presentedViewController != nil { 89 | navigationController.dismiss(animated: true, completion: nil) 90 | } 91 | 92 | navigationController.viewControllers = [] 93 | routeHosts.removeAll() 94 | return navigate(to: target, environmentObject, using: presenter) 95 | } 96 | 97 | // MARK: Navigation 98 | 99 | private func topLevelRouteHost() -> RouteHost? { 100 | for controller in navigationController.viewControllers.reversed() { 101 | if let routeHost = routeHosts.values.first(where: { $0.hostingController === controller }) { 102 | return routeHost 103 | } 104 | } 105 | 106 | return nil 107 | } 108 | 109 | /// - note: Not an implementation of the protocol requirement. 110 | @discardableResult 111 | open func navigate( 112 | to target: Target, 113 | _ environmentObject: Target.EnvironmentObjectDependency, 114 | using presenter: ThePresenter, source: RouteViewIdentifier? 115 | ) -> RouteViewIdentifier where Target : EnvironmentDependentRoute, ThePresenter : Presenter { 116 | routeHosts.garbageCollect() 117 | 118 | func topLevelRouteHostOrNew() -> (RouteHost, UIHostingController) { 119 | if let topHost = topLevelRouteHost(), let viewController = topHost.hostingController { 120 | return (topHost, viewController) 121 | } else { 122 | debugPrint("⚠️ Presenting route host for replacing presenter \(presenter) as root view, because an eligible view for presentation was not found.") 123 | 124 | let id = RouteViewIdentifier() 125 | let viewController = makeViewController(for: target, environmentObject: environmentObject, using: presenter, routeViewId: id) 126 | let routeHost = registerHostingController(hostingController: viewController, byRouteViewId: id) 127 | return (routeHost, viewController) 128 | } 129 | } 130 | 131 | let targetRouteViewId = RouteViewIdentifier() 132 | 133 | switch presenter.presentationMode { 134 | case .normal: // Push 💨 135 | let viewController = makeViewController(for: target, environmentObject: environmentObject, using: presenter, routeViewId: targetRouteViewId) 136 | registerHostingController(hostingController: viewController, byRouteViewId: targetRouteViewId) 137 | 138 | if navigationController.viewControllers.isEmpty { 139 | // For some reason, this is needed to work reliably on iOS 13. 140 | // On iOS 14, just pusing onto the empty navigation controller works fine. 141 | navigationController.viewControllers = [viewController] 142 | } else { 143 | navigationController.pushViewController(viewController, animated: true) 144 | } 145 | case .replaceParent, .sibling: 146 | let host: RouteHost 147 | let hostingController: UIHostingController 148 | 149 | if let source = source { 150 | if let theHost = routeHosts[source], let viewController = theHost.hostingController { 151 | host = theHost 152 | hostingController = viewController 153 | } else { 154 | debugPrint("⚠️ Trying to present on top of nonexisting source") 155 | 156 | (host, hostingController) = topLevelRouteHostOrNew() 157 | } 158 | } else { 159 | (host, hostingController) = topLevelRouteHostOrNew() 160 | } 161 | 162 | let state = target.prepareState(environmentObject: environmentObject) 163 | let presenterViewModel = PresenterViewModel() 164 | self.presenterViewModels[targetRouteViewId] = presenterViewModel 165 | 166 | let isPresentedBinding: Binding = Binding( 167 | get: { 168 | presenterViewModel.isPresented 169 | }, 170 | set: { [weak self] newValue in 171 | presenterViewModel.isPresented = newValue 172 | 173 | if newValue == false { 174 | self?.presenterViewModels[targetRouteViewId] = nil 175 | 176 | // Remove the presenter from the host. 177 | DispatchQueue.main.async { 178 | // Wait until the next iteration of the run loop, for example for sheet modifiers to dismiss themselves before removing them. 179 | hostingController.rootView = AnyView(host.root(sibling: EmptyView())) 180 | } 181 | } else { 182 | self?.presenterViewModels[targetRouteViewId] = presenterViewModel 183 | } 184 | } 185 | ) 186 | 187 | let makeRouter: PresentationContext.RouterViewFactory = { [unowned self] presentationContext in 188 | self.makeChildRouterView( 189 | rootRoute: SimpleRoute( 190 | dependency: Target.EnvironmentObjectDependency.self, 191 | prepareState: { _ in state }, 192 | body: target.body 193 | ), 194 | environmentObject: environmentObject, 195 | presentationContext: presentationContext, 196 | presenterViewModel: presenterViewModel 197 | ) 198 | } 199 | 200 | let presentationContext: PresentationContext 201 | 202 | switch presenter.presentationMode { 203 | case .replaceParent: 204 | presentationContext = PresentationContext( 205 | parent: host.root(sibling: EmptyView()), 206 | destination: AnyView(adjustView(target.body(state: state), environmentObject: environmentObject, routeViewId: targetRouteViewId)), 207 | isPresented: isPresentedBinding, 208 | makeRouter: makeRouter 209 | ) 210 | 211 | hostingController.rootView = AnyView(presenter.body(with: presentationContext)) 212 | case .sibling: 213 | presentationContext = PresentationContext( 214 | parent: EmptyView(), 215 | destination: adjustView(target.body(state: state), environmentObject: environmentObject, routeViewId: targetRouteViewId), 216 | isPresented: isPresentedBinding, 217 | makeRouter: makeRouter 218 | ) 219 | 220 | hostingController.rootView = AnyView(host.root(sibling: presenter.body(with: presentationContext))) 221 | case .normal: 222 | fatalError("Internal inconsistency") 223 | } 224 | 225 | presenterViewModel.$isPresented 226 | .first { $0 == false } 227 | .sink { [weak hostingController] _ in 228 | hostingController?.rootView = AnyView(host.root(sibling: EmptyView())) } 229 | .store(in: &cancellables) 230 | } 231 | 232 | return targetRouteViewId 233 | } 234 | 235 | /// Dismiss all up to, but not including, the root route 236 | public func dismissToRoot() { 237 | navigationController.popToRootViewController(animated: true) 238 | } 239 | 240 | /// Dismisses up to, but not including, the given `id`, so the route with that identifier becomes the topmost route. 241 | /// - Parameter id: The `id` of the route to dismiss up to. 242 | public func dismissUpTo(routeMatchesId id: RouteViewIdentifier) { 243 | guard let hostingController = routeHosts[id]?.hostingController else { 244 | if let (parentRouter, presentationContext) = parentRouter { 245 | presentationContext.isPresented = false 246 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { 247 | parentRouter.dismissUpTo(routeMatchesId: id) 248 | } 249 | return 250 | } 251 | 252 | debugPrint("⚠️ Cannot dismiss route that's not in the hierarchy") 253 | return 254 | } 255 | navigationController.popToViewController(hostingController, animated: true) 256 | 257 | if hostingController.presentedViewController != nil { 258 | hostingController.dismiss(animated: true, completion: nil) 259 | } 260 | } 261 | 262 | public func dismissUpToIncluding(routeMatchingId id: RouteViewIdentifier) { 263 | guard let hostingController = routeHosts[id]?.hostingController else { 264 | if let presenterViewModel = presenterViewModels[id] { 265 | presenterViewModel.isPresented = false 266 | return 267 | } 268 | 269 | if let (parentRouter, presentationContext) = parentRouter { 270 | presentationContext.isPresented = false 271 | DispatchQueue.main.async { 272 | parentRouter.dismissUpTo(routeMatchesId: id) 273 | } 274 | return 275 | } 276 | 277 | debugPrint("⚠️ Cannot dismiss route that's not in the hierarchy") 278 | return 279 | } 280 | 281 | if let viewControllerIndex = navigationController.viewControllers.firstIndex(of: hostingController) { 282 | if viewControllerIndex == 0 { 283 | if let parentRouter = parentRouter { 284 | parentRouter.1.isPresented = false 285 | } else { 286 | debugPrint("⚠️ Dismissal of root route is not possible") 287 | navigationController.popToRootViewController(animated: true) 288 | } 289 | return 290 | } 291 | 292 | let viewControllerBefore = navigationController.viewControllers[viewControllerIndex - 1] 293 | 294 | navigationController.popToViewController(viewControllerBefore, animated: true) 295 | } else { 296 | debugPrint("Dismissal of route whose view controller is not presented by the navigation controller") 297 | } 298 | } 299 | 300 | // MARK: Customisation points 301 | 302 | /// Generate the view controller (usually a hosting controller) for the given destination. 303 | /// - Parameter destination: A destination to route to. 304 | /// - Returns: A view controller for showing `destination`. 305 | open func makeViewController( 306 | for target: Target, 307 | environmentObject: Target.EnvironmentObjectDependency, 308 | using presenter: ThePresenter, 309 | routeViewId: RouteViewIdentifier 310 | ) -> UIHostingController { 311 | let state = target.prepareState(environmentObject: environmentObject) 312 | let presenterViewModel = PresenterViewModel() 313 | 314 | let context = PresentationContext( 315 | parent: EmptyView(), 316 | destination: target.body(state: state), 317 | isPresented: isPresentedBinding(forRouteMatchingId: routeViewId, presenterViewModel: presenterViewModel) 318 | ) { [unowned self] presentationContext in 319 | self.makeChildRouterView( 320 | rootRoute: target, 321 | environmentObject: environmentObject, 322 | presentationContext: presentationContext, 323 | presenterViewModel: presenterViewModel 324 | ) 325 | } 326 | 327 | return makeHostingController( 328 | // TODO: Pass source view 329 | rootView: adjustView( 330 | presenter.body(with: context), 331 | environmentObject: environmentObject, 332 | routeViewId: routeViewId 333 | ) 334 | ) 335 | } 336 | 337 | func adjustView(_ view: Input, environmentObject: Dependency, routeViewId: RouteViewIdentifier) -> some View { 338 | view 339 | .environment(\.router, self) 340 | .environmentObject(VoidObservableObject()) 341 | .environmentObject(environmentObject) 342 | .environment(\.routeViewId, routeViewId) 343 | } 344 | 345 | /// Takes a `View` and creates a hosting controller for it. 346 | /// 347 | /// If you need to add any additional customisations (for example, modifiers) to all views that you navigate to, this is the method you probably want to override. 348 | open func makeHostingController(rootView: Root) -> UIHostingController { 349 | return UIHostingController(rootView: AnyView(rootView)) 350 | } 351 | 352 | @discardableResult 353 | func registerHostingController(hostingController: UIHostingController, byRouteViewId routeViewId: RouteViewIdentifier) -> RouteHost { 354 | assert(!routeHosts.values.contains { $0.hostingController === hostingController }) 355 | 356 | let routeHost = RouteHost(hostingController: hostingController) 357 | routeHosts[routeViewId] = routeHost 358 | 359 | return routeHost 360 | } 361 | 362 | func makeChildRouterView( 363 | rootRoute: RootRoute, 364 | environmentObject: RootRoute.EnvironmentObjectDependency, 365 | presentationContext: PresentationContext, 366 | presenterViewModel: PresenterViewModel 367 | ) -> AnyView { 368 | let router = makeChildRouter(rootRoute: rootRoute, environmentObject: environmentObject, presentationContext: presentationContext, presenterViewModel: presenterViewModel) 369 | return AnyView(PresenterView(wrappedView: UINavigationControllerRouterView(router: router), viewModel: presenterViewModel)) 370 | } 371 | 372 | open func makeChildRouter( 373 | rootRoute: RootRoute, 374 | environmentObject: RootRoute.EnvironmentObjectDependency, 375 | presentationContext: PresentationContext, 376 | presenterViewModel: PresenterViewModel 377 | ) -> UINavigationControllerRouter { 378 | return UINavigationControllerRouter( 379 | root: rootRoute, 380 | environmentObject, 381 | parent: (self, presentationContext) 382 | ) 383 | } 384 | 385 | public func isPresenting(routeMatchingId id: RouteViewIdentifier) -> Bool { 386 | guard let viewController = routeHosts[id]?.hostingController else { 387 | return false 388 | } 389 | 390 | return navigationController.viewControllers.contains(viewController) 391 | } 392 | 393 | private func isPresentedBinding(forRouteMatchingId id: RouteViewIdentifier, presenterViewModel: PresenterViewModel) -> Binding { 394 | Binding( 395 | get: { [weak self] in 396 | self?.isPresenting(routeMatchingId: id) ?? false 397 | }, 398 | set: { [weak self] newValue in 399 | if !newValue { 400 | self?.dismissUpToIncluding(routeMatchingId: id) 401 | } 402 | } 403 | ) 404 | } 405 | } 406 | 407 | @available(iOS 13, macOS 10.15, *) 408 | public final class PresenterViewModel: ObservableObject { 409 | @Published internal var isPresented = true 410 | 411 | internal init() {} 412 | } 413 | 414 | @available(iOS 13, macOS 10.15, *) 415 | fileprivate struct PresenterView: View { 416 | let wrappedView: WrappedView 417 | @ObservedObject var viewModel: PresenterViewModel 418 | 419 | var body: some View { 420 | // Make sure SwiftUI registers the EnvironmentObject dependency for observation 421 | wrappedView.id(viewModel.isPresented) 422 | } 423 | } 424 | #endif 425 | -------------------------------------------------------------------------------- /Sources/Router/Router/UINavigationControllerRouterView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @available(iOS 13, macOS 10.15, *) 4 | public struct UINavigationControllerRouterView: UIViewControllerRepresentable { 5 | public let router: UINavigationControllerRouter 6 | 7 | public init(router: UINavigationControllerRouter) { 8 | self.router = router 9 | } 10 | 11 | public func makeUIViewController(context: Context) -> UINavigationController { 12 | router.navigationController 13 | } 14 | 15 | public func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Router/Views/RouterLink.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// A view that controls routing to a given destination. 4 | @available(iOS 13, macOS 10.15, *) 5 | public struct RouterLink: View { 6 | @Environment(\.router) private var router 7 | @EnvironmentObject private var dependency: Target.EnvironmentObjectDependency 8 | @Environment(\.presenter) private var presenter 9 | @Environment(\.routeViewId) private var source 10 | 11 | @usableFromInline 12 | var target: Target 13 | 14 | @usableFromInline 15 | var label: Label 16 | 17 | var replacesRoot: Bool = false 18 | 19 | /// Creates an instance that navigates to `destination`. 20 | /// - Parameters: 21 | /// - destination: The navigation target route. 22 | /// - label: A label describing the link. 23 | @inlinable 24 | public init(to destination: Target, @ViewBuilder label: () -> Label) { 25 | self.target = destination 26 | self.label = label() 27 | } 28 | 29 | /// Configure a link to use the `replaceRoot` router method instead of `navigate` 30 | public func replaceRoot() -> Self { 31 | var copy = self 32 | copy.replacesRoot = true 33 | return copy 34 | } 35 | 36 | public var body: some View { 37 | Button(action: navigate) { label } 38 | } 39 | 40 | private func navigate() { 41 | guard let router = router else { 42 | preconditionFailure("RouterLink needs to be used in a router context") 43 | } 44 | 45 | if replacesRoot { 46 | router.replaceRoot(with: target, dependency, using: presenter) 47 | } else { 48 | router.navigate(to: target, dependency, using: presenter, source: source) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Tests/RouterTests/RouterTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Router 3 | 4 | @available(iOS 13, *) 5 | final class RouterTests: XCTestCase { 6 | func testExample() { 7 | // This is an example of a functional test case. 8 | // Use XCTAssert and related functions to verify your tests produce the correct 9 | // results. 10 | // XCTAssertEqual(Router().text, "Hello, World!") 11 | } 12 | 13 | static var allTests = [ 14 | ("testExample", testExample), 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /Tests/RouterTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(RouterTests.allTests), 7 | ] 8 | } 9 | #endif 10 | --------------------------------------------------------------------------------