Skip to content

Commit 9892a92

Browse files
committed
Ease modifier values while an animated tree applies
1 parent 6335a6d commit 9892a92

1 file changed

Lines changed: 79 additions & 9 deletions

File tree

  • Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui

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

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ import androidx.compose.foundation.layout.padding
1818
import androidx.compose.foundation.layout.size
1919
import androidx.compose.foundation.layout.width
2020
import androidx.compose.animation.AnimatedContent
21+
import androidx.compose.animation.animateColorAsState
22+
import androidx.compose.animation.core.AnimationSpec
23+
import androidx.compose.animation.core.CubicBezierEasing
24+
import androidx.compose.animation.core.Easing
25+
import androidx.compose.animation.core.LinearEasing
26+
import androidx.compose.animation.core.animateDpAsState
27+
import androidx.compose.animation.core.animateFloatAsState
28+
import androidx.compose.animation.core.snap
29+
import androidx.compose.animation.core.spring
30+
import androidx.compose.animation.core.tween
2131
import androidx.compose.animation.togetherWith
2232
import androidx.compose.animation.slideInHorizontally
2333
import androidx.compose.animation.slideOutHorizontally
@@ -84,8 +94,10 @@ import androidx.compose.material.icons.filled.ThumbUp
8494
import androidx.compose.material.icons.filled.Warning
8595
import androidx.compose.material3.LocalContentColor
8696
import androidx.compose.runtime.Composable
97+
import androidx.compose.runtime.CompositionLocalProvider
8798
import androidx.compose.runtime.DisposableEffect
8899
import androidx.compose.runtime.LaunchedEffect
100+
import androidx.compose.runtime.compositionLocalOf
89101
import androidx.compose.runtime.getValue
90102
import androidx.compose.runtime.key
91103
import androidx.compose.runtime.mutableStateMapOf
@@ -112,18 +124,39 @@ import androidx.compose.ui.text.input.TextFieldValue
112124
import androidx.compose.ui.text.input.VisualTransformation
113125
import androidx.compose.ui.unit.TextUnit
114126
import androidx.compose.ui.unit.sp
127+
import androidx.compose.ui.unit.Dp
115128
import androidx.compose.ui.unit.IntOffset
116129
import androidx.compose.ui.unit.dp
117130
import androidx.compose.foundation.layout.fillMaxWidth
118131
import androidx.compose.foundation.layout.offset
119132
import androidx.compose.foundation.shape.RoundedCornerShape
120133

134+
/// The easing description a `withAnimation` tree carries on its root — while
135+
/// it is in scope, modifier folding eases changed numeric values instead of
136+
/// snapping them.
137+
internal data class AnimSpec(val curve: String, val durationMs: Int)
138+
139+
internal val LocalAnimationSpec = compositionLocalOf<AnimSpec?> { null }
140+
121141
/// Interprets a Swift-evaluated node tree into Material 3 composables.
122142
///
123143
/// One `when` per node type; unknown types render a diagnostic so schema
124144
/// drift is visible rather than silent.
145+
///
146+
/// The animation provider is unconditional (inheriting when the node carries
147+
/// no spec of its own — only the root ever does) so that an animated tree
148+
/// arriving does not change the composition's structure: a branch switch here
149+
/// would tear down every remembered Animatable and snap instead of easing.
125150
@Composable
126151
fun Render(node: ViewNode) {
152+
val spec = node.string("animationCurve")?.let {
153+
AnimSpec(it, (node.double("animationDurationMs") ?: 350.0).toInt())
154+
} ?: LocalAnimationSpec.current
155+
CompositionLocalProvider(LocalAnimationSpec provides spec) { RenderResolved(node) }
156+
}
157+
158+
@Composable
159+
private fun RenderResolved(node: ViewNode) {
127160
key(node.id) {
128161
RenderEffects(node)
129162
when (node.type) {
@@ -732,7 +765,16 @@ private fun fontWeightFor(name: String): FontWeight = when (name) {
732765

733766
/// Folds a node's ordered modifier chain into a Compose `Modifier`.
734767
/// The chain arrives outermost-first, which is exactly Compose's order.
768+
///
769+
/// Numeric values fold through `animate*AsState` unconditionally so their
770+
/// internal state persists across trees; the spec is a tween/spring while an
771+
/// animation (explicit `withAnimation` tree, or this node's `.animation`
772+
/// modifier) applies, and `snap` otherwise.
773+
@Composable
735774
internal fun ViewNode.composeModifiers(): Modifier {
775+
val implicit = modifiers.firstOrNull { it.kind == "animation" && it.args["curve"] != null }
776+
?.let { AnimSpec(it.args.string("curve") ?: "easeInOut", (it.args.double("durationMs") ?: 350.0).toInt()) }
777+
val spec = implicit ?: LocalAnimationSpec.current
736778
var modifier: Modifier = Modifier
737779
for (entry in modifiers) {
738780
modifier = when (entry.kind) {
@@ -754,34 +796,34 @@ internal fun ViewNode.composeModifiers(): Modifier {
754796
val width = entry.args.double("width")
755797
val height = entry.args.double("height")
756798
when {
757-
width != null && height != null -> modifier.size(width.dp, height.dp)
758-
width != null -> modifier.width(width.dp)
759-
height != null -> modifier.height(height.dp)
799+
width != null && height != null -> modifier.size(animatedDp(width.dp, spec), animatedDp(height.dp, spec))
800+
width != null -> modifier.width(animatedDp(width.dp, spec))
801+
height != null -> modifier.height(animatedDp(height.dp, spec))
760802
else -> modifier
761803
}
762804
}
763805

764806
"background" -> {
765807
val argb = entry.args.long("color") ?: 0
766-
modifier.background(Color(argb.toInt()))
808+
modifier.background(animatedColor(Color(argb.toInt()), spec))
767809
}
768810

769811
"cornerRadius" -> {
770812
val radius = entry.args.double("radius") ?: 0.0
771-
modifier.clip(RoundedCornerShape(radius.dp))
813+
modifier.clip(RoundedCornerShape(animatedDp(radius.dp, spec)))
772814
}
773815

774816
"offset" -> {
775817
val x = entry.args.double("x") ?: 0.0
776818
val y = entry.args.double("y") ?: 0.0
777-
modifier.offset { IntOffset((x.dp.value).toInt(), (y.dp.value).toInt()) }
819+
modifier.offset(x = animatedDp(x.dp, spec), y = animatedDp(y.dp, spec))
778820
}
779821

780-
"rotation" -> modifier.rotate((entry.args.double("degrees") ?: 0.0).toFloat())
822+
"rotation" -> modifier.rotate(animatedFloat((entry.args.double("degrees") ?: 0.0).toFloat(), spec))
781823

782-
"scale" -> modifier.scale((entry.args.double("scale") ?: 1.0).toFloat())
824+
"scale" -> modifier.scale(animatedFloat((entry.args.double("scale") ?: 1.0).toFloat(), spec))
783825

784-
"opacity" -> modifier.alpha((entry.args.double("opacity") ?: 1.0).toFloat())
826+
"opacity" -> modifier.alpha(animatedFloat((entry.args.double("opacity") ?: 1.0).toFloat(), spec))
785827

786828
"border" -> {
787829
val color = entry.args.long("color")?.let { Color(it.toInt()) } ?: Color.Black
@@ -818,6 +860,34 @@ internal fun ViewNode.composeModifiers(): Modifier {
818860
return modifier
819861
}
820862

863+
// Always-called animated wrappers: the underlying Animatable persists across
864+
// recompositions, so a later tween eases from wherever the value currently is.
865+
866+
@Composable
867+
private fun animatedDp(value: Dp, spec: AnimSpec?): Dp =
868+
animateDpAsState(value, animationSpec(spec), label = "dp").value
869+
870+
@Composable
871+
private fun animatedFloat(value: Float, spec: AnimSpec?): Float =
872+
animateFloatAsState(value, animationSpec(spec), label = "float").value
873+
874+
@Composable
875+
private fun animatedColor(value: Color, spec: AnimSpec?): Color =
876+
animateColorAsState(value, animationSpec(spec), label = "color").value
877+
878+
private fun <T> animationSpec(spec: AnimSpec?): AnimationSpec<T> = when {
879+
spec == null -> snap()
880+
spec.curve == "spring" -> spring()
881+
else -> tween(spec.durationMs, easing = easingFor(spec.curve))
882+
}
883+
884+
private fun easingFor(curve: String): Easing = when (curve) {
885+
"linear" -> LinearEasing
886+
"easeIn" -> CubicBezierEasing(0.42f, 0f, 1f, 1f)
887+
"easeOut" -> CubicBezierEasing(0f, 0f, 0.58f, 1f)
888+
else -> CubicBezierEasing(0.42f, 0f, 0.58f, 1f) // easeInOut
889+
}
890+
821891

822892
@OptIn(ExperimentalMaterial3Api::class)
823893
@Composable

0 commit comments

Comments
 (0)