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 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. 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 new file mode 100644 index 0000000..17ca02b --- /dev/null +++ b/Sources/Router/Router/MacRouter.swift @@ -0,0 +1,361 @@ +#if canImport(AppKit) +import Combine +import SwiftUI + +@available(macOS 10.15, *) +fileprivate final class RouteHost: Hashable { + + // MARK: State + + let rootView: AnyView + + func root(sibling: Sibling) -> some View { + self.rootView + .overlay(AnyView(sibling)) + } + + // MARK: Init + + init(rootView: AnyView) { + self.rootView = rootView + } + + // 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 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 { + if viewModel.isPresented { + // 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( + rootView: Root, + _ environmentObject: Root.EnvironmentObjectDependency, + parent: (Router, PresentationContext)? = nil + ) where Root: EnvironmentDependentRoute { + self.hostingController = NSHostingController(rootView: AnyView(EmptyView())) + self.parentRouter = parent + replaceRoot(with: rootView, environmentObject) + } + + public init( + rootView: Root + ) where Root: Route { + self.hostingController = NSHostingController(rootView: AnyView(EmptyView())) + self.parentRouter = nil + replaceRoot(with: rootView) + } + + // 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 { + 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) + return registerRouteHost(view: view, byRouteViewId: id, replaceParent: presenter.presentationMode == .replaceParent) + } + } + + let targetRouteViewId = RouteViewIdentifier() + + 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 + 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 { + 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 + + 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, + presentationContext: presentationContext, + presenterViewModel: presenterViewModel + ) + } + + 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) + } + + return targetRouteViewId + } + + public func dismissUpTo(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(routeMatchingId: 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?.rootView { + 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(routeMatchingId: 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?.rootView { + 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] presentationContext in + self.makeChildRouterView( + rootRoute: target, + environmentObject: environmentObject, + 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, WeakRouter(_router: self)) + .environmentObject(VoidObservableObject()) + .environmentObject(environmentObject) + .environment(\.routeViewId, routeViewId) + .id(routeViewId) + } + + @discardableResult + 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 + } + + func makeChildRouterView( + rootRoute: RootRoute, + environmentObject: RootRoute.EnvironmentObjectDependency, + presentationContext: PresentationContext, + presenterViewModel: PresenterViewModel + ) -> AnyView { + let router = MacRouter( + rootView: 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: 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..d00cdaf --- /dev/null +++ b/Sources/Router/Router/MacRouterView.swift @@ -0,0 +1,19 @@ +#if canImport(AppKit) +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) { } +} +#endif diff --git a/Sources/Router/Router/MasterDetailRouter.swift b/Sources/Router/Router/MasterDetailRouter.swift new file mode 100644 index 0000000..7f86702 --- /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, WeakRouter(_router: detailRouter)) + .environmentObject(VoidObservableObject()) + + makeDetailView(detailRouter) + } + #else + HStack(spacing: 0) { + masterView + .environment(\.router, WeakRouter(_router: detailRouter)) + .environmentObject(VoidObservableObject()) + + Divider() + + makeDetailView(detailRouter) + } + #endif + } +} + diff --git a/Sources/Router/Router/Router.swift b/Sources/Router/Router/Router.swift index 71c28fb..a4581ee 100644 --- a/Sources/Router/Router/Router.swift +++ b/Sources/Router/Router/Router.swift @@ -46,7 +46,7 @@ public protocol Router { /// Dismiss up to, but not including, the route matching `id`. /// /// The actual dismissal behavior can differ between router implementations. - func dismissUpTo(routeMatchesId id: RouteViewIdentifier) + func dismissUpTo(routeMatchingId id: RouteViewIdentifier) /// Dismiss all routes up to, and including, the route matching `id`. /// diff --git a/Sources/Router/Router/RouterView.swift b/Sources/Router/Router/RouterView.swift index a1856d1..93e9234 100644 --- a/Sources/Router/Router/RouterView.swift +++ b/Sources/Router/Router/RouterView.swift @@ -1,7 +1,8 @@ import SwiftUI +#if canImport(UIKit) @available(iOS 13, *) -public struct RouterView: View { +public struct StackRouterView: View { @State var router: UINavigationControllerRouter public init(root: RootRoute) { @@ -12,7 +13,12 @@ public struct RouterView: 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) } } +#endif diff --git a/Sources/Router/Router/SwiftUIRouter.swift b/Sources/Router/Router/SwiftUIRouter.swift new file mode 100644 index 0000000..8fb41fc --- /dev/null +++ b/Sources/Router/Router/SwiftUIRouter.swift @@ -0,0 +1,436 @@ +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 + +#if canImport(UIKit) || canImport(AppKit) +import Combine +import SwiftUI + +@available(iOS 13, macOS 10.15, *) +fileprivate final class RouteHost: Hashable { + // MARK: State + + let rootView: AnyView + let id: RouteViewIdentifier + + func root(sibling: Sibling) -> some View { + self.rootView + .overlay(AnyView(sibling)) + } + + let presenterViewModel: SwiftUIPresenterViewModel + + // MARK: Init + + init(rootView: AnyView, id: RouteViewIdentifier, presenterViewModel: SwiftUIPresenterViewModel) { + self.rootView = rootView + self.id = id + 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 + + @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 + } + } + } +} + +@available(iOS 13, macOS 10.15, *) +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 = HostingController(rootView: AnyView(EmptyView())) + self.parentRouter = parent + replaceRoot(with: root, environmentObject) + } + + public init( + root: Root + ) where Root: Route { + self.hostingController = HostingController(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 { + for item in stack { + item.presenterViewModel.isPresented = false + } + + self.stack.removeAll() + self.routeHosts.removeAll() + + 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() + + switch presenter.presentationMode { + case .normal: // Push 💨 + let view = makeView( + for: target, + environmentObject: environmentObject, + presenterViewModel: presenterViewModel, + using: presenter, + routeViewId: targetRouteViewId + ) + registerRouteHost(view: view, presenterViewModel: presenterViewModel, byRouteViewId: targetRouteViewId) + hostingController.rootView = view + 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 { + 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 presentationContext: PresentationContext + + 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, + presentationContext: presentationContext, + presenterViewModel: presenterViewModel + ) + } + + 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") + } + } + + return targetRouteViewId + } + + public func dismissUpTo(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(routeMatchingId: 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 { + hostingController.rootView = newRoot.rootView + } + 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(routeMatchingId: 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 { + hostingController.rootView = newRoot.rootView + } + 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, WeakRouter(_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(rootView: view, id: routeViewId, presenterViewModel: presenterViewModel) + routeHosts[routeViewId] = routeHost + + stack.append(routeHost) + + return routeHost + } + + func makeChildRouterView( + rootRoute: RootRoute, + environmentObject: RootRoute.EnvironmentObjectDependency, + presentationContext: PresentationContext, + presenterViewModel: SwiftUIPresenterViewModel + ) -> AnyView { + let router = SwiftUIRouter( + root: rootRoute, + environmentObject, + parent: (self, presentationContext) + ) + + return AnyView(PresenterView( + wrappedView: SwiftUIRouterView(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..aff2f4d --- /dev/null +++ b/Sources/Router/Router/SwiftUIRouterView.swift @@ -0,0 +1,63 @@ +import SwiftUI + +#if canImport(AppKit) +@available(iOS 13, macOS 10.15, *) +fileprivate struct HostingControllerRepresentable: NSViewControllerRepresentable { + let hostingController: HostingController + + func makeNSViewController(context: Context) -> HostingController { + hostingController + } + + func updateNSViewController(_ nsViewController: HostingController, context: Context) { } +} +#elseif canImport(UIKit) +@available(iOS 13, macOS 10.15, *) +fileprivate struct HostingControllerRepresentable: UIViewControllerRepresentable { + let hostingController: HostingController + + func makeUIViewController(context: Context) -> HostingController { + hostingController + } + + func updateUIViewController(_ nsViewController: HostingController, context: Context) { } +} +#endif + +#if canImport(AppKit) || canImport(UIKit) +@available(iOS 13, macOS 10.15, *) +public struct SwiftUIRouterView: View { + let router: SwiftUIRouter + + public init(router: SwiftUIRouter) { + self.router = router + } + + public var body: some View { + NavigationView { + HostingControllerRepresentable(hostingController: router.hostingController) + } + } +} + +@available(iOS 13, macOS 10.15, *) +public struct SwiftUIMasterDetailRouterView: View { + let makeMaster: () -> Master + let router: SwiftUIRouter + + public init(router: SwiftUIRouter, @ViewBuilder makeMaster: @escaping () -> Master) { + self.router = router + self.makeMaster = makeMaster + } + + public var body: some View { + NavigationView { + makeMaster() + .environmentObject(VoidObservableObject()) + .environment(\.router, WeakRouter(_router: router)) + + HostingControllerRepresentable(hostingController: router.hostingController) + } + } +} +#endif diff --git a/Sources/Router/Router/UINavigationControllerRouter.swift b/Sources/Router/Router/UINavigationControllerRouter.swift index 9b68b69..ee7e0cb 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 @@ -146,7 +146,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 @@ -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), @@ -230,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 } @@ -261,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 } @@ -328,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) @@ -342,7 +349,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(hostingController: hostingController) @@ -397,7 +404,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() {} 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 5cd7e72..2e1a220 100644 --- a/Sources/Router/Views/RouterLink.swift +++ b/Sources/Router/Views/RouterLink.swift @@ -34,7 +34,7 @@ public struct RouterLink: View { } public var body: some View { - Button(action: navigate) { label } + label.onTapGesture(perform: navigate) } private func navigate() {