Skip to content

Commit 3e8b44c

Browse files
committed
Scope Android withAnimation transactions
1 parent 2dacfc3 commit 3e8b44c

5 files changed

Lines changed: 248 additions & 2 deletions

File tree

Sources/SkipUI/SkipUI/Animation/Animation.swift

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: MPL-2.0
33
#if !SKIP_BRIDGE
44
import Foundation
5+
import SkipModel
56
#if SKIP
67
import androidx.compose.animation.Animatable
78
import androidx.compose.animation.core.Animatable
@@ -92,6 +93,20 @@ extension View {
9293
final class AnimationHolder {
9394
var animation: Animation?
9495
}
96+
97+
final class AnimationTransaction: StateMutationTransaction {
98+
let id: Int
99+
let animation: Animation?
100+
101+
init(id: Int, animation: Animation?) {
102+
self.id = id
103+
self.animation = animation
104+
}
105+
}
106+
107+
public enum AnimationDebug {
108+
public static var decisionSink: ((String) -> Void)?
109+
}
95110
#endif
96111

97112
// SKIP @bridge
@@ -109,7 +124,13 @@ public struct Animation : Hashable {
109124
var isNested = false
110125
synchronized (withAnimationLock) {
111126
isNested = _withAnimation != nil
127+
nextWithAnimationTransactionID += 1
128+
let transaction = AnimationTransaction(id: nextWithAnimationTransactionID, animation: animation)
129+
withAnimationTransactions.append(transaction)
130+
StateTracking.currentMutationTransaction = transaction
131+
StateTracking.clearMutationReads()
112132
_withAnimation = animation
133+
debugLog("open withAnimation \(debugDescription(for: withAnimationTransactions.last)) nested=\(isNested)")
113134
}
114135
return isNested
115136
#else
@@ -120,10 +141,17 @@ public struct Animation : Hashable {
120141
// SKIP @bridge
121142
public static func postBodyWithAnimation() {
122143
#if SKIP
144+
synchronized (withAnimationLock) {
145+
debugLog("clear withAnimation transactions after body")
146+
withAnimationTransactions.removeAll()
147+
StateTracking.currentMutationTransaction = nil
148+
}
123149
GlobalScope.async(Dispatchers.Main) {
124150
awaitFrame()
125151
synchronized (withAnimationLock) {
152+
debugLog("clear global withAnimation after frame")
126153
_withAnimation = nil
154+
StateTracking.clearMutationReads()
127155
}
128156
}
129157
#endif
@@ -151,25 +179,90 @@ public struct Animation : Hashable {
151179
return isAnimating ? rememberedAnimation : nil
152180
}
153181

182+
/// The current active animation for normal value animatables.
183+
///
184+
/// Value animatables should be driven by explicit `.animation(..., value:)`
185+
/// environment state or by a transaction attached to the state write that
186+
/// caused the value change. The legacy global `_withAnimation` frame window
187+
/// remains only for transition surfaces that cannot yet be tied to reads.
188+
@Composable static func currentForAnimatable(isAnimating: Bool) -> Animation? {
189+
let environmentAnimation = EnvironmentValues.shared._animation
190+
let transaction = StateTracking.consumeMutationRead() as? AnimationTransaction
191+
let animation = environmentAnimation ?? transaction?.animation
192+
193+
let rememberedAnimationHolder = remember { AnimationHolder() }
194+
let rememberedAnimation = rememberedAnimationHolder.animation
195+
if animation != nil {
196+
rememberedAnimationHolder.animation = animation
197+
} else if !isAnimating {
198+
rememberedAnimationHolder.animation = nil
199+
}
200+
201+
debugDecision("animatable transaction=\(debugDescription(for: transaction)) environmentAnimation=\(environmentAnimation != nil) usesAnimation=\(animation != nil)")
202+
203+
guard animation == nil else {
204+
return animation
205+
}
206+
return isAnimating ? rememberedAnimation : nil
207+
}
208+
154209
/// Whether we're in a `withAnimation` block.
155210
static var isInWithAnimation: Bool {
156211
synchronized (withAnimationLock) {
157212
return _withAnimation != nil
158213
}
159214
}
160215

216+
static var currentTransaction: AnimationTransaction? {
217+
synchronized (withAnimationLock) {
218+
return withAnimationTransactions.last
219+
}
220+
}
221+
222+
static func debugDescription(for transaction: AnimationTransaction?) -> String {
223+
if let transaction {
224+
return "#\(transaction.id) animation=\(transaction.animation != nil)"
225+
} else {
226+
return "none"
227+
}
228+
}
229+
230+
static func debugLog(_ message: String) {
231+
if debugWithAnimationTransactions {
232+
android.util.Log.d("SkipUI.Animation", message)
233+
}
234+
}
235+
236+
static func debugDecision(_ message: String) {
237+
AnimationDebug.decisionSink?(message)
238+
debugLog(message)
239+
}
240+
161241
/// Internal implementation of global `withAnimation` SwiftUI function.
162242
static func withAnimation<Result>(_ animation: Animation? = .default, _ body: () throws -> Result) rethrows -> Result {
163243
let isNested = preBodyWithAnimation(animation)
164244
defer {
245+
finishBodyWithAnimation()
165246
if !isNested {
166247
postBodyWithAnimation()
167248
}
168249
}
169250
return body()
170251
}
171252

253+
private static func finishBodyWithAnimation() {
254+
synchronized (withAnimationLock) {
255+
if !withAnimationTransactions.isEmpty {
256+
withAnimationTransactions.removeLast()
257+
}
258+
StateTracking.currentMutationTransaction = withAnimationTransactions.last
259+
}
260+
}
261+
172262
private static var _withAnimation: Animation?
263+
private static var withAnimationTransactions: [AnimationTransaction] = []
264+
private static var nextWithAnimationTransactionID = 0
265+
private static let debugWithAnimationTransactions = false
173266
private static let withAnimationLock: java.lang.Object = java.lang.Object()
174267

175268
private let spec: AnimationSpec<Any>
@@ -458,7 +551,8 @@ public enum AnimationCompletionCriteria : Hashable {
458551
let animatable = remember { Animatable(resetValue.value ?? value, converter) }
459552
let isAnimating = animatable.isRunning || animatable.value != animatable.targetValue
460553
if isAnimating || animatable.value != value {
461-
let animation = Animation.current(isAnimating: isAnimating)
554+
let animation = Animation.currentForAnimatable(isAnimating: isAnimating)
555+
Animation.debugDecision("animatable change value=\(value) isAnimating=\(isAnimating) usesAnimation=\(animation != nil)")
462556
LaunchedEffect(value, animation) {
463557
if let animation {
464558
if animation.isInfinite {

Sources/SkipUI/SkipUI/BridgeSupport/StateSupport.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,16 @@ public final class StateSupport: StateTracker {
4141
// SKIP @bridge
4242
public func access() {
4343
#if SKIP
44+
StateTracking.recordMutationRead(lastAnimationTransaction)
4445
let _ = state?.value
4546
#endif
4647
}
4748

4849
// SKIP @bridge
4950
public func update() {
5051
#if SKIP
52+
lastAnimationTransaction = StateTracking.currentMutationTransaction as? AnimationTransaction
53+
Animation.debugLog("bridged StateSupport update transaction=\(Animation.debugDescription(for: lastAnimationTransaction))")
5154
state?.value += 1
5255
#endif
5356
}
@@ -58,6 +61,10 @@ public final class StateSupport: StateTracker {
5861
state = mutableStateOf(0)
5962
#endif
6063
}
64+
65+
#if SKIP
66+
var lastAnimationTransaction: AnimationTransaction?
67+
#endif
6168
}
6269

6370
#endif

Sources/SkipUI/SkipUI/Properties/State.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public final class State<Value>: StateTracker {
2222
get {
2323
#if SKIP
2424
if let _wrappedValueState {
25+
StateTracking.recordMutationRead(lastAnimationTransaction)
2526
return _wrappedValueState.value
2627
}
2728
#endif
@@ -30,13 +31,16 @@ public final class State<Value>: StateTracker {
3031
set {
3132
_wrappedValue = newValue
3233
#if SKIP
34+
lastAnimationTransaction = StateTracking.currentMutationTransaction as? AnimationTransaction
35+
Animation.debugLog("State write transaction=\(Animation.debugDescription(for: lastAnimationTransaction))")
3336
_wrappedValueState?.value = _wrappedValue
3437
#endif
3538
}
3639
}
3740
private var _wrappedValue: Value
3841
#if SKIP
3942
private var _wrappedValueState: MutableState<Value>?
43+
var lastAnimationTransaction: AnimationTransaction?
4044
#endif
4145

4246
public var projectedValue: Binding<Value> {
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright 2026 Skip
2+
// SPDX-License-Identifier: MPL-2.0
3+
import SwiftUI
4+
import XCTest
5+
6+
#if SKIP
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.runtime.getValue
9+
import androidx.compose.runtime.mutableStateOf
10+
import androidx.compose.runtime.saveable.Saver
11+
import androidx.compose.runtime.saveable.rememberSaveable
12+
import androidx.compose.runtime.setValue
13+
import androidx.compose.ui.test.junit4.createComposeRule
14+
import androidx.compose.ui.test.onNodeWithTag
15+
import androidx.compose.ui.test.performClick
16+
import org.junit.Rule
17+
#endif
18+
19+
final class AnimationTransactionTests: SkipUITestCase {
20+
// SKIP INSERT: @get:Rule val composeRule = createComposeRule()
21+
22+
func testOutsideThenInsideWithAnimationScopesOnlyInsideWrite() throws {
23+
#if !SKIP
24+
throw XCTSkip("Animation transaction decisions are Android-only")
25+
#else
26+
let decisions = try runScenario(.outsideThenInside)
27+
XCTAssertDecision(decisions, at: 0, usesAnimation: false)
28+
XCTAssertDecision(decisions, at: 1, usesAnimation: true)
29+
#endif
30+
}
31+
32+
func testBothInsideWithAnimationBothAnimate() throws {
33+
#if !SKIP
34+
throw XCTSkip("Animation transaction decisions are Android-only")
35+
#else
36+
let decisions = try runScenario(.bothInside)
37+
XCTAssertDecision(decisions, at: 0, usesAnimation: true)
38+
XCTAssertDecision(decisions, at: 1, usesAnimation: true)
39+
#endif
40+
}
41+
42+
func testWithoutWithAnimationBothSnap() throws {
43+
#if !SKIP
44+
throw XCTSkip("Animation transaction decisions are Android-only")
45+
#else
46+
let decisions = try runScenario(.neitherInside)
47+
XCTAssertDecision(decisions, at: 0, usesAnimation: false)
48+
XCTAssertDecision(decisions, at: 1, usesAnimation: false)
49+
#endif
50+
}
51+
52+
func testValueAnimationEnvironmentTakesPrecedence() throws {
53+
#if !SKIP
54+
throw XCTSkip("Animation transaction decisions are Android-only")
55+
#else
56+
let decisions = try runScenario(.environmentOverridesTransaction)
57+
XCTAssertDecision(decisions, at: 0, usesAnimation: true)
58+
XCTAssertDecision(decisions, at: 1, usesAnimation: true)
59+
XCTAssertTrue(decisions.contains { $0.contains("environmentAnimation=true") && $0.contains("usesAnimation=true") }, decisions.joined(separator: "\n"))
60+
#endif
61+
}
62+
63+
#if SKIP
64+
private func runScenario(_ scenario: AnimationTransactionScenario) throws -> [String] {
65+
var decisions: [String] = []
66+
AnimationDebug.decisionSink = { decisions.append($0) }
67+
defer { AnimationDebug.decisionSink = nil }
68+
69+
composeRule.setContent {
70+
AnimationTransactionTestView(scenario: scenario).Compose()
71+
}
72+
composeRule.waitForIdle()
73+
74+
decisions.removeAll()
75+
composeRule.onNodeWithTag("run").performClick()
76+
composeRule.waitForIdle()
77+
78+
return decisions
79+
}
80+
81+
private func XCTAssertDecision(_ decisions: [String], at index: Int, usesAnimation: Bool) {
82+
let expected = "usesAnimation=\(usesAnimation)"
83+
let changes = decisions.filter { $0.contains("animatable change value=") }
84+
XCTAssertTrue(changes.count > index, decisions.joined(separator: "\n"))
85+
XCTAssertTrue(changes[index].contains(expected), decisions.joined(separator: "\n"))
86+
}
87+
#endif
88+
}
89+
90+
#if SKIP
91+
private enum AnimationTransactionScenario {
92+
case outsideThenInside
93+
case bothInside
94+
case neitherInside
95+
case environmentOverridesTransaction
96+
}
97+
98+
private struct AnimationTransactionTestView: View {
99+
let scenario: AnimationTransactionScenario
100+
101+
@State private var redOpacity = 1.0
102+
@State private var greenOpacity = 1.0
103+
104+
var body: some View {
105+
VStack {
106+
Color.red
107+
.opacity(redOpacity)
108+
.frame(width: 8.0, height: 8.0)
109+
Color.green
110+
.opacity(greenOpacity)
111+
.frame(width: 8.0, height: 8.0)
112+
Button("Run") {
113+
switch scenario {
114+
case .outsideThenInside:
115+
redOpacity = 0.25
116+
withAnimation(.linear(duration: 1.5)) {
117+
greenOpacity = 0.5
118+
}
119+
case .bothInside:
120+
withAnimation(.linear(duration: 1.5)) {
121+
redOpacity = 0.25
122+
greenOpacity = 0.5
123+
}
124+
case .neitherInside:
125+
redOpacity = 0.25
126+
greenOpacity = 0.5
127+
case .environmentOverridesTransaction:
128+
withAnimation(.linear(duration: 1.5)) {
129+
redOpacity = 0.25
130+
greenOpacity = 0.5
131+
}
132+
}
133+
}
134+
.accessibilityIdentifier("run")
135+
.buttonStyle(.bordered)
136+
}
137+
.animation(scenario == .environmentOverridesTransaction ? .easeInOut(duration: 0.25) : nil, value: redOpacity)
138+
.animation(scenario == .environmentOverridesTransaction ? .easeInOut(duration: 0.25) : nil, value: greenOpacity)
139+
}
140+
}
141+
#endif

Tests/SkipUITests/XCSkipTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ open class SkipUITestCase : XCTestCase {
2828
public var testName: String? {
2929
#if SKIP
3030
let tname = _testName.methodName // "testLocalizedText$SkipUI_debugUnitTest"
31-
return tname.split(separator: "$").first
31+
return tname.split(separator: Character("$")).first
3232
#else
3333
let tname = testRun?.test.name // "-[SkipUITests testLocalizedText]"
3434
return tname?

0 commit comments

Comments
 (0)