@@ -18,6 +18,16 @@ import androidx.compose.foundation.layout.padding
1818import androidx.compose.foundation.layout.size
1919import androidx.compose.foundation.layout.width
2020import 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
2131import androidx.compose.animation.togetherWith
2232import androidx.compose.animation.slideInHorizontally
2333import androidx.compose.animation.slideOutHorizontally
@@ -84,8 +94,10 @@ import androidx.compose.material.icons.filled.ThumbUp
8494import androidx.compose.material.icons.filled.Warning
8595import androidx.compose.material3.LocalContentColor
8696import androidx.compose.runtime.Composable
97+ import androidx.compose.runtime.CompositionLocalProvider
8798import androidx.compose.runtime.DisposableEffect
8899import androidx.compose.runtime.LaunchedEffect
100+ import androidx.compose.runtime.compositionLocalOf
89101import androidx.compose.runtime.getValue
90102import androidx.compose.runtime.key
91103import androidx.compose.runtime.mutableStateMapOf
@@ -112,18 +124,39 @@ import androidx.compose.ui.text.input.TextFieldValue
112124import androidx.compose.ui.text.input.VisualTransformation
113125import androidx.compose.ui.unit.TextUnit
114126import androidx.compose.ui.unit.sp
127+ import androidx.compose.ui.unit.Dp
115128import androidx.compose.ui.unit.IntOffset
116129import androidx.compose.ui.unit.dp
117130import androidx.compose.foundation.layout.fillMaxWidth
118131import androidx.compose.foundation.layout.offset
119132import 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
126151fun 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
735774internal 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