Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 58 additions & 10 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,39 @@
// Environment.swift
// AndroidSwiftUICore
//
// Object environment: values flow DOWN the evaluation (they shape body
// evaluation) and never cross the bridge. `@Environment(Model.self)` reads an
// object a parent injected with `.environment(model)`.
// Environment values flow DOWN evaluation (they shape body evaluation) and
// never cross the bridge. Two forms: object environment
// (`@Environment(Model.self)`, injected with `.environment(model)`) and
// keyPath environment (`@Environment(\.dismiss)`, read from EnvironmentValues).
//

/// Per-subtree environment values, keyed by object type.
/// Keyed environment values a subtree reads by keyPath.
public struct EnvironmentValues: Sendable {

/// Dismisses the nearest presentation (a pushed nav entry or a sheet).
public var dismiss = DismissAction { }

public init() {}
}

/// Closes the nearest presentation context. `dismiss()` calls it.
public struct DismissAction: Sendable {

private let action: @Sendable () -> Void

public init(_ action: @escaping @Sendable () -> Void) {
self.action = action
}

public func callAsFunction() {
action()
}
}

/// Per-subtree environment: keyed values plus injected objects.
public struct EnvironmentStorage {

public var values = EnvironmentValues()
var objects: [ObjectIdentifier: AnyObject] = [:]

public init() {}
Expand All @@ -23,23 +48,35 @@ public struct EnvironmentStorage {
}
}

/// Reads an object from the environment.
/// Reads a value from the environment — an injected object or a keyPath value.
@propertyWrapper
public struct Environment<Value: AnyObject> {
public struct Environment<Value> {

internal enum Source {
case object
case keyPath(KeyPath<EnvironmentValues, Value>)
}

internal let source: Source
internal let box = EnvironmentBox<Value>()

public init(_ type: Value.Type) {}
public init(_ type: Value.Type) where Value: AnyObject {
self.source = .object
}

public init(_ keyPath: KeyPath<EnvironmentValues, Value>) {
self.source = .keyPath(keyPath)
}

public var wrappedValue: Value {
guard let value = box.value else {
fatalError("@Environment(\(Value.self).self) read before injection — missing .environment(_:)?")
fatalError("@Environment read before injection — missing .environment(_:) or presentation context?")
}
return value
}
}

internal final class EnvironmentBox<Value: AnyObject> {
internal final class EnvironmentBox<Value> {
var value: Value?
}

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

extension Environment: _EnvironmentProperty {
public func _inject(from storage: EnvironmentStorage) {
box.value = storage.object(of: Value.self)
switch source {
case .object:
if let object = storage.objects[objectKey] as? Value {
box.value = object
}
case .keyPath(let keyPath):
box.value = storage.values[keyPath: keyPath]
}
}

private var objectKey: ObjectIdentifier {
ObjectIdentifier(Value.self)
}
}

Expand Down
31 changes: 31 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,23 @@ public struct ResolveContext {
public let storage: StateStorage
public let callbacks: CallbackRegistry
public var environment: EnvironmentStorage
/// Collects the `navigationTitle` of the screen currently being resolved.
public var titleSink: TitleSink?
public var path: String
public var depth: Int

public init(
storage: StateStorage,
callbacks: CallbackRegistry,
environment: EnvironmentStorage = EnvironmentStorage(),
titleSink: TitleSink? = nil,
path: String = "",
depth: Int = 0
) {
self.storage = storage
self.callbacks = callbacks
self.environment = environment
self.titleSink = titleSink
self.path = path
self.depth = depth
}
Expand Down Expand Up @@ -72,6 +76,21 @@ public protocol _GroupView {
func _flatten(into nodes: inout [RenderNode], context: ResolveContext)
}

/// A content wrapper with a side effect during resolution (registering a
/// navigation destination, recording a title, presenting a sheet). Returns the
/// content to continue resolving, and may mutate the context.
public protocol _ResolutionEffectView {
func _applyEffect(_ context: inout ResolveContext) -> any View
}

/// Per-screen scratchpad collecting presentation attributes declared inside a
/// screen's body — its `navigationTitle` and a sheet's `presentationDetents`.
public final class TitleSink {
public var title: String?
public var detents: [PresentationDetent] = []
public init() {}
}

public enum Evaluator {

/// Guards against a self-referential `body`.
Expand All @@ -96,7 +115,15 @@ public enum Evaluator {
var node = resolve(modifier._modifiedContent, context)
node.modifiers.insert(modifier._modifierNode, at: 0)
return node
case let effect as _ResolutionEffectView:
var context = context
let content = effect._applyEffect(&context)
return resolve(content, context)
case let primitive as PrimitiveView:
// primitives may hold container @State (navigation stack, tab
// selection) and read @Environment, so wire both before rendering
context.storage.install(in: view, path: context.path)
EnvironmentInjector.inject(context.environment, into: view)
return primitive._render(in: context)
case let anyView as AnyView:
return resolve(anyView.storage, context.descending("any"))
Expand Down Expand Up @@ -144,6 +171,10 @@ public enum Evaluator {
var child = context.descending("env")
child.environment.set(writer._object)
flatten(writer._content, into: &nodes, context: child)
case let effect as _ResolutionEffectView:
var context = context
let content = effect._applyEffect(&context)
flatten(content, into: &nodes, context: context)
default:
nodes.append(resolve(view, context))
}
Expand Down
204 changes: 204 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Navigation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
//
// Navigation.swift
// AndroidSwiftUICore
//
// Swift owns the navigation stack. Links push, `dismiss` pops, and the stack
// emits every screen (root + pushed) so Compose can animate between them and
// cache the outgoing screen during a pop. The model persists across
// re-evaluation via the stack's identity path.
//

/// The pushed navigation stack, shared with the links of the hosted screens.
public final class NavigationModel: @unchecked Sendable {

enum Entry {
case view(() -> any View)
case value(AnyHashable)
}

var stack: [Entry] = []
var destinations: [ObjectIdentifier: (Any) -> (any View)?] = [:]
var onChange: (() -> Void)?

public init() {}

func pushView(_ content: @escaping () -> any View) {
stack.append(.view(content))
onChange?()
}

func pushValue(_ value: AnyHashable) {
stack.append(.value(value))
onChange?()
}

func pop() {
guard !stack.isEmpty else { return }
stack.removeLast()
onChange?()
}

func register<V>(_ type: V.Type, _ build: @escaping (V) -> any View) {
destinations[ObjectIdentifier(type)] = { ($0 as? V).map(build) }
}

func resolve(_ entry: Entry) -> (any View)? {
switch entry {
case .view(let content):
return content()
case .value(let value):
return destinations[ObjectIdentifier(type(of: value.base))]?(value.base) ?? nil
}
}
}

/// A stack-based navigation container.
public struct NavigationStack<Root: View>: View {

internal let root: Root

public init(@ViewBuilder root: () -> Root) {
self.root = root()
}

public typealias Body = Never
}

extension NavigationStack: PrimitiveView {

public func _render(in context: ResolveContext) -> RenderNode {
let model = context.storage.persistentObject(at: context.path + ".navModel") {
NavigationModel()
}
model.onChange = context.storage.onChange

// resolve each screen with the model in scope; a per-screen title sink
// captures its navigationTitle
var screens: [RenderNode] = []
var titles: [PropValue] = []

func resolveScreen(_ view: any View, index: Int, canDismiss: Bool) {
var childContext = context.descending("screen\(index)")
childContext.environment.set(model)
if canDismiss {
childContext.environment.values.dismiss = DismissAction { model.pop() }
}
let sink = TitleSink()
childContext.titleSink = sink
screens.append(Evaluator.resolve(view, childContext))
titles.append(.string(sink.title ?? ""))
}

resolveScreen(root, index: 0, canDismiss: false)
for (offset, entry) in model.stack.enumerated() {
let view = model.resolve(entry) ?? AnyView(EmptyView())
resolveScreen(view, index: offset + 1, canDismiss: true)
}

let popID = context.callbacks.register(.void { model.pop() })
return RenderNode(
type: "NavStack",
id: context.path,
props: ["titles": .array(titles), "onPop": .int(Int(popID))],
children: screens
)
}
}

/// A button that pushes a destination onto the enclosing navigation stack.
public struct NavigationLink<Label: View>: View {

internal enum Destination {
case view(() -> any View)
case value(AnyHashable)
}

internal let destination: Destination
internal let label: Label

public init<V: View>(destination: V, @ViewBuilder label: () -> Label) {
self.destination = .view { destination }
self.label = label()
}

public init<P: Hashable>(value: P, @ViewBuilder label: () -> Label) {
self.destination = .value(AnyHashable(value))
self.label = label()
}

public typealias Body = Never
}

public extension NavigationLink where Label == Text {
init<S: StringProtocol, V: View>(_ title: S, destination: V) {
self.init(destination: destination) { Text(title) }
}
init<S: StringProtocol, P: Hashable>(_ title: S, value: P) {
self.init(value: value) { Text(title) }
}
}

extension NavigationLink: PrimitiveView {

public func _render(in context: ResolveContext) -> RenderNode {
let model = context.environment.object(of: NavigationModel.self)
let destination = self.destination
let callbackID = context.callbacks.register(.void {
switch destination {
case .view(let content): model?.pushView(content)
case .value(let value): model?.pushValue(value)
}
})
return RenderNode(
type: "NavigationLink",
id: context.path,
props: ["onTap": .int(Int(callbackID))],
children: Evaluator.resolveChildren(label, context.descending("label"))
)
}
}

// MARK: - Modifiers

/// Records a screen's navigation title.
public struct _NavigationTitleView<Content: View>: View {
internal let title: String
internal let content: Content
public typealias Body = Never
}

extension _NavigationTitleView: _ResolutionEffectView {
public func _applyEffect(_ context: inout ResolveContext) -> any View {
context.titleSink?.title = title
return content
}
}

public extension View {
func navigationTitle<S: StringProtocol>(_ title: S) -> _NavigationTitleView<Self> {
_NavigationTitleView(title: String(title), content: self)
}
}

/// Registers a value-type destination builder with the enclosing stack.
public struct _NavigationDestinationView<Content: View, D: Hashable>: View {
internal let build: (D) -> any View
internal let content: Content
public typealias Body = Never
}

extension _NavigationDestinationView: _ResolutionEffectView {
public func _applyEffect(_ context: inout ResolveContext) -> any View {
context.environment.object(of: NavigationModel.self)?.register(D.self, build)
return content
}
}

public extension View {
func navigationDestination<D: Hashable, C: View>(
for type: D.Type,
@ViewBuilder destination: @escaping (D) -> C
) -> _NavigationDestinationView<Self, D> {
_NavigationDestinationView(build: { destination($0) }, content: self)
}
}
Loading
Loading