Skip to content
This repository was archived by the owner on Jul 6, 2022. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ jobs:
matrix:
destination:
- generic/platform=iOS
- generic/platform=macOS

steps:
- uses: actions/checkout@v2
Expand Down
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
322 changes: 322 additions & 0 deletions Sources/Router/Router/MacRouter.swift
Original file line number Diff line number Diff line change
@@ -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<WrappedView: View>: 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<AnyView>
let parentRouter: (Router, PresentationContext)?

/// key: `ObjectIdentifier` of the `HostingController`
fileprivate var routeHosts: [RouteViewIdentifier: RouteHost] = [:]
fileprivate var stack = [RouteHost]()

/// 🗑 Combine cancellables.
private var cancellables = Set<AnyCancellable>()

public init<Root>(
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: Root
) where Root: Route {
self.hostingController = NSHostingController(rootView: AnyView(EmptyView()))
self.parentRouter = nil
replaceRoot(with: root)
}

// MARK: Root view replacement

open func replaceRoot<Target, ThePresenter>(
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<Target, ThePresenter>(
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<Target: EnvironmentDependentRoute, ThePresenter: Presenter>(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<Input: View, Dependency: ObservableObject>(_ 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: EnvironmentDependentRoute>(
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<Bool> {
Binding(
get: { [weak self] in
self?.isPresenting(routeMatchingId: id) ?? false
},
set: { [weak self] newValue in
if !newValue {
self?.dismissUpToIncluding(routeMatchingId: id)
}
}
)
}
}
#endif
19 changes: 19 additions & 0 deletions Sources/Router/Router/MacRouterView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#if canImport(AppKit)
import SwiftUI

public struct MacRouterView: NSViewControllerRepresentable {
public typealias NSViewControllerType = NSHostingController<AnyView>

public let router: MacRouter

public init(router: MacRouter) {
self.router = router
}

public func makeNSViewController(context: Context) -> NSHostingController<AnyView> {
router.hostingController
}

public func updateNSViewController(_ nsViewController: NSHostingController<AnyView>, context: Context) { }
}
#endif
4 changes: 3 additions & 1 deletion Sources/Router/Router/RouterView.swift
Original file line number Diff line number Diff line change
@@ -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<RootRoute: Route>(root: RootRoute) {
Expand All @@ -16,3 +17,4 @@ public struct RouterView: View {
UINavigationControllerRouterView(router: router)
}
}
#endif
Loading