diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Environment.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Environment.swift index 839cc9a..1e004e1 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Environment.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Environment.swift @@ -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() {} @@ -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 { +public struct Environment { + + internal enum Source { + case object + case keyPath(KeyPath) + } + internal let source: Source internal let box = EnvironmentBox() - public init(_ type: Value.Type) {} + public init(_ type: Value.Type) where Value: AnyObject { + self.source = .object + } + + public init(_ keyPath: KeyPath) { + 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 { +internal final class EnvironmentBox { var value: Value? } @@ -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) } } diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift index e566ce7..08a3a51 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift @@ -15,6 +15,8 @@ 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 @@ -22,12 +24,14 @@ public struct ResolveContext { 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 } @@ -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`. @@ -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")) @@ -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)) } diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Navigation.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Navigation.swift new file mode 100644 index 0000000..9cefc6a --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Navigation.swift @@ -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(_ 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: 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: View { + + internal enum Destination { + case view(() -> any View) + case value(AnyHashable) + } + + internal let destination: Destination + internal let label: Label + + public init(destination: V, @ViewBuilder label: () -> Label) { + self.destination = .view { destination } + self.label = label() + } + + public init(value: P, @ViewBuilder label: () -> Label) { + self.destination = .value(AnyHashable(value)) + self.label = label() + } + + public typealias Body = Never +} + +public extension NavigationLink where Label == Text { + init(_ title: S, destination: V) { + self.init(destination: destination) { Text(title) } + } + init(_ 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: 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(_ title: S) -> _NavigationTitleView { + _NavigationTitleView(title: String(title), content: self) + } +} + +/// Registers a value-type destination builder with the enclosing stack. +public struct _NavigationDestinationView: 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( + for type: D.Type, + @ViewBuilder destination: @escaping (D) -> C + ) -> _NavigationDestinationView { + _NavigationDestinationView(build: { destination($0) }, content: self) + } +} diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Presentation.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Presentation.swift new file mode 100644 index 0000000..8e3f402 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Presentation.swift @@ -0,0 +1,165 @@ +// +// Presentation.swift +// AndroidSwiftUICore +// +// Sheets and alerts: modal content presented over a screen, shown/hidden by a +// bound flag. Drag-to-dismiss and the dialog buttons write the flag back +// through a callback so Swift stays the source of truth. +// + +/// Detent for a sheet's resting height. +public enum PresentationDetent: String, Sendable { + case medium, large +} + +/// Presents a sheet over its content while `isPresented` is true. +public struct _SheetView: View { + + internal let isPresented: Binding + internal let detents: [PresentationDetent] + internal let content: Content + internal let sheet: SheetBody + + public typealias Body = Never +} + +extension _SheetView: PrimitiveView { + + public func _render(in context: ResolveContext) -> RenderNode { + var node = Evaluator.resolve(content, context.descending("content")) + + guard isPresented.wrappedValue else { + return node + } + let binding = isPresented + let dismissID = context.callbacks.register(.void { binding.wrappedValue = false }) + + var sheetContext = context.descending("sheet") + sheetContext.environment.values.dismiss = DismissAction { binding.wrappedValue = false } + let sink = TitleSink() + sheetContext.titleSink = sink + let sheetNode = Evaluator.resolve(sheet, sheetContext) + + // carry the sheet as a hidden child the interpreter presents modally + let presentation = RenderNode( + type: "Sheet", + id: context.path + "/sheet", + props: [ + "onDismiss": .int(Int(dismissID)), + "detents": .array(sink.detents.map { .string($0.rawValue) }), + ], + children: [sheetNode] + ) + node.children.append(presentation) + node.props["hasSheet"] = .bool(true) + return node + } +} + +public extension View { + func sheet( + isPresented: Binding, + @ViewBuilder content: () -> C + ) -> _SheetView { + _SheetView(isPresented: isPresented, detents: [], content: self, sheet: content()) + } +} + +/// Sets a sheet's detents. +public struct _PresentationDetentsView: View { + internal let detents: [PresentationDetent] + internal let content: Content + public typealias Body = Never +} + +extension _PresentationDetentsView: _ResolutionEffectView { + public func _applyEffect(_ context: inout ResolveContext) -> any View { + context.titleSink?.detents = detents + return content + } +} + +public extension View { + func presentationDetents(_ detents: Set) -> _PresentationDetentsView { + _PresentationDetentsView(detents: Array(detents), content: self) + } +} + +// MARK: - Alert + +/// A button in an alert. +public struct AlertButton { + public enum Role { case normal, cancel, destructive } + let title: String + let role: Role + let action: () -> Void + + public init(_ title: String, role: Role = .normal, action: @escaping () -> Void = {}) { + self.title = title + self.role = role + self.action = action + } +} + +public struct _AlertView: View { + internal let title: String + internal let isPresented: Binding + internal let message: String? + internal let buttons: [AlertButton] + internal let content: Content + public typealias Body = Never +} + +extension _AlertView: PrimitiveView { + + public func _render(in context: ResolveContext) -> RenderNode { + var node = Evaluator.resolve(content, context.descending("content")) + guard isPresented.wrappedValue else { return node } + + let binding = isPresented + var buttonNodes: [PropValue] = [] + for button in buttons { + let action = button.action + let id = context.callbacks.register(.void { + action() + binding.wrappedValue = false + }) + buttonNodes.append(.array([ + .string(button.title), + .string(role(button.role)), + .int(Int(id)), + ])) + } + let dismissID = context.callbacks.register(.void { binding.wrappedValue = false }) + + var props: [String: PropValue] = [ + "title": .string(title), + "buttons": .array(buttonNodes), + "onDismiss": .int(Int(dismissID)), + ] + if let message { props["message"] = .string(message) } + + node.children.append(RenderNode(type: "Alert", id: context.path + "/alert", props: props)) + node.props["hasAlert"] = .bool(true) + return node + } + + private func role(_ role: AlertButton.Role) -> String { + switch role { + case .normal: return "normal" + case .cancel: return "cancel" + case .destructive: return "destructive" + } + } +} + +public extension View { + func alert( + _ title: S, + isPresented: Binding, + message: String? = nil, + buttons: [AlertButton] = [AlertButton("OK")] + ) -> _AlertView { + _AlertView(title: String(title), isPresented: isPresented, message: message, buttons: buttons, content: self) + } +} diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/State.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/State.swift index 79db82a..72a9cd7 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/State.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/State.swift @@ -107,6 +107,20 @@ public final class StateStorage { self.reflector = reflector } + private var persistentObjects: [String: AnyObject] = [:] + + /// Retrieves (or creates) a persistent reference object keyed by identity + /// path — the backing store for container state like a navigation stack, + /// which must survive re-evaluation without living in a view's `@State`. + public func persistentObject(at path: String, create: () -> T) -> T { + if let existing = persistentObjects[path] as? T { + return existing + } + let object = create() + persistentObjects[path] = object + return object + } + /// Registers the state properties of a freshly built view at `path`. public func install(in view: any View, path: String) { reflector.forEachStateProperty(in: view) { label, anyBox in diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/TabView.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/TabView.swift new file mode 100644 index 0000000..09d2017 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/TabView.swift @@ -0,0 +1,73 @@ +// +// TabView.swift +// AndroidSwiftUICore +// +// A tab container. Each child contributes a tab: its `.tabItem` label and its +// `.tag` selection value. Swift owns the selection binding; the interpreter +// renders a bottom bar and shows the selected tab's content. +// + +/// A tabbed container with an integer selection binding. +public struct TabView: View { + + internal let selection: Binding + internal let content: Content + + public init(selection: Binding, @ViewBuilder content: () -> Content) { + self.selection = selection + self.content = content() + } + + public typealias Body = Never +} + +extension TabView: PrimitiveView { + + public func _render(in context: ResolveContext) -> RenderNode { + // each child flattens to one node carrying its tabItem/tag as modifiers + let tabs = Evaluator.resolveChildren(content, context.descending("content")) + let binding = selection + let selectID = context.callbacks.register(.int { binding.wrappedValue = $0 }) + return RenderNode( + type: "TabView", + id: context.path, + props: ["selection": .int(selection.wrappedValue), "onSelect": .int(Int(selectID))], + children: tabs + ) + } +} + +/// Sets a tab's bar label. The label's text is captured for the bar. +public struct _TabItemView: View { + internal let label: Label + internal let content: Content + public typealias Body = Never +} + +extension _TabItemView: _ModifierProvider { + public var _modifierNode: ModifierNode { + ModifierNode(kind: "tabItem", args: ["text": .string(firstText(in: label) ?? "")]) + } + public var _modifiedContent: any View { content } +} + +public extension View { + func tabItem(@ViewBuilder _ label: () -> L) -> _TabItemView { + _TabItemView(label: label(), content: self) + } +} + +// A tab's selection value uses the same `.tag(_:)` as Picker (string-encoded); +// TabView parses it back to Int. + +/// Extracts the first `Text` string from a small label view tree, for bar labels. +internal func firstText(in view: Any, depth: Int = 0) -> String? { + if depth > 8 { return nil } + if let text = view as? Text { return text.content } + for child in Mirror(reflecting: view).children { + if let found = firstText(in: child.value, depth: depth + 1) { + return found + } + } + return nil +} diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift new file mode 100644 index 0000000..ec48a28 --- /dev/null +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/NavigationTests.swift @@ -0,0 +1,151 @@ +// +// NavigationTests.swift +// AndroidSwiftUICoreTests +// + +import Testing +@testable import AndroidSwiftUICore + +@Suite("Navigation, sheets, tabs") +struct NavigationTests { + + @Test("NavigationStack starts with only its root screen") + func rootOnly() { + struct Screen: View { + var body: some View { + NavigationStack { + Text("Root").navigationTitle("Home") + } + } + } + let node = ViewHost(Screen()).evaluate() + #expect(node.type == "NavStack") + #expect(node.children.count == 1) + if case .array(let titles)? = node.props["titles"] { + #expect(titles == [.string("Home")]) + } else { + Issue.record("no titles") + } + } + + @Test("A NavigationLink push adds a screen and its title") + func classicPush() { + struct Detail: View { + var body: some View { Text("Detail").navigationTitle("Detail") } + } + struct Screen: View { + var body: some View { + NavigationStack { + NavigationLink("Go", destination: Detail()) + .navigationTitle("Home") + } + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(node.children.count == 1) + // tap the link (found in the root screen's subtree) + let linkID = findOnTap(node) + #expect(linkID != nil) + host.callbacks.invokeVoid(linkID!) + node = host.evaluate() + #expect(node.children.count == 2) + if case .array(let titles)? = node.props["titles"] { + #expect(titles == [.string("Home"), .string("Detail")]) + } + } + + @Test("Value-based push resolves through navigationDestination") + func valuePush() { + struct Screen: View { + var body: some View { + NavigationStack { + NavigationLink("Push 1", value: 1) + .navigationDestination(for: Int.self) { value in + Text("Value \(value)").navigationTitle("V\(value)") + } + } + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + host.callbacks.invokeVoid(findOnTap(node)!) + node = host.evaluate() + #expect(node.children.count == 2) + if case .array(let titles)? = node.props["titles"] { + #expect(titles.last == .string("V1")) + } + } + + @Test("dismiss pops the pushed screen") + func dismissPops() { + struct Detail: View { + @Environment(\.dismiss) var dismiss + var body: some View { Button("Back") { dismiss() } } + } + struct Screen: View { + var body: some View { + NavigationStack { NavigationLink("Go", destination: Detail()) } + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + host.callbacks.invokeVoid(findOnTap(node)!) // push + node = host.evaluate() + #expect(node.children.count == 2) + host.callbacks.invokeVoid(findOnTap(node.children[1])!) // dismiss from detail + node = host.evaluate() + #expect(node.children.count == 1) + } + + @Test("Sheet appears as a hidden child only while presented") + func sheet() { + struct Screen: View { + @State var shown = false + var body: some View { + Button("Show") { shown = true } + .sheet(isPresented: $shown) { Text("Sheet body") } + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(node.props["hasSheet"] == nil) + host.callbacks.invokeVoid(findOnTap(node)!) + node = host.evaluate() + #expect(node.props["hasSheet"] == .bool(true)) + #expect(node.children.contains { $0.type == "Sheet" }) + } + + @Test("TabView emits tabs with their item labels and selection") + func tabs() { + struct Screen: View { + @State var selection = 0 + var body: some View { + TabView(selection: $selection) { + Text("One").tabItem { Text("First") }.tag(0) + Text("Two").tabItem { Text("Second") }.tag(1) + } + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(node.type == "TabView") + #expect(node.children.count == 2) + #expect(node.props["selection"] == .int(0)) + // switching selection through the callback updates state + if case .int(let id)? = node.props["onSelect"] { + host.callbacks.invokeInt(Int64(id), 1) + } + node = host.evaluate() + #expect(node.props["selection"] == .int(1)) + } +} + +/// Finds the first `onTap` callback id anywhere in a node subtree. +private func findOnTap(_ node: RenderNode) -> Int64? { + if case .int(let id)? = node.props["onTap"] { return Int64(id) } + for child in node.children { + if let id = findOnTap(child) { return id } + } + return nil +} diff --git a/Demo/App.swiftpm/Sources/ContentView.swift b/Demo/App.swiftpm/Sources/ContentView.swift index 910bb74..366956e 100644 --- a/Demo/App.swiftpm/Sources/ContentView.swift +++ b/Demo/App.swiftpm/Sources/ContentView.swift @@ -4,32 +4,28 @@ import AndroidSwiftUI import SwiftUI #endif -/// Gallery of playground screens. A picker selects the active screen; full -/// `NavigationStack`/`List` navigation returns in a later step. +/// Gallery of playground screens, navigated with a `NavigationStack`. Each row +/// pushes a feature screen; `List`-based navigation returns with lazy +/// containers in the next step. struct ContentView: View { - @State - private var screen = "Controls" - var body: some View { - VStack(spacing: 0) { - Picker("Screen", selection: $screen) { - Text("Text").tag("Text") - Text("Buttons").tag("Buttons") - Text("Stacks").tag("Stacks") - Text("State").tag("State") - Text("Controls").tag("Controls") - Text("Modifiers").tag("Modifiers") - Text("Observation").tag("Observation") + NavigationStack { + ScrollView { + VStack(spacing: 0) { + NavigationLink("Text", destination: TextScreen()) + NavigationLink("Buttons", destination: ButtonScreen()) + NavigationLink("Stacks", destination: StacksScreen()) + NavigationLink("State", destination: StateScreen()) + NavigationLink("Controls", destination: ControlsScreen()) + NavigationLink("Modifiers", destination: ModifierScreen()) + NavigationLink("Observation", destination: ObservationScreen()) + NavigationLink("Navigation", destination: NavigationScreen()) + NavigationLink("Sheets", destination: SheetScreen()) + NavigationLink("Tabs", destination: TabScreen()) + } } - Divider() - if screen == "Text" { TextScreen() } - if screen == "Buttons" { ButtonScreen() } - if screen == "Stacks" { StacksScreen() } - if screen == "State" { StateScreen() } - if screen == "Controls" { ControlsScreen() } - if screen == "Modifiers" { ModifierScreen() } - if screen == "Observation" { ObservationScreen() } + .navigationTitle("Gallery") } } } diff --git a/Demo/App.swiftpm/Sources/NavigationScreen.swift b/Demo/App.swiftpm/Sources/NavigationScreen.swift new file mode 100644 index 0000000..251f973 --- /dev/null +++ b/Demo/App.swiftpm/Sources/NavigationScreen.swift @@ -0,0 +1,77 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Classic and value-based navigation, plus the dismiss environment action. +struct NavigationScreen: View { + + @Environment(\.dismiss) + private var dismiss + + @State + private var showsAlert = false + + var body: some View { + VStack(spacing: 16) { + Button("Show alert") { showsAlert = true } + Text("Value-based navigation") + NavigationLink("Push value 1", value: 1) + NavigationLink(value: 2) { + Text("Push value 2") + } + Divider() + Text("Classic navigation") + NavigationLink("Push destination view", destination: ClassicDestination()) + Divider() + Button("Pop with dismiss") { + dismiss() + } + } + .navigationDestination(for: Int.self) { value in + ValueDestination(value: value) + } + .alert("An alert", isPresented: $showsAlert, message: "This is an alert message.", buttons: [ + AlertButton("Cancel", role: .cancel), + AlertButton("OK"), + ]) + .navigationTitle("Navigation") + } +} + +struct ValueDestination: View { + + let value: Int + + @Environment(\.dismiss) + private var dismiss + + var body: some View { + VStack(spacing: 16) { + Text(verbatim: "Destination for value \(value)") + if value < 3 { + NavigationLink("Push value \(value + 1)", value: value + 1) + } + Button("Pop with dismiss") { + dismiss() + } + } + .navigationTitle("Value \(value)") + } +} + +struct ClassicDestination: View { + + @Environment(\.dismiss) + private var dismiss + + var body: some View { + VStack(spacing: 16) { + Text("Classic destination") + Button("Pop with dismiss") { + dismiss() + } + } + } +} diff --git a/Demo/App.swiftpm/Sources/SheetScreen.swift b/Demo/App.swiftpm/Sources/SheetScreen.swift new file mode 100644 index 0000000..2db7c54 --- /dev/null +++ b/Demo/App.swiftpm/Sources/SheetScreen.swift @@ -0,0 +1,50 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Sheet presentation, detents and the dismiss environment action. +struct SheetScreen: View { + + @State + private var showsSheet = false + + @State + private var showsDetentSheet = false + + var body: some View { + VStack(spacing: 16) { + Button("Present sheet") { + showsSheet = true + } + Button("Present medium detent sheet") { + showsDetentSheet = true + } + } + .sheet(isPresented: $showsSheet) { + SheetContent(title: "Full size sheet") + } + .sheet(isPresented: $showsDetentSheet) { + SheetContent(title: "Medium sheet") + .presentationDetents([.medium]) + } + } +} + +struct SheetContent: View { + + let title: String + + @Environment(\.dismiss) + private var dismiss + + var body: some View { + VStack(spacing: 16) { + Text(title) + Button("Dismiss") { + dismiss() + } + } + } +} diff --git a/Demo/App.swiftpm/Sources/TabScreen.swift b/Demo/App.swiftpm/Sources/TabScreen.swift new file mode 100644 index 0000000..80ca2e3 --- /dev/null +++ b/Demo/App.swiftpm/Sources/TabScreen.swift @@ -0,0 +1,31 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Tab bar with selection. +struct TabScreen: View { + + @State + private var selection = 0 + + var body: some View { + TabView(selection: $selection) { + VStack(spacing: 16) { + Text("First tab") + Button("Select third tab") { + selection = 2 + } + } + .tabItem { Text("One") } + .tag(0) + Text("Second tab") + .tabItem { Text("Two") } + .tag(1) + Text("Third tab") + .tabItem { Text("Three") } + .tag(2) + } + } +} diff --git a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt index c42d291..a261fc4 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -15,20 +15,39 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.togetherWith +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.layout.Column as LayoutColumn +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -159,6 +178,15 @@ fun Render(node: ViewNode) { "Picker" -> RenderPicker(node) + "NavStack" -> RenderNavStack(node) + + "NavigationLink" -> TextButton( + onClick = { node.long("onTap")?.let { SwiftBridge.sink.invokeVoid(it) } }, + modifier = node.composeModifiers(), + ) { RenderChildren(node) } + + "TabView" -> RenderTabView(node) + "EmptyView" -> Unit "Composable" -> ComposableRegistry.Render(node) @@ -172,9 +200,14 @@ fun Render(node: ViewNode) { } } +// Sheets and alerts ride as hidden children; the presentation layer shows +// them, so the normal child loop skips them. +private fun ViewNode.isPresentation(): Boolean = type == "Sheet" || type == "Alert" + @Composable private fun RenderChildren(node: ViewNode) { for (child in node.children) { + if (child.isPresentation()) continue Render(child) } } @@ -250,6 +283,7 @@ private fun pickerLabel(node: ViewNode): String { @Composable private fun ColumnScope.RenderColumnChildren(node: ViewNode) { for (child in node.children) { + if (child.isPresentation()) continue if (child.type == "Spacer") { Spacer(modifier = Modifier.weight(1f)) } else { @@ -261,6 +295,7 @@ private fun ColumnScope.RenderColumnChildren(node: ViewNode) { @Composable private fun RowScope.RenderRowChildren(node: ViewNode) { for (child in node.children) { + if (child.isPresentation()) continue if (child.type == "Spacer") { Spacer(modifier = Modifier.weight(1f)) } else { @@ -343,3 +378,134 @@ internal fun ViewNode.composeModifiers(): Modifier { } return modifier } + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RenderNavStack(node: ViewNode) { + val titles = node.stringArray("titles") + val onPop = node.long("onPop") + val depth = node.children.size + // cache rendered screens by index so a popped screen can animate out + val topIndex = depth - 1 + Scaffold( + topBar = { + val title = titles.getOrNull(topIndex).orEmpty() + if (title.isNotEmpty() || depth > 1) { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + if (depth > 1) { + IconButton(onClick = { onPop?.let { SwiftBridge.sink.invokeVoid(it) } }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + }, + ) + } + }, + ) { padding -> + AnimatedContent( + targetState = topIndex, + transitionSpec = { + if (targetState > initialState) { + (slideInHorizontally { it }) togetherWith (slideOutHorizontally { -it / 3 }) + } else { + (slideInHorizontally { -it / 3 }) togetherWith (slideOutHorizontally { it }) + } + }, + modifier = Modifier.fillMaxSize(), + label = "nav", + ) { index -> + LayoutColumn(modifier = Modifier.padding(padding)) { + node.children.getOrNull(index)?.let { Render(it) } + } + } + } + RenderSheetsAndAlerts(node.children.getOrNull(topIndex)) +} + +@Composable +private fun RenderTabView(node: ViewNode) { + val selection = node.long("selection")?.toInt() ?: 0 + val onSelect = node.long("onSelect") + Scaffold( + bottomBar = { + NavigationBar { + node.children.forEachIndexed { index, tab -> + val label = tab.modifiers.firstOrNull { it.kind == "tabItem" } + ?.args?.let { (it["text"] as? kotlinx.serialization.json.JsonPrimitive)?.content } ?: "" + val tag = tab.modifiers.firstOrNull { it.kind == "tag" } + ?.args?.let { (it["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toIntOrNull() } ?: index + NavigationBarItem( + selected = tag == selection, + onClick = { onSelect?.let { SwiftBridge.sink.invokeInt(it, tag) } }, + icon = {}, + label = { Text(label) }, + ) + } + } + }, + ) { padding -> + val current = node.children.firstOrNull { tab -> + val tag = tab.modifiers.firstOrNull { it.kind == "tag" } + ?.args?.let { (it["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toIntOrNull() } + tag == selection + } ?: node.children.getOrNull(selection) + LayoutColumn(modifier = Modifier.padding(padding)) { + current?.let { Render(it) } + } + } +} + +// Sheets and alerts ride as hidden children of a screen node; present them +// when the screen carries the corresponding flag. +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RenderSheetsAndAlerts(screen: ViewNode?) { + if (screen == null) return + for (child in screen.children) { + when (child.type) { + "Sheet" -> { + val onDismiss = child.long("onDismiss") + ModalBottomSheet(onDismissRequest = { onDismiss?.let { SwiftBridge.sink.invokeVoid(it) } }) { + child.children.firstOrNull()?.let { Render(it) } + } + } + "Alert" -> RenderAlert(child) + } + } +} + +@Composable +private fun RenderAlert(node: ViewNode) { + val onDismiss = node.long("onDismiss") + val buttons = (node.props["buttons"] as? kotlinx.serialization.json.JsonArray) ?: kotlinx.serialization.json.JsonArray(emptyList()) + val parsed = buttons.mapNotNull { entry -> + val arr = entry as? kotlinx.serialization.json.JsonArray ?: return@mapNotNull null + val title = (arr[0] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return@mapNotNull null + val id = (arr[2] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toLongOrNull() ?: return@mapNotNull null + title to id + } + AlertDialog( + onDismissRequest = { onDismiss?.let { SwiftBridge.sink.invokeVoid(it) } }, + title = { Text(node.string("title") ?: "") }, + text = { node.string("message")?.let { Text(it) } }, + confirmButton = { + parsed.firstOrNull()?.let { (title, id) -> + TextButton(onClick = { SwiftBridge.sink.invokeVoid(id) }) { Text(title) } + } + }, + dismissButton = { + if (parsed.size > 1) { + val (title, id) = parsed[1] + TextButton(onClick = { SwiftBridge.sink.invokeVoid(id) }) { Text(title) } + } + }, + ) +} + +private fun ViewNode.stringArray(key: String): List { + val arr = props[key] as? kotlinx.serialization.json.JsonArray ?: return emptyList() + return arr.mapNotNull { (it as? kotlinx.serialization.json.JsonPrimitive)?.content } +}