244 lines
10 KiB
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))
|
|
})
|
|
}
|
|
}
|
|
}
|