Skip to content

Commit 6832bb5

Browse files
authored
Merge pull request #34 from PureSwift/feature/animation
Animation: withAnimation and the animation modifier
2 parents 0e7db5c + 006d536 commit 6832bb5

6 files changed

Lines changed: 305 additions & 11 deletions

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
//
2+
// Animation.swift
3+
// AndroidSwiftUICore
4+
//
5+
// Animation is interpreter-side: Swift only describes intent. `withAnimation`
6+
// records the animation on the transaction; the state writes inside its body
7+
// mark the host's next evaluation, which stamps the root node. The Compose
8+
// interpreter then eases changed modifier values instead of snapping them.
9+
//
10+
11+
/// A description of how value changes should be eased.
12+
public struct Animation: Equatable, Sendable {
13+
14+
internal var curve: String
15+
internal var duration: Double // seconds
16+
17+
public static let `default` = Animation(curve: "easeInOut", duration: 0.35)
18+
19+
public static func linear(duration: Double = 0.35) -> Animation {
20+
Animation(curve: "linear", duration: duration)
21+
}
22+
23+
public static func easeIn(duration: Double = 0.35) -> Animation {
24+
Animation(curve: "easeIn", duration: duration)
25+
}
26+
27+
public static func easeOut(duration: Double = 0.35) -> Animation {
28+
Animation(curve: "easeOut", duration: duration)
29+
}
30+
31+
public static func easeInOut(duration: Double = 0.35) -> Animation {
32+
Animation(curve: "easeInOut", duration: duration)
33+
}
34+
35+
public static func spring() -> Animation {
36+
Animation(curve: "spring", duration: 0.5)
37+
}
38+
}
39+
40+
/// The animation context active while a `withAnimation` body runs. Main-thread
41+
/// confined by the same contract as the rest of the pipeline.
42+
public enum Transaction {
43+
nonisolated(unsafe) public static var _current: Animation?
44+
}
45+
46+
/// Runs `body`, easing the view updates its state writes produce.
47+
public func withAnimation<Result>(
48+
_ animation: Animation = .default,
49+
_ body: () throws -> Result
50+
) rethrows -> Result {
51+
let previous = Transaction._current
52+
Transaction._current = animation
53+
defer { Transaction._current = previous }
54+
return try body()
55+
}
56+
57+
// MARK: - Implicit animation
58+
59+
/// Marks a view so changes to its modifier values ease whenever `value`
60+
/// changes, without an explicit `withAnimation` at the write site.
61+
public struct _AnimationModifier: RenderModifier {
62+
let animation: Animation?
63+
let token: String
64+
public var _modifierNode: ModifierNode {
65+
guard let animation else { return ModifierNode(kind: "animation") }
66+
return ModifierNode(kind: "animation", args: [
67+
"curve": .string(animation.curve),
68+
"durationMs": .double(animation.duration * 1000),
69+
"token": .string(token),
70+
])
71+
}
72+
}
73+
74+
public extension View {
75+
func animation<V: Equatable>(_ animation: Animation?, value: V) -> ModifiedContent<Self, _AnimationModifier> {
76+
modifier(_AnimationModifier(animation: animation, token: String(describing: value)))
77+
}
78+
}

AndroidSwiftUICore/Sources/AndroidSwiftUICore/ViewHost.swift

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,17 @@ public final class ViewHost: @unchecked Sendable {
2626
/// result across the bridge; tests read `evaluate()` directly instead.
2727
public var onStateChange: (() -> Void)?
2828

29+
/// The animation captured from the transaction at write time, carried to
30+
/// the (coalesced) evaluation the write scheduled.
31+
private var pendingAnimation: Animation?
32+
2933
public init(_ root: any View, reflector: StateReflector = MirrorStateReflector()) {
3034
self.root = root
3135
self.storage = StateStorage(reflector: reflector)
3236
self.storage.onChange = { [weak self] in
37+
if let animation = Transaction._current {
38+
self?.pendingAnimation = animation
39+
}
3340
self?.onStateChange?()
3441
}
3542
}
@@ -43,13 +50,24 @@ public final class ViewHost: @unchecked Sendable {
4350
// Track @Observable reads during evaluation: a later mutation of any
4451
// observed property schedules a re-evaluation, exactly like a @State
4552
// write. Re-arms itself because the change handler triggers evaluate().
46-
return withObservationTracking {
53+
var node = withObservationTracking {
4754
Evaluator.resolve(root, context)
4855
} onChange: { [weak self] in
56+
if let animation = Transaction._current {
57+
self?.pendingAnimation = animation
58+
}
4959
self?.onStateChange?()
5060
}
5161
#else
52-
return Evaluator.resolve(root, context)
62+
var node = Evaluator.resolve(root, context)
5363
#endif
64+
// A tree produced by a withAnimation write carries the animation on its
65+
// root; the interpreter eases changed modifier values while it applies.
66+
if let animation = pendingAnimation {
67+
pendingAnimation = nil
68+
node.props["animationCurve"] = .string(animation.curve)
69+
node.props["animationDurationMs"] = .double(animation.duration * 1000)
70+
}
71+
return node
5472
}
5573
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,56 @@ struct GraphicsTests {
249249
#expect(node.props["vertical"] == .string("bottom"))
250250
}
251251
}
252+
253+
// MARK: - Animation
254+
255+
@Suite("Animation")
256+
struct AnimationTests {
257+
258+
@Test("A withAnimation write stamps the next tree's root, once")
259+
func withAnimationStampsRoot() {
260+
struct Screen: View {
261+
@State var on = false
262+
var body: some View { Text("x").opacity(on ? 1 : 0) }
263+
}
264+
let host = ViewHost(Screen())
265+
var node = host.evaluate()
266+
#expect(node.props["animationCurve"] == nil)
267+
// find the opacity toggle by writing through a registered callback:
268+
// simplest is to mutate via withAnimation around a state write driven
269+
// by re-linking — use onTapGesture to reach the state.
270+
struct Tappable: View {
271+
@State var on = false
272+
var body: some View {
273+
Text("x").opacity(on ? 1 : 0).onTapGesture { withAnimation(.easeIn(duration: 0.2)) { on.toggle() } }
274+
}
275+
}
276+
let tapHost = ViewHost(Tappable())
277+
node = tapHost.evaluate()
278+
guard case .int(let id)? = node.modifiers.first(where: { $0.kind == "onTapGesture" })?.args["action"] else {
279+
Issue.record("missing tap id"); return
280+
}
281+
tapHost.callbacks.invokeVoid(Int64(id))
282+
node = tapHost.evaluate()
283+
#expect(node.props["animationCurve"] == .string("easeIn"))
284+
#expect(node.props["animationDurationMs"] == .double(200))
285+
// a subsequent plain write does not animate
286+
node = tapHost.evaluate()
287+
#expect(node.props["animationCurve"] == nil)
288+
}
289+
290+
@Test("withAnimation returns its body's value and restores the transaction")
291+
func withAnimationScoping() {
292+
let result = withAnimation(.linear(duration: 1)) { 41 + 1 }
293+
#expect(result == 42)
294+
#expect(Transaction._current == nil)
295+
}
296+
297+
@Test(".animation emits curve, duration, and value token")
298+
func implicitAnimation() {
299+
let node = ViewHost(Text("x").animation(.spring(), value: 3)).evaluate()
300+
let anim = node.modifiers.first { $0.kind == "animation" }
301+
#expect(anim?.args["curve"] == .string("spring"))
302+
#expect(anim?.args["token"] == .string("3"))
303+
}
304+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
#endif
6+
7+
struct AnimationPlayground: View {
8+
@State private var moved = false
9+
@State private var faded = false
10+
@State private var grown = false
11+
@State private var recolored = false
12+
@State private var crawled = false
13+
var body: some View {
14+
ScrollView {
15+
VStack(alignment: .leading, spacing: 0) {
16+
Example("withAnimation: offset") {
17+
VStack(alignment: .leading, spacing: 8) {
18+
Circle()
19+
.fill(.blue)
20+
.frame(width: 44, height: 44)
21+
.offset(x: moved ? 220 : 0, y: 0)
22+
Button(moved ? "Slide back" : "Slide right") {
23+
withAnimation { moved.toggle() }
24+
}
25+
}
26+
}
27+
Example("withAnimation: slow motion (2s linear)") {
28+
VStack(alignment: .leading, spacing: 8) {
29+
Rectangle()
30+
.fill(.red)
31+
.frame(width: 44, height: 44)
32+
.offset(x: crawled ? 220 : 0, y: 0)
33+
Button(crawled ? "Crawl back" : "Crawl right") {
34+
withAnimation(.linear(duration: 2)) { crawled.toggle() }
35+
}
36+
}
37+
}
38+
Example("withAnimation: opacity & scale") {
39+
VStack(alignment: .leading, spacing: 8) {
40+
Text("Watch me")
41+
.padding()
42+
.background(Color.purple)
43+
.cornerRadius(8)
44+
.opacity(faded ? 0.2 : 1)
45+
.scaleEffect(faded ? 0.7 : 1)
46+
Button(faded ? "Restore" : "Fade & shrink") {
47+
withAnimation(.easeOut(duration: 0.6)) { faded.toggle() }
48+
}
49+
}
50+
}
51+
Example("withAnimation: spring frame") {
52+
VStack(alignment: .leading, spacing: 8) {
53+
RoundedRectangle(cornerRadius: 10)
54+
.fill(.green)
55+
.frame(width: grown ? 260 : 90, height: 44)
56+
Button(grown ? "Shrink" : "Grow") {
57+
withAnimation(.spring()) { grown.toggle() }
58+
}
59+
}
60+
}
61+
Example(".animation(value:) implicit color") {
62+
VStack(alignment: .leading, spacing: 8) {
63+
Text("Background eases on its own")
64+
.padding()
65+
.background(recolored ? Color.orange : Color.blue)
66+
.cornerRadius(8)
67+
.animation(.easeInOut(duration: 0.8), value: recolored)
68+
Button("Swap color") { recolored.toggle() }
69+
}
70+
}
71+
}
72+
}
73+
}
74+
}

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ struct CatalogEntry: Identifiable {
4242
CatalogEntry(id: "form", title: "Form", screen: AnyCatalogScreen(FormPlayground())),
4343
CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())),
4444
CatalogEntry(id: "appearance", title: "Appearance", screen: AnyCatalogScreen(AppearancePlayground())),
45+
CatalogEntry(id: "animation", title: "Animation", screen: AnyCatalogScreen(AnimationPlayground())),
4546
CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())),
4647
CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())),
4748
CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())),

0 commit comments

Comments
 (0)