When Compose Meets SwiftUI — Animation
Work has kept me busy lately, and I haven’t had the chance to deepen my development skills — but now it’s time to change that!
This article is based on KMP and implements a simple tarot cards shuffling animation using both Compose and SwiftUI.
This project includes the following modules:
- common module: provide Log(Napier), Serialization, .etc based function
- network module: wrapper for Ktor framework to create HTTP client
- shared module: share no ui logic function, such as the factory of tarot cards shuffling animation frames, ChatService, .etc
- composeApp module: dependencies shared module and compose foundation, provide UI and ViewModel
- iosApp: Xcode project
Get Stared!
First, define a data class for card animation frame, and object LottieUtils to build shuffling random frames
data class ShuffleCardFrame(
val duration: Long,
val position: Pair<Float,Float>,
val rotation: Float = 0f,
val scale: Float = 1f,
)
object LottieUtils {
fun build(
size: Int = 78,
canvasWH: Pair<Float, Float> = Pair(390f, 500f),
imageScaleRatio: Float = 3f,
imageWH: Pair<Float, Float> = Pair(84f, 140f),
imageMarginTop: Float = 158f,
cardsBottomOffset: Float = 14f,
marginH: Float = 20f,
): LottieData {
//...random frames
return LottieData(
w = canvasWH.first,
h = canvasWH.second,
name = "shuffle_tarot_cards",
assets = assets,
layers = layers,
op = duration,
)
}
}
Second, ShuffleCardViewModel. The load method posts the animation frame data for each frame.
class ShuffleCardViewModel : ViewModel() {
private var _uiState = MutableStateFlow<List<ShuffleCardFrame>>(emptyList())
val uiState: StateFlow<List<ShuffleCardFrame>> = _uiState
private var reverse = true
private var animateJob: Job? = null
fun load() {
reverse = !reverse
animateJob?.cancel()
animateJob = null
animateJob = viewModelScope.launch(provideIoDispatcher()) {
try {
val cardsFrame = mutableListOf<List<ShuffleCardFrame>>()
//...
//LottieUtils.build
//LottieData map to ShuffleCardFrame list
//...
val frameCount = cardsFrame.firstOrNull()?.size ?: 0
(0..<frameCount).forEach { frameIndex ->
val cardState = cardsFrame.mapNotNull { card ->
card.getOrNull(frameIndex)
}
_uiState.update {
cardState.reversed()
}
val frame = cardsFrame.firstOrNull()?.getOrNull(frameIndex)
val duration = frame?.duration ?: 0L
delay(duration + 100L)
}
} catch (e: Throwable) {
}
}
}
}
Third, UI
Compose
ShuffleCardView is the group of tarot cards. CardView uses implicit animations to render each frame of the shuffling effect.
@Composable
fun ShuffleCardView(
uiState: List<ShuffleCardFrame>,
) {
Box(modifier = Modifier.size(390f.dp, 500f.dp)) {
uiState.forEach {
CardView(it)
}
}
}
@Composable
fun CardView(frame: ShuffleCardFrame) {
val transition = updateTransition(frame)
val offset by transition.animateIntOffset(transitionSpec = {
tween(frame.duration.toInt())
}) {
IntOffset(
it.position.first.roundToInt(),
it.position.second.roundToInt(),
)
}
val rotation by transition.animateFloat(
transitionSpec = { tween(frame.duration.toInt()) },
) {
it.rotation
}
Image(
painter = painterResource(Res.drawable.bg_tarot_card_back),
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier.width(84.dp).offset { offset }.rotate(rotation),
)
}
Compose Android demo
Compose iOS demo
SwiftUI
With the help of the Skie plugin, it's easy to use StateFlow in SwiftUI.
struct ShuffleCardView: View {
private let viewModel: ShuffleCardViewModel = .init()
private let canvasWH = CGSize(width: 390, height: 500)
private let imageWH = CGSize(width: 84, height: 140)
var body: some View {
Observing(viewModel.uiState) { uiState in
VStack(spacing: 16) {
Button(action: {
viewModel.load()
}) {
Text("run")
.frame(width: 300, height: 40)
}
.buttonStyle(.bordered)
if !uiState.isEmpty {
Spacer()
ZStack(alignment: .topLeading) {
ForEach(0 ..< uiState.count, id: \.self) { index in
let card = uiState[index]
Image("bg_tarot_card_back")
.resizable()
.scaledToFit()
.frame(width: imageWH.width)
.rotationEffect(.degrees(Double(card.rotation)))
.position(
x: CGFloat(truncating: card.position.first ?? 0) + imageWH.width / 2,
y: CGFloat(truncating: card.position.second ?? 0) + imageWH.height / 2
.animation(
.linear(duration: TimeInterval(Double(card.duration) / 1000)),
value: card
)
}
}
.frame(width: canvasWH.width, height: canvasWH.height)
}
Spacer()
}
}
}
}
Summary
- SwiftUI’s implicit animations are more convenient than those in Compose, requiring no extra animation code.
- Compose delivers relatively smooth animation effects even on iOS.
- While the KMP shared logic approach enables consistent business logic across platforms and can be used with SwiftUI, integrating the KMP framework currently breaks SwiftUI Preview functionality.