Skip to content

Commit 12c4ac9

Browse files
authored
Merge pull request #45 from PureSwift/feature/transitions
2 parents d58c6ca + fe4ba35 commit 12c4ac9

4 files changed

Lines changed: 216 additions & 12 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// Transition.swift
3+
// AndroidSwiftUICore
4+
//
5+
// `.transition(_:)` describes how a view animates in and out as it is inserted
6+
// into or removed from the tree (typically inside a `withAnimation`). The
7+
// interpreter wraps a transition-carrying child of a stack in an
8+
// AnimatedVisibility whose enter/exit is derived from the transition kind.
9+
//
10+
11+
public enum Edge: String, Sendable {
12+
case top, bottom, leading, trailing
13+
}
14+
15+
public struct AnyTransition: Equatable, Sendable {
16+
17+
internal let kind: String
18+
internal let edge: String?
19+
20+
private init(kind: String, edge: String? = nil) {
21+
self.kind = kind
22+
self.edge = edge
23+
}
24+
25+
public static let identity = AnyTransition(kind: "identity")
26+
public static let opacity = AnyTransition(kind: "opacity")
27+
public static let slide = AnyTransition(kind: "slide")
28+
public static let scale = AnyTransition(kind: "scale")
29+
30+
public static func move(edge: Edge) -> AnyTransition {
31+
AnyTransition(kind: "move", edge: edge.rawValue)
32+
}
33+
}
34+
35+
public struct _TransitionModifier: RenderModifier {
36+
let transition: AnyTransition
37+
public var _modifierNode: ModifierNode {
38+
var args: [String: PropValue] = ["kind": .string(transition.kind)]
39+
if let edge = transition.edge { args["edge"] = .string(edge) }
40+
return ModifierNode(kind: "transition", args: args)
41+
}
42+
}
43+
44+
public extension View {
45+
func transition(_ transition: AnyTransition) -> ModifiedContent<Self, _TransitionModifier> {
46+
modifier(_TransitionModifier(transition: transition))
47+
}
48+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,14 @@ struct ModifierTests {
233233
let node = ViewHost(Text("x").tint(.green)).evaluate()
234234
#expect(node.modifiers.first { $0.kind == "tint" }?.args["color"] == Color.green.propValue)
235235
}
236+
237+
@Test("transition emits its kind and edge")
238+
func transition() {
239+
let node = ViewHost(Text("x").transition(.move(edge: .bottom))).evaluate()
240+
let transition = node.modifiers.first { $0.kind == "transition" }
241+
#expect(transition?.args["kind"] == .string("move"))
242+
#expect(transition?.args["edge"] == .string("bottom"))
243+
}
236244
}
237245

238246
// MARK: - Graphics

Demo/App.swiftpm/Sources/AnimationPlaygrounds.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,41 @@ struct AnimationPlayground: View {
1010
@State private var grown = false
1111
@State private var recolored = false
1212
@State private var crawled = false
13+
@State private var showSlide = false
14+
@State private var showScale = false
1315
var body: some View {
1416
ScrollView {
1517
VStack(alignment: .leading, spacing: 0) {
18+
Example("Transition: move + fade") {
19+
VStack(alignment: .leading, spacing: 8) {
20+
Button(showSlide ? "Remove" : "Insert") {
21+
withAnimation { showSlide.toggle() }
22+
}
23+
if showSlide {
24+
Text("I move in from the leading edge and fade")
25+
.foregroundColor(.white)
26+
.padding()
27+
.background(Color.blue)
28+
.cornerRadius(8)
29+
.transition(.move(edge: .leading))
30+
}
31+
}
32+
}
33+
Example("Transition: scale") {
34+
VStack(alignment: .leading, spacing: 8) {
35+
Button(showScale ? "Remove" : "Insert") {
36+
withAnimation(.spring()) { showScale.toggle() }
37+
}
38+
if showScale {
39+
Text("I scale in and out")
40+
.foregroundColor(.white)
41+
.padding()
42+
.background(Color.purple)
43+
.cornerRadius(8)
44+
.transition(.scale)
45+
}
46+
}
47+
}
1648
Example("withAnimation: offset") {
1749
VStack(alignment: .leading, spacing: 8) {
1850
Circle()

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)