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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Evaluator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Self, _OnTapGestureModifier> {
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<Self, _OnAppearModifier> {
modifier(_OnAppearModifier(action: action))
}
func onDisappear(perform action: @escaping () -> Void) -> ModifiedContent<Self, _OnDisappearModifier> {
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<Self, _TaskModifier> {
modifier(_TaskModifier(action: action))
}
}

// MARK: - onChange

public struct _OnChangeModifier<V: Equatable>: 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<V: Equatable>(of value: V, perform action: @escaping () -> Void) -> ModifiedContent<Self, _OnChangeModifier<V>> {
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<Self, _DisabledModifier> {
modifier(_DisabledModifier(disabled: disabled))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<M: RenderModifier>(_ modifier: M) -> ModifiedContent<Self, M> {
ModifiedContent(content: self, modifier: modifier)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
Expand Down
48 changes: 48 additions & 0 deletions Demo/App.swiftpm/Sources/InteractionPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -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() }
}
}
}
}
}
}
50 changes: 50 additions & 0 deletions Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -83,13 +85,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape
@Composable
fun Render(node: ViewNode) {
key(node.id) {
RenderEffects(node)
when (node.type) {
"Text" -> RenderText(node)

"Button" -> {
val onTap = node.long("onTap")
Button(
onClick = { onTap?.let { SwiftBridge.sink.invokeVoid(it) } },
enabled = !node.isDisabled(),
modifier = node.composeModifiers(),
) {
RenderChildren(node)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down
Loading