Skip to content

Commit 0ac3158

Browse files
committed
Animate stack children appearing and disappearing
1 parent cb16525 commit 0ac3158

1 file changed

Lines changed: 128 additions & 12 deletions

File tree

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

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

Lines changed: 128 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ import androidx.compose.foundation.layout.width
2222
import androidx.compose.foundation.layout.widthIn
2323
import androidx.compose.foundation.layout.wrapContentSize
2424
import 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
2535
import androidx.compose.animation.animateColorAsState
2636
import androidx.compose.animation.core.AnimationSpec
2737
import 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
605615
private 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
617630
private 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

Comments
 (0)