@@ -22,6 +22,16 @@ import androidx.compose.foundation.layout.width
2222import androidx.compose.foundation.layout.widthIn
2323import androidx.compose.foundation.layout.wrapContentSize
2424import androidx.compose.animation.AnimatedContent
25+ import androidx.compose.animation.AnimatedVisibility
26+ import androidx.compose.animation.EnterTransition
27+ import androidx.compose.animation.ExitTransition
28+ import androidx.compose.animation.fadeIn
29+ import androidx.compose.animation.fadeOut
30+ import androidx.compose.animation.scaleIn
31+ import androidx.compose.animation.scaleOut
32+ import androidx.compose.animation.slideInVertically
33+ import androidx.compose.animation.slideOutVertically
34+ import androidx.compose.animation.core.MutableTransitionState
2535import androidx.compose.animation.animateColorAsState
2636import androidx.compose.animation.core.AnimationSpec
2737import androidx.compose.animation.core.CubicBezierEasing
@@ -277,7 +287,7 @@ private fun RenderResolved(node: ViewNode) {
277287 contentAlignment = zStackAlignment(node),
278288 modifier = node.composeModifiers(),
279289 ) {
280- RenderChildren (node)
290+ RenderZStackChildren (node)
281291 }
282292
283293 // Outside a stack, a Spacer has no axis to expand along; render as empty.
@@ -603,25 +613,130 @@ private fun pickerLabel(node: ViewNode): String {
603613
604614@Composable
605615private fun ColumnScope.RenderColumnChildren (node : ViewNode ) {
606- for (child in node.children) {
607- if (child.isPresentation()) continue
608- if (child.type == " Spacer" ) {
609- Spacer (modifier = Modifier .weight(1f ))
610- } else {
611- Render (child)
616+ val slots = rememberTransitionSlots(node)
617+ val spec = LocalAnimationSpec .current
618+ for ((child, slot) in orderedTransitionChildren(node, slots)) {
619+ key(child.id) {
620+ when {
621+ child.type == " Spacer" -> Spacer (modifier = Modifier .weight(1f ))
622+ slot != null -> AnimatedVisibility (slot.state, enter = enterFor(child, spec), exit = exitFor(child, spec)) { Render (child) }
623+ else -> Render (child)
624+ }
612625 }
613626 }
614627}
615628
616629@Composable
617630private fun RowScope.RenderRowChildren (node : ViewNode ) {
618- for (child in node.children) {
619- if (child.isPresentation()) continue
620- if (child.type == " Spacer" ) {
621- Spacer (modifier = Modifier .weight(1f ))
631+ val slots = rememberTransitionSlots(node)
632+ val spec = LocalAnimationSpec .current
633+ for ((child, slot) in orderedTransitionChildren(node, slots)) {
634+ key(child.id) {
635+ when {
636+ child.type == " Spacer" -> Spacer (modifier = Modifier .weight(1f ))
637+ slot != null -> AnimatedVisibility (slot.state, enter = enterFor(child, spec), exit = exitFor(child, spec)) { Render (child) }
638+ else -> Render (child)
639+ }
640+ }
641+ }
642+ }
643+
644+ // ZStack children, with transition support.
645+ @Composable
646+ private fun RenderZStackChildren (node : ViewNode ) {
647+ val slots = rememberTransitionSlots(node)
648+ val spec = LocalAnimationSpec .current
649+ for ((child, slot) in orderedTransitionChildren(node, slots)) {
650+ key(child.id) {
651+ if (slot != null ) {
652+ AnimatedVisibility (slot.state, enter = enterFor(child, spec), exit = exitFor(child, spec)) { Render (child) }
653+ } else {
654+ Render (child)
655+ }
656+ }
657+ }
658+ }
659+
660+ // --- Transitions: a child with `.transition()` animates as it appears/leaves ---
661+
662+ private fun ViewNode.transition (): ModifierNode ? = modifiers.firstOrNull { it.kind == " transition" }
663+ private fun ViewNode.hasTransition (): Boolean = transition() != null
664+
665+ // A slot keeps an exiting child's node and visibility state alive after it has
666+ // left the tree, so its exit animation can play out.
667+ private class TransitionSlot (var node : ViewNode , val state : MutableTransitionState <Boolean >)
668+
669+ @Composable
670+ private fun rememberTransitionSlots (node : ViewNode ): Map <String , TransitionSlot > {
671+ val slots = remember { mutableStateMapOf<String , TransitionSlot >() }
672+ val presentIds = node.children.filter { ! it.isPresentation() && it.hasTransition() }.mapTo(HashSet ()) { it.id }
673+ node.children.filter { ! it.isPresentation() && it.hasTransition() }.forEach { child ->
674+ val slot = slots[child.id]
675+ if (slot == null ) {
676+ slots[child.id] = TransitionSlot (child, MutableTransitionState (false ).apply { targetState = true })
622677 } else {
623- Render (child)
678+ slot.node = child
679+ slot.state.targetState = true
680+ }
681+ }
682+ val finished = mutableListOf<String >()
683+ slots.forEach { (id, slot) ->
684+ if (id !in presentIds) {
685+ slot.state.targetState = false // begin exit
686+ if (slot.state.isIdle && ! slot.state.currentState) finished + = id // exit done
687+ }
688+ }
689+ finished.forEach { slots.remove(it) }
690+ return slots
691+ }
692+
693+ // The children to render, in order: present children (each paired with its
694+ // transition slot if it has one), then any exiting slots not in the tree — so a
695+ // transition child is rendered at ONE stable call site (keyed by id) whether
696+ // present or exiting, letting Compose animate the handoff instead of tearing it
697+ // down.
698+ private fun orderedTransitionChildren (
699+ node : ViewNode ,
700+ slots : Map <String , TransitionSlot >,
701+ ): List <Pair <ViewNode , TransitionSlot ?>> {
702+ val present = node.children.filter { ! it.isPresentation() }
703+ val presentIds = present.mapTo(HashSet ()) { it.id }
704+ val result = present.map { it to slots[it.id] }
705+ val exiting = slots.entries.filter { it.key !in presentIds }.map { it.value.node to it.value }
706+ return result + exiting
707+ }
708+
709+ private fun enterFor (node : ViewNode , spec : AnimSpec ? ): EnterTransition {
710+ val d = spec?.durationMs ? : 300
711+ return when (node.transition()?.args?.string(" kind" )) {
712+ " opacity" -> fadeIn(tween(d))
713+ " scale" -> scaleIn(tween(d)) + fadeIn(tween(d))
714+ " slide" -> slideInHorizontally(tween(d)) { it } + fadeIn(tween(d))
715+ " move" -> when (node.transition()?.args?.string(" edge" )) {
716+ " top" -> slideInVertically(tween(d)) { - it }
717+ " bottom" -> slideInVertically(tween(d)) { it }
718+ " leading" -> slideInHorizontally(tween(d)) { - it }
719+ else -> slideInHorizontally(tween(d)) { it }
720+ }
721+ " identity" -> EnterTransition .None
722+ else -> fadeIn(tween(d))
723+ }
724+ }
725+
726+ private fun exitFor (node : ViewNode , spec : AnimSpec ? ): ExitTransition {
727+ val d = spec?.durationMs ? : 300
728+ return when (node.transition()?.args?.string(" kind" )) {
729+ " opacity" -> fadeOut(tween(d))
730+ " scale" -> scaleOut(tween(d)) + fadeOut(tween(d))
731+ " slide" -> slideOutHorizontally(tween(d)) { it } + fadeOut(tween(d))
732+ " move" -> when (node.transition()?.args?.string(" edge" )) {
733+ " top" -> slideOutVertically(tween(d)) { - it }
734+ " bottom" -> slideOutVertically(tween(d)) { it }
735+ " leading" -> slideOutHorizontally(tween(d)) { - it }
736+ else -> slideOutHorizontally(tween(d)) { it }
624737 }
738+ " identity" -> ExitTransition .None
739+ else -> fadeOut(tween(d))
625740 }
626741}
627742
@@ -997,6 +1112,7 @@ private val KNOWN_MODIFIER_KINDS = setOf(
9971112 " scale" , " opacity" , " border" , " shadow" , " clipShape" , " onTapGesture" , " disabled" ,
9981113 " font" , " fontWeight" , " italic" , " foregroundColor" , " lineLimit" , " multilineTextAlignment" ,
9991114 " tint" , " onAppear" , " onDisappear" , " task" , " onChange" , " animation" , " tag" , " tabItem" ,
1115+ " transition" ,
10001116)
10011117
10021118// Folds a frame entry: fixed size, fill (maxWidth/Height .infinity), bounded
0 commit comments