diff --git a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/InteractionModifiers.swift b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/InteractionModifiers.swift index ba5e960..214518a 100644 --- a/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/InteractionModifiers.swift +++ b/AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/InteractionModifiers.swift @@ -56,15 +56,36 @@ public extension View { // MARK: - task +/// Running `.task` closures, keyed by the view's stable identity path. Keying by +/// path (not the generation-scoped callback id) lets the cancel fired on disappear +/// stop the right Task no matter how many times the view re-evaluated in between. +enum _TaskRegistry { + nonisolated(unsafe) static var running: [String: Task] = [:] + + static func start(path: String, action: @escaping @Sendable () async -> Void) { + running[path]?.cancel() // replace a stale run at this path + running[path] = Task { await action() } + } + + static func cancel(path: String) { + running[path]?.cancel() + running[path] = nil + } +} + 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.) + // Two callbacks: the interpreter fires `start` on appear and `cancel` on + // disappear. The launched Task is stored by identity path, so the closure + // it runs is cooperatively cancelled (Task.isCancelled / Task.sleep throws) + // when the view leaves the tree. let action = self.action - let id = context.callbacks.register(.void { Task { await action() } }) - return ModifierNode(kind: "task", args: ["action": .int(Int(id))]) + let path = context.path + let start = context.callbacks.register(.void { _TaskRegistry.start(path: path, action: action) }) + let cancel = context.callbacks.register(.void { _TaskRegistry.cancel(path: path) }) + return ModifierNode(kind: "task", args: ["start": .int(Int(start)), "cancel": .int(Int(cancel))]) } } diff --git a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift index 6e047c0..91aa689 100644 --- a/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift +++ b/AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift @@ -194,6 +194,22 @@ struct ModifierTests { #expect(node.modifiers.contains { $0.kind == "onDisappear" }) } + @Test("task emits start and cancel callback ids that drive the same Task") + func taskStartAndCancel() async { + let host = ViewHost(Text("x").task { try? await Task.sleep(nanoseconds: 10_000_000_000) }) + let node = host.evaluate() + let task = node.modifiers.first { $0.kind == "task" } + guard case .int(let start)? = task?.args["start"], + case .int(let cancel)? = task?.args["cancel"] else { + Issue.record("missing start/cancel ids"); return + } + #expect(start != cancel) + host.callbacks.invokeVoid(Int64(start)) // launches the Task + #expect(!_TaskRegistry.running.isEmpty) + host.callbacks.invokeVoid(Int64(cancel)) // cancels and clears it + #expect(_TaskRegistry.running.isEmpty) + } + @Test("onChange emits a token describing the observed value") func onChange() { let node = ViewHost(Text("x").onChange(of: 42) {}).evaluate() diff --git a/Demo/App.swiftpm/Sources/InteractionPlaygrounds.swift b/Demo/App.swiftpm/Sources/InteractionPlaygrounds.swift index a8b8da4..df3b50f 100644 --- a/Demo/App.swiftpm/Sources/InteractionPlaygrounds.swift +++ b/Demo/App.swiftpm/Sources/InteractionPlaygrounds.swift @@ -10,6 +10,8 @@ struct InteractionPlayground: View { @State private var slider = 0.0 @State private var sliderChanges = 0 @State private var blocked = true + @State private var ticking = false + @State private var ticks = 0 var body: some View { ScrollView { VStack(alignment: .leading, spacing: 0) { @@ -35,6 +37,26 @@ struct InteractionPlayground: View { } .onChange(of: slider) { sliderChanges += 1 } } + Example(".task (cancels on disappear)") { + VStack(alignment: .leading, spacing: 8) { + Text("Ticks: \(ticks)") + Button(ticking ? "Hide (cancels task)" : "Show") { ticking.toggle() } + if ticking { + Text("Ticking every 0.5s while visible") + .foregroundColor(.white) + .padding() + .background(Color.green) + .cornerRadius(8) + .task { + while true { + do { try await Task.sleep(nanoseconds: 500_000_000) } + catch { break } // cancelled on disappear → stop + ticks += 1 + } + } + } + } + } Example("disabled") { VStack(alignment: .leading, spacing: 8) { Button(blocked ? "Disabled button" : "Enabled button") { taps += 1 } 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 3d6b251..265122e 100644 --- a/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt +++ b/Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt @@ -764,7 +764,9 @@ private fun zStackAlignment(node: ViewNode): Alignment { 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 taskMod = node.modifiers.firstOrNull { it.kind == "task" } + val taskStart = taskMod?.args?.long("start") + val taskCancel = taskMod?.args?.long("cancel") val onChange = node.modifiers.firstOrNull { it.kind == "onChange" } if (onAppear != null || onDisappear != null) { @@ -773,8 +775,16 @@ private fun RenderEffects(node: ViewNode) { onDispose { onDisappear?.let { SwiftBridge.sink.invokeVoid(it) } } } } - if (task != null) { - LaunchedEffect(Unit) { SwiftBridge.sink.invokeVoid(task) } + if (taskStart != null && taskCancel != null) { + // The cancel id changes each evaluation; onDispose can fire generations + // after appear, so hand it the freshest id (kept in a remembered holder) + // — still resolvable, and it cancels by stable path regardless. + val cancelHolder = remember { longArrayOf(taskCancel) } + cancelHolder[0] = taskCancel + DisposableEffect(Unit) { + SwiftBridge.sink.invokeVoid(taskStart) + onDispose { SwiftBridge.sink.invokeVoid(cancelHolder[0]) } + } } if (onChange != null) { val token = onChange.args.string("token")