Skip to content

Commit eb0e2a0

Browse files
authored
Merge pull request #46 from PureSwift/feature/task-cancel
2 parents 12c4ac9 + 702f00e commit eb0e2a0

4 files changed

Lines changed: 76 additions & 7 deletions

File tree

AndroidSwiftUICore/Sources/AndroidSwiftUICore/Modifiers/InteractionModifiers.swift

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,15 +56,36 @@ public extension View {
5656

5757
// MARK: - task
5858

59+
/// Running `.task` closures, keyed by the view's stable identity path. Keying by
60+
/// path (not the generation-scoped callback id) lets the cancel fired on disappear
61+
/// stop the right Task no matter how many times the view re-evaluated in between.
62+
enum _TaskRegistry {
63+
nonisolated(unsafe) static var running: [String: Task<Void, Never>] = [:]
64+
65+
static func start(path: String, action: @escaping @Sendable () async -> Void) {
66+
running[path]?.cancel() // replace a stale run at this path
67+
running[path] = Task { await action() }
68+
}
69+
70+
static func cancel(path: String) {
71+
running[path]?.cancel()
72+
running[path] = nil
73+
}
74+
}
75+
5976
public struct _TaskModifier: RenderModifier, _CallbackModifier {
6077
let action: @Sendable () async -> Void
6178
public var _modifierNode: ModifierNode { ModifierNode(kind: "task") }
6279
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.)
80+
// Two callbacks: the interpreter fires `start` on appear and `cancel` on
81+
// disappear. The launched Task is stored by identity path, so the closure
82+
// it runs is cooperatively cancelled (Task.isCancelled / Task.sleep throws)
83+
// when the view leaves the tree.
6584
let action = self.action
66-
let id = context.callbacks.register(.void { Task { await action() } })
67-
return ModifierNode(kind: "task", args: ["action": .int(Int(id))])
85+
let path = context.path
86+
let start = context.callbacks.register(.void { _TaskRegistry.start(path: path, action: action) })
87+
let cancel = context.callbacks.register(.void { _TaskRegistry.cancel(path: path) })
88+
return ModifierNode(kind: "task", args: ["start": .int(Int(start)), "cancel": .int(Int(cancel))])
6889
}
6990
}
7091

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,22 @@ struct ModifierTests {
194194
#expect(node.modifiers.contains { $0.kind == "onDisappear" })
195195
}
196196

197+
@Test("task emits start and cancel callback ids that drive the same Task")
198+
func taskStartAndCancel() async {
199+
let host = ViewHost(Text("x").task { try? await Task.sleep(nanoseconds: 10_000_000_000) })
200+
let node = host.evaluate()
201+
let task = node.modifiers.first { $0.kind == "task" }
202+
guard case .int(let start)? = task?.args["start"],
203+
case .int(let cancel)? = task?.args["cancel"] else {
204+
Issue.record("missing start/cancel ids"); return
205+
}
206+
#expect(start != cancel)
207+
host.callbacks.invokeVoid(Int64(start)) // launches the Task
208+
#expect(!_TaskRegistry.running.isEmpty)
209+
host.callbacks.invokeVoid(Int64(cancel)) // cancels and clears it
210+
#expect(_TaskRegistry.running.isEmpty)
211+
}
212+
197213
@Test("onChange emits a token describing the observed value")
198214
func onChange() {
199215
let node = ViewHost(Text("x").onChange(of: 42) {}).evaluate()

Demo/App.swiftpm/Sources/InteractionPlaygrounds.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ struct InteractionPlayground: View {
1010
@State private var slider = 0.0
1111
@State private var sliderChanges = 0
1212
@State private var blocked = true
13+
@State private var ticking = false
14+
@State private var ticks = 0
1315
var body: some View {
1416
ScrollView {
1517
VStack(alignment: .leading, spacing: 0) {
@@ -35,6 +37,26 @@ struct InteractionPlayground: View {
3537
}
3638
.onChange(of: slider) { sliderChanges += 1 }
3739
}
40+
Example(".task (cancels on disappear)") {
41+
VStack(alignment: .leading, spacing: 8) {
42+
Text("Ticks: \(ticks)")
43+
Button(ticking ? "Hide (cancels task)" : "Show") { ticking.toggle() }
44+
if ticking {
45+
Text("Ticking every 0.5s while visible")
46+
.foregroundColor(.white)
47+
.padding()
48+
.background(Color.green)
49+
.cornerRadius(8)
50+
.task {
51+
while true {
52+
do { try await Task.sleep(nanoseconds: 500_000_000) }
53+
catch { break } // cancelled on disappear → stop
54+
ticks += 1
55+
}
56+
}
57+
}
58+
}
59+
}
3860
Example("disabled") {
3961
VStack(alignment: .leading, spacing: 8) {
4062
Button(blocked ? "Disabled button" : "Enabled button") { taps += 1 }

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,9 @@ private fun zStackAlignment(node: ViewNode): Alignment {
764764
private fun RenderEffects(node: ViewNode) {
765765
val onAppear = node.modifiers.firstOrNull { it.kind == "onAppear" }?.args?.long("action")
766766
val onDisappear = node.modifiers.firstOrNull { it.kind == "onDisappear" }?.args?.long("action")
767-
val task = node.modifiers.firstOrNull { it.kind == "task" }?.args?.long("action")
767+
val taskMod = node.modifiers.firstOrNull { it.kind == "task" }
768+
val taskStart = taskMod?.args?.long("start")
769+
val taskCancel = taskMod?.args?.long("cancel")
768770
val onChange = node.modifiers.firstOrNull { it.kind == "onChange" }
769771

770772
if (onAppear != null || onDisappear != null) {
@@ -773,8 +775,16 @@ private fun RenderEffects(node: ViewNode) {
773775
onDispose { onDisappear?.let { SwiftBridge.sink.invokeVoid(it) } }
774776
}
775777
}
776-
if (task != null) {
777-
LaunchedEffect(Unit) { SwiftBridge.sink.invokeVoid(task) }
778+
if (taskStart != null && taskCancel != null) {
779+
// The cancel id changes each evaluation; onDispose can fire generations
780+
// after appear, so hand it the freshest id (kept in a remembered holder)
781+
// — still resolvable, and it cancels by stable path regardless.
782+
val cancelHolder = remember { longArrayOf(taskCancel) }
783+
cancelHolder[0] = taskCancel
784+
DisposableEffect(Unit) {
785+
SwiftBridge.sink.invokeVoid(taskStart)
786+
onDispose { SwiftBridge.sink.invokeVoid(cancelHolder[0]) }
787+
}
778788
}
779789
if (onChange != null) {
780790
val token = onChange.args.string("token")

0 commit comments

Comments
 (0)