portico/Sources/Portico/Navigation/NavigationView+Extras.swift

244 lines
10 KiB
Swift

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))
})
}
}
}