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
78 changes: 78 additions & 0 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/Animation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// Animation.swift
// AndroidSwiftUICore
//
// Animation is interpreter-side: Swift only describes intent. `withAnimation`
// records the animation on the transaction; the state writes inside its body
// mark the host's next evaluation, which stamps the root node. The Compose
// interpreter then eases changed modifier values instead of snapping them.
//

/// A description of how value changes should be eased.
public struct Animation: Equatable, Sendable {

internal var curve: String
internal var duration: Double // seconds

public static let `default` = Animation(curve: "easeInOut", duration: 0.35)

public static func linear(duration: Double = 0.35) -> Animation {
Animation(curve: "linear", duration: duration)
}

public static func easeIn(duration: Double = 0.35) -> Animation {
Animation(curve: "easeIn", duration: duration)
}

public static func easeOut(duration: Double = 0.35) -> Animation {
Animation(curve: "easeOut", duration: duration)
}

public static func easeInOut(duration: Double = 0.35) -> Animation {
Animation(curve: "easeInOut", duration: duration)
}

public static func spring() -> Animation {
Animation(curve: "spring", duration: 0.5)
}
}

/// The animation context active while a `withAnimation` body runs. Main-thread
/// confined by the same contract as the rest of the pipeline.
public enum Transaction {
nonisolated(unsafe) public static var _current: Animation?
}

/// Runs `body`, easing the view updates its state writes produce.
public func withAnimation<Result>(
_ animation: Animation = .default,
_ body: () throws -> Result
) rethrows -> Result {
let previous = Transaction._current
Transaction._current = animation
defer { Transaction._current = previous }
return try body()
}

// MARK: - Implicit animation

/// Marks a view so changes to its modifier values ease whenever `value`
/// changes, without an explicit `withAnimation` at the write site.
public struct _AnimationModifier: RenderModifier {
let animation: Animation?
let token: String
public var _modifierNode: ModifierNode {
guard let animation else { return ModifierNode(kind: "animation") }
return ModifierNode(kind: "animation", args: [
"curve": .string(animation.curve),
"durationMs": .double(animation.duration * 1000),
"token": .string(token),
])
}
}

public extension View {
func animation<V: Equatable>(_ animation: Animation?, value: V) -> ModifiedContent<Self, _AnimationModifier> {
modifier(_AnimationModifier(animation: animation, token: String(describing: value)))
}
}
22 changes: 20 additions & 2 deletions AndroidSwiftUICore/Sources/AndroidSwiftUICore/ViewHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,17 @@ public final class ViewHost: @unchecked Sendable {
/// result across the bridge; tests read `evaluate()` directly instead.
public var onStateChange: (() -> Void)?

/// The animation captured from the transaction at write time, carried to
/// the (coalesced) evaluation the write scheduled.
private var pendingAnimation: Animation?

public init(_ root: any View, reflector: StateReflector = MirrorStateReflector()) {
self.root = root
self.storage = StateStorage(reflector: reflector)
self.storage.onChange = { [weak self] in
if let animation = Transaction._current {
self?.pendingAnimation = animation
}
self?.onStateChange?()
}
}
Expand All @@ -43,13 +50,24 @@ public final class ViewHost: @unchecked Sendable {
// Track @Observable reads during evaluation: a later mutation of any
// observed property schedules a re-evaluation, exactly like a @State
// write. Re-arms itself because the change handler triggers evaluate().
return withObservationTracking {
var node = withObservationTracking {
Evaluator.resolve(root, context)
} onChange: { [weak self] in
if let animation = Transaction._current {
self?.pendingAnimation = animation
}
self?.onStateChange?()
}
#else
return Evaluator.resolve(root, context)
var node = Evaluator.resolve(root, context)
#endif
// A tree produced by a withAnimation write carries the animation on its
// root; the interpreter eases changed modifier values while it applies.
if let animation = pendingAnimation {
pendingAnimation = nil
node.props["animationCurve"] = .string(animation.curve)
node.props["animationDurationMs"] = .double(animation.duration * 1000)
}
return node
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,56 @@ struct GraphicsTests {
#expect(node.props["vertical"] == .string("bottom"))
}
}

// MARK: - Animation

@Suite("Animation")
struct AnimationTests {

@Test("A withAnimation write stamps the next tree's root, once")
func withAnimationStampsRoot() {
struct Screen: View {
@State var on = false
var body: some View { Text("x").opacity(on ? 1 : 0) }
}
let host = ViewHost(Screen())
var node = host.evaluate()
#expect(node.props["animationCurve"] == nil)
// find the opacity toggle by writing through a registered callback:
// simplest is to mutate via withAnimation around a state write driven
// by re-linking — use onTapGesture to reach the state.
struct Tappable: View {
@State var on = false
var body: some View {
Text("x").opacity(on ? 1 : 0).onTapGesture { withAnimation(.easeIn(duration: 0.2)) { on.toggle() } }
}
}
let tapHost = ViewHost(Tappable())
node = tapHost.evaluate()
guard case .int(let id)? = node.modifiers.first(where: { $0.kind == "onTapGesture" })?.args["action"] else {
Issue.record("missing tap id"); return
}
tapHost.callbacks.invokeVoid(Int64(id))
node = tapHost.evaluate()
#expect(node.props["animationCurve"] == .string("easeIn"))
#expect(node.props["animationDurationMs"] == .double(200))
// a subsequent plain write does not animate
node = tapHost.evaluate()
#expect(node.props["animationCurve"] == nil)
}

@Test("withAnimation returns its body's value and restores the transaction")
func withAnimationScoping() {
let result = withAnimation(.linear(duration: 1)) { 41 + 1 }
#expect(result == 42)
#expect(Transaction._current == nil)
}

@Test(".animation emits curve, duration, and value token")
func implicitAnimation() {
let node = ViewHost(Text("x").animation(.spring(), value: 3)).evaluate()
let anim = node.modifiers.first { $0.kind == "animation" }
#expect(anim?.args["curve"] == .string("spring"))
#expect(anim?.args["token"] == .string("3"))
}
}
74 changes: 74 additions & 0 deletions Demo/App.swiftpm/Sources/AnimationPlaygrounds.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#if canImport(AndroidSwiftUI)
import AndroidSwiftUI
#else
import SwiftUI
#endif

struct AnimationPlayground: View {
@State private var moved = false
@State private var faded = false
@State private var grown = false
@State private var recolored = false
@State private var crawled = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Example("withAnimation: offset") {
VStack(alignment: .leading, spacing: 8) {
Circle()
.fill(.blue)
.frame(width: 44, height: 44)
.offset(x: moved ? 220 : 0, y: 0)
Button(moved ? "Slide back" : "Slide right") {
withAnimation { moved.toggle() }
}
}
}
Example("withAnimation: slow motion (2s linear)") {
VStack(alignment: .leading, spacing: 8) {
Rectangle()
.fill(.red)
.frame(width: 44, height: 44)
.offset(x: crawled ? 220 : 0, y: 0)
Button(crawled ? "Crawl back" : "Crawl right") {
withAnimation(.linear(duration: 2)) { crawled.toggle() }
}
}
}
Example("withAnimation: opacity & scale") {
VStack(alignment: .leading, spacing: 8) {
Text("Watch me")
.padding()
.background(Color.purple)
.cornerRadius(8)
.opacity(faded ? 0.2 : 1)
.scaleEffect(faded ? 0.7 : 1)
Button(faded ? "Restore" : "Fade & shrink") {
withAnimation(.easeOut(duration: 0.6)) { faded.toggle() }
}
}
}
Example("withAnimation: spring frame") {
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 10)
.fill(.green)
.frame(width: grown ? 260 : 90, height: 44)
Button(grown ? "Shrink" : "Grow") {
withAnimation(.spring()) { grown.toggle() }
}
}
}
Example(".animation(value:) implicit color") {
VStack(alignment: .leading, spacing: 8) {
Text("Background eases on its own")
.padding()
.background(recolored ? Color.orange : Color.blue)
.cornerRadius(8)
.animation(.easeInOut(duration: 0.8), value: recolored)
Button("Swap color") { recolored.toggle() }
}
}
}
}
}
}
1 change: 1 addition & 0 deletions Demo/App.swiftpm/Sources/Catalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ struct CatalogEntry: Identifiable {
CatalogEntry(id: "form", title: "Form", screen: AnyCatalogScreen(FormPlayground())),
CatalogEntry(id: "modifier", title: "Modifiers", screen: AnyCatalogScreen(ModifierPlayground())),
CatalogEntry(id: "appearance", title: "Appearance", screen: AnyCatalogScreen(AppearancePlayground())),
CatalogEntry(id: "animation", title: "Animation", screen: AnyCatalogScreen(AnimationPlayground())),
CatalogEntry(id: "interaction", title: "Interaction", screen: AnyCatalogScreen(InteractionPlayground())),
CatalogEntry(id: "navigation", title: "Navigation", screen: AnyCatalogScreen(NavigationPlayground())),
CatalogEntry(id: "tab", title: "TabView", screen: AnyCatalogScreen(TabViewPlayground())),
Expand Down
Loading
Loading