Skip to content

Commit 41683d1

Browse files
authored
Merge pull request #27 from PureSwift/feature/interaction
2 parents f63a2d5 + 2013933 commit 41683d1

7 files changed

Lines changed: 278 additions & 2 deletions

File tree

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,26 @@ public protocol _ModifierProvider {
7474
var _modifiedContent: any View { get }
7575
}
7676

77+
/// A modifier provider that needs the resolve context to emit its node — e.g.
78+
/// to register a callback. The evaluator prefers this over the context-free
79+
/// `_modifierNode` property when a provider conforms to it.
80+
public protocol _ContextualModifierProvider {
81+
func _modifierNode(in context: ResolveContext) -> ModifierNode
82+
}
83+
84+
/// A modifier whose emitted node embeds a callback registered against the
85+
/// resolve context (e.g. `onTapGesture`, `onAppear`, `onChange`).
86+
public protocol _CallbackModifier {
87+
func _callbackNode(in context: ResolveContext) -> ModifierNode
88+
}
89+
90+
extension _ModifierProvider {
91+
/// The emitted node, using the resolve context when the provider needs it.
92+
func resolvedModifierNode(in context: ResolveContext) -> ModifierNode {
93+
(self as? _ContextualModifierProvider)?._modifierNode(in: context) ?? _modifierNode
94+
}
95+
}
96+
7797
/// A group that flattens into a sequence of sibling nodes (TupleView, ForEach,
7898
/// conditionals, optionals).
7999
public protocol _GroupView {
@@ -123,7 +143,7 @@ public enum Evaluator {
123143
case let modifier as _ModifierProvider:
124144
// identity-transparent: resolve content at the SAME path, then prepend
125145
var node = resolve(modifier._modifiedContent, context)
126-
node.modifiers.insert(modifier._modifierNode, at: 0)
146+
node.modifiers.insert(modifier.resolvedModifierNode(in: context), at: 0)
127147
return node
128148
case let effect as _ResolutionEffectView:
129149
var context = context
@@ -171,8 +191,9 @@ public enum Evaluator {
171191
var before = nodes.count
172192
flatten(modifier._modifiedContent, into: &nodes, context: context)
173193
// apply the modifier to each node the content produced
194+
let modifierNode = modifier.resolvedModifierNode(in: context)
174195
while before < nodes.count {
175-
nodes[before].modifiers.insert(modifier._modifierNode, at: 0)
196+
nodes[before].modifiers.insert(modifierNode, at: 0)
176197
before += 1
177198
}
178199
case let anyView as AnyView:
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
//
2+
// InteractionModifiers.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Gesture and lifecycle modifiers. These carry a closure, so they register a
6+
// callback against the resolve context (via `_CallbackModifier`) and embed its
7+
// id in the emitted node; the interpreter wires the id to a Compose
8+
// `clickable`, `DisposableEffect`, or `LaunchedEffect`.
9+
//
10+
11+
// MARK: - onTapGesture
12+
13+
public struct _OnTapGestureModifier: RenderModifier, _CallbackModifier {
14+
let action: () -> Void
15+
public var _modifierNode: ModifierNode { ModifierNode(kind: "onTapGesture") }
16+
public func _callbackNode(in context: ResolveContext) -> ModifierNode {
17+
let id = context.callbacks.register(.void(action))
18+
return ModifierNode(kind: "onTapGesture", args: ["action": .int(Int(id))])
19+
}
20+
}
21+
22+
public extension View {
23+
func onTapGesture(perform action: @escaping () -> Void) -> ModifiedContent<Self, _OnTapGestureModifier> {
24+
modifier(_OnTapGestureModifier(action: action))
25+
}
26+
}
27+
28+
// MARK: - onAppear / onDisappear
29+
30+
public struct _OnAppearModifier: RenderModifier, _CallbackModifier {
31+
let action: () -> Void
32+
public var _modifierNode: ModifierNode { ModifierNode(kind: "onAppear") }
33+
public func _callbackNode(in context: ResolveContext) -> ModifierNode {
34+
let id = context.callbacks.register(.void(action))
35+
return ModifierNode(kind: "onAppear", args: ["action": .int(Int(id))])
36+
}
37+
}
38+
39+
public struct _OnDisappearModifier: RenderModifier, _CallbackModifier {
40+
let action: () -> Void
41+
public var _modifierNode: ModifierNode { ModifierNode(kind: "onDisappear") }
42+
public func _callbackNode(in context: ResolveContext) -> ModifierNode {
43+
let id = context.callbacks.register(.void(action))
44+
return ModifierNode(kind: "onDisappear", args: ["action": .int(Int(id))])
45+
}
46+
}
47+
48+
public extension View {
49+
func onAppear(perform action: @escaping () -> Void) -> ModifiedContent<Self, _OnAppearModifier> {
50+
modifier(_OnAppearModifier(action: action))
51+
}
52+
func onDisappear(perform action: @escaping () -> Void) -> ModifiedContent<Self, _OnDisappearModifier> {
53+
modifier(_OnDisappearModifier(action: action))
54+
}
55+
}
56+
57+
// MARK: - task
58+
59+
public struct _TaskModifier: RenderModifier, _CallbackModifier {
60+
let action: @Sendable () async -> Void
61+
public var _modifierNode: ModifierNode { ModifierNode(kind: "task") }
62+
public func _callbackNode(in context: ResolveContext) -> ModifierNode {
63+
// The interpreter fires this once on appear; the closure runs as a Task.
64+
// (v1 does not cancel it on disappear.)
65+
let action = self.action
66+
let id = context.callbacks.register(.void { Task { await action() } })
67+
return ModifierNode(kind: "task", args: ["action": .int(Int(id))])
68+
}
69+
}
70+
71+
public extension View {
72+
func task(_ action: @escaping @Sendable () async -> Void) -> ModifiedContent<Self, _TaskModifier> {
73+
modifier(_TaskModifier(action: action))
74+
}
75+
}
76+
77+
// MARK: - onChange
78+
79+
public struct _OnChangeModifier<V: Equatable>: RenderModifier, _CallbackModifier {
80+
let value: V
81+
let action: () -> Void
82+
public var _modifierNode: ModifierNode { ModifierNode(kind: "onChange") }
83+
public func _callbackNode(in context: ResolveContext) -> ModifierNode {
84+
let id = context.callbacks.register(.void(action))
85+
// The interpreter fires the action when this token changes between
86+
// evaluations (skipping the first composition).
87+
return ModifierNode(kind: "onChange", args: [
88+
"token": .string(String(describing: value)),
89+
"action": .int(Int(id)),
90+
])
91+
}
92+
}
93+
94+
public extension View {
95+
func onChange<V: Equatable>(of value: V, perform action: @escaping () -> Void) -> ModifiedContent<Self, _OnChangeModifier<V>> {
96+
modifier(_OnChangeModifier(value: value, action: action))
97+
}
98+
}
99+
100+
// MARK: - disabled
101+
102+
public struct _DisabledModifier: RenderModifier {
103+
let disabled: Bool
104+
public var _modifierNode: ModifierNode {
105+
ModifierNode(kind: "disabled", args: ["value": .bool(disabled)])
106+
}
107+
}
108+
109+
public extension View {
110+
func disabled(_ disabled: Bool) -> ModifiedContent<Self, _DisabledModifier> {
111+
modifier(_DisabledModifier(disabled: disabled))
112+
}
113+
}

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/Modifiers.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ extension ModifiedContent: _ModifierProvider {
3131
public var _modifiedContent: any View { content }
3232
}
3333

34+
extension ModifiedContent: _ContextualModifierProvider {
35+
public func _modifierNode(in context: ResolveContext) -> ModifierNode {
36+
if let callback = modifier as? _CallbackModifier {
37+
return callback._callbackNode(in: context)
38+
}
39+
return modifier._modifierNode
40+
}
41+
}
42+
3443
public extension View {
3544
func modifier<M: RenderModifier>(_ modifier: M) -> ModifiedContent<Self, M> {
3645
ModifiedContent(content: self, modifier: modifier)

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,38 @@ struct ModifierTests {
146146
let limit = node.modifiers.first { $0.kind == "lineLimit" }
147147
#expect(limit?.args["count"] == .int(2))
148148
}
149+
150+
@Test("onTapGesture registers a callback and emits its id")
151+
func onTapGesture() {
152+
var tapped = false
153+
let host = ViewHost(Text("x").onTapGesture { tapped = true })
154+
let node = host.evaluate()
155+
let tap = node.modifiers.first { $0.kind == "onTapGesture" }
156+
guard case .int(let id)? = tap?.args["action"] else {
157+
Issue.record("missing action id"); return
158+
}
159+
host.callbacks.invokeVoid(Int64(id))
160+
#expect(tapped)
161+
}
162+
163+
@Test("onAppear and onDisappear emit distinct callback kinds")
164+
func appearDisappear() {
165+
let node = ViewHost(Text("x").onAppear {}.onDisappear {}).evaluate()
166+
#expect(node.modifiers.contains { $0.kind == "onAppear" })
167+
#expect(node.modifiers.contains { $0.kind == "onDisappear" })
168+
}
169+
170+
@Test("onChange emits a token describing the observed value")
171+
func onChange() {
172+
let node = ViewHost(Text("x").onChange(of: 42) {}).evaluate()
173+
let change = node.modifiers.first { $0.kind == "onChange" }
174+
#expect(change?.args["token"] == .string("42"))
175+
}
176+
177+
@Test("disabled emits its flag")
178+
func disabled() {
179+
let node = ViewHost(Text("x").disabled(true)).evaluate()
180+
let flag = node.modifiers.first { $0.kind == "disabled" }
181+
#expect(flag?.args["value"] == .bool(true))
182+
}
149183
}

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ struct CatalogEntry: Identifiable {
3838
CatalogEntry(id: "list", title: "List", screen: AnyCatalogScreen(ListPlayground())),
3939
CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())),
4040
CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())),
41+
CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())),
4142
CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())),
4243
CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())),
4344
CatalogEntry(id: "sheet", title: "Sheet", screen: AnyCatalogScreen(SheetPlayground())),
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct InteractionPlayground: View {
8+
@State private var taps = 0
9+
@State private var appearances = 0
10+
@State private var slider = 0.0
11+
@State private var sliderChanges = 0
12+
@State private var blocked = true
13+
var body: some View {
14+
ScrollView {
15+
VStack(alignment: .leading, spacing: 0) {
16+
Example("onTapGesture") {
17+
VStack(alignment: .leading, spacing: 8) {
18+
Text("Tapped \(taps) time(s)")
19+
Text("Tap this row")
20+
.foregroundColor(.white)
21+
.padding()
22+
.background(Color.blue)
23+
.cornerRadius(8)
24+
.onTapGesture { taps += 1 }
25+
}
26+
}
27+
Example("onAppear") {
28+
Text("This row appeared \(appearances) time(s)")
29+
.onAppear { appearances += 1 }
30+
}
31+
Example("onChange") {
32+
VStack(alignment: .leading, spacing: 8) {
33+
Slider(value: $slider, in: 0...1)
34+
Text("Slider changed \(sliderChanges) time(s)")
35+
}
36+
.onChange(of: slider) { sliderChanges += 1 }
37+
}
38+
Example("disabled") {
39+
VStack(alignment: .leading, spacing: 8) {
40+
Button(blocked ? "Disabled button" : "Enabled button") { taps += 1 }
41+
.disabled(blocked)
42+
Button("Toggle disabled") { blocked.toggle() }
43+
}
44+
}
45+
}
46+
}
47+
}
48+
}

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.pureswift.swiftui
22

33
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.clickable
45
import androidx.compose.foundation.rememberScrollState
56
import androidx.compose.foundation.horizontalScroll
67
import androidx.compose.foundation.verticalScroll
@@ -50,6 +51,7 @@ import androidx.compose.material3.TopAppBar
5051
import androidx.compose.material.icons.Icons
5152
import androidx.compose.material.icons.automirrored.filled.ArrowBack
5253
import androidx.compose.runtime.Composable
54+
import androidx.compose.runtime.DisposableEffect
5355
import androidx.compose.runtime.LaunchedEffect
5456
import androidx.compose.runtime.getValue
5557
import androidx.compose.runtime.key
@@ -83,13 +85,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
8385
@Composable
8486
fun Render(node: ViewNode) {
8587
key(node.id) {
88+
RenderEffects(node)
8689
when (node.type) {
8790
"Text" -> RenderText(node)
8891

8992
"Button" -> {
9093
val onTap = node.long("onTap")
9194
Button(
9295
onClick = { onTap?.let { SwiftBridge.sink.invokeVoid(it) } },
96+
enabled = !node.isDisabled(),
9397
modifier = node.composeModifiers(),
9498
) {
9599
RenderChildren(node)
@@ -334,6 +338,40 @@ private fun zStackAlignment(node: ViewNode): Alignment {
334338
}
335339
}
336340

341+
// Lifecycle modifiers (onAppear/onDisappear/task/onChange) install Compose
342+
// effects rather than folding into the Modifier chain. Keyed within the node's
343+
// `key(id)` scope, so onAppear fires once on entry and onDisappear on exit,
344+
// surviving recomposition.
345+
@Composable
346+
private fun RenderEffects(node: ViewNode) {
347+
val onAppear = node.modifiers.firstOrNull { it.kind == "onAppear" }?.args?.long("action")
348+
val onDisappear = node.modifiers.firstOrNull { it.kind == "onDisappear" }?.args?.long("action")
349+
val task = node.modifiers.firstOrNull { it.kind == "task" }?.args?.long("action")
350+
val onChange = node.modifiers.firstOrNull { it.kind == "onChange" }
351+
352+
if (onAppear != null || onDisappear != null) {
353+
DisposableEffect(Unit) {
354+
onAppear?.let { SwiftBridge.sink.invokeVoid(it) }
355+
onDispose { onDisappear?.let { SwiftBridge.sink.invokeVoid(it) } }
356+
}
357+
}
358+
if (task != null) {
359+
LaunchedEffect(Unit) { SwiftBridge.sink.invokeVoid(task) }
360+
}
361+
if (onChange != null) {
362+
val token = onChange.args.string("token")
363+
val action = onChange.args.long("action")
364+
// skip the initial composition; fire when the token changes thereafter
365+
var primed by remember(node.id) { mutableStateOf(false) }
366+
LaunchedEffect(token) {
367+
if (primed) action?.let { SwiftBridge.sink.invokeVoid(it) } else primed = true
368+
}
369+
}
370+
}
371+
372+
private fun ViewNode.isDisabled(): Boolean =
373+
modifiers.any { it.kind == "disabled" && (it.args["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true" }
374+
337375
// Text-styling modifiers describe attributes of the Text composable itself,
338376
// not the layout Modifier chain, so they are read off the node here and passed
339377
// as `Text(...)` parameters. Later (innermost) entries win for scalar
@@ -465,6 +503,18 @@ internal fun ViewNode.composeModifiers(): Modifier {
465503

466504
"opacity" -> modifier.alpha((entry.args.double("opacity") ?: 1.0).toFloat())
467505

506+
"onTapGesture" -> {
507+
val id = entry.args.long("action")
508+
if (id != null) modifier.clickable { SwiftBridge.sink.invokeVoid(id) } else modifier
509+
}
510+
511+
// Dim disabled content; controls also drop interactivity via their
512+
// own `enabled` parameter (e.g. Button).
513+
"disabled" -> {
514+
val off = (entry.args["value"] as? kotlinx.serialization.json.JsonPrimitive)?.content == "true"
515+
if (off) modifier.alpha(0.38f) else modifier
516+
}
517+
468518
else -> modifier // unknown modifier: ignore, never crash rendering
469519
}
470520
}

0 commit comments

Comments
 (0)