Add path-driven NavigationView with NavigationStack semantics
This commit is contained in:
parent
772a3c3a71
commit
b398edf3ee
9 changed files with 974 additions and 21 deletions
|
|
@ -28,14 +28,17 @@ private struct StatusPane: View {
|
|||
@Environment(DemoModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
VStack {
|
||||
@Bindable var model = model
|
||||
|
||||
Label($model.label)
|
||||
.title2()
|
||||
VStack(spacing: 8) {
|
||||
Label($model.label)
|
||||
.title2()
|
||||
|
||||
Label("Font: \(Int(model.fontSize))pt, \(model.isDark ? "Dark" : "Light")")
|
||||
.dimmed()
|
||||
Label("Font: \(Int(model.fontSize))pt, \(model.isDark ? "Dark" : "Light")")
|
||||
.dimmed()
|
||||
}
|
||||
.margin(16)
|
||||
}
|
||||
.card()
|
||||
.halign(.center)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import Portico
|
||||
|
||||
private struct ExamplePark: Hashable { let name: String }
|
||||
|
||||
@main
|
||||
struct ExampleApp: App {
|
||||
var applicationId: String? { "dev.bscubed.PorticoExample" }
|
||||
|
|
@ -9,30 +11,50 @@ struct ExampleApp: App {
|
|||
@State private var darkMode = false
|
||||
@State private var volume = 75.0
|
||||
@State private var currentPage: UInt32 = 0
|
||||
@State private var parkPath: [ExamplePark] = []
|
||||
|
||||
@State private var uptime = 0
|
||||
|
||||
var body: some Scene {
|
||||
ApplicationWindow { _ in
|
||||
VStack {
|
||||
HeaderBar()
|
||||
NavigationView(path: $parkPath) {
|
||||
VStack {
|
||||
HeaderBar()
|
||||
|
||||
Carousel {
|
||||
introPage
|
||||
counterPage
|
||||
settingsPage
|
||||
AsyncDemoPage()
|
||||
EnvironmentDemoPage()
|
||||
Carousel {
|
||||
introPage
|
||||
counterPage
|
||||
settingsPage
|
||||
AsyncDemoPage()
|
||||
EnvironmentDemoPage()
|
||||
navigationDemoPage
|
||||
}
|
||||
.onPageChanged { page in currentPage = page }
|
||||
|
||||
Label("Uptime: \(uptime)s, Page \(currentPage + 1) of 6")
|
||||
.dimmed()
|
||||
.halign(.center)
|
||||
.margin(8)
|
||||
.task(every: .seconds(1)) { uptime += 1 }
|
||||
}
|
||||
.navigationTitle("Portico Demo")
|
||||
.navigationDestination(for: ExamplePark.self) { park in
|
||||
VStack {
|
||||
HeaderBar()
|
||||
Label("Welcome to \(park.name)")
|
||||
.title1()
|
||||
.halign(.center)
|
||||
.margin(24)
|
||||
Label("This page was pushed programmatically via the path binding.")
|
||||
.dimmed()
|
||||
.halign(.center)
|
||||
}
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
.navigationTitle(park.name)
|
||||
}
|
||||
.onPageChanged { page in currentPage = page }
|
||||
|
||||
Label("Uptime: \(uptime)s, Page \(currentPage + 1) of 5")
|
||||
.dimmed()
|
||||
.halign(.center)
|
||||
.margin(8)
|
||||
.task(every: .seconds(1)) { uptime += 1 }
|
||||
}
|
||||
}
|
||||
.title("Portico Demo")
|
||||
.defaultSize(width: 1080, height: 720)
|
||||
}
|
||||
|
||||
|
|
@ -104,4 +126,34 @@ struct ExampleApp: App {
|
|||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
}
|
||||
|
||||
var navigationDemoPage: some View {
|
||||
Clamp {
|
||||
StatusPage {
|
||||
ListBox {
|
||||
ButtonRow()
|
||||
.title("See Yosemite")
|
||||
.onActivated {
|
||||
parkPath.append(ExamplePark(name: "Yosemite"))
|
||||
}
|
||||
ButtonRow()
|
||||
.title("See Yellowstone")
|
||||
.onActivated {
|
||||
parkPath.append(ExamplePark(name: "Yellowstone"))
|
||||
}
|
||||
ButtonRow()
|
||||
.title("See Zion")
|
||||
.onActivated {
|
||||
parkPath.append(ExamplePark(name: "Zion"))
|
||||
}
|
||||
}
|
||||
.boxedList()
|
||||
}
|
||||
.title("Navigation")
|
||||
.description("Navigating is easy and reactive in Portico! Just bind an array to a NavigationView and it updates when changes are made in either direction!")
|
||||
}
|
||||
.valign(.center)
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,17 @@ import Gdk
|
|||
}
|
||||
}
|
||||
|
||||
// PorticoGen: generateInits(static) | source: Gtk.DropDown.init(strings:)
|
||||
/// Creates a new `GtkDropDown` that is populated with
|
||||
/// the strings.
|
||||
///
|
||||
/// Applied once at mount; use the `Binding` or closure overload for values that change.
|
||||
///
|
||||
/// - Parameter strings: The `strings` value forwarded to `Gtk.DropDown`.
|
||||
public init(strings: [String]) {
|
||||
make = { _ in Gtk.DropDown(strings: strings) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension DropDown: WidgetView {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,75 @@ import Gtk
|
|||
import Gio
|
||||
import Gdk
|
||||
|
||||
// PorticoGen: generateStruct | source: Gtk.ScaleButton
|
||||
/// Provides a button which pops up a scale widget.
|
||||
///
|
||||
/// This kind of widget is commonly used for volume controls in multimedia
|
||||
/// applications, and GTK provides a [class`Gtk`.VolumeButton] subclass that
|
||||
/// is tailored for this use case.
|
||||
///
|
||||
/// # Shortcuts and Gestures
|
||||
///
|
||||
/// The following signals have default keybindings:
|
||||
///
|
||||
/// - [signal`Gtk`.ScaleButton::popup]
|
||||
///
|
||||
/// # CSS nodes
|
||||
///
|
||||
/// ```
|
||||
/// scalebutton.scale
|
||||
/// ╰── button.toggle
|
||||
/// ╰── <icon>
|
||||
/// ```
|
||||
///
|
||||
/// `GtkScaleButton` has a single CSS node with name scalebutton and `.scale`
|
||||
/// style class, and contains a `button` node with a `.toggle` style class.
|
||||
///
|
||||
/// A Portico view that mounts a `Gtk.ScaleButton`.
|
||||
@MainActor public struct ScaleButton: View {
|
||||
private let make: (MountContext) -> Gtk.ScaleButton
|
||||
private var configure: [(Gtk.ScaleButton, MountContext) -> Void] = []
|
||||
|
||||
public var body: Never { fatalError() }
|
||||
|
||||
// PorticoGen: generateInits(static) | source: Gtk.ScaleButton.init(min:max:step:icons:)
|
||||
/// Creates a `GtkScaleButton`.
|
||||
///
|
||||
/// The new scale button has a range between `min` and `max`,
|
||||
/// with a stepping of `step`.
|
||||
///
|
||||
/// Applied once at mount; use the `Binding` or closure overload for values that change.
|
||||
///
|
||||
/// - Parameter min: The `min` value forwarded to `Gtk.ScaleButton`.
|
||||
/// - Parameter max: The `max` value forwarded to `Gtk.ScaleButton`.
|
||||
/// - Parameter step: The `step` value forwarded to `Gtk.ScaleButton`.
|
||||
/// - Parameter icons: Sets the icons to be used by the scale button.
|
||||
public init(min: Double, max: Double, step: Double, icons: [String]) {
|
||||
make = { _ in Gtk.ScaleButton(min: min, max: max, step: step, icons: icons) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension ScaleButton: WidgetView {
|
||||
public typealias Target = Gtk.ScaleButton
|
||||
|
||||
@_spi(Portico) public func appending(
|
||||
_ step: @escaping (Gtk.ScaleButton, MountContext) -> Void
|
||||
) -> Self {
|
||||
var c = self
|
||||
c.configure.append(step)
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
@_spi(Portico) extension ScaleButton: Mountable {
|
||||
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
|
||||
let w = make(ctx)
|
||||
for step in configure { step(w, ctx) }
|
||||
return w
|
||||
}
|
||||
}
|
||||
|
||||
// PorticoGen: generateModifierExtension | source: Gtk.ScaleButton
|
||||
/// Modifiers for `Gtk.ScaleButton`, available on every Portico view whose
|
||||
/// backing widget is `Gtk.ScaleButton` or one of its subclasses.
|
||||
|
|
|
|||
118
Sources/Portico/Navigation/NavigationDestination.swift
Normal file
118
Sources/Portico/Navigation/NavigationDestination.swift
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
124
Sources/Portico/Navigation/NavigationPath.swift
Normal file
124
Sources/Portico/Navigation/NavigationPath.swift
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - NavigationPath
|
||||
|
||||
nonisolated public struct NavigationPath: Equatable {
|
||||
/// The erased destination values, root-most first. Consumed by
|
||||
/// `NavigationView`'s path-driven initializer.
|
||||
@_spi(Portico) public private(set) var elements: [AnyHashable]
|
||||
|
||||
/// Creates an empty path.
|
||||
public init() { self.elements = [] }
|
||||
|
||||
/// Creates a path from a sequence of homogeneous destination values.
|
||||
public init<S: Sequence>(_ elements: S) where S.Element: Hashable {
|
||||
self.elements = elements.map { AnyHashable($0) }
|
||||
}
|
||||
|
||||
/// Creates a path from already-erased values, used when writing a truncated
|
||||
/// stack back into the binding.
|
||||
@_spi(Portico) public init(erased elements: [AnyHashable]) {
|
||||
self.elements = elements
|
||||
}
|
||||
|
||||
/// The number of destinations on the path.
|
||||
public var count: Int { elements.count }
|
||||
|
||||
/// Whether the path presents no destinations.
|
||||
public var isEmpty: Bool { elements.isEmpty }
|
||||
|
||||
/// Appends a destination value of any `Hashable` type.
|
||||
public mutating func append<V: Hashable>(_ value: V) {
|
||||
elements.append(AnyHashable(value))
|
||||
}
|
||||
|
||||
/// Removes the last `k` destinations, clamped to the path's length.
|
||||
///
|
||||
/// Unlike SwiftUI's `NavigationPath.removeLast(_:)`, an out-of-range `k`
|
||||
/// clamps instead of trapping: a widget-initiated pop and a programmatic
|
||||
/// `removeLast` can observe the same path in either order, and a crash is
|
||||
/// the wrong outcome for that race.
|
||||
public mutating func removeLast(_ k: Int = 1) {
|
||||
elements.removeLast(min(max(k, 0), elements.count))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CodableRepresentation
|
||||
|
||||
extension NavigationPath {
|
||||
/// A `Codable` snapshot of a path whose every element is `Codable`.
|
||||
///
|
||||
/// Encodes as a flat unkeyed container of `2 * count` strings, root-most
|
||||
/// element first: each element contributes its mangled type name followed
|
||||
/// by its UTF-8 JSON encoding. Decoding resolves each type name through
|
||||
/// `_typeByName`, so a type that was renamed, moved module, or is absent
|
||||
/// from the decoding binary fails with `DecodingError.dataCorrupted`.
|
||||
public struct CodableRepresentation: Codable {
|
||||
let elements: [AnyHashable]
|
||||
|
||||
init(elements: [AnyHashable]) {
|
||||
self.elements = elements
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
var container = try decoder.unkeyedContainer()
|
||||
var decoded: [AnyHashable] = []
|
||||
while !container.isAtEnd {
|
||||
let name = try container.decode(String.self)
|
||||
let json = try container.decode(String.self)
|
||||
guard let resolved = _typeByName(name) as? any (Decodable & Hashable).Type else {
|
||||
throw DecodingError.dataCorrupted(
|
||||
.init(codingPath: container.codingPath,
|
||||
debugDescription: "NavigationPath: unknown element type \(name)")
|
||||
)
|
||||
}
|
||||
decoded.append(try _decodeNavigationElement(resolved, from: Data(json.utf8)))
|
||||
}
|
||||
self.elements = decoded
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.unkeyedContainer()
|
||||
for element in elements {
|
||||
guard let encodable = element.base as? any Encodable,
|
||||
let name = _mangledTypeName(type(of: element.base))
|
||||
else {
|
||||
throw EncodingError.invalidValue(
|
||||
element.base,
|
||||
.init(codingPath: container.codingPath,
|
||||
debugDescription: "NavigationPath: element is not Encodable")
|
||||
)
|
||||
}
|
||||
try container.encode(name)
|
||||
try container.encode(String(decoding: JSONEncoder().encode(encodable), as: UTF8.self))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Codable` snapshot, or `nil` when any element's type is not `Codable`
|
||||
/// (matching SwiftUI, where one non-codable element makes the whole path
|
||||
/// unserializable rather than partially serializable).
|
||||
public var codable: CodableRepresentation? {
|
||||
for element in elements {
|
||||
guard element.base is any Codable,
|
||||
_mangledTypeName(type(of: element.base)) != nil
|
||||
else { return nil }
|
||||
}
|
||||
return CodableRepresentation(elements: elements)
|
||||
}
|
||||
|
||||
/// Restores a path from a decoded snapshot.
|
||||
public init(_ codable: CodableRepresentation) {
|
||||
self.elements = codable.elements
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes one erased element. A free generic function so that passing an
|
||||
/// `any (Decodable & Hashable).Type` opens the existential into `T` (SE-0352
|
||||
/// implicit existential opening); the metatype cannot be handed to
|
||||
/// `JSONDecoder.decode` directly.
|
||||
private func _decodeNavigationElement<T: Decodable & Hashable>(
|
||||
_ type: T.Type, from data: Data
|
||||
) throws -> AnyHashable {
|
||||
AnyHashable(try JSONDecoder().decode(T.self, from: data))
|
||||
}
|
||||
244
Sources/Portico/Navigation/NavigationView+Extras.swift
Normal file
244
Sources/Portico/Navigation/NavigationView+Extras.swift
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import Foundation
|
||||
import Adw
|
||||
import Gtk
|
||||
|
||||
// MARK: - Path access shim
|
||||
|
||||
/// Type-erased read/write access to a `NavigationView`'s bound path.
|
||||
///
|
||||
/// `read` MUST go through `Binding.wrappedValue` so the enclosing
|
||||
/// ``DependencyTracker`` registers with the backing state box; `write` MUST go
|
||||
/// through the setter so the change notifies.
|
||||
struct NavigationPathAccess {
|
||||
let read: () -> [AnyHashable]
|
||||
let write: ([AnyHashable]) -> Void
|
||||
}
|
||||
|
||||
// MARK: - Page construction helper
|
||||
|
||||
/// Wraps `content` in a fresh `Adw.NavigationPage` tagged `tag`.
|
||||
///
|
||||
/// The holder `Gtk.Box` lets the page exist before its content mounts:
|
||||
/// `Adw.NavigationPage` requires a child at construction, while the content must
|
||||
/// see the page in its environment for `.navigationTitle` to work.
|
||||
private func makeNavigationPage(
|
||||
tag: String, content: [AnyView], in scope: MountContext
|
||||
) -> Adw.NavigationPage {
|
||||
let holder = Gtk.Box(orientation: .vertical, spacing: 0)
|
||||
let page = Adw.NavigationPage(child: holder, title: "", tag: tag)
|
||||
let pageCtx = scope.withEnvironment { $0.navigationPage = page }
|
||||
for w in content.map({ $0.makeWidget(pageCtx) }) { holder.append(child: w) }
|
||||
return page
|
||||
}
|
||||
|
||||
// MARK: - Public initializers
|
||||
|
||||
public extension NavigationView {
|
||||
/// Creates a navigation view showing a single root page, with no bound path.
|
||||
///
|
||||
/// Static: no reactivity and no subscriptions are installed.
|
||||
init(@ViewBuilder root: () -> [AnyView]) {
|
||||
self.init()
|
||||
self = self.navigationRoot(root(), path: nil)
|
||||
}
|
||||
|
||||
/// Creates a navigation view whose page stack mirrors a homogeneous path.
|
||||
///
|
||||
/// One page is pushed per path element, on top of a permanent root page.
|
||||
/// Appending to `path` pushes; truncating it pops. Widget-initiated pops
|
||||
/// (Escape, header-bar back button, swipe) truncate `path` in turn, and a
|
||||
/// forward gesture re-pushes the most recently removed element.
|
||||
///
|
||||
/// Reactivity requires a state-backed `Binding` (e.g. `$parks`); a custom
|
||||
/// `Binding(get:set:)` does not track and will not update.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - path: The reactive stack of presented values.
|
||||
/// - root: The always-present root page's content.
|
||||
init<Data: Hashable>(
|
||||
path: Binding<[Data]>,
|
||||
@ViewBuilder root: () -> [AnyView]
|
||||
) {
|
||||
self.init()
|
||||
self = self.navigationRoot(
|
||||
root(),
|
||||
path: NavigationPathAccess(
|
||||
read: { path.wrappedValue.map { AnyHashable($0) } },
|
||||
write: { erased in path.wrappedValue = erased.compactMap { $0.base as? Data } }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a navigation view whose page stack mirrors a heterogeneous
|
||||
/// ``NavigationPath``, allowing pages of different value types.
|
||||
init(
|
||||
path: Binding<NavigationPath>,
|
||||
@ViewBuilder root: () -> [AnyView]
|
||||
) {
|
||||
self.init()
|
||||
self = self.navigationRoot(
|
||||
root(),
|
||||
path: NavigationPathAccess(
|
||||
read: { path.wrappedValue.elements },
|
||||
write: { erased in path.wrappedValue = NavigationPath(erased: erased) }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reconciler
|
||||
|
||||
private extension NavigationView {
|
||||
/// Returns a copy of this view with the configure step that builds the root
|
||||
/// page and, when `path` is non-nil, installs the path <-> stack reconciler.
|
||||
func navigationRoot(
|
||||
_ rootContent: [AnyView], path: NavigationPathAccess?
|
||||
) -> NavigationView {
|
||||
appending { nav, ctx in
|
||||
let destinations = NavigationDestinationRegistry()
|
||||
let contentCtx = ctx.withEnvironment { $0.navigationDestinations = destinations }
|
||||
|
||||
// The root page is `add`ed, not pushed: Adw keeps added pages
|
||||
// forever (so it is never destroyed and is always a valid
|
||||
// `popToPage`/`replace` anchor), and adding while nothing is visible
|
||||
// pushes it automatically. It shares the view's registry - it never
|
||||
// unmounts on its own.
|
||||
let rootPage = makeNavigationPage(
|
||||
tag: "portico.nav.root", content: rootContent, in: contentCtx
|
||||
)
|
||||
nav.add(page: rootPage)
|
||||
|
||||
guard let path else { return }
|
||||
|
||||
typealias Entry = (
|
||||
key: AnyHashable, tag: String,
|
||||
page: Adw.NavigationPage, registry: NodeRegistry
|
||||
)
|
||||
|
||||
// Live mirror of the pushed pages, one entry per path element, in
|
||||
// order. Captured by the tracker and all three signal handlers,
|
||||
// which therefore share one storage box (the `ForEach` `rows`
|
||||
// pattern).
|
||||
var pages: [Entry] = []
|
||||
// Browser-style forward stack of values removed by a back
|
||||
// navigation; `last` is the next to restore. Cleared by any push of
|
||||
// a genuinely new element.
|
||||
var forward: [AnyHashable] = []
|
||||
// Page built for the in-flight `get-next-page` emission, not yet
|
||||
// committed. Adw may emit that signal repeatedly for one gesture.
|
||||
var pending: Entry?
|
||||
var seq: UInt64 = 0
|
||||
|
||||
@MainActor func makeEntry(_ key: AnyHashable) -> Entry {
|
||||
seq += 1
|
||||
let tag = "portico.nav.\(seq)"
|
||||
let scope = contentCtx.makeChild()
|
||||
let content = destinations.build(key)
|
||||
if content == nil {
|
||||
FileHandle.standardError.write(Data("""
|
||||
Portico: NavigationView has no .navigationDestination \
|
||||
for \(type(of: key.base)); using an empty page.
|
||||
|
||||
""".utf8))
|
||||
}
|
||||
return (key, tag, makeNavigationPage(tag: tag, content: content ?? [], in: scope),
|
||||
scope.registry)
|
||||
}
|
||||
|
||||
@MainActor func release(_ entries: [Entry]) {
|
||||
for e in entries {
|
||||
e.registry.teardown()
|
||||
ctx.registry.removeChild(e.registry)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor func applyPath() {
|
||||
let target = path.read() // tracking read: registers this tracker
|
||||
|
||||
var common = 0
|
||||
while common < target.count, common < pages.count,
|
||||
pages[common].key == target[common] {
|
||||
common += 1
|
||||
}
|
||||
let removing = pages.count - common
|
||||
let adding = target.count - common
|
||||
guard removing > 0 || adding > 0 else { return }
|
||||
|
||||
if removing > 0 && adding == 0 {
|
||||
// Pure truncation. Drop mirror entries BEFORE popping:
|
||||
// `popToPage` emits `popped` per page, possibly
|
||||
// synchronously, and with the tags already gone that
|
||||
// handler cannot re-enter this diff.
|
||||
let doomed = Array(pages[common...])
|
||||
pages.removeSubrange(common...)
|
||||
_ = nav.popToPage(page: pages.last?.page ?? rootPage)
|
||||
forward.append(contentsOf: doomed.map(\.key).reversed())
|
||||
release(doomed)
|
||||
} else if removing == 0 {
|
||||
// Pure append.
|
||||
let added = target[common...].map(makeEntry)
|
||||
pages.append(contentsOf: added)
|
||||
for e in added { nav.push(page: e.page) }
|
||||
forward.removeAll()
|
||||
} else {
|
||||
// Divergence: one atomic, animation-free replace.
|
||||
let doomed = Array(pages[common...])
|
||||
pages.removeSubrange(common...)
|
||||
pages.append(contentsOf: target[common...].map(makeEntry))
|
||||
nav.replace(pages: [rootPage] + pages.map(\.page))
|
||||
release(doomed)
|
||||
forward.removeAll()
|
||||
}
|
||||
|
||||
// A cancelled forward gesture can leave a page built for a value
|
||||
// that is no longer next; drop it rather than keep it alive
|
||||
// until the whole view unmounts.
|
||||
if let p = pending, p.key != forward.last {
|
||||
release([p])
|
||||
pending = nil
|
||||
}
|
||||
}
|
||||
|
||||
// `observing: false` because the body mounts subtrees - see
|
||||
// `DependencyTracker`'s doc comment.
|
||||
let tracker = DependencyTracker(observing: false) { applyPath() }
|
||||
tracker.run()
|
||||
ctx.registry.add(tracker)
|
||||
|
||||
// Widget-initiated pops (Escape, header-bar back button, swipe) are
|
||||
// a second writer of the path; mirror them back into the binding.
|
||||
ctx.registry.add(nav.connectPopped { _, popped in
|
||||
guard let tag = popped.getTag(),
|
||||
let idx = pages.firstIndex(where: { $0.tag == tag })
|
||||
else { return } // already reconciled by `applyPath`, or the root
|
||||
let doomed = Array(pages[idx...])
|
||||
pages.removeSubrange(idx...)
|
||||
forward.append(contentsOf: doomed.map(\.key).reversed())
|
||||
path.write(pages.map(\.key))
|
||||
release(doomed)
|
||||
})
|
||||
|
||||
// Forward shortcut/gesture. MUST be pure: Adw emits this repeatedly
|
||||
// for one gesture (including on cancel), so the page is built once
|
||||
// and cached, and the path is only mutated in `pushed`.
|
||||
ctx.registry.add(nav.connectGetNextPage { _ in
|
||||
guard let key = forward.last else { return nil }
|
||||
if let p = pending, p.key == key { return p.page }
|
||||
let entry = makeEntry(key)
|
||||
pending = entry
|
||||
return entry.page
|
||||
})
|
||||
|
||||
// Commit point for a forward navigation.
|
||||
ctx.registry.add(nav.connectPushed { _ in
|
||||
guard let p = pending,
|
||||
nav.getVisiblePage()?.getTag() == p.tag
|
||||
else { return } // one of our own programmatic pushes
|
||||
pending = nil
|
||||
forward.removeLast()
|
||||
pages.append(p)
|
||||
path.write(pages.map(\.key))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue