Skip to content

Commit 33aa263

Browse files
authored
Merge pull request #23 from PureSwift/feature/navigation-r6
Navigation, sheets, alert, and tabs
2 parents 4939464 + 6b188d2 commit 33aa263

12 files changed

Lines changed: 1038 additions & 32 deletions

File tree

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Environment.swift

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,39 @@
22
// Environment.swift
33
// AndroidSwiftUICore
44
//
5-
// Object environment: values flow DOWN the evaluation (they shape body
6-
// evaluation) and never cross the bridge. `@Environment(Model.self)` reads an
7-
// object a parent injected with `.environment(model)`.
5+
// Environment values flow DOWN evaluation (they shape body evaluation) and
6+
// never cross the bridge. Two forms: object environment
7+
// (`@Environment(Model.self)`, injected with `.environment(model)`) and
8+
// keyPath environment (`@Environment(\.dismiss)`, read from EnvironmentValues).
89
//
910

10-
/// Per-subtree environment values, keyed by object type.
11+
/// Keyed environment values a subtree reads by keyPath.
12+
public struct EnvironmentValues: Sendable {
13+
14+
/// Dismisses the nearest presentation (a pushed nav entry or a sheet).
15+
public var dismiss = DismissAction { }
16+
17+
public init() {}
18+
}
19+
20+
/// Closes the nearest presentation context. `dismiss()` calls it.
21+
public struct DismissAction: Sendable {
22+
23+
private let action: @Sendable () -> Void
24+
25+
public init(_ action: @escaping @Sendable () -> Void) {
26+
self.action = action
27+
}
28+
29+
public func callAsFunction() {
30+
action()
31+
}
32+
}
33+
34+
/// Per-subtree environment: keyed values plus injected objects.
1135
public struct EnvironmentStorage {
1236

37+
public var values = EnvironmentValues()
1338
var objects: [ObjectIdentifier: AnyObject] = [:]
1439

1540
public init() {}
@@ -23,23 +48,35 @@ public struct EnvironmentStorage {
2348
}
2449
}
2550

26-
/// Reads an object from the environment.
51+
/// Reads a value from the environment — an injected object or a keyPath value.
2752
@propertyWrapper
28-
public struct Environment<Value: AnyObject> {
53+
public struct Environment<Value> {
54+
55+
internal enum Source {
56+
case object
57+
case keyPath(KeyPath<EnvironmentValues, Value>)
58+
}
2959

60+
internal let source: Source
3061
internal let box = EnvironmentBox<Value>()
3162

32-
public init(_ type: Value.Type) {}
63+
public init(_ type: Value.Type) where Value: AnyObject {
64+
self.source = .object
65+
}
66+
67+
public init(_ keyPath: KeyPath<EnvironmentValues, Value>) {
68+
self.source = .keyPath(keyPath)
69+
}
3370

3471
public var wrappedValue: Value {
3572
guard let value = box.value else {
36-
fatalError("@Environment(\(Value.self).self) read before injection — missing .environment(_:)?")
73+
fatalError("@Environment read before injection — missing .environment(_:) or presentation context?")
3774
}
3875
return value
3976
}
4077
}
4178

42-
internal final class EnvironmentBox<Value: AnyObject> {
79+
internal final class EnvironmentBox<Value> {
4380
var value: Value?
4481
}
4582

@@ -50,7 +87,18 @@ public protocol _EnvironmentProperty {
5087

5188
extension Environment: _EnvironmentProperty {
5289
public func _inject(from storage: EnvironmentStorage) {
53-
box.value = storage.object(of: Value.self)
90+
switch source {
91+
case .object:
92+
if let object = storage.objects[objectKey] as? Value {
93+
box.value = object
94+
}
95+
case .keyPath(let keyPath):
96+
box.value = storage.values[keyPath: keyPath]
97+
}
98+
}
99+
100+
private var objectKey: ObjectIdentifier {
101+
ObjectIdentifier(Value.self)
54102
}
55103
}
56104

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,23 @@ public struct ResolveContext {
1515
public let storage: StateStorage
1616
public let callbacks: CallbackRegistry
1717
public var environment: EnvironmentStorage
18+
/// Collects the `navigationTitle` of the screen currently being resolved.
19+
public var titleSink: TitleSink?
1820
public var path: String
1921
public var depth: Int
2022

2123
public init(
2224
storage: StateStorage,
2325
callbacks: CallbackRegistry,
2426
environment: EnvironmentStorage = EnvironmentStorage(),
27+
titleSink: TitleSink? = nil,
2528
path: String = "",
2629
depth: Int = 0
2730
) {
2831
self.storage = storage
2932
self.callbacks = callbacks
3033
self.environment = environment
34+
self.titleSink = titleSink
3135
self.path = path
3236
self.depth = depth
3337
}
@@ -72,6 +76,21 @@ public protocol _GroupView {
7276
func _flatten(into nodes: inout [RenderNode], context: ResolveContext)
7377
}
7478

79+
/// A content wrapper with a side effect during resolution (registering a
80+
/// navigation destination, recording a title, presenting a sheet). Returns the
81+
/// content to continue resolving, and may mutate the context.
82+
public protocol _ResolutionEffectView {
83+
func _applyEffect(_ context: inout ResolveContext) -> any View
84+
}
85+
86+
/// Per-screen scratchpad collecting presentation attributes declared inside a
87+
/// screen's body — its `navigationTitle` and a sheet's `presentationDetents`.
88+
public final class TitleSink {
89+
public var title: String?
90+
public var detents: [PresentationDetent] = []
91+
public init() {}
92+
}
93+
7594
public enum Evaluator {
7695

7796
/// Guards against a self-referential `body`.
@@ -96,7 +115,15 @@ public enum Evaluator {
96115
var node = resolve(modifier._modifiedContent, context)
97116
node.modifiers.insert(modifier._modifierNode, at: 0)
98117
return node
118+
case let effect as _ResolutionEffectView:
119+
var context = context
120+
let content = effect._applyEffect(&context)
121+
return resolve(content, context)
99122
case let primitive as PrimitiveView:
123+
// primitives may hold container @State (navigation stack, tab
124+
// selection) and read @Environment, so wire both before rendering
125+
context.storage.install(in: view, path: context.path)
126+
EnvironmentInjector.inject(context.environment, into: view)
100127
return primitive._render(in: context)
101128
case let anyView as AnyView:
102129
return resolve(anyView.storage, context.descending("any"))
@@ -144,6 +171,10 @@ public enum Evaluator {
144171
var child = context.descending("env")
145172
child.environment.set(writer._object)
146173
flatten(writer._content, into: &nodes, context: child)
174+
case let effect as _ResolutionEffectView:
175+
var context = context
176+
let content = effect._applyEffect(&context)
177+
flatten(content, into: &nodes, context: context)
147178
default:
148179
nodes.append(resolve(view, context))
149180
}
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
//
2+
// Navigation.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Swift owns the navigation stack. Links push, `dismiss` pops, and the stack
6+
// emits every screen (root + pushed) so Compose can animate between them and
7+
// cache the outgoing screen during a pop. The model persists across
8+
// re-evaluation via the stack's identity path.
9+
//
10+
11+
/// The pushed navigation stack, shared with the links of the hosted screens.
12+
public final class NavigationModel: @unchecked Sendable {
13+
14+
enum Entry {
15+
case view(() -> any View)
16+
case value(AnyHashable)
17+
}
18+
19+
var stack: [Entry] = []
20+
var destinations: [ObjectIdentifier: (Any) -> (any View)?] = [:]
21+
var onChange: (() -> Void)?
22+
23+
public init() {}
24+
25+
func pushView(_ content: @escaping () -> any View) {
26+
stack.append(.view(content))
27+
onChange?()
28+
}
29+
30+
func pushValue(_ value: AnyHashable) {
31+
stack.append(.value(value))
32+
onChange?()
33+
}
34+
35+
func pop() {
36+
guard !stack.isEmpty else { return }
37+
stack.removeLast()
38+
onChange?()
39+
}
40+
41+
func register<V>(_ type: V.Type, _ build: @escaping (V) -> any View) {
42+
destinations[ObjectIdentifier(type)] = { ($0 as? V).map(build) }
43+
}
44+
45+
func resolve(_ entry: Entry) -> (any View)? {
46+
switch entry {
47+
case .view(let content):
48+
return content()
49+
case .value(let value):
50+
return destinations[ObjectIdentifier(type(of: value.base))]?(value.base) ?? nil
51+
}
52+
}
53+
}
54+
55+
/// A stack-based navigation container.
56+
public struct NavigationStack<Root: View>: View {
57+
58+
internal let root: Root
59+
60+
public init(@ViewBuilder root: () -> Root) {
61+
self.root = root()
62+
}
63+
64+
public typealias Body = Never
65+
}
66+
67+
extension NavigationStack: PrimitiveView {
68+
69+
public func _render(in context: ResolveContext) -> RenderNode {
70+
let model = context.storage.persistentObject(at: context.path + ".navModel") {
71+
NavigationModel()
72+
}
73+
model.onChange = context.storage.onChange
74+
75+
// resolve each screen with the model in scope; a per-screen title sink
76+
// captures its navigationTitle
77+
var screens: [RenderNode] = []
78+
var titles: [PropValue] = []
79+
80+
func resolveScreen(_ view: any View, index: Int, canDismiss: Bool) {
81+
var childContext = context.descending("screen\(index)")
82+
childContext.environment.set(model)
83+
if canDismiss {
84+
childContext.environment.values.dismiss = DismissAction { model.pop() }
85+
}
86+
let sink = TitleSink()
87+
childContext.titleSink = sink
88+
screens.append(Evaluator.resolve(view, childContext))
89+
titles.append(.string(sink.title ?? ""))
90+
}
91+
92+
resolveScreen(root, index: 0, canDismiss: false)
93+
for (offset, entry) in model.stack.enumerated() {
94+
let view = model.resolve(entry) ?? AnyView(EmptyView())
95+
resolveScreen(view, index: offset + 1, canDismiss: true)
96+
}
97+
98+
let popID = context.callbacks.register(.void { model.pop() })
99+
return RenderNode(
100+
type: "NavStack",
101+
id: context.path,
102+
props: ["titles": .array(titles), "onPop": .int(Int(popID))],
103+
children: screens
104+
)
105+
}
106+
}
107+
108+
/// A button that pushes a destination onto the enclosing navigation stack.
109+
public struct NavigationLink<Label: View>: View {
110+
111+
internal enum Destination {
112+
case view(() -> any View)
113+
case value(AnyHashable)
114+
}
115+
116+
internal let destination: Destination
117+
internal let label: Label
118+
119+
public init<V: View>(destination: V, @ViewBuilder label: () -> Label) {
120+
self.destination = .view { destination }
121+
self.label = label()
122+
}
123+
124+
public init<P: Hashable>(value: P, @ViewBuilder label: () -> Label) {
125+
self.destination = .value(AnyHashable(value))
126+
self.label = label()
127+
}
128+
129+
public typealias Body = Never
130+
}
131+
132+
public extension NavigationLink where Label == Text {
133+
init<S: StringProtocol, V: View>(_ title: S, destination: V) {
134+
self.init(destination: destination) { Text(title) }
135+
}
136+
init<S: StringProtocol, P: Hashable>(_ title: S, value: P) {
137+
self.init(value: value) { Text(title) }
138+
}
139+
}
140+
141+
extension NavigationLink: PrimitiveView {
142+
143+
public func _render(in context: ResolveContext) -> RenderNode {
144+
let model = context.environment.object(of: NavigationModel.self)
145+
let destination = self.destination
146+
let callbackID = context.callbacks.register(.void {
147+
switch destination {
148+
case .view(let content): model?.pushView(content)
149+
case .value(let value): model?.pushValue(value)
150+
}
151+
})
152+
return RenderNode(
153+
type: "NavigationLink",
154+
id: context.path,
155+
props: ["onTap": .int(Int(callbackID))],
156+
children: Evaluator.resolveChildren(label, context.descending("label"))
157+
)
158+
}
159+
}
160+
161+
// MARK: - Modifiers
162+
163+
/// Records a screen's navigation title.
164+
public struct _NavigationTitleView<Content: View>: View {
165+
internal let title: String
166+
internal let content: Content
167+
public typealias Body = Never
168+
}
169+
170+
extension _NavigationTitleView: _ResolutionEffectView {
171+
public func _applyEffect(_ context: inout ResolveContext) -> any View {
172+
context.titleSink?.title = title
173+
return content
174+
}
175+
}
176+
177+
public extension View {
178+
func navigationTitle<S: StringProtocol>(_ title: S) -> _NavigationTitleView<Self> {
179+
_NavigationTitleView(title: String(title), content: self)
180+
}
181+
}
182+
183+
/// Registers a value-type destination builder with the enclosing stack.
184+
public struct _NavigationDestinationView<Content: View, D: Hashable>: View {
185+
internal let build: (D) -> any View
186+
internal let content: Content
187+
public typealias Body = Never
188+
}
189+
190+
extension _NavigationDestinationView: _ResolutionEffectView {
191+
public func _applyEffect(_ context: inout ResolveContext) -> any View {
192+
context.environment.object(of: NavigationModel.self)?.register(D.self, build)
193+
return content
194+
}
195+
}
196+
197+
public extension View {
198+
func navigationDestination<D: Hashable, C: View>(
199+
for type: D.Type,
200+
@ViewBuilder destination: @escaping (D) -> C
201+
) -> _NavigationDestinationView<Self, D> {
202+
_NavigationDestinationView(build: { destination($0) }, content: self)
203+
}
204+
}

0 commit comments

Comments
 (0)