From a5d214ee1d263f93e27d147af542e822f7925185 Mon Sep 17 00:00:00 2001 From: Joannis orlandos Date: Thu, 17 Dec 2020 14:28:07 +0100 Subject: [PATCH 01/11] macOS support --- Sources/Router/Router/MacRouter.swift | 314 ++++++++++++++++++ Sources/Router/Router/MacRouterView.swift | 17 + Sources/Router/Router/RouterView.swift | 8 +- .../Router/UINavigationControllerRouter.swift | 9 +- .../UINavigationControllerRouterView.swift | 4 +- Sources/Router/Views/RouterLink.swift | 2 +- 6 files changed, 344 insertions(+), 10 deletions(-) create mode 100644 Sources/Router/Router/MacRouter.swift create mode 100644 Sources/Router/Router/MacRouterView.swift diff --git a/Sources/Router/Router/MacRouter.swift b/Sources/Router/Router/MacRouter.swift new file mode 100644 index 0000000..b99e517 --- /dev/null +++ b/Sources/Router/Router/MacRouter.swift @@ -0,0 +1,314 @@ +#if canImport(AppKit) +import Combine +import SwiftUI +import Router + +@available(macOS 10.15, *) +fileprivate final class RouteHost: Hashable { + + // MARK: State + + let root: AnyView + + // MARK: Init + + init(root: AnyView) { + self.root = root + } + + // MARK: Equatable / hashable + + static func == (lhs: RouteHost, rhs: RouteHost) -> Bool { + lhs === rhs + } + + func hash(into hasher: inout Hasher) { + ObjectIdentifier(self).hash(into: &hasher) + } +} + +@available(iOS 13, macOS 10.15, *) +public final class PresenterViewModel: ObservableObject { + @Published internal var isPresented = true + + internal init() {} +} + +@available(iOS 13, macOS 10.15, *) +fileprivate struct PresenterView: View { + let wrappedView: WrappedView + @ObservedObject var viewModel: PresenterViewModel + + var body: some View { + // Make sure SwiftUI registers the EnvironmentObject dependency for observation + wrappedView.id(viewModel.isPresented) + } +} + +open class MacRouter: Router { + let hostingController: NSHostingController + let parentRouter: (Router, PresentationContext)? + + /// key: `ObjectIdentifier` of the `HostingController` + fileprivate var routeHosts: [RouteViewIdentifier: RouteHost] = [:] + fileprivate var stack = [RouteHost]() + + /// 🗑 Combine cancellables. + private var cancellables = Set() + + public init( + root: Root, + _ environmentObject: Root.EnvironmentObjectDependency, + parent: (Router, PresentationContext)? = nil + ) where Root: Route { + self.hostingController = NSHostingController(rootView: AnyView(EmptyView())) + self.parentRouter = parent + replaceRoot(with: root, environmentObject) + } + + public init( + root: Root + ) where Root: Route { + self.hostingController = NSHostingController(rootView: AnyView(EmptyView())) + self.parentRouter = nil + replaceRoot(with: root) + } + + // MARK: Root view replacement + + open func replaceRoot( + with target: Target, + _ environmentObject: Target.EnvironmentObjectDependency + ) { + navigate(to: target, environmentObject, using: DestinationPresenter()) + } + + open func replaceRoot( + with target: Target + ) { + self.replaceRoot(with: target, VoidObservableObject()) + } + + // MARK: Navigation + + fileprivate func topLevelRouteHost() -> RouteHost? { + stack.last + } + + /// - note: Not an implementation of the protocol requirement. + @discardableResult + open func navigate( + to target: Target, + _ environmentObject: Target.EnvironmentObjectDependency, + using presenter: ThePresenter, + source: RouteViewIdentifier? + ) -> RouteViewIdentifier where Target : EnvironmentDependentRoute, ThePresenter : Presenter { + func topLevelRouteHostOrNew() -> RouteHost { + if let topHost = topLevelRouteHost() { + return topHost + } else { + debugPrint("⚠️ Presenting route host for replacing presenter \(presenter) as root view, because an eligible view for presentation was not found.") + + let id = RouteViewIdentifier() + let view = makeView(for: target, environmentObject: environmentObject, using: presenter, routeViewId: id) + let routeHost = registerRouteHost(view: view, byRouteViewId: id) + return routeHost + } + } + + let targetRouteViewId = RouteViewIdentifier() + + if !presenter.replacesParent { // Push 💨 + let view = makeView(for: target, environmentObject: environmentObject, using: presenter, routeViewId: targetRouteViewId) + registerRouteHost(view: view, byRouteViewId: targetRouteViewId) + hostingController.rootView = view + } else { + let host: RouteHost + + if let source = source { + if let theHost = routeHosts[source] { + host = theHost + } else { + debugPrint("⚠️ Trying to present on top of nonexisting source") + + host = topLevelRouteHostOrNew() + } + } else { + host = topLevelRouteHostOrNew() + } + + let state = target.prepareState(environmentObject: environmentObject) + let presenterViewModel = PresenterViewModel() + + let presentationContext = PresentationContext( + parent: host.root, + destination: AnyView(adjustView(target.body(state: state), environmentObject: environmentObject, routeViewId: targetRouteViewId)), + isPresented: Binding( + get: { + presenterViewModel.isPresented + }, + set: { newValue in + presenterViewModel.isPresented = newValue + } + ) + ) { [unowned self] rootRoute, presentationContext in + self.makeChildRouterView(rootRoute: rootRoute, presentationContext: presentationContext, presenterViewModel: presenterViewModel) + } + + presenterViewModel.$isPresented + .first { $0 == false } + .sink { [weak hostingController] _ in + hostingController?.rootView = AnyView(presenter.body(with: presentationContext)) } + .store(in: &cancellables) + + let view = AnyView(presenter.body(with: presentationContext)) + registerRouteHost(view: view, byRouteViewId: targetRouteViewId) + hostingController.rootView = view + } + + return targetRouteViewId + } + + public func dismissUpTo(routeMatchesId id: RouteViewIdentifier) { + while !routeHosts.isEmpty, let lastRouteHost = stack.last { + guard + let route = routeHosts.first(where: { $0.value == lastRouteHost }) + else { + if let (parentRouter, presentationContext) = parentRouter { + #warning("When dismissing `self` as a child of `parentRouter`, make sure the current presenter is removed from the hierarchy by replacing the hierarchy with the routeHost.rootView") + presentationContext.isPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + parentRouter.dismissUpTo(routeMatchesId: id) + } + return + } + + debugPrint("⚠️ Cannot dismiss route that's not in the hierarchy") + return + } + + if id == route.key { + // Found the route we're looking for, but this is up to not including + if let newRoot = stack.last?.root { + hostingController.rootView = newRoot + } + return + } + + routeHosts[route.key] = nil + stack.removeLast() + } + } + + public func dismissUpToIncluding(routeMatchingId id: RouteViewIdentifier) { + while !routeHosts.isEmpty, let lastRouteHost = stack.last { + guard + let route = routeHosts.first(where: { $0.value == lastRouteHost }) + else { + if let (parentRouter, presentationContext) = parentRouter { + #warning("When dismissing `self` as a child of `parentRouter`, make sure the current presenter is removed from the hierarchy by replacing the hierarchy with the routeHost.rootView") + presentationContext.isPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + parentRouter.dismissUpTo(routeMatchesId: id) + } + return + } + + debugPrint("⚠️ Cannot dismiss route that's not in the hierarchy") + return + } + + routeHosts[route.key] = nil + stack.removeLast() + + if id == route.key { + // Found the route we're looking for, and this is up to AND including + if let newRoot = stack.last?.root { + hostingController.rootView = newRoot + } + return + } + } + } + + // MARK: Customisation points + + /// Generate the view controller (usually a hosting controller) for the given destination. + /// - Parameter destination: A destination to route to. + /// - Returns: A view controller for showing `destination`. + open func makeView(for target: Target, environmentObject: Target.EnvironmentObjectDependency, using presenter: ThePresenter, routeViewId: RouteViewIdentifier) -> AnyView { + let state = target.prepareState(environmentObject: environmentObject) + let presenterViewModel = PresenterViewModel() + + let context = PresentationContext( + parent: EmptyView(), + destination: target.body(state: state), + isPresented: isPresentedBinding(forRouteMatchingId: routeViewId, presenterViewModel: presenterViewModel) + ) { [unowned self] rootRoute, presentationContext in + self.makeChildRouterView( + rootRoute: rootRoute, + presentationContext: presentationContext, + presenterViewModel: presenterViewModel + ) + } + + return AnyView(adjustView( + presenter.body(with: context), + environmentObject: environmentObject, + routeViewId: routeViewId + )) + } + + func adjustView(_ view: Input, environmentObject: Dependency, routeViewId: RouteViewIdentifier) -> some View { + view + .environment(\.router, self) + .environmentObject(VoidObservableObject()) + .environmentObject(environmentObject) + .environment(\.routeViewId, routeViewId) + .id(routeViewId) + } + + @discardableResult + fileprivate func registerRouteHost(view: AnyView, byRouteViewId routeViewId: RouteViewIdentifier) -> RouteHost { + let routeHost = RouteHost(root: view) + routeHosts[routeViewId] = routeHost + stack.append(routeHost) + + return routeHost + } + + func makeChildRouterView( + rootRoute: RootRoute, + presentationContext: PresentationContext, + presenterViewModel: PresenterViewModel + ) -> AnyView { + let router = MacRouter( + root: rootRoute, + VoidObservableObject(), + parent: (self, presentationContext) + ) + return AnyView(PresenterView(wrappedView: MacRouterView(router: router), viewModel: presenterViewModel)) + } + + public func isPresenting(routeMatchingId id: RouteViewIdentifier) -> Bool { + guard let routeHost = routeHosts[id] else { + return false + } + + return stack.contains(routeHost) + } + + private func isPresentedBinding(forRouteMatchingId id: RouteViewIdentifier, presenterViewModel: PresenterViewModel) -> Binding { + Binding( + get: { [weak self] in + self?.isPresenting(routeMatchingId: id) ?? false + }, + set: { [weak self] newValue in + if !newValue { + self?.dismissUpToIncluding(routeMatchingId: id) + } + } + ) + } +} +#endif diff --git a/Sources/Router/Router/MacRouterView.swift b/Sources/Router/Router/MacRouterView.swift new file mode 100644 index 0000000..02eff68 --- /dev/null +++ b/Sources/Router/Router/MacRouterView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +public struct MacRouterView: NSViewControllerRepresentable { + public typealias NSViewControllerType = NSHostingController + + public let router: MacRouter + + public init(router: MacRouter) { + self.router = router + } + + public func makeNSViewController(context: Context) -> NSHostingController { + router.hostingController + } + + public func updateNSViewController(_ nsViewController: NSHostingController, context: Context) { } +} diff --git a/Sources/Router/Router/RouterView.swift b/Sources/Router/Router/RouterView.swift index 390a967..11b5178 100644 --- a/Sources/Router/Router/RouterView.swift +++ b/Sources/Router/Router/RouterView.swift @@ -1,14 +1,15 @@ import SwiftUI +#if canImport(UIKit) @available(iOS 13, *) -public struct RouterView: View { +public struct StackRouterView: View { @State var router: UINavigationControllerRouter - public init(root: RootRoute) where RootRoute.EnvironmentObjectDependency == VoidObservableObject { + public init(root: RootRoute) { self._router = State(wrappedValue: UINavigationControllerRouter(root: root)) } - public init(root: RootRoute, dependency: RootRoute.EnvironmentObjectDependency) { + public init(root: RootRoute, dependency: RootRoute.EnvironmentObjectDependency) { self._router = State(wrappedValue: UINavigationControllerRouter(root: root, dependency)) } @@ -16,3 +17,4 @@ public struct RouterView: View { UINavigationControllerRouterView(router: router) } } +#endif diff --git a/Sources/Router/Router/UINavigationControllerRouter.swift b/Sources/Router/Router/UINavigationControllerRouter.swift index a864036..aae0c58 100644 --- a/Sources/Router/Router/UINavigationControllerRouter.swift +++ b/Sources/Router/Router/UINavigationControllerRouter.swift @@ -4,7 +4,7 @@ import SwiftUI import Combine @available(iOS 13, macOS 10.15, *) -final class RouteHost: Hashable { +fileprivate final class RouteHost: Hashable { // MARK: State @@ -40,7 +40,6 @@ extension Dictionary where Value == RouteHost { } } - /// A `Router` implementation that pushes routed views onto a `UINavigationController`. @available(iOS 13, *) open class UINavigationControllerRouter: Router { @@ -110,7 +109,7 @@ open class UINavigationControllerRouter: Router { debugPrint("⚠️ Presenting route host for replacing presenter \(presenter) as root view, because an eligible view for presentation was not found.") let id = RouteViewIdentifier() - let viewController = makeViewController(for: target, environmentObject: environmentObject, using: presenter, routeViewId: id) + let viewController = makeView(for: target, environmentObject: environmentObject, using: presenter, routeViewId: id) let routeHost = registerHostingController(hostingController: viewController, byRouteViewId: id) return (routeHost, viewController) } @@ -119,7 +118,7 @@ open class UINavigationControllerRouter: Router { let targetRouteViewId = RouteViewIdentifier() if !presenter.replacesParent { // Push 💨 - let viewController = makeViewController(for: target, environmentObject: environmentObject, using: presenter, routeViewId: targetRouteViewId) + let viewController = makeView(for: target, environmentObject: environmentObject, using: presenter, routeViewId: targetRouteViewId) registerHostingController(hostingController: viewController, byRouteViewId: targetRouteViewId) navigationController.pushViewController(viewController, animated: true) } else { @@ -222,7 +221,7 @@ open class UINavigationControllerRouter: Router { /// Generate the view controller (usually a hosting controller) for the given destination. /// - Parameter destination: A destination to route to. /// - Returns: A view controller for showing `destination`. - open func makeViewController(for target: Target, environmentObject: Target.EnvironmentObjectDependency, using presenter: ThePresenter, routeViewId: RouteViewIdentifier) -> UIHostingController { + open func makeView(for target: Target, environmentObject: Target.EnvironmentObjectDependency, using presenter: ThePresenter, routeViewId: RouteViewIdentifier) -> UIHostingController { let state = target.prepareState(environmentObject: environmentObject) let presenterViewModel = PresenterViewModel() diff --git a/Sources/Router/Router/UINavigationControllerRouterView.swift b/Sources/Router/Router/UINavigationControllerRouterView.swift index ac322ce..eed6797 100644 --- a/Sources/Router/Router/UINavigationControllerRouterView.swift +++ b/Sources/Router/Router/UINavigationControllerRouterView.swift @@ -1,6 +1,7 @@ import SwiftUI -@available(iOS 13, macOS 10.15, *) +#if canImport(UIKit) +@available(iOS 13, *) public struct UINavigationControllerRouterView: UIViewControllerRepresentable { public let router: UINavigationControllerRouter @@ -14,3 +15,4 @@ public struct UINavigationControllerRouterView: UIViewControllerRepresentable { public func updateUIViewController(_ uiViewController: UINavigationController, context: Context) {} } +#endif diff --git a/Sources/Router/Views/RouterLink.swift b/Sources/Router/Views/RouterLink.swift index 79370ad..b4738ba 100644 --- a/Sources/Router/Views/RouterLink.swift +++ b/Sources/Router/Views/RouterLink.swift @@ -25,7 +25,7 @@ public struct RouterLink: View { } public var body: some View { - Button(action: navigate) { label } + label.onTapGesture(perform: navigate) } private func navigate() { From 3f357359036c2b0a042328cda750e9227bb40064 Mon Sep 17 00:00:00 2001 From: Joannis orlandos Date: Thu, 17 Dec 2020 14:29:17 +0100 Subject: [PATCH 02/11] Add macOS support to the Package manifest --- Package.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 806d55a..fc5ffd2 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,8 @@ import PackageDescription let package = Package( name: "Router", platforms: [ - .iOS(.v11) + .iOS(.v11), + .macOS(.v10_15) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. From 76cf8e877585faee59da9ce0c5cbe37a5de581be Mon Sep 17 00:00:00 2001 From: Joannis orlandos Date: Fri, 18 Dec 2020 13:44:45 +0100 Subject: [PATCH 03/11] Add macOS as a tested platform --- .github/workflows/main.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d8e7f45..a95cfcc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,6 +11,7 @@ jobs: matrix: destination: - generic/platform=iOS + - generic/platform=macOS steps: - uses: actions/checkout@v2 From 284a032df338da0f64b49a145d3a2f7be9b726dc Mon Sep 17 00:00:00 2001 From: Joannis orlandos Date: Fri, 18 Dec 2020 13:47:05 +0100 Subject: [PATCH 04/11] On PR to master --- .github/workflows/main.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a95cfcc..e8c7edf 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,6 +1,9 @@ name: CI on: + pull_request: + branches: + - master push jobs: From 34f79d6b0f65982d013d80fef0f1f811f65704fc Mon Sep 17 00:00:00 2001 From: Joannis orlandos Date: Fri, 18 Dec 2020 13:53:37 +0100 Subject: [PATCH 05/11] Fix the compilation after recent PRs by obbut --- Sources/Router/Router/MacRouter.swift | 24 +++++++++++-------- Sources/Router/Router/MacRouterView.swift | 2 ++ .../Router/UINavigationControllerRouter.swift | 2 +- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/Sources/Router/Router/MacRouter.swift b/Sources/Router/Router/MacRouter.swift index b99e517..f3a5355 100644 --- a/Sources/Router/Router/MacRouter.swift +++ b/Sources/Router/Router/MacRouter.swift @@ -76,17 +76,21 @@ open class MacRouter: Router { // MARK: Root view replacement - open func replaceRoot( + open func replaceRoot( with target: Target, - _ environmentObject: Target.EnvironmentObjectDependency - ) { - navigate(to: target, environmentObject, using: DestinationPresenter()) - } - - open func replaceRoot( - with target: Target - ) { - self.replaceRoot(with: target, VoidObservableObject()) + _ environmentObject: Target.EnvironmentObjectDependency, + using presenter: ThePresenter + ) -> RouteViewIdentifier where Target : EnvironmentDependentRoute, ThePresenter : Presenter { + self.stack.removeAll(keepingCapacity: true) + self.routeHosts.removeAll(keepingCapacity: true) + self.hostingController.rootView = AnyView(EmptyView()) + + return navigate( + to: target, + environmentObject, + using: presenter, + source: nil + ) } // MARK: Navigation diff --git a/Sources/Router/Router/MacRouterView.swift b/Sources/Router/Router/MacRouterView.swift index 02eff68..d00cdaf 100644 --- a/Sources/Router/Router/MacRouterView.swift +++ b/Sources/Router/Router/MacRouterView.swift @@ -1,3 +1,4 @@ +#if canImport(AppKit) import SwiftUI public struct MacRouterView: NSViewControllerRepresentable { @@ -15,3 +16,4 @@ public struct MacRouterView: NSViewControllerRepresentable { public func updateNSViewController(_ nsViewController: NSHostingController, context: Context) { } } +#endif diff --git a/Sources/Router/Router/UINavigationControllerRouter.swift b/Sources/Router/Router/UINavigationControllerRouter.swift index d4b6905..7e80c17 100644 --- a/Sources/Router/Router/UINavigationControllerRouter.swift +++ b/Sources/Router/Router/UINavigationControllerRouter.swift @@ -270,7 +270,7 @@ open class UINavigationControllerRouter: Router { } @discardableResult - func registerHostingController(hostingController: UIHostingController, byRouteViewId routeViewId: RouteViewIdentifier) -> RouteHost { + fileprivate func registerHostingController(hostingController: UIHostingController, byRouteViewId routeViewId: RouteViewIdentifier) -> RouteHost { assert(!routeHosts.values.contains { $0.hostingController === hostingController }) let routeHost = RouteHost(root: hostingController.rootView, hostingController: hostingController) From 6bc518aa3137c6cd63d6cb7af79d7759ad6a8f60 Mon Sep 17 00:00:00 2001 From: Joannis orlandos Date: Fri, 18 Dec 2020 13:56:58 +0100 Subject: [PATCH 06/11] Revert the commit hook --- .github/workflows/main.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e8c7edf..a95cfcc 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,9 +1,6 @@ name: CI on: - pull_request: - branches: - - master push jobs: From 06311f3c8c40d71978667fee99c5f05b85d4b5c6 Mon Sep 17 00:00:00 2001 From: Joannis orlandos Date: Wed, 13 Jan 2021 12:50:03 +0100 Subject: [PATCH 07/11] Support SwiftUI's NavigationView as Router type --- Sources/Router/Router/MacRouter.swift | 4 +- Sources/Router/Router/RouterView.swift | 4 + Sources/Router/Router/SwiftUIRouter.swift | 388 ++++++++++++++++++ Sources/Router/Router/SwiftUIRouterView.swift | 59 +++ .../Router/UINavigationControllerRouter.swift | 8 +- 5 files changed, 457 insertions(+), 6 deletions(-) create mode 100644 Sources/Router/Router/SwiftUIRouter.swift create mode 100644 Sources/Router/Router/SwiftUIRouterView.swift diff --git a/Sources/Router/Router/MacRouter.swift b/Sources/Router/Router/MacRouter.swift index 864cee9..f9772bb 100644 --- a/Sources/Router/Router/MacRouter.swift +++ b/Sources/Router/Router/MacRouter.swift @@ -27,7 +27,7 @@ fileprivate final class RouteHost: Hashable { } @available(iOS 13, macOS 10.15, *) -public final class PresenterViewModel: ObservableObject { +public class PresenterViewModel: ObservableObject { @Published internal var isPresented = true internal init() {} @@ -128,7 +128,7 @@ open class MacRouter: Router { } else { let host: RouteHost - if let source = source { + if let source = source, source != .none { if let theHost = routeHosts[source] { host = theHost } else { diff --git a/Sources/Router/Router/RouterView.swift b/Sources/Router/Router/RouterView.swift index 11b5178..93e9234 100644 --- a/Sources/Router/Router/RouterView.swift +++ b/Sources/Router/Router/RouterView.swift @@ -13,6 +13,10 @@ public struct StackRouterView: View { self._router = State(wrappedValue: UINavigationControllerRouter(root: root, dependency)) } + public init(router: UINavigationControllerRouter) { + self._router = State(wrappedValue: router) + } + public var body: some View { UINavigationControllerRouterView(router: router) } diff --git a/Sources/Router/Router/SwiftUIRouter.swift b/Sources/Router/Router/SwiftUIRouter.swift new file mode 100644 index 0000000..f0db9f8 --- /dev/null +++ b/Sources/Router/Router/SwiftUIRouter.swift @@ -0,0 +1,388 @@ +import SwiftUI +import Combine + +#if canImport(UIKit) +internal typealias HostingController = UIHostingController +#elseif canImport(AppKit) +internal typealias HostingController = NSHostingController +#endif + +#if canImport(UIKit) || canImport(AppKit) +import Combine +import SwiftUI + +@available(macOS 10.15, *) +fileprivate final class RouteHost: Hashable { + // MARK: State + + let root: AnyView + let presenterViewModel: SwiftUIPresenterViewModel + + // MARK: Init + + init(root: AnyView, presenterViewModel: SwiftUIPresenterViewModel) { + self.root = root + self.presenterViewModel = presenterViewModel + } + + // MARK: Equatable / hashable + + static func == (lhs: RouteHost, rhs: RouteHost) -> Bool { + lhs === rhs + } + + func hash(into hasher: inout Hasher) { + ObjectIdentifier(self).hash(into: &hasher) + } +} + +@available(iOS 13, macOS 10.15, *) +public final class SwiftUIPresenterViewModel: PresenterViewModel { + fileprivate var nestedView: Binding? +} + +@available(iOS 13, macOS 10.15, *) +fileprivate struct PresenterView: View { + let wrappedView: WrappedView + @State var nestedView: AnyView? + @ObservedObject var viewModel: SwiftUIPresenterViewModel + + var body: some View { + // Make sure SwiftUI registers the EnvironmentObject dependency for observation + wrappedView + .id(viewModel.isPresented) + .background(Group { + if let nestedView = self.nestedView { + NavigationLink( + destination: nestedView, + isActive: .init( + get: { self.nestedView != nil }, + set: { newValue in + if !newValue { + self.nestedView = nil + } + }), + label: { + EmptyView() + } + ) + } + }).onAppear { + viewModel.nestedView = $nestedView + } + } +} + +open class SwiftUIRouter: Router { + internal let hostingController: HostingController + let parentRouter: (Router, PresentationContext)? + + /// key: `ObjectIdentifier` of the `HostingController` + fileprivate var routeHosts: [RouteViewIdentifier: RouteHost] = [:] + fileprivate var stack = [RouteHost]() + + /// 🗑 Combine cancellables. + private var cancellables = Set() + + public init( + root: Root, + _ environmentObject: Root.EnvironmentObjectDependency, + parent: (Router, PresentationContext)? = nil + ) where Root: EnvironmentDependentRoute { + self.hostingController = NSHostingController(rootView: AnyView(EmptyView())) + self.parentRouter = parent + replaceRoot(with: root, environmentObject) + } + + public init( + root: Root + ) where Root: Route { + self.hostingController = NSHostingController(rootView: AnyView(EmptyView())) + self.parentRouter = nil + replaceRoot(with: root) + } + + // MARK: Root view replacement + + open func replaceRoot( + with target: Target, + _ environmentObject: Target.EnvironmentObjectDependency, + using presenter: ThePresenter + ) -> RouteViewIdentifier where Target : EnvironmentDependentRoute, ThePresenter : Presenter { + self.stack.removeAll(keepingCapacity: true) + self.routeHosts.removeAll(keepingCapacity: true) + self.hostingController.rootView = AnyView(EmptyView()) + + return navigate( + to: target, + environmentObject, + using: presenter, + source: nil + ) + } + + // MARK: Navigation + + fileprivate func topLevelRouteHost() -> RouteHost? { + stack.last + } + + /// - note: Not an implementation of the protocol requirement. + @discardableResult + open func navigate( + to target: Target, + _ environmentObject: Target.EnvironmentObjectDependency, + using presenter: ThePresenter, + source: RouteViewIdentifier? + ) -> RouteViewIdentifier where Target : EnvironmentDependentRoute, ThePresenter : Presenter { + let presenterViewModel = SwiftUIPresenterViewModel() + + func topLevelRouteHostOrNew() -> RouteHost { + if let topHost = topLevelRouteHost() { + return topHost + } else { + debugPrint("⚠️ Presenting route host for replacing presenter \(presenter) as root view, because an eligible view for presentation was not found.") + + let id = RouteViewIdentifier() + let view = makeView( + for: target, + environmentObject: environmentObject, + presenterViewModel: presenterViewModel, + using: presenter, + routeViewId: id + ) + let routeHost = registerRouteHost(view: view, presenterViewModel: presenterViewModel, byRouteViewId: id) + return routeHost + } + } + + let targetRouteViewId = RouteViewIdentifier() + + if !presenter.replacesParent { // Push 💨 + let view = makeView( + for: target, + environmentObject: environmentObject, + presenterViewModel: presenterViewModel, + using: presenter, + routeViewId: targetRouteViewId + ) + registerRouteHost(view: view, presenterViewModel: presenterViewModel, byRouteViewId: targetRouteViewId) + hostingController.rootView = view + } else { + let host: RouteHost + + if let source = source, source != .none { + if let theHost = routeHosts[source] { + host = theHost + } else { + debugPrint("⚠️ Trying to present on top of nonexisting source") + + host = topLevelRouteHostOrNew() + } + } else { + host = topLevelRouteHostOrNew() + } + + let state = target.prepareState(environmentObject: environmentObject) + + print(host.presenterViewModel.isPresented) + print(host.presenterViewModel.nestedView) + print(host.root) + + let presentationContext = PresentationContext( + parent: host.root, + destination: AnyView( + adjustView( + target.body(state: state), + presenterViewModel: presenterViewModel, + environmentObject: environmentObject, + routeViewId: targetRouteViewId + ) + ), + isPresented: Binding( + get: { + presenterViewModel.isPresented + }, + set: { newValue in + presenterViewModel.isPresented = newValue + } + ) + ) { [unowned self] presentationContext in + self.makeChildRouterView( + rootRoute: target, + environmentObject: environmentObject, + presentationContext: presentationContext, + presenterViewModel: presenterViewModel + ) + } + + presenterViewModel.$isPresented + .first { $0 == false } + .sink { [weak hostingController] _ in + hostingController?.rootView = AnyView(presenter.body(with: presentationContext)) } + .store(in: &cancellables) + + let view = AnyView(presenter.body(with: presentationContext)) + registerRouteHost(view: view, presenterViewModel: presenterViewModel, byRouteViewId: targetRouteViewId) + host.presenterViewModel.nestedView?.wrappedValue = view + } + + return targetRouteViewId + } + + public func dismissUpTo(routeMatchesId id: RouteViewIdentifier) { + while !routeHosts.isEmpty, let lastRouteHost = stack.last { + guard + let route = routeHosts.first(where: { $0.value == lastRouteHost }) + else { + if let (parentRouter, presentationContext) = parentRouter { + presentationContext.isPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + parentRouter.dismissUpTo(routeMatchesId: id) + } + return + } + + debugPrint("⚠️ Cannot dismiss route that's not in the hierarchy") + return + } + + if id == route.key { + // Found the route we're looking for, but this is up to not including + if let newRoot = stack.last?.root { + hostingController.rootView = newRoot + } + return + } + + routeHosts[route.key] = nil + stack.removeLast() + } + } + + public func dismissUpToIncluding(routeMatchingId id: RouteViewIdentifier) { + while !routeHosts.isEmpty, let lastRouteHost = stack.last { + guard + let route = routeHosts.first(where: { $0.value == lastRouteHost }) + else { + if let (parentRouter, presentationContext) = parentRouter { + presentationContext.isPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + parentRouter.dismissUpTo(routeMatchesId: id) + } + return + } + + debugPrint("⚠️ Cannot dismiss route that's not in the hierarchy") + return + } + + routeHosts[route.key] = nil + stack.removeLast() + + if id == route.key { + // Found the route we're looking for, and this is up to AND including + if let newRoot = stack.last?.root { + hostingController.rootView = newRoot + } + return + } + } + } + + // MARK: Customisation points + + /// Generate the view controller (usually a hosting controller) for the given destination. + /// - Parameter destination: A destination to route to. + /// - Returns: A view controller for showing `destination`. + open func makeView( + for target: Target, + environmentObject: Target.EnvironmentObjectDependency, + presenterViewModel: SwiftUIPresenterViewModel, + using presenter: ThePresenter, + routeViewId: RouteViewIdentifier + ) -> AnyView { + let state = target.prepareState(environmentObject: environmentObject) + + let context = PresentationContext( + parent: EmptyView(), + destination: target.body(state: state), + isPresented: isPresentedBinding(forRouteMatchingId: routeViewId, presenterViewModel: presenterViewModel) + ) { [unowned self] presentationContext in + self.makeChildRouterView( + rootRoute: target, + environmentObject: environmentObject, + presentationContext: presentationContext, + presenterViewModel: presenterViewModel + ) + } + + return AnyView(adjustView( + presenter.body(with: context), + presenterViewModel: presenterViewModel, + environmentObject: environmentObject, + routeViewId: routeViewId + )) + } + + func adjustView(_ view: Input, presenterViewModel: SwiftUIPresenterViewModel, environmentObject: Dependency, routeViewId: RouteViewIdentifier) -> some View { + return PresenterView( + wrappedView: view + .environment(\.router, self) + .environmentObject(VoidObservableObject()) + .environmentObject(environmentObject) + .environment(\.routeViewId, routeViewId) + .id(routeViewId), + viewModel: presenterViewModel + ) + } + + @discardableResult + fileprivate func registerRouteHost(view: AnyView, presenterViewModel: SwiftUIPresenterViewModel, byRouteViewId routeViewId: RouteViewIdentifier) -> RouteHost { + let routeHost = RouteHost(root: view, presenterViewModel: presenterViewModel) + routeHosts[routeViewId] = routeHost + stack.append(routeHost) + + return routeHost + } + + func makeChildRouterView( + rootRoute: RootRoute, + environmentObject: RootRoute.EnvironmentObjectDependency, + presentationContext: PresentationContext, + presenterViewModel: SwiftUIPresenterViewModel + ) -> AnyView { + let router = MacRouter( + root: rootRoute, + environmentObject, + parent: (self, presentationContext) + ) + + return AnyView(PresenterView( + wrappedView: MacRouterView(router: router), + viewModel: presenterViewModel + )) + } + + public func isPresenting(routeMatchingId id: RouteViewIdentifier) -> Bool { + guard let routeHost = routeHosts[id] else { + return false + } + + return stack.contains(routeHost) + } + + private func isPresentedBinding(forRouteMatchingId id: RouteViewIdentifier, presenterViewModel: SwiftUIPresenterViewModel) -> Binding { + Binding( + get: { [weak self] in + self?.isPresenting(routeMatchingId: id) ?? false + }, + set: { [weak self] newValue in + if !newValue { + self?.dismissUpToIncluding(routeMatchingId: id) + } + } + ) + } +} +#endif diff --git a/Sources/Router/Router/SwiftUIRouterView.swift b/Sources/Router/Router/SwiftUIRouterView.swift new file mode 100644 index 0000000..a8db1b5 --- /dev/null +++ b/Sources/Router/Router/SwiftUIRouterView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +#if canImport(AppKit) +fileprivate struct HostingControllerRepresentable: NSViewControllerRepresentable { + let hostingController: HostingController + + func makeNSViewController(context: Context) -> HostingController { + hostingController + } + + func updateNSViewController(_ nsViewController: HostingController, context: Context) { } +} +#elseif canImport(UIKit) +fileprivate struct HostingControllerRepresentable: UIViewControllerRepresentable { + let hostingController: NSHostingController + + func makeUIViewController(context: Context) -> HostingController { + hostingController + } + + func updateUIViewController(_ nsViewController: HostingController, context: Context) { } +} +#endif + +#if canImport(AppKit) || canImport(UIKit) +public struct SwiftUIRouterView: View { + @State var router: SwiftUIRouter + + public init(router: SwiftUIRouter) { + self._router = State(wrappedValue: router) + } + + public var body: some View { + NavigationView { + HostingControllerRepresentable(hostingController: router.hostingController) + } + } +} + +public struct SwiftUIMasterDetailRouterView: View { + let makeMaster: () -> Master + @State var router: SwiftUIRouter + + public init(router: SwiftUIRouter, @ViewBuilder makeMaster: @escaping () -> Master) { + self._router = State(wrappedValue: router) + self.makeMaster = makeMaster + } + + public var body: some View { + NavigationView { + makeMaster() + .environmentObject(VoidObservableObject()) + .environment(\.router, router) + + HostingControllerRepresentable(hostingController: router.hostingController) + } + } +} +#endif diff --git a/Sources/Router/Router/UINavigationControllerRouter.swift b/Sources/Router/Router/UINavigationControllerRouter.swift index d05ca9f..09080ed 100644 --- a/Sources/Router/Router/UINavigationControllerRouter.swift +++ b/Sources/Router/Router/UINavigationControllerRouter.swift @@ -112,7 +112,7 @@ open class UINavigationControllerRouter: Router { debugPrint("⚠️ Presenting route host for replacing presenter \(presenter) as root view, because an eligible view for presentation was not found.") let id = RouteViewIdentifier() - let viewController = makeView(for: target, environmentObject: environmentObject, using: presenter, routeViewId: id) + let viewController = makeViewController(for: target, environmentObject: environmentObject, using: presenter, routeViewId: id) let routeHost = registerHostingController(hostingController: viewController, byRouteViewId: id) return (routeHost, viewController) } @@ -121,7 +121,7 @@ open class UINavigationControllerRouter: Router { let targetRouteViewId = RouteViewIdentifier() if !presenter.replacesParent { // Push 💨 - let viewController = makeView(for: target, environmentObject: environmentObject, using: presenter, routeViewId: targetRouteViewId) + let viewController = makeViewController(for: target, environmentObject: environmentObject, using: presenter, routeViewId: targetRouteViewId) registerHostingController(hostingController: viewController, byRouteViewId: targetRouteViewId) if navigationController.viewControllers.isEmpty { @@ -135,7 +135,7 @@ open class UINavigationControllerRouter: Router { let host: RouteHost let hostingController: UIHostingController - if let source = source { + if let source = source, source != .none { if let theHost = routeHosts[source], let viewController = theHost.hostingController { host = theHost hostingController = viewController @@ -341,7 +341,7 @@ open class UINavigationControllerRouter: Router { } @available(iOS 13, macOS 10.15, *) -public final class PresenterViewModel: ObservableObject { +public class PresenterViewModel: ObservableObject { @Published internal var isPresented = true internal init() {} From eb65d9ced943a9bc78d13ed83ab6b51812a15310 Mon Sep 17 00:00:00 2001 From: Joannis orlandos Date: Wed, 13 Jan 2021 12:53:40 +0100 Subject: [PATCH 08/11] Support compiling on pre-iOS 13 targets --- .../Router/Router/MasterDetailRouter.swift | 37 +++++++++++++++++++ Sources/Router/Router/SwiftUIRouter.swift | 9 ++--- Sources/Router/Router/SwiftUIRouterView.swift | 4 ++ 3 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 Sources/Router/Router/MasterDetailRouter.swift diff --git a/Sources/Router/Router/MasterDetailRouter.swift b/Sources/Router/Router/MasterDetailRouter.swift new file mode 100644 index 0000000..6836688 --- /dev/null +++ b/Sources/Router/Router/MasterDetailRouter.swift @@ -0,0 +1,37 @@ +import SwiftUI + +@available(iOS 13, macOS 10.15, *) +public struct MasterDetailRouter: View { + let masterView: MasterView + @State var detailRouter: DetailRouter + let makeDetailView: (DetailRouter) -> DetailView + + public init(masterView: MasterView, detailRouter: DetailRouter, @ViewBuilder makeDetailView: @escaping (DetailRouter) -> DetailView) { + self.masterView = masterView + self._detailRouter = State(wrappedValue: detailRouter) + self.makeDetailView = makeDetailView + } + + public var body: some View { + #if os(macOS) + HSplitView { + masterView + .environment(\.router, detailRouter) + .environmentObject(VoidObservableObject()) + + makeDetailView(detailRouter) + } + #else + HStack(spacing: 0) { + masterView + .environment(\.router, detailRouter) + .environmentObject(VoidObservableObject()) + + Divider() + + makeDetailView(detailRouter) + } + #endif + } +} + diff --git a/Sources/Router/Router/SwiftUIRouter.swift b/Sources/Router/Router/SwiftUIRouter.swift index f0db9f8..fdb0bbd 100644 --- a/Sources/Router/Router/SwiftUIRouter.swift +++ b/Sources/Router/Router/SwiftUIRouter.swift @@ -2,8 +2,10 @@ import SwiftUI import Combine #if canImport(UIKit) +@available(iOS 13, macOS 10.15, *) internal typealias HostingController = UIHostingController #elseif canImport(AppKit) +@available(iOS 13, macOS 10.15, *) internal typealias HostingController = NSHostingController #endif @@ -11,7 +13,7 @@ internal typealias HostingController = NSHostingController import Combine import SwiftUI -@available(macOS 10.15, *) +@available(iOS 13, macOS 10.15, *) fileprivate final class RouteHost: Hashable { // MARK: State @@ -73,6 +75,7 @@ fileprivate struct PresenterView: View { } } +@available(iOS 13, macOS 10.15, *) open class SwiftUIRouter: Router { internal let hostingController: HostingController let parentRouter: (Router, PresentationContext)? @@ -185,10 +188,6 @@ open class SwiftUIRouter: Router { let state = target.prepareState(environmentObject: environmentObject) - print(host.presenterViewModel.isPresented) - print(host.presenterViewModel.nestedView) - print(host.root) - let presentationContext = PresentationContext( parent: host.root, destination: AnyView( diff --git a/Sources/Router/Router/SwiftUIRouterView.swift b/Sources/Router/Router/SwiftUIRouterView.swift index a8db1b5..eed8c74 100644 --- a/Sources/Router/Router/SwiftUIRouterView.swift +++ b/Sources/Router/Router/SwiftUIRouterView.swift @@ -1,6 +1,7 @@ import SwiftUI #if canImport(AppKit) +@available(iOS 13, macOS 10.15, *) fileprivate struct HostingControllerRepresentable: NSViewControllerRepresentable { let hostingController: HostingController @@ -11,6 +12,7 @@ fileprivate struct HostingControllerRepresentable: NSViewControllerRepr func updateNSViewController(_ nsViewController: HostingController, context: Context) { } } #elseif canImport(UIKit) +@available(iOS 13, macOS 10.15, *) fileprivate struct HostingControllerRepresentable: UIViewControllerRepresentable { let hostingController: NSHostingController @@ -23,6 +25,7 @@ fileprivate struct HostingControllerRepresentable: UIViewControllerRepr #endif #if canImport(AppKit) || canImport(UIKit) +@available(iOS 13, macOS 10.15, *) public struct SwiftUIRouterView: View { @State var router: SwiftUIRouter @@ -37,6 +40,7 @@ public struct SwiftUIRouterView: View { } } +@available(iOS 13, macOS 10.15, *) public struct SwiftUIMasterDetailRouterView: View { let makeMaster: () -> Master @State var router: SwiftUIRouter From fc619d6f31f662f7603a335db62aa4b24ee3723d Mon Sep 17 00:00:00 2001 From: Joannis orlandos Date: Wed, 13 Jan 2021 12:57:51 +0100 Subject: [PATCH 09/11] Fix compilation on iOS --- Sources/Router/Router/SwiftUIRouter.swift | 8 ++++---- Sources/Router/Router/SwiftUIRouterView.swift | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Router/Router/SwiftUIRouter.swift b/Sources/Router/Router/SwiftUIRouter.swift index fdb0bbd..08081c8 100644 --- a/Sources/Router/Router/SwiftUIRouter.swift +++ b/Sources/Router/Router/SwiftUIRouter.swift @@ -92,7 +92,7 @@ open class SwiftUIRouter: Router { _ environmentObject: Root.EnvironmentObjectDependency, parent: (Router, PresentationContext)? = nil ) where Root: EnvironmentDependentRoute { - self.hostingController = NSHostingController(rootView: AnyView(EmptyView())) + self.hostingController = HostingController(rootView: AnyView(EmptyView())) self.parentRouter = parent replaceRoot(with: root, environmentObject) } @@ -100,7 +100,7 @@ open class SwiftUIRouter: Router { public init( root: Root ) where Root: Route { - self.hostingController = NSHostingController(rootView: AnyView(EmptyView())) + self.hostingController = HostingController(rootView: AnyView(EmptyView())) self.parentRouter = nil replaceRoot(with: root) } @@ -351,14 +351,14 @@ open class SwiftUIRouter: Router { presentationContext: PresentationContext, presenterViewModel: SwiftUIPresenterViewModel ) -> AnyView { - let router = MacRouter( + let router = SwiftUIRouter( root: rootRoute, environmentObject, parent: (self, presentationContext) ) return AnyView(PresenterView( - wrappedView: MacRouterView(router: router), + wrappedView: SwiftUIRouterView(router: router), viewModel: presenterViewModel )) } diff --git a/Sources/Router/Router/SwiftUIRouterView.swift b/Sources/Router/Router/SwiftUIRouterView.swift index eed8c74..dd70ca5 100644 --- a/Sources/Router/Router/SwiftUIRouterView.swift +++ b/Sources/Router/Router/SwiftUIRouterView.swift @@ -14,7 +14,7 @@ fileprivate struct HostingControllerRepresentable: NSViewControllerRepr #elseif canImport(UIKit) @available(iOS 13, macOS 10.15, *) fileprivate struct HostingControllerRepresentable: UIViewControllerRepresentable { - let hostingController: NSHostingController + let hostingController: HostingController func makeUIViewController(context: Context) -> HostingController { hostingController From 523af52c684ce4daf4f8f4cab8172f6453155c24 Mon Sep 17 00:00:00 2001 From: Joannis orlandos Date: Fri, 11 Jun 2021 19:59:42 +0200 Subject: [PATCH 10/11] Update --- Sources/Router/Router/MacRouter.swift | 87 ++++++++++------ Sources/Router/Router/SwiftUIRouter.swift | 98 +++++++++++++------ .../Router/UINavigationControllerRouter.swift | 9 +- 3 files changed, 133 insertions(+), 61 deletions(-) diff --git a/Sources/Router/Router/MacRouter.swift b/Sources/Router/Router/MacRouter.swift index f9772bb..f787188 100644 --- a/Sources/Router/Router/MacRouter.swift +++ b/Sources/Router/Router/MacRouter.swift @@ -7,12 +7,17 @@ fileprivate final class RouteHost: Hashable { // MARK: State - let root: AnyView + let rootView: AnyView + + func root(sibling: Sibling) -> some View { + self.rootView + .overlay(AnyView(sibling)) + } // MARK: Init - init(root: AnyView) { - self.root = root + init(rootView: AnyView) { + self.rootView = rootView } // MARK: Equatable / hashable @@ -56,21 +61,21 @@ open class MacRouter: Router { private var cancellables = Set() public init( - root: Root, + rootView: Root, _ environmentObject: Root.EnvironmentObjectDependency, parent: (Router, PresentationContext)? = nil ) where Root: EnvironmentDependentRoute { self.hostingController = NSHostingController(rootView: AnyView(EmptyView())) self.parentRouter = parent - replaceRoot(with: root, environmentObject) + replaceRoot(with: rootView, environmentObject) } public init( - root: Root + rootView: Root ) where Root: Route { self.hostingController = NSHostingController(rootView: AnyView(EmptyView())) self.parentRouter = nil - replaceRoot(with: root) + replaceRoot(with: rootView) } // MARK: Root view replacement @@ -121,11 +126,12 @@ open class MacRouter: Router { let targetRouteViewId = RouteViewIdentifier() - if !presenter.replacesParent { // Push 💨 + switch presenter.presentationMode { + case .normal: // Push 💨 let view = makeView(for: target, environmentObject: environmentObject, using: presenter, routeViewId: targetRouteViewId) registerRouteHost(view: view, byRouteViewId: targetRouteViewId) hostingController.rootView = view - } else { + case .replaceParent, .sibling: let host: RouteHost if let source = source, source != .none { @@ -142,19 +148,18 @@ open class MacRouter: Router { let state = target.prepareState(environmentObject: environmentObject) let presenterViewModel = PresenterViewModel() + let presentationContext: PresentationContext - let presentationContext = PresentationContext( - parent: host.root, - destination: AnyView(adjustView(target.body(state: state), environmentObject: environmentObject, routeViewId: targetRouteViewId)), - isPresented: Binding( - get: { - presenterViewModel.isPresented - }, - set: { newValue in - presenterViewModel.isPresented = newValue - } - ) - ) { [unowned self] presentationContext in + let isPresentedBinding = Binding( + get: { + presenterViewModel.isPresented + }, + set: { newValue in + presenterViewModel.isPresented = newValue + } + ) + + let makeRouter: PresentationContext.RouterViewFactory = { [unowned self] presentationContext in self.makeChildRouterView( rootRoute: target, environmentObject: environmentObject, @@ -163,15 +168,39 @@ open class MacRouter: Router { ) } + switch presenter.presentationMode { + case .replaceParent: + presentationContext = PresentationContext( + parent: host.root(sibling: EmptyView()), + destination: AnyView(adjustView(target.body(state: state), environmentObject: environmentObject, routeViewId: targetRouteViewId)), + isPresented: isPresentedBinding, + makeRouter: makeRouter + ) + + let view = AnyView(presenter.body(with: presentationContext)) + registerRouteHost(view: view, byRouteViewId: targetRouteViewId) + hostingController.rootView = view + case .sibling: + presentationContext = PresentationContext( + parent: EmptyView(), + destination: adjustView(target.body(state: state), environmentObject: environmentObject, routeViewId: targetRouteViewId), + isPresented: isPresentedBinding, + makeRouter: makeRouter + ) + + let view = AnyView(presenter.body(with: presentationContext)) + registerRouteHost(view: view, byRouteViewId: targetRouteViewId) + + hostingController.rootView = AnyView(host.root(sibling: presenter.body(with: presentationContext))) + case .normal: + fatalError("Internal inconsistency") + } + presenterViewModel.$isPresented .first { $0 == false } .sink { [weak hostingController] _ in hostingController?.rootView = AnyView(presenter.body(with: presentationContext)) } .store(in: &cancellables) - - let view = AnyView(presenter.body(with: presentationContext)) - registerRouteHost(view: view, byRouteViewId: targetRouteViewId) - hostingController.rootView = view } return targetRouteViewId @@ -196,7 +225,7 @@ open class MacRouter: Router { if id == route.key { // Found the route we're looking for, but this is up to not including - if let newRoot = stack.last?.root { + if let newRoot = stack.last?.rootView { hostingController.rootView = newRoot } return @@ -229,7 +258,7 @@ open class MacRouter: Router { if id == route.key { // Found the route we're looking for, and this is up to AND including - if let newRoot = stack.last?.root { + if let newRoot = stack.last?.rootView { hostingController.rootView = newRoot } return @@ -277,7 +306,7 @@ open class MacRouter: Router { @discardableResult fileprivate func registerRouteHost(view: AnyView, byRouteViewId routeViewId: RouteViewIdentifier) -> RouteHost { - let routeHost = RouteHost(root: view) + let routeHost = RouteHost(rootView: view) routeHosts[routeViewId] = routeHost stack.append(routeHost) @@ -291,7 +320,7 @@ open class MacRouter: Router { presenterViewModel: PresenterViewModel ) -> AnyView { let router = MacRouter( - root: rootRoute, + rootView: rootRoute, environmentObject, parent: (self, presentationContext) ) diff --git a/Sources/Router/Router/SwiftUIRouter.swift b/Sources/Router/Router/SwiftUIRouter.swift index 08081c8..880cebe 100644 --- a/Sources/Router/Router/SwiftUIRouter.swift +++ b/Sources/Router/Router/SwiftUIRouter.swift @@ -17,13 +17,19 @@ import SwiftUI fileprivate final class RouteHost: Hashable { // MARK: State - let root: AnyView + let rootView: AnyView + + func root(sibling: Sibling) -> some View { + self.rootView + .overlay(AnyView(sibling)) + } + let presenterViewModel: SwiftUIPresenterViewModel // MARK: Init - init(root: AnyView, presenterViewModel: SwiftUIPresenterViewModel) { - self.root = root + init(rootView: AnyView, presenterViewModel: SwiftUIPresenterViewModel) { + self.rootView = rootView self.presenterViewModel = presenterViewModel } @@ -161,7 +167,8 @@ open class SwiftUIRouter: Router { let targetRouteViewId = RouteViewIdentifier() - if !presenter.replacesParent { // Push 💨 + switch presenter.presentationMode { + case .normal: // Push 💨 let view = makeView( for: target, environmentObject: environmentObject, @@ -171,7 +178,7 @@ open class SwiftUIRouter: Router { ) registerRouteHost(view: view, presenterViewModel: presenterViewModel, byRouteViewId: targetRouteViewId) hostingController.rootView = view - } else { + case .replaceParent, .sibling: let host: RouteHost if let source = source, source != .none { @@ -187,26 +194,18 @@ open class SwiftUIRouter: Router { } let state = target.prepareState(environmentObject: environmentObject) + let presentationContext: PresentationContext - let presentationContext = PresentationContext( - parent: host.root, - destination: AnyView( - adjustView( - target.body(state: state), - presenterViewModel: presenterViewModel, - environmentObject: environmentObject, - routeViewId: targetRouteViewId - ) - ), - isPresented: Binding( - get: { - presenterViewModel.isPresented - }, - set: { newValue in - presenterViewModel.isPresented = newValue - } - ) - ) { [unowned self] presentationContext in + let isPresentedBinding = Binding( + get: { + presenterViewModel.isPresented + }, + set: { newValue in + presenterViewModel.isPresented = newValue + } + ) + + let makeRouter: PresentationContext.RouterViewFactory = { [unowned self] presentationContext in self.makeChildRouterView( rootRoute: target, environmentObject: environmentObject, @@ -215,15 +214,52 @@ open class SwiftUIRouter: Router { ) } + switch presenter.presentationMode { + case .replaceParent: + presentationContext = PresentationContext( + parent: host.rootView, + destination: AnyView( + adjustView( + target.body(state: state), + presenterViewModel: presenterViewModel, + environmentObject: environmentObject, + routeViewId: targetRouteViewId + ) + ), + isPresented: Binding( + get: { + presenterViewModel.isPresented + }, + set: { newValue in + presenterViewModel.isPresented = newValue + } + ), + makeRouter: makeRouter + ) + + let view = AnyView(presenter.body(with: presentationContext)) + registerRouteHost(view: view, presenterViewModel: presenterViewModel, byRouteViewId: targetRouteViewId) + host.presenterViewModel.nestedView?.wrappedValue = view + case .sibling: + presentationContext = PresentationContext( + parent: EmptyView(), + destination: adjustView(target.body(state: state), presenterViewModel: presenterViewModel, environmentObject: environmentObject, routeViewId: targetRouteViewId), + isPresented: isPresentedBinding, + makeRouter: makeRouter + ) + + let view = AnyView(presenter.body(with: presentationContext)) + registerRouteHost(view: view, presenterViewModel: presenterViewModel, byRouteViewId: targetRouteViewId) + host.presenterViewModel.nestedView?.wrappedValue = view + case .normal: + fatalError("Internal inconsistency") + } + presenterViewModel.$isPresented .first { $0 == false } .sink { [weak hostingController] _ in hostingController?.rootView = AnyView(presenter.body(with: presentationContext)) } .store(in: &cancellables) - - let view = AnyView(presenter.body(with: presentationContext)) - registerRouteHost(view: view, presenterViewModel: presenterViewModel, byRouteViewId: targetRouteViewId) - host.presenterViewModel.nestedView?.wrappedValue = view } return targetRouteViewId @@ -248,7 +284,7 @@ open class SwiftUIRouter: Router { if id == route.key { // Found the route we're looking for, but this is up to not including - if let newRoot = stack.last?.root { + if let newRoot = stack.last?.rootView { hostingController.rootView = newRoot } return @@ -281,7 +317,7 @@ open class SwiftUIRouter: Router { if id == route.key { // Found the route we're looking for, and this is up to AND including - if let newRoot = stack.last?.root { + if let newRoot = stack.last?.rootView { hostingController.rootView = newRoot } return @@ -338,7 +374,7 @@ open class SwiftUIRouter: Router { @discardableResult fileprivate func registerRouteHost(view: AnyView, presenterViewModel: SwiftUIPresenterViewModel, byRouteViewId routeViewId: RouteViewIdentifier) -> RouteHost { - let routeHost = RouteHost(root: view, presenterViewModel: presenterViewModel) + let routeHost = RouteHost(rootView: view, presenterViewModel: presenterViewModel) routeHosts[routeViewId] = routeHost stack.append(routeHost) diff --git a/Sources/Router/Router/UINavigationControllerRouter.swift b/Sources/Router/Router/UINavigationControllerRouter.swift index 41d15d8..8523f24 100644 --- a/Sources/Router/Router/UINavigationControllerRouter.swift +++ b/Sources/Router/Router/UINavigationControllerRouter.swift @@ -157,6 +157,13 @@ open class UINavigationControllerRouter: Router { } } else { (host, hostingController) = topLevelRouteHostOrNew() + + if navigationController.viewControllers.isEmpty { + navigationController.viewControllers = [ + hostingController + ] + return targetRouteViewId + } } let state = target.prepareState(environmentObject: environmentObject) @@ -205,7 +212,7 @@ open class UINavigationControllerRouter: Router { ) hostingController.rootView = AnyView(presenter.body(with: presentationContext)) - case .sibling: + case .sibling: // Overlay parent with child presentationContext = PresentationContext( parent: EmptyView(), destination: adjustView(target.body(state: state), environmentObject: environmentObject, routeViewId: targetRouteViewId), From 2f6ab4aba97c4aa3b700382763f50eddbaf536bc Mon Sep 17 00:00:00 2001 From: Joannis Orlandos Date: Tue, 30 Nov 2021 20:56:47 +0100 Subject: [PATCH 11/11] Fix macOS bugs --- .../Router/Router/Environment+router.swift | 25 +++++ Sources/Router/Router/MacRouter.swift | 30 ++++-- .../Router/Router/MasterDetailRouter.swift | 6 +- Sources/Router/Router/Router.swift | 2 +- Sources/Router/Router/SwiftUIRouter.swift | 101 ++++++++++-------- Sources/Router/Router/SwiftUIRouterView.swift | 10 +- .../Router/UINavigationControllerRouter.swift | 8 +- 7 files changed, 115 insertions(+), 67 deletions(-) diff --git a/Sources/Router/Router/Environment+router.swift b/Sources/Router/Router/Environment+router.swift index 6e0e806..dd206c8 100644 --- a/Sources/Router/Router/Environment+router.swift +++ b/Sources/Router/Router/Environment+router.swift @@ -1,5 +1,30 @@ import SwiftUI +@available(iOS 13, macOS 10.15, *) +struct WeakRouter: Router { + weak var _router: R? + + func navigate(to target: Target, _ environmentObject: Target.EnvironmentObjectDependency, using presenter: ThePresenter, source: RouteViewIdentifier?) -> RouteViewIdentifier where Target : EnvironmentDependentRoute, ThePresenter : Presenter { + _router?.navigate(to: target, environmentObject, using: presenter, source: source) ?? .init() + } + + func replaceRoot(with target: Target, _ environmentObject: Target.EnvironmentObjectDependency, using presenter: ThePresenter) -> RouteViewIdentifier where Target : EnvironmentDependentRoute, ThePresenter : Presenter { + _router?.replaceRoot(with: target, environmentObject, using: presenter) ?? .init() + } + + func dismissUpTo(routeMatchingId id: RouteViewIdentifier) { + _router?.dismissUpTo(routeMatchingId: id) + } + + func dismissUpToIncluding(routeMatchingId id: RouteViewIdentifier) { + _router?.dismissUpToIncluding(routeMatchingId: id) + } + + func isPresenting(routeMatchingId id: RouteViewIdentifier) -> Bool { + _router?.isPresenting(routeMatchingId: id) ?? false + } +} + @available(iOS 13, macOS 10.15, *) fileprivate struct RouterKey: EnvironmentKey { typealias Value = Router? diff --git a/Sources/Router/Router/MacRouter.swift b/Sources/Router/Router/MacRouter.swift index f787188..17ca02b 100644 --- a/Sources/Router/Router/MacRouter.swift +++ b/Sources/Router/Router/MacRouter.swift @@ -44,8 +44,10 @@ fileprivate struct PresenterView: View { @ObservedObject var viewModel: PresenterViewModel var body: some View { - // Make sure SwiftUI registers the EnvironmentObject dependency for observation - wrappedView.id(viewModel.isPresented) + if viewModel.isPresented { + // Make sure SwiftUI registers the EnvironmentObject dependency for observation + wrappedView.id(viewModel.isPresented) + } } } @@ -119,8 +121,7 @@ open class MacRouter: Router { let id = RouteViewIdentifier() let view = makeView(for: target, environmentObject: environmentObject, using: presenter, routeViewId: id) - let routeHost = registerRouteHost(view: view, byRouteViewId: id) - return routeHost + return registerRouteHost(view: view, byRouteViewId: id, replaceParent: presenter.presentationMode == .replaceParent) } } @@ -131,7 +132,11 @@ open class MacRouter: Router { let view = makeView(for: target, environmentObject: environmentObject, using: presenter, routeViewId: targetRouteViewId) registerRouteHost(view: view, byRouteViewId: targetRouteViewId) hostingController.rootView = view - case .replaceParent, .sibling: + case .replaceParent: + let view = makeView(for: target, environmentObject: environmentObject, using: presenter, routeViewId: targetRouteViewId) + registerRouteHost(view: view, byRouteViewId: targetRouteViewId, replaceParent: false) + hostingController.rootView = view + case .sibling: let host: RouteHost if let source = source, source != .none { @@ -206,7 +211,7 @@ open class MacRouter: Router { return targetRouteViewId } - public func dismissUpTo(routeMatchesId id: RouteViewIdentifier) { + public func dismissUpTo(routeMatchingId id: RouteViewIdentifier) { while !routeHosts.isEmpty, let lastRouteHost = stack.last { guard let route = routeHosts.first(where: { $0.value == lastRouteHost }) @@ -214,7 +219,7 @@ open class MacRouter: Router { if let (parentRouter, presentationContext) = parentRouter { presentationContext.isPresented = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - parentRouter.dismissUpTo(routeMatchesId: id) + parentRouter.dismissUpTo(routeMatchingId: id) } return } @@ -244,7 +249,7 @@ open class MacRouter: Router { if let (parentRouter, presentationContext) = parentRouter { presentationContext.isPresented = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - parentRouter.dismissUpTo(routeMatchesId: id) + parentRouter.dismissUpTo(routeMatchingId: id) } return } @@ -297,7 +302,7 @@ open class MacRouter: Router { func adjustView(_ view: Input, environmentObject: Dependency, routeViewId: RouteViewIdentifier) -> some View { view - .environment(\.router, self) + .environment(\.router, WeakRouter(_router: self)) .environmentObject(VoidObservableObject()) .environmentObject(environmentObject) .environment(\.routeViewId, routeViewId) @@ -305,9 +310,14 @@ open class MacRouter: Router { } @discardableResult - fileprivate func registerRouteHost(view: AnyView, byRouteViewId routeViewId: RouteViewIdentifier) -> RouteHost { + fileprivate func registerRouteHost(view: AnyView, byRouteViewId routeViewId: RouteViewIdentifier, replaceParent: Bool = false) -> RouteHost { let routeHost = RouteHost(rootView: view) routeHosts[routeViewId] = routeHost + + if !stack.isEmpty, replaceParent { + stack.removeLast() + } + stack.append(routeHost) return routeHost diff --git a/Sources/Router/Router/MasterDetailRouter.swift b/Sources/Router/Router/MasterDetailRouter.swift index 6836688..7f86702 100644 --- a/Sources/Router/Router/MasterDetailRouter.swift +++ b/Sources/Router/Router/MasterDetailRouter.swift @@ -1,7 +1,7 @@ import SwiftUI @available(iOS 13, macOS 10.15, *) -public struct MasterDetailRouter: View { +public struct MasterDetailRouter: View { let masterView: MasterView @State var detailRouter: DetailRouter let makeDetailView: (DetailRouter) -> DetailView @@ -16,7 +16,7 @@ public struct MasterDetailRouter(sibling: Sibling) -> some View { self.rootView @@ -28,8 +29,9 @@ fileprivate final class RouteHost: Hashable { // MARK: Init - init(rootView: AnyView, presenterViewModel: SwiftUIPresenterViewModel) { + init(rootView: AnyView, id: RouteViewIdentifier, presenterViewModel: SwiftUIPresenterViewModel) { self.rootView = rootView + self.id = id self.presenterViewModel = presenterViewModel } @@ -55,29 +57,31 @@ fileprivate struct PresenterView: View { @State var nestedView: AnyView? @ObservedObject var viewModel: SwiftUIPresenterViewModel - var body: some View { - // Make sure SwiftUI registers the EnvironmentObject dependency for observation - wrappedView - .id(viewModel.isPresented) - .background(Group { - if let nestedView = self.nestedView { - NavigationLink( - destination: nestedView, - isActive: .init( - get: { self.nestedView != nil }, - set: { newValue in - if !newValue { - self.nestedView = nil - } - }), - label: { - EmptyView() - } - ) + @ViewBuilder var body: some View { + if viewModel.isPresented { + // Make sure SwiftUI registers the EnvironmentObject dependency for observation + wrappedView + .id(viewModel.isPresented) + .background(Group { + if let nestedView = self.nestedView { + NavigationLink( + destination: nestedView, + isActive: .init( + get: { self.nestedView != nil }, + set: { newValue in + if !newValue { + self.nestedView = nil + } + }), + label: { + EmptyView() + } + ) + } + }).onAppear { + viewModel.nestedView = $nestedView } - }).onAppear { - viewModel.nestedView = $nestedView - } + } } } @@ -98,7 +102,7 @@ open class SwiftUIRouter: Router { _ environmentObject: Root.EnvironmentObjectDependency, parent: (Router, PresentationContext)? = nil ) where Root: EnvironmentDependentRoute { - self.hostingController = HostingController(rootView: AnyView(EmptyView())) + self.hostingController = HostingController(rootView: AnyView(EmptyView())) self.parentRouter = parent replaceRoot(with: root, environmentObject) } @@ -106,7 +110,7 @@ open class SwiftUIRouter: Router { public init( root: Root ) where Root: Route { - self.hostingController = HostingController(rootView: AnyView(EmptyView())) + self.hostingController = HostingController(rootView: AnyView(EmptyView())) self.parentRouter = nil replaceRoot(with: root) } @@ -118,9 +122,12 @@ open class SwiftUIRouter: Router { _ environmentObject: Target.EnvironmentObjectDependency, using presenter: ThePresenter ) -> RouteViewIdentifier where Target : EnvironmentDependentRoute, ThePresenter : Presenter { - self.stack.removeAll(keepingCapacity: true) - self.routeHosts.removeAll(keepingCapacity: true) - self.hostingController.rootView = AnyView(EmptyView()) + for item in stack { + item.presenterViewModel.isPresented = false + } + + self.stack.removeAll() + self.routeHosts.removeAll() return navigate( to: target, @@ -178,7 +185,18 @@ open class SwiftUIRouter: Router { ) registerRouteHost(view: view, presenterViewModel: presenterViewModel, byRouteViewId: targetRouteViewId) hostingController.rootView = view - case .replaceParent, .sibling: + case .replaceParent: + let view = makeView( + for: target, + environmentObject: environmentObject, + presenterViewModel: presenterViewModel, + using: presenter, + routeViewId: targetRouteViewId + ) + + registerRouteHost(view: view, presenterViewModel: presenterViewModel, byRouteViewId: targetRouteViewId) + hostingController.rootView = view + case .sibling: let host: RouteHost if let source = source, source != .none { @@ -254,18 +272,12 @@ open class SwiftUIRouter: Router { case .normal: fatalError("Internal inconsistency") } - - presenterViewModel.$isPresented - .first { $0 == false } - .sink { [weak hostingController] _ in - hostingController?.rootView = AnyView(presenter.body(with: presentationContext)) } - .store(in: &cancellables) } return targetRouteViewId } - public func dismissUpTo(routeMatchesId id: RouteViewIdentifier) { + public func dismissUpTo(routeMatchingId id: RouteViewIdentifier) { while !routeHosts.isEmpty, let lastRouteHost = stack.last { guard let route = routeHosts.first(where: { $0.value == lastRouteHost }) @@ -273,7 +285,7 @@ open class SwiftUIRouter: Router { if let (parentRouter, presentationContext) = parentRouter { presentationContext.isPresented = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - parentRouter.dismissUpTo(routeMatchesId: id) + parentRouter.dismissUpTo(routeMatchingId: id) } return } @@ -284,8 +296,8 @@ open class SwiftUIRouter: Router { if id == route.key { // Found the route we're looking for, but this is up to not including - if let newRoot = stack.last?.rootView { - hostingController.rootView = newRoot + if let newRoot = stack.last { + hostingController.rootView = newRoot.rootView } return } @@ -303,7 +315,7 @@ open class SwiftUIRouter: Router { if let (parentRouter, presentationContext) = parentRouter { presentationContext.isPresented = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - parentRouter.dismissUpTo(routeMatchesId: id) + parentRouter.dismissUpTo(routeMatchingId: id) } return } @@ -317,8 +329,8 @@ open class SwiftUIRouter: Router { if id == route.key { // Found the route we're looking for, and this is up to AND including - if let newRoot = stack.last?.rootView { - hostingController.rootView = newRoot + if let newRoot = stack.last { + hostingController.rootView = newRoot.rootView } return } @@ -363,7 +375,7 @@ open class SwiftUIRouter: Router { func adjustView(_ view: Input, presenterViewModel: SwiftUIPresenterViewModel, environmentObject: Dependency, routeViewId: RouteViewIdentifier) -> some View { return PresenterView( wrappedView: view - .environment(\.router, self) + .environment(\.router, WeakRouter(_router: self)) .environmentObject(VoidObservableObject()) .environmentObject(environmentObject) .environment(\.routeViewId, routeViewId) @@ -374,8 +386,9 @@ open class SwiftUIRouter: Router { @discardableResult fileprivate func registerRouteHost(view: AnyView, presenterViewModel: SwiftUIPresenterViewModel, byRouteViewId routeViewId: RouteViewIdentifier) -> RouteHost { - let routeHost = RouteHost(rootView: view, presenterViewModel: presenterViewModel) + let routeHost = RouteHost(rootView: view, id: routeViewId, presenterViewModel: presenterViewModel) routeHosts[routeViewId] = routeHost + stack.append(routeHost) return routeHost diff --git a/Sources/Router/Router/SwiftUIRouterView.swift b/Sources/Router/Router/SwiftUIRouterView.swift index dd70ca5..aff2f4d 100644 --- a/Sources/Router/Router/SwiftUIRouterView.swift +++ b/Sources/Router/Router/SwiftUIRouterView.swift @@ -27,10 +27,10 @@ fileprivate struct HostingControllerRepresentable: UIViewControllerRepr #if canImport(AppKit) || canImport(UIKit) @available(iOS 13, macOS 10.15, *) public struct SwiftUIRouterView: View { - @State var router: SwiftUIRouter + let router: SwiftUIRouter public init(router: SwiftUIRouter) { - self._router = State(wrappedValue: router) + self.router = router } public var body: some View { @@ -43,10 +43,10 @@ public struct SwiftUIRouterView: View { @available(iOS 13, macOS 10.15, *) public struct SwiftUIMasterDetailRouterView: View { let makeMaster: () -> Master - @State var router: SwiftUIRouter + let router: SwiftUIRouter public init(router: SwiftUIRouter, @ViewBuilder makeMaster: @escaping () -> Master) { - self._router = State(wrappedValue: router) + self.router = router self.makeMaster = makeMaster } @@ -54,7 +54,7 @@ public struct SwiftUIMasterDetailRouterView: View { NavigationView { makeMaster() .environmentObject(VoidObservableObject()) - .environment(\.router, router) + .environment(\.router, WeakRouter(_router: router)) HostingControllerRepresentable(hostingController: router.hostingController) } diff --git a/Sources/Router/Router/UINavigationControllerRouter.swift b/Sources/Router/Router/UINavigationControllerRouter.swift index 8523f24..ee7e0cb 100644 --- a/Sources/Router/Router/UINavigationControllerRouter.swift +++ b/Sources/Router/Router/UINavigationControllerRouter.swift @@ -237,12 +237,12 @@ open class UINavigationControllerRouter: Router { /// Dismisses up to, but not including, the given `id`, so the route with that identifier becomes the topmost route. /// - Parameter id: The `id` of the route to dismiss up to. - public func dismissUpTo(routeMatchesId id: RouteViewIdentifier) { + public func dismissUpTo(routeMatchingId id: RouteViewIdentifier) { guard let hostingController = routeHosts[id]?.hostingController else { if let (parentRouter, presentationContext) = parentRouter { presentationContext.isPresented = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - parentRouter.dismissUpTo(routeMatchesId: id) + parentRouter.dismissUpTo(routeMatchingId: id) } return } @@ -268,7 +268,7 @@ open class UINavigationControllerRouter: Router { if let (parentRouter, presentationContext) = parentRouter { presentationContext.isPresented = false DispatchQueue.main.async { - parentRouter.dismissUpTo(routeMatchesId: id) + parentRouter.dismissUpTo(routeMatchingId: id) } return } @@ -335,7 +335,7 @@ open class UINavigationControllerRouter: Router { func adjustView(_ view: Input, environmentObject: Dependency, routeViewId: RouteViewIdentifier) -> some View { view - .environment(\.router, self) + .environment(\.router, WeakRouter(_router: self)) .environmentObject(VoidObservableObject()) .environmentObject(environmentObject) .environment(\.routeViewId, routeViewId)