When Compose Meets SwiftUI  — Animation

129 阅读2分钟

When Compose Meets SwiftUI  — Animation

bg.png

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:

image-20250524023932775.png

  • 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? = nullfun 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 Android demo

Compose iOS 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()
            }
        }
    }
}

SwiftUI demo

Summary

  1. SwiftUI’s implicit animations are more convenient than those in Compose, requiring no extra animation code.
  2. Compose delivers relatively smooth animation effects even on iOS.
  3. 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.