Jetpack Compose Snapshot System

Jetpack Compose 스냅샷 시스템 응용 — withAnimation

SwiftUI 의 withAnimation 따라 만들기

Ji Sungbin
성빈랜드

--

Photo by Shubham Dhage on Unsplash

Jetpack Compose 의 Snapshot 은 정말 재밌고 신기한 시스템입니다. 이번 글에선 개인적으로 SwiftUI 를 사용하면서 가장 재밌고 신기했었던 withAnimation 기능을 Jetpack Compose 로 구현해 보려 합니다.

SwiftUI 에 있는 withAnimationwithAnimation 람다 안에서 상태 변경이 일어나면 해당 변경에 맞는 애니메이션이 자동으로 적용되는 함수입니다. 코드로 보면 다음과 같습니다.

struct ContentView: View {
@State private var opacity = 1.0

var body: some View {
Button("opacity animation") {
withAnimation { // here!
opacity -= 0.2
}
}
.opacity(opacity)
}
}

Jetpack Compose 에서 withAnimation 은 다음과 같은 방법으로 구현할 수 있습니다.

  1. MutableState 의 값 변경을 외부 스냅샷으로 받고 dispose 합니다.
  2. 애니메이션에 맞게 수동으로 MutableState 값을 업데이트합니다.

단, 이러한 상황도 보장돼야 합니다.

  1. 애니메이션이 진행 중일 때 다른 애니메이션이 요청된 경우 애니메이션 velocity 가 유지돼야 합니다.
  2. 애니메이션이 진행 중일 때 다른 애니메이션이 요청된 경우 애니메이션 재생 시간이 유지돼야 합니다. (0 에서 10 까지 올리는 애니메이션 진행 ➝ 5 까지 애니메이션 됐을 때 0 에서 20 까지 올리는 다른 애니메이션 요청으로 인한 interrupt ➝ 0 부터 시작하는 게 아닌 마지막으로 애니메이션 된 값인 5 부터 20 까지 애니메이션 시작)

이 조건을 모두 만족시키면서 withAnimation 을 구현해 보겠습니다. Snapshot System 이 어색하신 분은 아래 두 아티클을 확인해 주세요.

먼저, 값 변경이 요청되기 전 값과 값 변경이 적용된 후의 값을 얻어와야 합니다.

값 변경이 요청된 MutableState 를 담을 statesToAnimation 리스트를 만들어 주었습니다.

이 글에선 간단함을 위해 Int 타입만 처리하겠습니다.

이어서 snapshot 변수로 별도 스냅샷을 만들어 주고, writeObserver 인자로 값 변경이 요청된 MutableStatestatesToAnimation 리스트에 추가하게 설정하였습니다.

이렇게 하고 snapshot 스냅샷 안에서 MutableState 에 쓰기 이벤트를 발생시키면 글로벌 스냅샷엔 영향을 미치지 않고 고립되게 값 변경을 적용할 수 있습니다. 이 원리를 이용하여 값 변경이 요청되기 전 값과 값 변경이 적용된 후의 값을 얻어옵니다.

얻어온 값들을 Map<MutableState, Pair<InitialValue, TargetValue>> 형태로 저장하고, 값을 애니메이션하여 적용시키는 animateValues 함수로 넘겨주었습니다.

animateValues 함수는 이렇게 시작합니다. OngoingIntAnimationCache 는 위에서 언급한 2가지 조건을 보장하기 위해 만든 싱글톤 클래스입니다.

1. 애니메이션이 진행 중일 때 다른 애니메이션이 요청된 경우 애니메이션 velocity 가 유지돼야 합니다.

2. 애니메이션이 진행 중일 때 다른 애니메이션이 요청된 경우 애니메이션 재생 시간이 유지돼야 합니다. (0 에서 10 까지 올리는 애니메이션 진행 ➝ 5 까지 애니메이션 됐을 때 0 에서 20 까지 올리는 다른 애니메이션 요청으로 인한 interrupt ➝ 0 부터 시작하는 게 아닌 마지막으로 애니메이션 된 값인 5 부터 20 까지 애니메이션 시작)

  • animation: 진행되는 애니메이션 객체
  • lastestAnimatedValue: 마지막으로 애니메이션 된 값. 만약 애니메이션이 아직 시작되지 않았다면 항상 기본 값을 나타냅니다.
  • startTimeNanos: 애니메이션이 시작된 프레임의 나노초
  • lastFrameNanos: 마지막으로 애니메이션 된 프레임의 나노초

startTimeNanoslastFrameNanos 에 기본 값으로 사용된 AnimationConstants 는 compose-animation 아티팩트에서 제공됩니다.

이어서, 위 animateValues 함수의 initializeAnimations 함수를 보겠습니다.

이 함수에서 바로 전에도 언급한 2가지 조건을 만족시키는 작업이 진행됩니다.

주어진 MutableState 로 이전에 진행된 애니메이션이 있다면 마지막으로 애니메이션 된 프레임의 나노초 — 애니메이션이 시작된 프레임의 나노초 로 애니메이션이 진행된 시간을 구하여 이전 애니메이션의 velocity 를 구해줍니다. (initialVelocity 변수)

이렇게 구한 initialVelocity 로 새로운 애니메이션 객체를 만들어 줍니다. (newAnimation 변수)

다시 한번, 만약 주어진 MutableState 로 이전에 진행된 애니메이션이 있다면 다음과 작은 작업으로 애니메이션 상태(IntStateAnimation)를 최신에 맞게 업데이트해 줍니다.

  • animation 을 새로 만든 newAnimation 객체로 변경
  • startTimeNanoslastFrameNanos 로 변경
  • lastestAnimatedValueinitialValue 로 변경

만약 이번에 처음으로 진행되는 애니메이션이라면 다음과 같은 작업으로 애니메이션 상태를 초기화합니다.

  • newAnimationinitialValue 값을 기반으로 IntStateAnimation 객체 생성 (stateAnimation 변수)
  • OngoingIntAnimationCachestateAnimation 등록

위 작업들로 구한 IntStateAnimation 을 해당 MutableState 와 Map 하여 반환하는 것으로 initializeAnimations 함수가 끝납니다.

다시 animateValues 함수로 돌아오겠습니다.

animateValues 함수는 IntStateAnimationlastestAnimatedValue 를 실제 애니메이션된 값으로 업데이트하는 updateAnimations 함수로 이어집니다.

// frameNanos: 요청된 프레임의 나노초
fun updateAnimations(frameNanos: Long) {
newValues.clear()
val it = animations.iterator()

while (it.hasNext()) {
val (stateObject, stateAnimation) = it.next()

updateAnimationsinitializeAnimations 의 결과를 담은 변수인 animations 을 자유롭게 contiune, remove 하며 순회하기 위해 Iterable 로 바꾸며 시작합니다.

if (animationCache[stateObject]?.animation !== stateAnimation.animation) {
it.remove()
continue
}

만약, 현재 순회 중인 MutableStateOngoingIntAnimationCache 에 있고, 이에 할당된 애니메이션이 현재 진행하고자 하는 애니메이션과 다르다면 다른 withAnimation 호출에 의해 이전에 요청한 애니메이션이 interrupt 됐음을 의미합니다. 이런 경우에는 interrupt 을 처리하기 위해 현재 순회 중인 애니메이션 요청을 remove 하며 다음 순회로 넘어가 줍니다.

if (stateObject.value != stateAnimation.latestAnimatedValue) {
it.remove()
animationCache -= stateObject
continue
}

만약, 현재 순회 중인 MutableState 의 값과 이에 해당하는 IntStateAnimationlastestAnimatedValue 의 값이 다르다면 외부에 의해 값이 직접적으로 변경됐음을 의미합니다. 예를 들어 다음과 같은 상황에 해당합니다.

@Composable
fun main() {
var number by remember { mutableStateOf(1) }
Button(onClick = {
withAnimation { number += 100 } // withAnimation 람다로 애니메이션이 요청됐지만
}) {
Text(text = number.toString())
}

SideEffect { number = 10_000 } // 외부에 의해 값이 직접적으로 변경됨
}

이런 경우에는 해당 애니메이션 요청이 더 이상 유효하지 않음을 나타내므로 remove 에 추가로 OngoingIntAnimationCache 에서 제거하면서 다음 순회로 넘어가 줍니다.

if (stateAnimation.startTimeNanos == AnimationConstants.UnspecifiedTime) {
stateAnimation.startTimeNanos = frameNanos
}

만약, 현재 순회 중인 IntAnimationStatestartTimeNanos 가 아직 선언되지 않았다면 현재 프레임의 나노초를 의미하는 frameNanos 인자로 값을 설정해 줍니다.

val playTime = frameNanos - stateAnimation.startTimeNanos
if (stateAnimation.animation.isFinishedFromNanos(playTime)) {
stateAnimation.latestAnimatedValue = stateAnimation.animation.targetValue
it.remove()
animationCache -= stateObject
} else {
val newValue = stateAnimation.animation.getValueFromNanos(playTime)
stateAnimation.latestAnimatedValue = newValue
stateAnimation.lastFrameNanos = frameNanos
}
newValues[stateObject] = stateAnimation

최종 작업으로 현재 프레임의 나노초 — 애니메이션이 시작된 프레임의 나노초 로 애니메이션이 진행된 시간을 구하여 애니메이션이 종료돼야 하는 상황인지에 따른 분기가 진행됩니다.

만약 요청된 애니메이션이 종료돼야 하는 상황이라면 현재 순회 중인 IntAnimationStatelastestAnimatedValue 를 요청된 애니메이션의 최종 값으로 설정해 주고, remove 및 OngoingIntAnimationCache 에서 제거해 줍니다.

그렇지 않다면 애니메이션이 진행된 시간으로 애니메이션 값을 구해서 현재 순회 중인 IntAnimationStatelastestAnimatedValue 값으로 설정하고, lastFrameNanos 도 현재 프레임 나노초로 설정해 줍니다.

마지막으로 분기와 무관하게 newValues 에 현재 MutableState 와 업데이트된 IntStateAnimation 을 넣는 작업으로 updateAnimations 함수가 끝납니다.

animateValues 함수의 마지막 작업은 animations 가 비어있지 않을 때까지 반복하며 매 프레임마다 updateAnimations 함수를 호출합니다. 이어서, 애니메이션 요청된 MutableState 의 값으로 애니메이션 된 lastestAnimatedValue 값을 설정합니다.

여기에 사용되는 withFrameNanos 함수는 Choreographer 에 의해 다음 프레임이 요청됐을 때 해당 나노초와 함께 주어진 block 을 실행하는 함수입니다.

compose-runtime 아티팩트에 위치하며 다음 프레임이 요청될 때까지 suspending 하기 때문에 suspend function 으로 설계돼 있습니다.

모든 애니메이션이 처리되고 while 문이 끝나면서 실행되는 finally 블록에서는 아직 실행 중인 애니메이션을 무효화하기 위해 OngoingIntAnimationCache 에서 제거하고 있습니다.

이렇게 해서 animateValues 가 끝나고, 이를 사용하는 withAnimation 함수도 끝납니다.

이제 withAnimation 을 사용하기 위해 CoroutineScope 를 인자로 받는 AutoTransition 클래스를 만들어 주겠습니다.

실제 사용은 다음과 같습니다.

끝!

이번 글에선 컴포즈의 Snapshot System 을 응용하여 직접 animateIntAsState 없이 Int 애니메이션을 자동으로 적용시키는 방법을 다뤄 보았습니다.

전체 코드는 아래 깃허브에서 확인하실 수 있습니다.

원래의 구현은 zach-klippenstein 님의 compose-autotransition 을 참고하였습니다.

끝까지 읽어주셔서 감사합니다.

--

--

Ji Sungbin
성빈랜드

Experience Engineers for us. I love development that creates references.