diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift index 7ecce2c..5ee13a1 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift @@ -74,6 +74,26 @@ public protocol _ModifierProvider { var _modifiedContent: any View { get } } +/// A modifier provider that needs the resolve context to emit its node — e.g. +/// to register a callback. The evaluator prefers this over the context-free +/// `_modifierNode` property when a provider conforms to it. +public protocol _ContextualModifierProvider { + func _modifierNode(in context: ResolveContext) -> ModifierNode +} + +/// A modifier whose emitted node embeds a callback registered against the +/// resolve context (e.g. `onTapGesture`, `onAppear`, `onChange`). +public protocol _CallbackModifier { + func _callbackNode(in context: ResolveContext) -> ModifierNode +} + +extension _ModifierProvider { + /// The emitted node, using the resolve context when the provider needs it. + func resolvedModifierNode(in context: ResolveContext) -> ModifierNode { + (self as? _ContextualModifierProvider)?._modifierNode(in: context) ?? _modifierNode + } +} + /// A group that flattens into a sequence of sibling nodes (TupleView, ForEach, /// conditionals, optionals). public protocol _GroupView { @@ -123,7 +143,7 @@ public enum Evaluator { case let modifier as _ModifierProvider: // identity-transparent: resolve content at the SAME path, then prepend var node = resolve(modifier._modifiedContent, context) - node.modifiers.insert(modifier._modifierNode, at: 0) + node.modifiers.insert(modifier.resolvedModifierNode(in: context), at: 0) return node case let effect as _ResolutionEffectView: var context = context @@ -171,8 +191,9 @@ public enum Evaluator { var before = nodes.count flatten(modifier._modifiedContent, into: &nodes, context: context) // apply the modifier to each node the content produced + let modifierNode = modifier.resolvedModifierNode(in: context) while before < nodes.count { - nodes[before].modifiers.insert(modifier._modifierNode, at: 0) + nodes[before].modifiers.insert(modifierNode, at: 0) before += 1 } case let anyView as AnyView: diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/InteractionModifiers.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/InteractionModifiers.swift new file mode 100644 index 0000000..ba5e960 --- /dev/null +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/InteractionModifiers.swift @@ -0,0 +1,113 @@ +// +// InteractionModifiers.swift +// AndroidSwiftUICore +// +// Gesture and lifecycle modifiers. These carry a closure, so they register a +// callback against the resolve context (via `_CallbackModifier`) and embed its +// id in the emitted node; the interpreter wires the id to a Compose +// `clickable`, `DisposableEffect`, or `LaunchedEffect`. +// + +// MARK: - onTapGesture + +public struct _OnTapGestureModifier: RenderModifier, _CallbackModifier { + let action: () -> Void + public var _modifierNode: ModifierNode { ModifierNode(kind: "onTapGesture") } + public func _callbackNode(in context: ResolveContext) -> ModifierNode { + let id = context.callbacks.register(.void(action)) + return ModifierNode(kind: "onTapGesture", args: ["action": .int(Int(id))]) + } +} + +public extension View { + func onTapGesture(perform action: @escaping () -> Void) -> ModifiedContent { + modifier(_OnTapGestureModifier(action: action)) + } +} + +// MARK: - onAppear / onDisappear + +public struct _OnAppearModifier: RenderModifier, _CallbackModifier { + let action: () -> Void + public var _modifierNode: ModifierNode { ModifierNode(kind: "onAppear") } + public func _callbackNode(in context: ResolveContext) -> ModifierNode { + let id = context.callbacks.register(.void(action)) + return ModifierNode(kind: "onAppear", args: ["action": .int(Int(id))]) + } +} + +public struct _OnDisappearModifier: RenderModifier, _CallbackModifier { + let action: () -> Void + public var _modifierNode: ModifierNode { ModifierNode(kind: "onDisappear") } + public func _callbackNode(in context: ResolveContext) -> ModifierNode { + let id = context.callbacks.register(.void(action)) + return ModifierNode(kind: "onDisappear", args: ["action": .int(Int(id))]) + } +} + +public extension View { + func onAppear(perform action: @escaping () -> Void) -> ModifiedContent { + modifier(_OnAppearModifier(action: action)) + } + func onDisappear(perform action: @escaping () -> Void) -> ModifiedContent { + modifier(_OnDisappearModifier(action: action)) + } +} + +// MARK: - task + +public struct _TaskModifier: RenderModifier, _CallbackModifier { + let action: @Sendable () async -> Void + public var _modifierNode: ModifierNode { ModifierNode(kind: "task") } + public func _callbackNode(in context: ResolveContext) -> ModifierNode { + // The interpreter fires this once on appear; the closure runs as a Task. + // (v1 does not cancel it on disappear.) + let action = self.action + let id = context.callbacks.register(.void { Task { await action() } }) + return ModifierNode(kind: "task", args: ["action": .int(Int(id))]) + } +} + +public extension View { + func task(_ action: @escaping @Sendable () async -> Void) -> ModifiedContent { + modifier(_TaskModifier(action: action)) + } +} + +// MARK: - onChange + +public struct _OnChangeModifier: RenderModifier, _CallbackModifier { + let value: V + let action: () -> Void + public var _modifierNode: ModifierNode { ModifierNode(kind: "onChange") } + public func _callbackNode(in context: ResolveContext) -> ModifierNode { + let id = context.callbacks.register(.void(action)) + // The interpreter fires the action when this token changes between + // evaluations (skipping the first composition). + return ModifierNode(kind: "onChange", args: [ + "token": .string(String(describing: value)), + "action": .int(Int(id)), + ]) + } +} + +public extension View { + func onChange(of value: V, perform action: @escaping () -> Void) -> ModifiedContent> { + modifier(_OnChangeModifier(value: value, action: action)) + } +} + +// MARK: - disabled + +public struct _DisabledModifier: RenderModifier { + let disabled: Bool + public var _modifierNode: ModifierNode { + ModifierNode(kind: "disabled", args: ["value": .bool(disabled)]) + } +} + +public extension View { + func disabled(_ disabled: Bool) -> ModifiedContent { + modifier(_DisabledModifier(disabled: disabled)) + } +} diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Modifiers.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Modifiers.swift index e36ecf4..a9c0314 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Modifiers.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Modifiers.swift @@ -31,6 +31,15 @@ extension ModifiedContent: _ModifierProvider { public var _modifiedContent: any View { content } } +extension ModifiedContent: _ContextualModifierProvider { + public func _modifierNode(in context: ResolveContext) -> ModifierNode { + if let callback = modifier as? _CallbackModifier { + return callback._callbackNode(in: context) + } + return modifier._modifierNode + } +} + public extension View { func modifier(_ modifier: M) -> ModifiedContent { ModifiedContent(content: self, modifier: modifier) diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index b1aaaed..a6144ac 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -146,4 +146,38 @@ struct ModifierTests { let limit = node.modifiers.first { $0.kind == "lineLimit" } #expect(limit?.args["count"] == .int(2)) } + + @Test("onTapGesture registers a callback and emits its id") + func onTapGesture() { + var tapped = false + let host = ViewHost(Text("x").onTapGesture { tapped = true }) + let node = host.evaluate() + let tap = node.modifiers.first { $0.kind == "onTapGesture" } + guard case .int(let id)? = tap?.args["action"] else { + Issue.record("missing action id"); return + } + host.callbacks.invokeVoid(Int64(id)) + #expect(tapped) + } + + @Test("onAppear and onDisappear emit distinct callback kinds") + func appearDisappear() { + let node = ViewHost(Text("x").onAppear {}.onDisappear {}).evaluate() + #expect(node.modifiers.contains { $0.kind == "onAppear" }) + #expect(node.modifiers.contains { $0.kind == "onDisappear" }) + } + + @Test("onChange emits a token describing the observed value") + func onChange() { + let node = ViewHost(Text("x").onChange(of: 42) {}).evaluate() + let change = node.modifiers.first { $0.kind == "onChange" } + #expect(change?.args["token"] == .string("42")) + } + + @Test("disabled emits its flag") + func disabled() { + let node = ViewHost(Text("x").disabled(true)).evaluate() + let flag = node.modifiers.first { $0.kind == "disabled" } + #expect(flag?.args["value"] == .bool(true)) + } } diff --git a/Demo/App.swiftpm/Sources/Catalog.swift b/Demo/App.swiftpm/Sources/Catalog.swift index b758bff..3131291 100644 --- a/Demo/App.swiftpm/Sources/Catalog.swift +++ b/Demo/App.swiftpm/Sources/Catalog.swift @@ -38,6 +38,7 @@ struct CatalogEntry: Identifiable { CatalogEntry(id: "list", title: "List", screen: AnyCatalogScreen(ListPlayground())), CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())), CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())), + CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())), CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())), CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())), CatalogEntry(id: "sheet", title: "Sheet", screen: AnyCatalogScreen(SheetPlayground())), diff --git a/Demo/App.swiftpm/Sources/InteractionPlaygrounds.swift b/Demo/App.swiftpm/Sources/InteractionPlaygrounds.swift new file mode 100644 index 0000000..a8b8da4 --- /dev/null +++ b/Demo/App.swiftpm/Sources/InteractionPlaygrounds.swift @@ -0,0 +1,48 @@ +#if canImport(AndroidSwiftUI) +import AndroidSwiftUI +#else +import SwiftUI +#endif + +struct InteractionPlayground: View { + @State private var taps = 0 + @State private var appearances = 0 + @State private var slider = 0.0 + @State private var sliderChanges = 0 + @State private var blocked = true + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Example("onTapGesture") { + VStack(alignment: .leading, spacing: 8) { + Text("Tapped \(taps) time(s)") + Text("Tap this row") + .foregroundColor(.white) + .padding() + .background(Color.blue) + .cornerRadius(8) + .onTapGesture { taps += 1 } + } + } + Example("onAppear") { + Text("This row appeared \(appearances) time(s)") + .onAppear { appearances += 1 } + } + Example("onChange") { + VStack(alignment: .leading, spacing: 8) { + Slider(value: $slider, in: 0...1) + Text("Slider changed \(sliderChanges) time(s)") + } + .onChange(of: slider) { sliderChanges += 1 } + } + Example("disabled") { + VStack(alignment: .leading, spacing: 8) { + Button(blocked ? "Disabled button" : "Enabled button") { taps += 1 } + .disabled(blocked) + Button("Toggle disabled") { blocked.toggle() } + } + } + } + } + } +} 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 454989b..680856f 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,7 @@ package com.pureswift.swiftui import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.verticalScroll @@ -50,6 +51,7 @@ 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key @@ -83,6 +85,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape @Composable fun Render(node: ViewNode) { key(node.id) { + RenderEffects(node) when (node.type) { "Text" -> RenderText(node) @@ -90,6 +93,7 @@ fun Render(node: ViewNode) { val onTap = node.long("onTap") Button( onClick = { onTap?.let { SwiftBridge.sink.invokeVoid(it) } }, + enabled = !node.isDisabled(), modifier = node.composeModifiers(), ) { RenderChildren(node) @@ -334,6 +338,40 @@ private fun zStackAlignment(node: ViewNode): Alignment { } } +// Lifecycle modifiers (onAppear/onDisappear/task/onChange) install Compose +// effects rather than folding into the Modifier chain. Keyed within the node's +// `key(id)` scope, so onAppear fires once on entry and onDisappear on exit, +// surviving recomposition. +@Composable +private fun RenderEffects(node: ViewNode) { + val onAppear = node.modifiers.firstOrNull { it.kind == "onAppear" }?.args?.long("action") + val onDisappear = node.modifiers.firstOrNull { it.kind == "onDisappear" }?.args?.long("action") + val task = node.modifiers.firstOrNull { it.kind == "task" }?.args?.long("action") + val onChange = node.modifiers.firstOrNull { it.kind == "onChange" } + + if (onAppear != null || onDisappear != null) { + DisposableEffect(Unit) { + onAppear?.let { SwiftBridge.sink.invokeVoid(it) } + onDispose { onDisappear?.let { SwiftBridge.sink.invokeVoid(it) } } + } + } + if (task != null) { + LaunchedEffect(Unit) { SwiftBridge.sink.invokeVoid(task) } + } + if (onChange != null) { + val token = onChange.args.string("token") + val action = onChange.args.long("action") + // skip the initial composition; fire when the token changes thereafter + var primed by remember(node.id) { mutableStateOf(false) } + LaunchedEffect(token) { + if (primed) action?.let { SwiftBridge.sink.invokeVoid(it) } else primed = true + } + } +} + +private fun ViewNode.isDisabled(): Boolean = + modifiers.any { it.kind == "disabled" && (it.args["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true" } + // Text-styling modifiers describe attributes of the Text composable itself, // not the layout Modifier chain, so they are read off the node here and passed // as `Text(...)` parameters. Later (innermost) entries win for scalar @@ -465,6 +503,18 @@ internal fun ViewNode.composeModifiers(): Modifier { "opacity" -> modifier.alpha((entry.args.double("opacity") ?: 1.0).toFloat()) + "onTapGesture" -> { + val id = entry.args.long("action") + if (id != null) modifier.clickable { SwiftBridge.sink.invokeVoid(id) } else modifier + } + + // Dim disabled content; controls also drop interactivity via their + // own `enabled` parameter (e.g. Button). + "disabled" -> { + val off = (entry.args["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true" + if (off) modifier.alpha(0.38f) else modifier + } + else -> modifier // unknown modifier: ignore, never crash rendering } }