portico/Sources/Portico/Navigation/NavigationDestination.swift

118 lines
4.4 KiB
Swift

import Adw
import Gtk
// MARK: - Destination Registry
/// Maps a destination value's dynamic type to the builder that renders it.
///
/// Registration happens at mount time: `.navigationDestination(for:)` looks the
/// registry up in the environment injected by the enclosing `NavigationView`.
/// A second registration for the same type replaces the first, so the innermost
/// (latest-mounted) declaration wins, matching SwiftUI.
@MainActor final class NavigationDestinationRegistry {
private var builders: [ObjectIdentifier: (AnyHashable) -> [AnyView]] = [:]
func register<D: Hashable>(_ type: D.Type, _ build: @escaping (D) -> [AnyView]) {
builders[ObjectIdentifier(type)] = { erased in
guard let value = erased.base as? D else { return [] }
return build(value)
}
}
/// The views for `value`, or `nil` when no builder is registered for its
/// exact dynamic type.
func build(_ value: AnyHashable) -> [AnyView]? {
builders[ObjectIdentifier(type(of: value.base))]?(value)
}
}
// MARK: - Environment slots
private enum NavigationDestinationsKey: EnvironmentKey {
static var defaultValue: NavigationDestinationRegistry? { nil }
}
private enum NavigationPageKey: EnvironmentKey {
static var defaultValue: Adw.NavigationPage? { nil }
}
extension EnvironmentValues {
/// The destination registry of the innermost enclosing `NavigationView`.
var navigationDestinations: NavigationDestinationRegistry? {
get { self[NavigationDestinationsKey.self] }
set { self[NavigationDestinationsKey.self] = newValue }
}
/// The `Adw.NavigationPage` this subtree is mounted into.
var navigationPage: Adw.NavigationPage? {
get { self[NavigationPageKey.self] }
set { self[NavigationPageKey.self] = newValue }
}
}
// MARK: - navigationDestination(for:destination:)
extension View {
/// Associates a destination view with a presented data type, for use within
/// the enclosing path-driven `NavigationView`.
///
/// Registration happens when this view mounts, so a destination declared
/// inside another destination is only available once that outer page has
/// been pushed - the same lazy-registration caveat SwiftUI has.
///
/// - Parameters:
/// - data: The exact type of value this destination renders. Lookup is by
/// dynamic type; subclasses are not matched.
/// - destination: Builds the page content from a value snapshot.
/// - Returns: The receiver, unchanged, with the destination registered.
public func navigationDestination<D: Hashable>(
for data: D.Type,
@ViewBuilder destination: @escaping (D) -> [AnyView]
) -> AnyView {
AnyView(makeWidget: { ctx in
ctx.environment.navigationDestinations?.register(data, destination)
return AnyView(self).makeWidget(ctx)
})
}
}
// MARK: - navigationTitle
extension View {
/// Sets the title of the `Adw.NavigationPage` this view is mounted into
/// (static, applied once at mount).
///
/// `Adw.HeaderBar` inside the page displays this title automatically, and
/// the next page's back button uses it as its tooltip. No-op outside a
/// `NavigationView` page.
public func navigationTitle(_ title: String) -> AnyView {
AnyView(makeWidget: { ctx in
ctx.environment.navigationPage?.setTitle(title: title)
return AnyView(self).makeWidget(ctx)
})
}
/// Sets the page title, tracking a ``Binding``.
public func navigationTitle(_ title: Binding<String>) -> AnyView {
AnyView(makeWidget: { ctx in
if let page = ctx.environment.navigationPage {
page.setTitle(title: title.wrappedValue)
ctx.registry.add(title.subscribe { [page] t in page.setTitle(title: t) })
}
return AnyView(self).makeWidget(ctx)
})
}
/// Sets the page title from a tracked closure, re-evaluated when any
/// `@State` or observable property it reads changes.
public func navigationTitle(_ title: @escaping () -> String) -> AnyView {
AnyView(makeWidget: { ctx in
if let page = ctx.environment.navigationPage {
let tracker = DependencyTracker { [page] in page.setTitle(title: title()) }
tracker.run()
ctx.registry.add(tracker)
}
return AnyView(self).makeWidget(ctx)
})
}
}