diff --git a/AndroidSwiftUICore/Package.swift b/AndroidSwiftUICore/Package.swift index 33d8536..dce1e7d 100644 --- a/AndroidSwiftUICore/Package.swift +++ b/AndroidSwiftUICore/Package.swift @@ -8,7 +8,7 @@ import PackageDescription // ClassicUI-style desktop renderer it shares its design with). let package = Package( name: "AndroidSwiftUICore", - platforms: [.macOS(.v13)], + platforms: [.macOS(.v14)], products: [ .library(name: "AndroidSwiftUICore", targets: ["AndroidSwiftUICore"]) ], diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Environment.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Environment.swift new file mode 100644 index 0000000..839cc9a --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Environment.swift @@ -0,0 +1,82 @@ +// +// 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)`. +// + +/// Per-subtree environment values, keyed by object type. +public struct EnvironmentStorage { + + var objects: [ObjectIdentifier: AnyObject] = [:] + + public init() {} + + mutating func set(_ object: AnyObject) { + objects[ObjectIdentifier(type(of: object))] = object + } + + func object(of type: T.Type) -> T? { + objects[ObjectIdentifier(type)] as? T + } +} + +/// Reads an object from the environment. +@propertyWrapper +public struct Environment { + + internal let box = EnvironmentBox() + + public init(_ type: Value.Type) {} + + public var wrappedValue: Value { + guard let value = box.value else { + fatalError("@Environment(\(Value.self).self) read before injection — missing .environment(_:)?") + } + return value + } +} + +internal final class EnvironmentBox { + var value: Value? +} + +/// Type-erased injection hook, discovered via the reflection seam. +public protocol _EnvironmentProperty { + func _inject(from storage: EnvironmentStorage) +} + +extension Environment: _EnvironmentProperty { + public func _inject(from storage: EnvironmentStorage) { + box.value = storage.object(of: Value.self) + } +} + +/// The `.environment(_:)` wrapper: sets an object for the subtree. +public struct _EnvironmentWriterView: View { + + internal let object: AnyObject + internal let content: Content + + public typealias Body = Never +} + +public extension View { + func environment(_ object: T) -> _EnvironmentWriterView { + _EnvironmentWriterView(object: object, content: self) + } +} + +/// Injects environment values into a view's `@Environment` properties. +public enum EnvironmentInjector { + + public static func inject(_ storage: EnvironmentStorage, into view: any View) { + for child in Mirror(reflecting: view).children { + if let property = child.value as? _EnvironmentProperty { + property._inject(from: storage) + } + } + } +} diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift index d7a0acd..e566ce7 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift @@ -14,22 +14,44 @@ public struct ResolveContext { public let storage: StateStorage public let callbacks: CallbackRegistry + public var environment: EnvironmentStorage public var path: String public var depth: Int - public init(storage: StateStorage, callbacks: CallbackRegistry, path: String = "", depth: Int = 0) { + public init( + storage: StateStorage, + callbacks: CallbackRegistry, + environment: EnvironmentStorage = EnvironmentStorage(), + path: String = "", + depth: Int = 0 + ) { self.storage = storage self.callbacks = callbacks + self.environment = environment self.path = path self.depth = depth } /// Context for a child at a structurally stable position. public func descending(_ component: String) -> ResolveContext { - ResolveContext(storage: storage, callbacks: callbacks, path: path + "/" + component, depth: depth + 1) + var context = self + context.path += "/" + component + context.depth += 1 + return context } } +/// Type-erased access to the environment-writer wrapper. +internal protocol _AnyEnvironmentWriter { + var _object: AnyObject { get } + var _content: any View { get } +} + +extension _EnvironmentWriterView: _AnyEnvironmentWriter { + var _object: AnyObject { object } + var _content: any View { content } +} + // MARK: - Primitive / dispatch protocols /// A view that resolves directly to a node (leaf or container). @@ -78,9 +100,14 @@ public enum Evaluator { return primitive._render(in: context) case let anyView as AnyView: return resolve(anyView.storage, context.descending("any")) + case let writer as _AnyEnvironmentWriter: + var child = context.descending("env") + child.environment.set(writer._object) + return resolve(writer._content, child) default: let child = context.descending("\(type(of: view))") child.storage.install(in: view, path: child.path) + EnvironmentInjector.inject(child.environment, into: view) return resolve(body(of: view), child) } } @@ -113,6 +140,10 @@ public enum Evaluator { } case let anyView as AnyView: flatten(anyView.storage, into: &nodes, context: context.descending("any")) + case let writer as _AnyEnvironmentWriter: + var child = context.descending("env") + child.environment.set(writer._object) + flatten(writer._content, into: &nodes, context: child) default: nodes.append(resolve(view, context)) } diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Modifiers.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Modifiers.swift index 0f7ef02..e36ecf4 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Modifiers.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Modifiers.swift @@ -104,3 +104,79 @@ public extension View { modifier(_BackgroundColorModifier(color: color)) } } + +// MARK: - Corner radius + +public struct _CornerRadiusModifier: RenderModifier { + let radius: Double + public var _modifierNode: ModifierNode { + ModifierNode(kind: "cornerRadius", args: ["radius": .double(radius)]) + } +} + +public extension View { + func cornerRadius(_ radius: Double) -> ModifiedContent { + modifier(_CornerRadiusModifier(radius: radius)) + } +} + +// MARK: - Offset + +public struct _OffsetModifier: RenderModifier { + let x: Double + let y: Double + public var _modifierNode: ModifierNode { + ModifierNode(kind: "offset", args: ["x": .double(x), "y": .double(y)]) + } +} + +public extension View { + func offset(x: Double = 0, y: Double = 0) -> ModifiedContent { + modifier(_OffsetModifier(x: x, y: y)) + } +} + +// MARK: - Rotation + +public struct _RotationModifier: RenderModifier { + let degrees: Double + public var _modifierNode: ModifierNode { + ModifierNode(kind: "rotation", args: ["degrees": .double(degrees)]) + } +} + +public extension View { + func rotationEffect(_ angle: Angle) -> ModifiedContent { + modifier(_RotationModifier(degrees: angle.degrees)) + } +} + +// MARK: - Scale + +public struct _ScaleModifier: RenderModifier { + let scale: Double + public var _modifierNode: ModifierNode { + ModifierNode(kind: "scale", args: ["scale": .double(scale)]) + } +} + +public extension View { + func scaleEffect(_ scale: Double) -> ModifiedContent { + modifier(_ScaleModifier(scale: scale)) + } +} + +// MARK: - Opacity + +public struct _OpacityModifier: RenderModifier { + let opacity: Double + public var _modifierNode: ModifierNode { + ModifierNode(kind: "opacity", args: ["opacity": .double(opacity)]) + } +} + +public extension View { + func opacity(_ opacity: Double) -> ModifiedContent { + modifier(_OpacityModifier(opacity: opacity)) + } +} diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Picker.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Picker.swift new file mode 100644 index 0000000..c8987fe --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Picker.swift @@ -0,0 +1,61 @@ +// +// Picker.swift +// AndroidSwiftUICore +// + +/// Associates a selection value with a picker row. +public struct _TagModifier: RenderModifier { + let value: String + public var _modifierNode: ModifierNode { + ModifierNode(kind: "tag", args: ["value": .string(value)]) + } +} + +public extension View { + func tag(_ value: V) -> ModifiedContent { + modifier(_TagModifier(value: identityString(value))) + } +} + +/// A control for selecting one of several tagged options. +/// +/// Selection values round-trip the bridge as strings, so they must be +/// `LosslessStringConvertible` (String and Int both are). +public struct Picker: View { + + internal let title: String + internal let selection: Binding + internal let content: Content + + public init( + _ title: S, + selection: Binding, + @ViewBuilder content: () -> Content + ) { + self.title = String(title) + self.selection = selection + self.content = content() + } + + public typealias Body = Never +} + +extension Picker: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + let binding = selection + let callbackID = context.callbacks.register(.string { raw in + guard let value = SelectionValue(raw) else { return } + binding.wrappedValue = value + }) + return RenderNode( + type: "Picker", + id: context.path, + props: [ + "title": .string(title), + "selection": .string(identityString(selection.wrappedValue)), + "onChange": .int(Int(callbackID)), + ], + children: Evaluator.resolveChildren(content, context.descending("content")) + ) + } +} diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Text.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Text.swift index e788960..02d86b4 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Text.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Text.swift @@ -16,6 +16,10 @@ public struct Text: View { self.content = String(content) } + public init(verbatim content: String) { + self.content = content + } + public typealias Body = Never } diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift new file mode 100644 index 0000000..03bccee --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Primitives/Views.swift @@ -0,0 +1,170 @@ +// +// Views.swift +// AndroidSwiftUICore +// +// Primitives added for the first gallery wave. +// + +/// A scrollable container. +public struct ScrollView: View { + + internal let axis: Axis + internal let content: Content + + public enum Axis: String, Sendable { case vertical, horizontal } + + public init(_ axis: Axis = .vertical, @ViewBuilder content: () -> Content) { + self.axis = axis + self.content = content() + } + + public typealias Body = Never +} + +extension ScrollView: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + RenderNode( + type: "ScrollView", + id: context.path, + props: ["axis": .string(axis.rawValue)], + children: Evaluator.resolveChildren(content, context.descending("content")) + ) + } +} + +/// A color used as a view fills its proposed space. +extension Color: View { + public typealias Body = Never +} + +extension Color: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + RenderNode(type: "Color", id: context.path, props: ["color": propValue]) + } +} + +/// An image referenced by name. Rendering is placeholder-level until an asset +/// pipeline exists. +public struct Image: View { + + internal let name: String + + public init(_ name: String) { + self.name = name + } + + public typealias Body = Never +} + +extension Image: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + RenderNode(type: "Image", id: context.path, props: ["name": .string(name)]) + } +} + +/// Progress: indeterminate (spinner) or fractional (bar). +public struct ProgressView: View { + + internal let value: Double? + + public init() { + self.value = nil + } + + public init(value: V?, total: V = 1.0) { + self.value = value.map { Double($0 / total) } + } + + public typealias Body = Never +} + +extension ProgressView: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + var props: [String: PropValue] = [:] + if let value { props["value"] = .double(value) } + return RenderNode(type: "ProgressView", id: context.path, props: props) + } +} + +/// A control for selecting a value from a bounded range. +public struct Slider: View { + + internal let value: Binding + internal let minimum: Double + internal let maximum: Double + + public init(value: Binding, in bounds: ClosedRange = 0...1) { + self.value = Binding( + get: { Double(value.wrappedValue) }, + set: { value.wrappedValue = V($0) } + ) + self.minimum = Double(bounds.lowerBound) + self.maximum = Double(bounds.upperBound) + } + + public typealias Body = Never +} + +extension Slider: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + let binding = value + let callbackID = context.callbacks.register(.double { binding.wrappedValue = $0 }) + return RenderNode( + type: "Slider", + id: context.path, + props: [ + "value": .double(value.wrappedValue), + "min": .double(minimum), + "max": .double(maximum), + "onChange": .int(Int(callbackID)), + ] + ) + } +} + +/// An editable text field bound to a string. +public struct TextField: View { + + internal let placeholder: String + internal let text: Binding + + public init(_ placeholder: S, text: Binding) { + self.placeholder = String(placeholder) + self.text = text + } + + public typealias Body = Never +} + +extension TextField: PrimitiveView { + public func _render(in context: ResolveContext) -> RenderNode { + let binding = text + let callbackID = context.callbacks.register(.string { binding.wrappedValue = $0 }) + return RenderNode( + type: "TextField", + id: context.path, + props: [ + "text": .string(text.wrappedValue), + "placeholder": .string(placeholder), + "onChange": .int(Int(callbackID)), + ] + ) + } +} + +/// Eager stand-ins: laziness is an optimization the lazy container path (R7) +/// provides; semantics match the eager stacks. +public typealias LazyVStack = VStack +public typealias LazyHStack = HStack + +/// An angle in degrees or radians. +public struct Angle: Equatable, Sendable { + + public var degrees: Double + + public init(degrees: Double) { self.degrees = degrees } + + public static func degrees(_ value: Double) -> Angle { Angle(degrees: value) } + + public static func radians(_ value: Double) -> Angle { Angle(degrees: value * 180 / .pi) } +} diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/ViewHost.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/ViewHost.swift index 30a9196..cd33ea8 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/ViewHost.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/ViewHost.swift @@ -8,7 +8,14 @@ // same way; the tests drive it directly. // -public final class ViewHost { +#if canImport(Observation) +import Observation +#endif + +// Main-thread confined by contract (evaluation, callbacks, and state writes +// all happen on the platform main thread); @unchecked so the observation +// change handler — which is @Sendable — can reach onStateChange. +public final class ViewHost: @unchecked Sendable { private let root: any View private let storage: StateStorage @@ -32,6 +39,17 @@ public final class ViewHost { public func evaluate() -> RenderNode { callbacks.beginGeneration() let context = ResolveContext(storage: storage, callbacks: callbacks, path: "root") + #if canImport(Observation) + // Track @Observable reads during evaluation: a later mutation of any + // observed property schedules a re-evaluation, exactly like a @State + // write. Re-arms itself because the change handler triggers evaluate(). + return withObservationTracking { + Evaluator.resolve(root, context) + } onChange: { [weak self] in + self?.onStateChange?() + } + #else return Evaluator.resolve(root, context) + #endif } } diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift new file mode 100644 index 0000000..ebd2730 --- /dev/null +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/ControlTests.swift @@ -0,0 +1,113 @@ +// +// ControlTests.swift +// AndroidSwiftUICoreTests +// + +import Testing +@testable import AndroidSwiftUICore + +#if canImport(Observation) +import Observation + +@Observable final class ObservableModel { var counter = 0 } +#endif + +@Suite("Controls and environment") +struct ControlTests { + + @Test("Slider emits value, bounds, and a working double callback") + func slider() { + struct Screen: View { + @State var value = 0.5 + var body: some View { Slider(value: $value, in: 0...1) } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(node.props["value"] == .double(0.5)) + if case .int(let id)? = node.props["onChange"] { + host.callbacks.invokeDouble(Int64(id), 0.75) + } + node = host.evaluate() + #expect(node.props["value"] == .double(0.75)) + } + + @Test("TextField round-trips its binding through the string callback") + func textField() { + struct Screen: View { + @State var name = "" + var body: some View { TextField("Name", text: $name) } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(node.props["text"] == .string("")) + if case .int(let id)? = node.props["onChange"] { + host.callbacks.invokeString(Int64(id), "Coleman") + } + node = host.evaluate() + #expect(node.props["text"] == .string("Coleman")) + } + + @Test("Picker emits tagged children and maps the selection string back") + func picker() { + struct Screen: View { + @State var fruit = "Apple" + var body: some View { + Picker("Fruit", selection: $fruit) { + Text("Apple").tag("Apple") + Text("Banana").tag("Banana") + } + } + } + let host = ViewHost(Screen()) + var node = host.evaluate() + #expect(node.props["selection"] == .string("Apple")) + #expect(node.children.count == 2) + // each child carries its tag as a modifier + let tags = node.children.compactMap { child -> String? in + guard let tag = child.modifiers.first(where: { $0.kind == "tag" }), + case .string(let value)? = tag.args["value"] else { return nil } + return value + } + #expect(tags == ["Apple", "Banana"]) + if case .int(let id)? = node.props["onChange"] { + host.callbacks.invokeString(Int64(id), "Banana") + } + node = host.evaluate() + #expect(node.props["selection"] == .string("Banana")) + } + + @Test("Environment objects reach @Environment properties in the subtree") + func environmentInjection() { + final class Model { var value = 42 } + struct Child: View { + @Environment(Model.self) var model + var body: some View { Text("value \(model.value)") } + } + struct Screen: View { + let model: Model + var body: some View { Child().environment(model) } + } + let node = ViewHost(Screen(model: Model())).evaluate() + #expect(node.props["text"] == .string("value 42")) + } + + #if canImport(Observation) + @Test("@Observable mutation triggers the state-change hook") + func observation() { + struct Screen: View { + let model: ObservableModel + var body: some View { Text("count \(model.counter)") } + } + let model = ObservableModel() + let host = ViewHost(Screen(model: model)) + var fired = false + host.onStateChange = { fired = true } + var node = host.evaluate() + #expect(node.props["text"] == .string("count 0")) + model.counter += 1 + #expect(fired) + node = host.evaluate() + #expect(node.props["text"] == .string("count 1")) + } + #endif +} diff --git a/Demo/App.swiftpm/Sources/App.swift b/Demo/App.swiftpm/Sources/App.swift index b999ac3..90ba1aa 100644 --- a/Demo/App.swiftpm/Sources/App.swift +++ b/Demo/App.swiftpm/Sources/App.swift @@ -4,22 +4,6 @@ import AndroidSwiftUI import SwiftUI #endif -// The gallery screens are restored alongside the view types they exercise as the -// Compose-backed renderer is built up. - -struct ContentView: View { - - @State private var count = 0 - - var body: some View { - VStack(spacing: 12) { - Text("Count: \(count)") - Button("Increment") { count += 1 } - Toggle("Feature flag", isOn: .constant(true)) - } - } -} - #if canImport(AndroidSwiftUI) /// App launch point, called from `MainActivity`. diff --git a/Demo/App.swiftpm/Sources/ButtonScreen.swift b/Demo/App.swiftpm/Sources/ButtonScreen.swift new file mode 100644 index 0000000..dfddee3 --- /dev/null +++ b/Demo/App.swiftpm/Sources/ButtonScreen.swift @@ -0,0 +1,30 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Button initializers and actions. +struct ButtonScreen: View { + + @State + private var tapCount = 0 + + var body: some View { + VStack(spacing: 16) { + Text(verbatim: "Taps: \(tapCount)") + Button("Title initializer") { + tapCount += 1 + } + Button(action: { tapCount += 1 }) { + Text("Label closure initializer") + } + Button(action: { tapCount += 1 }) { + Image("globe") + } + Button("Reset") { + tapCount = 0 + } + } + } +} diff --git a/Demo/App.swiftpm/Sources/ContentView.swift b/Demo/App.swiftpm/Sources/ContentView.swift new file mode 100644 index 0000000..910bb74 --- /dev/null +++ b/Demo/App.swiftpm/Sources/ContentView.swift @@ -0,0 +1,35 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Gallery of playground screens. A picker selects the active screen; full +/// `NavigationStack`/`List` navigation returns in a later 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") + } + 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() } + } + } +} diff --git a/Demo/App.swiftpm/Sources/ControlsScreen.swift b/Demo/App.swiftpm/Sources/ControlsScreen.swift new file mode 100644 index 0000000..82ab822 --- /dev/null +++ b/Demo/App.swiftpm/Sources/ControlsScreen.swift @@ -0,0 +1,60 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Form controls and their state bindings. +struct ControlsScreen: View { + + @State + private var isOn = false + + @State + private var progress = 0.25 + + @State + private var sliderValue = 0.5 + + @State + private var name = "" + + @State + private var fruit = "Apple" + + var body: some View { + ScrollView { + VStack(spacing: 24) { + Text("Toggle") + Toggle("Enabled", isOn: $isOn) + Text(isOn ? "Toggle is on" : "Toggle is off") + Divider() + Text("Indeterminate progress") + ProgressView() + Divider() + Text("Determinate progress") + ProgressView(value: progress) + Text("\(Int(progress * 100))%") + Button("Advance") { + progress = progress >= 1 ? 0 : progress + 0.25 + } + Divider() + Text("Slider") + Slider(value: $sliderValue, in: 0...1) + Text("Value: \(Int(sliderValue * 100))%") + Divider() + Text("Text field") + TextField("Name", text: $name) + Text(name.isEmpty ? "Nothing typed yet" : "Hello, \(name)") + Divider() + Text("Picker") + Picker("Fruit", selection: $fruit) { + Text("Apple").tag("Apple") + Text("Banana").tag("Banana") + Text("Cherry").tag("Cherry") + } + Text("Selected: \(fruit)") + } + } + } +} diff --git a/Demo/App.swiftpm/Sources/ModifierScreen.swift b/Demo/App.swiftpm/Sources/ModifierScreen.swift new file mode 100644 index 0000000..57bb779 --- /dev/null +++ b/Demo/App.swiftpm/Sources/ModifierScreen.swift @@ -0,0 +1,72 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Layout and effect modifiers. +struct ModifierScreen: View { + + var body: some View { + ScrollView { + VStack(spacing: 24) { + ModifierSection(title: "Padding") { + Text("Padded") + .padding() + .background(Color.blue) + } + ModifierSection(title: "Frame") { + Text("Fixed 200x60") + .frame(width: 200, height: 60) + .background(Color.green) + } + ModifierSection(title: "Background") { + Text("Colored background") + .padding() + .background(Color.orange) + } + ModifierSection(title: "Clip + corner radius") { + Text("Rounded") + .padding() + .background(Color.purple) + .cornerRadius(16) + } + ModifierSection(title: "Offset") { + Text("Shifted") + .offset(x: 30, y: 0) + .background(Color.red) + } + ModifierSection(title: "Rotation") { + Text("Rotated") + .background(Color.yellow) + .rotationEffect(.degrees(15)) + } + ModifierSection(title: "Scale") { + Text("Scaled") + .background(Color.pink) + .scaleEffect(1.5) + } + } + } + } +} + +struct ModifierSection: View { + + let title: String + + let content: Content + + init(title: String, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(spacing: 8) { + Text(title) + content + Divider() + } + } +} diff --git a/Demo/App.swiftpm/Sources/ObservationScreen.swift b/Demo/App.swiftpm/Sources/ObservationScreen.swift new file mode 100644 index 0000000..3678f1f --- /dev/null +++ b/Demo/App.swiftpm/Sources/ObservationScreen.swift @@ -0,0 +1,41 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +import Observation + +/// Observable objects shared through the environment. +@Observable +final class GalleryModel { + + var counter = 0 +} + +struct ObservationScreen: View { + + @State + private var model = GalleryModel() + + var body: some View { + ObservationCounterView() + .environment(model) + } +} + +struct ObservationCounterView: View { + + @Environment(GalleryModel.self) + private var model + + var body: some View { + VStack(spacing: 16) { + Text("Observable environment object") + Text(verbatim: "Counter: \(model.counter)") + Button("Increment") { + model.counter += 1 + } + } + } +} diff --git a/Demo/App.swiftpm/Sources/StacksScreen.swift b/Demo/App.swiftpm/Sources/StacksScreen.swift new file mode 100644 index 0000000..02ec94c --- /dev/null +++ b/Demo/App.swiftpm/Sources/StacksScreen.swift @@ -0,0 +1,73 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Stack containers and cross-axis alignment. +struct StacksScreen: View { + + var body: some View { + ScrollView { + VStack(spacing: 16) { + AlignmentSection(title: "VStack .leading", alignment: .leading) + AlignmentSection(title: "VStack .center", alignment: .center) + AlignmentSection(title: "VStack .trailing", alignment: .trailing) + Divider() + Text("HStack with Spacer") + HStack { + Text("Start") + Spacer() + Text("End") + } + Divider() + Text("LazyVStack") + LazyVStack(alignment: .leading) { + Text("Lazy one") + Text("Lazy two, longer") + } + Divider() + ZStackSection(title: "ZStack .center", alignment: .center) + ZStackSection(title: "ZStack .topLeading", alignment: .topLeading) + ZStackSection(title: "ZStack .bottomTrailing", alignment: .bottomTrailing) + } + } + } +} + +struct ZStackSection: View { + + let title: String + + let alignment: Alignment + + var body: some View { + VStack(spacing: 8) { + Text(title) + ZStack(alignment: alignment) { + Color.blue + .frame(width: 240, height: 120) + Text("On top") + } + Divider() + } + } +} + +struct AlignmentSection: View { + + let title: String + + let alignment: HorizontalAlignment + + var body: some View { + VStack(spacing: 8) { + Text(title) + VStack(alignment: alignment) { + Text("Row one") + Text("Row two, longer") + } + Divider() + } + } +} diff --git a/Demo/App.swiftpm/Sources/StateScreen.swift b/Demo/App.swiftpm/Sources/StateScreen.swift new file mode 100644 index 0000000..35f1eb5 --- /dev/null +++ b/Demo/App.swiftpm/Sources/StateScreen.swift @@ -0,0 +1,50 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Lazy state initialization: the stored class is constructed once per view +/// lifetime, no matter how often the parent re-renders. +struct StateScreen: View { + + @State + private var parentRenders = 0 + + var body: some View { + VStack(spacing: 16) { + Text(verbatim: "Parent re-renders: \(parentRenders)") + Button("Re-render parent") { + parentRenders += 1 + } + Divider() + StateChildView() + } + } +} + +struct StateChildView: View { + + @State + private var model = CountedModel() + + var body: some View { + VStack(spacing: 16) { + Text(verbatim: "Model instance: #\(model.instance)") + Text(verbatim: "Total constructions: \(CountedModel.constructions)") + } + } +} + +/// Counts how many times it has ever been constructed. +final class CountedModel { + + nonisolated(unsafe) static var constructions = 0 + + let instance: Int + + init() { + Self.constructions += 1 + instance = Self.constructions + } +} diff --git a/Demo/App.swiftpm/Sources/TextScreen.swift b/Demo/App.swiftpm/Sources/TextScreen.swift new file mode 100644 index 0000000..99d016d --- /dev/null +++ b/Demo/App.swiftpm/Sources/TextScreen.swift @@ -0,0 +1,25 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +/// Text initializers and dynamic content. +struct TextScreen: View { + + @State + private var counter = 0 + + var body: some View { + VStack(spacing: 16) { + Text("Plain text") + Text(verbatim: "Verbatim text") + Text("Interpolated counter: \(counter)") + Button("Increment") { + counter += 1 + } + Divider() + Text("Multiline text that should wrap when it becomes longer than a single line on screen") + } + } +} diff --git a/Demo/swiftui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt b/Demo/swiftui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt index c83588a..5a20144 100644 --- a/Demo/swiftui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt +++ b/Demo/swiftui/src/androidMain/kotlin/com/pureswift/swiftui/SwiftUIHostView.kt @@ -3,8 +3,10 @@ package com.pureswift.swiftui import android.content.Context import android.view.ViewGroup import android.widget.FrameLayout +import androidx.compose.foundation.layout.safeDrawingPadding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView // The Android host: one Compose island rendering the whole Swift-evaluated @@ -19,7 +21,7 @@ class SwiftUIHostView(context: Context) : FrameLayout(context) { val composeView = ComposeView(context) composeView.setContent { MaterialTheme { - Surface { + Surface(modifier = Modifier.safeDrawingPadding()) { store.root?.let { Render(it) } } } 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 f528f51..c42d291 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -1,6 +1,9 @@ package com.pureswift.swiftui import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -13,15 +16,35 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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.OutlinedTextField +import androidx.compose.material3.Slider import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.shape.RoundedCornerShape /// Interprets a Swift-evaluated node tree into Material 3 composables. /// @@ -95,6 +118,47 @@ fun Render(node: ViewNode) { "Divider" -> HorizontalDivider(modifier = node.composeModifiers()) + "ScrollView" -> { + val state = rememberScrollState() + if (node.string("axis") == "horizontal") { + Row(modifier = node.composeModifiers().horizontalScroll(state)) { RenderChildren(node) } + } else { + Column(modifier = node.composeModifiers().verticalScroll(state)) { RenderChildren(node) } + } + } + + "Color" -> Box( + modifier = node.composeModifiers() + .background(Color((node.long("color") ?: 0).toInt())) + ) + + "Image" -> Text("[${node.string("name") ?: "image"}]", modifier = node.composeModifiers()) + + "ProgressView" -> { + val value = node.double("value") + if (value != null) { + LinearProgressIndicator(progress = { value.toFloat() }, modifier = node.composeModifiers().fillMaxWidth()) + } else { + CircularProgressIndicator(modifier = node.composeModifiers()) + } + } + + "Slider" -> { + val onChange = node.long("onChange") + val min = (node.double("min") ?: 0.0).toFloat() + val max = (node.double("max") ?: 1.0).toFloat() + Slider( + value = (node.double("value") ?: 0.0).toFloat(), + onValueChange = { onChange?.let { id -> SwiftBridge.sink.invokeDouble(id, it.toDouble()) } }, + valueRange = min..max, + modifier = node.composeModifiers().fillMaxWidth(), + ) + } + + "TextField" -> RenderTextField(node) + + "Picker" -> RenderPicker(node) + "EmptyView" -> Unit "Composable" -> ComposableRegistry.Render(node) @@ -115,6 +179,71 @@ private fun RenderChildren(node: ViewNode) { } } +// Uncontrolled-with-reconciliation: Compose owns the field's TextFieldValue +// (cursor/selection); `lastSent` tracks the value we last pushed to Swift, so +// an echo (Swift's tree carrying our own text back) leaves the cursor alone, +// while an external change adopts Swift's value with the cursor at the end. +@Composable +private fun RenderTextField(node: ViewNode) { + val onChange = node.long("onChange") + val swiftText = node.string("text") ?: "" + var local by remember(node.id) { mutableStateOf(TextFieldValue(swiftText)) } + var lastSent by remember(node.id) { mutableStateOf(swiftText) } + if (swiftText != lastSent) { + local = TextFieldValue(swiftText, selection = androidx.compose.ui.text.TextRange(swiftText.length)) + lastSent = swiftText + } + OutlinedTextField( + value = local, + onValueChange = { v -> + local = v + if (v.text != lastSent) { + lastSent = v.text + onChange?.let { SwiftBridge.sink.invokeString(it, v.text) } + } + }, + label = { Text(node.string("placeholder") ?: "") }, + modifier = node.composeModifiers().fillMaxWidth(), + ) +} + +@Composable +private fun RenderPicker(node: ViewNode) { + val onChange = node.long("onChange") + val selection = node.string("selection") + var expanded by remember { mutableStateOf(false) } + // options come from the tagged children: (tag value, display text) + val options = node.children.mapNotNull { child -> + val tag = child.modifiers.firstOrNull { it.kind == "tag" }?.args?.let { (it["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content } + val text = pickerLabel(child) + if (tag != null) tag to text else null + } + val currentLabel = options.firstOrNull { it.first == selection }?.second ?: (selection ?: "") + Row(modifier = node.composeModifiers().fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(node.string("title") ?: "") + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = { expanded = true }) { Text(currentLabel) } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + for ((value, label) in options) { + DropdownMenuItem(text = { Text(label) }, onClick = { + expanded = false + onChange?.let { SwiftBridge.sink.invokeString(it, value) } + }) + } + } + } +} + +// A picker row's display text: the first Text descendant. +private fun pickerLabel(node: ViewNode): String { + if (node.type == "Text") return node.string("text") ?: "" + for (child in node.children) { + val label = pickerLabel(child) + if (label.isNotEmpty()) return label + } + return "" +} + // `Modifier.weight` only exists inside RowScope/ColumnScope, so stacks render // their children through scope-aware loops that give Spacer its expansion. @@ -192,6 +321,23 @@ internal fun ViewNode.composeModifiers(): Modifier { modifier.background(Color(argb.toInt())) } + "cornerRadius" -> { + val radius = entry.args.double("radius") ?: 0.0 + modifier.clip(RoundedCornerShape(radius.dp)) + } + + "offset" -> { + val x = entry.args.double("x") ?: 0.0 + val y = entry.args.double("y") ?: 0.0 + modifier.offset { IntOffset((x.dp.value).toInt(), (y.dp.value).toInt()) } + } + + "rotation" -> modifier.rotate((entry.args.double("degrees") ?: 0.0).toFloat()) + + "scale" -> modifier.scale((entry.args.double("scale") ?: 1.0).toFloat()) + + "opacity" -> modifier.alpha((entry.args.double("opacity") ?: 1.0).toFloat()) + else -> modifier // unknown modifier: ignore, never crash rendering } }