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/MacRouter.swift b/Sources/Router/Router/MacRouter.swift new file mode 100644 index 0000000..864cee9 --- /dev/null +++ b/Sources/Router/Router/MacRouter.swift @@ -0,0 +1,322 @@ +#if canImport(AppKit) +import Combine +import SwiftUI + +@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: 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 { + 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] 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, 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 { + 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, 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, 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, + environmentObject: RootRoute.EnvironmentObjectDependency, + presentationContext: PresentationContext, + presenterViewModel: PresenterViewModel + ) -> 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: 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/RouterView.swift b/Sources/Router/Router/RouterView.swift index a1856d1..11b5178 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) { @@ -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 15d3b85..d05ca9f 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 { @@ -113,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 = 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) } @@ -122,7 +121,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) if navigationController.viewControllers.isEmpty { @@ -296,7 +295,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) 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() {