sealed class TransitionState<S> {
// currentState와 targetState가 다르면 애니메이션이 진행 중
// 현재 상태
abstract var currentState: S
internal set
// 목표 상태
abstract var targetState: S
internal set
// 진행 여부
internal var isRunning: Boolean by mutableStateOf(false)
// 해당 transition 설정될 때 초기 설정
internal abstract fun
transitionConfigured(transition: Transition<S>)
// transition 제거 될 때 필요한 정리 작업이나 상태 변경 작업 수행
internal abstract fun transitionRemoved()
}
Transition 애니메이션 시스템에서 트랜지션 상태를 동적으로 관리하고 변경할 수 있도록 하는 클래스
targetState를 외부에서 동적으로 변경할 수 있다
class MutableTransitionState<S>(initialState: S)
: TransitionState<S>() {
// initialState로 설정하고 transition이 진행 중 일때만 업데이트
// targetState와 다르게 internal set인걸 주목하자
override var currentState: S by mutableStateOf(initialState)
internal set
// 외부에서 변경가능
// targetState가 변경되면 애니메이션이 시작된다
override var targetState: S by mutableStateOf(initialState)
public set
// transition 완료 여부
// (currentState == targetState)과 !isRunning이
// 항상 같은 값이 아닌 수 있기 때문에 정확하게 평가하기 위함
// ex. ) 매우 짧은 애니메이션에서 사용자가 상태를 변경할 경우에
// 애니메이션 진행 중 currentState와 targetState가
// 같은 경우가 발생할 수 있음
val isIdle: Boolean
get() = (currentState == targetState) && !isRunning
override fun transitionConfigured(transition: Transition<S>) {
}
override fun transitionRemoved() {
}
}
동작 과정
1. 사용자가 항목 선택
2. 선택된 항목은 애니메이션 진행(모서리 둥글, 배경 색 변경)
3. 애니메이션 완료 후 "Nice choice" 텍스트 표시
@Composable
fun SelectableItem(selectedState: MutableTransitionState<Boolean>) {
val transition = rememberTransition(selectedState)
// Corner radius에 대한 애니메이션 설정
val cornerRadius by transition.animateDp { selected ->
if (selected) 10.dp else 0.dp
}
// 배경 색상에 대한 애니메이션 설정
val backgroundColor by transition.animateColor { selected ->
if (selected) Color.Red else Color.White
}
// Box에 배경 색상과 코너 반경 적용
Box(
Modifier
.background(
backgroundColor,
RoundedCornerShape(cornerRadius)
)
) {
// content
}
}
@OptIn(ExperimentalTransitionApi::class)
@Composable
fun ItemsSample(selectedId: Int) {
Column {
repeat(3) { id ->
Box {
// 초기 상태 설정(false)
val selectedState
= remember { MutableTransitionState(false) }
// 상태를 업데이트
selectedState.targetState = id == selectedId
// SelectableItem에 상태를 전달하고 상태 변화를 관찰
SelectableItem(selectedState)
// 애니메이션 완료 확인
if (selectedState.isIdle &&
selectedState.targetState)
{
Text("Nice choice")
}
}
}
}
}
seekTo또는 animateTo로 애니메이션을 조작할 수 있다
SeekableTransitionState는 할당된 이후 transition을 변경할 수 없다
애니메이션의 특정지점으로 이동하고 싶은경우(비디오 타임라인 이동 같은)나 사용자가 애니메이션을 직접 조작할 수 있게 하는 UI에서 활용할 수 있다
SeekableTrasitionState에서는 위 세가지 함수를 잘 사용하면 된다
SeekableTrasitionState에서 targetState가 변경이 불가능하기 때문에 snapTo, seekTo, animateTo 함수를 통해 적절히 조절하면 된다
suspend fun snapTo(targState: S)
현재 상태를 targetState로 즉시 맞춘다.(애니메이션 실행 x)
suspend fun seekTo(
@FloatRange(from = 0.0, to = 1.0) fraction: Float,
targetState: S = this.targetState
)
targetState가 이전과 같은 경우(=fraction 값만 제공) 현재 진행 중인
애니메이션을 중지하고 fraction에 맞게 즉시 설정된다
만약 targetState가 변경된 경우 currentState가 targetState로 변경 된 후
새로운 targetState로 변경한다
이 때 애니메이션을 부드럽게 이어주기 위해 currentState가
이전의 targetState로 변경될 때 애니메이션 된다(제공한 fraction에 맞게)
suspend fun animateTo(
targetState: S = this.targetState,
animationSpec: FiniteAnimationSpec<Float>? = null
)
targetState로 애니메이션을 실행
animationSpec이 제공되지 않는 경우 선형 애니메이션 진행
class SeekableTransitionState<S>(
initialState: S
) : TransitionState<S>() {
override var targetState: S by mutableStateOf(initialState)
internal set
override var currentState: S by mutableStateOf(initialState)
internal set
// 애니메이션이 진행되는 동안 컴포지션이 인식하고 있는 targetState
// targetState가 변경되더라도 즉시 컴포지션에 반영되지 않기 때문에 필요하다
// 반영된 이후에 seeking을 진행할 수 있다
internal var composedTargetState = initialState
// 하나의 transition과만 연결
// 즉, 할당된 이후 transition을 변경할 수 없다
private var transition: Transition<S>? = null
// seekToFraction을 계산하기 위함.
// 계산속에서 매번 새로운 객체나 변수를 생성하면 메모리 할당이 발생하기 때문에
// 이를 피하기 위함
internal var totalDurationNanos = 0L
private val recalculateTotalDurationNanos: () -> Unit = {
totalDurationNanos = transition?.totalDurationNanos ?: 0L
}
// currentState -> targetState로 진행되는 비율
// currentState == targetState라면 0이 된다.
@get:FloatRange(from = 0.0, to = 1.0)
var fraction: Float by mutableFloatStateOf(0f)
private set
// 컴포지션이 진행되는 동안 대기하기 위한 continuation
// CancellableContinuation: 비동기 작업을 관리하고, 일시 중단 및 재개,
// 취소 가능성을 제공하는 객체
internal var compositionContinuation:
CancellableContinuation<S>? = null
// compositionContinuation의 값을 확인하거나
// 수정하는 경우 Lock을 하기 위함
// compostedTargetState를 Lock하는 경우에도 사용
// Lock이란?: 여러 스레드가 동시에 공유 자원에 접근하지 못하도록 잠구는 것.
// 데이터 손상이나 일관성 문제를 예방할 수 있다
// internal val compositionContinuationMutex = Mutex()
// snapTo, seekTo, animateTo가 동시에 사용되는걸 방지하기 위함
private val mutatorMutex = MutatorMutex()
// 애니메이션이 실행 중일 때, 가장 최근의 프레임 시간
// 애니메이션이 멈췄을 경우 = AnimationConstants.UnspecifiedTime
private var lastFrameTimeNanos: Long =
AnimationConstants.UnspecifiedTime
// 초기 애니메이션 목록
// seekTo, snapTo, animateTo가 성공적으로 완료되면 목록은 비어있게 된다
private val initialValueAnimations =
MutableObjectList<SeekingAnimationState>()
// animateTo가 실행 중일 때, null이 아니며,
// 애니메이션 값에 대해 사용되는 정보를 제공
// seeking, snapTo 후에는 null
private var currentAnimation: SeekingAnimationState? = null
// 애니메이션의 첫 프레임이 실행될 때의 시간을 기록
// 애니메이션의 시간 계산에 사용된다
private val firstFrameLambda: (Long) -> Unit = { frameTimeNanos ->
lastFrameTimeNanos = frameTimeNanos
}
// animatedOneFrameLamda에서 사용
// animatedOneFrameLamda를 호출하기 직전에 설정
// 애니메이션의 속도를 조절하는 데 사용되는 값
private var durationScale: Float = 0f
// withFrameNanos 내에서 호출되며,
// 단일 프레임을 애니메이션화 하는 람다 객체
// withFrameNanos(onFrame:(Long) -> R):
// 코루틴을 사용하여 애니메이션 프레임을 처리하는 함수
// 새로운 프레임이 요청될 때까지 일시 중지(suspend)하고,
// 요청된 프레임의 시간을 인수로 받아 onFrame() 호출 후, 결과 반환
private val animateOneFrameLambda:
(Long) -> Unit = { frameTimeNanos ->
val delta = frameTimeNanos - lastFrameTimeNanos
lastFrameTimeNanos = frameTimeNanos
// 프레임 간의 시간 간격
val deltaPlayTimeNanos =
(delta / durationScale.toDouble()).roundToLong()
if (initialValueAnimations.isNotEmpty()) {
initialValueAnimations.forEach { animation ->
// recalculateAnimationValue하여 animation의 값 업데이트
recalculateAnimationValue(
animation,
deltaPlayTimeNanos
)
animation.isComplete = true
}
// updateInitialValues는
// 애니메이션이 완료되지 않으면 false로 설정
transition?.updateInitialValues()
// 완료된 애니메이션 제거
initialValueAnimations.removeIf { it.isComplete }
}
val currentAnimation = currentAnimation
if (currentAnimation != null) {
currentAnimation.durationNanos = totalDurationNanos
recalculateAnimationValue(
currentAnimation,
deltaPlayTimeNanos
)
fraction = currentAnimation.value
// 애니메이션이 완료되었음을 표시
if (currentAnimation.value == 1f) {
this@SeekableTransitionState.currentAnimation = null
}
// seekToFraction을 호출하여 애니메이션 진행 상황을 반영한다
seekToFraction()
}
}
// 모든 애니메이션 중지(초기 애니메이션 포함), faction을 1로 설정
private fun endAllAnimations() {
transition?.clearInitialAnimations()
initialValueAnimations.clear()
val current = currentAnimation
if (current != null) {
currentAnimation = null
fraction = 1f
seekToFraction()
}
}
// currentAnimation과 initialValueAnimations 모두 진행
// 이전 애니메이션이 중지된 경우
// (seekTo, snapTo 호출 또는 이전 애니매이션 실행 전)
// 애니메이션이 시작되기 전에 한 프레임의 시간을 캡쳐한다
private suspend fun runAnimations() {
if (initialValueAnimations.isEmpty()
&& currentAnimation == null) {
// nothing to animate
return
}
if (coroutineContext.durationScale == 0f) {
endAllAnimations()
lastFrameTimeNanos = AnimationConstants.UnspecifiedTime
return
}
if (lastFrameTimeNanos ==
AnimationConstants.UnspecifiedTime) {
// lastFrameTimeNanos를 firstFrameLambda로 조정
withFrameNanos(firstFrameLambda)
}
while (initialValueAnimations.isNotEmpty() ||
currentAnimation != null) {
animateOneFrame()
}
lastFrameTimeNanos = AnimationConstants.UnspecifiedTime
}
// 한 프레임 작업 수행
private suspend fun doOneFrame() {
if (lastFrameTimeNanos ==
AnimationConstants.UnspecifiedTime) {
// lastFrameTimeNanos를 firstFrameLambda로 조정
withFrameNanos(firstFrameLambda)
} else {
animateOneFrame()
}
}
// 모든 애니메이션 한 프레임 진행
private suspend fun animateOneFrame() {
val durationScale = coroutineContext.durationScale
if (durationScale <= 0f) {
endAllAnimations()
} else {
this@SeekableTransitionState.durationScale = durationScale
withFrameNanos(animateOneFrameLambda)
}
}
// deltaPlayTimeNanos를 기반으로
// SeekingAnimationState의 값을 재계산한다
// 재생 속도를 고려하지 않는다
private fun recalculateAnimationValue(
animation: SeekingAnimationState,
deltaPlayTimeNanos: Long
) {
val playTimeNanos = animation.progressNanos
+ deltaPlayTimeNanos
animation.progressNanos = playTimeNanos
val durationNanos = animation.animationSpecDuration
if (playTimeNanos >= durationNanos) {
animation.value = 1f
} else {
val animationSpec = animation.animationSpec
if (animationSpec != null) {
// 제공된 animateSpec을 사용하여 값을 계산하고 반영
animation.value = animationSpec.getValueFromNanos(
playTimeNanos,
animation.start,
Target1,
animation.initialVelocity ?: ZeroVelocity
)[0].coerceIn(0f, 1f)
} else {
// lerp함수를 하용하여 시작 값에서
// 종료까지 값을 비율에 따라 계산하여 설정한다
// lerp: "linear interpolation"의 약자로,
// 두 값 사이의 선형 보간을 계산하는 함수
// 애니메이션에서 두 포인트 간의
// 자연스러운 전환을 생성하기 위해 사용
animation.value = lerp(
animation.start[0],
1f,
playTimeNanos.toFloat() / durationNanos
)
}
}
}
// currentState와 targetState를 targetState로 설정하고,
// 현재 상태의 모든 값을 targetState로 즉시 맞춘다.
// 이후 transition에서 애니메이션이 실행되지 않는다
suspend fun snapTo(targetState: S) {
val transition = transition ?: return
if (currentState == targetState &&
this@SeekableTransitionState.targetState == targetState
) {
return
}
// mutate: 동시성 제어를 제공하여
// 여러 스레드가 동시에 상태를 변경하려고
// 할 경우 발생할 수 있느 충돌을 방지한다
// 상태 변경을 안전하게 수행 하기 위함
mutatorMutex.mutate {
// 모든 애니메이션 종료
endAllAnimations()
// 애니메이션 정지에 따른 값 설정
lastFrameTimeNanos = AnimationConstants.UnspecifiedTime
fraction = 0f
// targetState에 따라 fraction값 설정
// 애니메이션의 리셋 방식에 영향을 미친다
val fraction = when (targetState) {
currentState
-> ResetAnimationSnapCurrent
this@SeekableTransitionState.targetState
-> ResetAnimationSnapTarget
else -> ResetAnimationSnap
}
// targetState 업데이트하고, 재생 시간을 0으로 초기화
transition.updateTarget(targetState)
transition.playTimeNanos = 0L
this@SeekableTransitionState.targetState = targetState
this@SeekableTransitionState.fraction = 0f
currentState = targetState
transition.resetAnimationFraction(fraction)
if (fraction == ResetAnimationSnap) {
// 상태 변경 후 올바른 애니메이션 값을 얻기 위해
// 컴포지션을 기다린다
waitForCompositionAfterTargetStateChange()
}
transition.onTransitionEnd()
}
}
// targetState를 제공하지 않을 경우 애니메이션을 중지하고
// fraction을 새로운 값으로 즉시 설정한다
// 만약, targetState를 제공한 경우 애니메이션이 시작되어
// 새로운 targetState에 대해 fraction에 도달할 때 까지 진행한다.
suspend fun seekTo(
@FloatRange(from = 0.0, to = 1.0) fraction: Float,
targetState: S = this.targetState
) {
requirePrecondition(fraction in 0f..1f) {
"Expecting fraction between 0 and 1. Got $fraction"
}
val transition = transition ?: return
val oldTargetState = this@SeekableTransitionState.targetState
mutatorMutex.mutate {
coroutineScope {
if (targetState != oldTargetState) {
// 새로운 targetState로 진행해야하기 때문에 초기화 진행
moveAnimationToInitialState()
} else {
// targetState가 같다면
currentAnimation = null
if (currentState == targetState) {
return@coroutineScope
}
}
// 새로운 targetState로 업데이트 후 fraction 업데이트
if (targetState != oldTargetState) {
transition.updateTarget(targetState)
transition.playTimeNanos = 0L
this@SeekableTransitionState.targetState =
targetState
transition.resetAnimationFraction(fraction)
}
this@SeekableTransitionState.fraction = fraction
if (initialValueAnimations.isNotEmpty()) {
launch { runAnimations() }
} else {
lastFrameTimeNanos =
AnimationConstants.UnspecifiedTime
}
// 상태 변경 후 올바른 애니메이션 값을 얻기 위해 컴포지션 대기
waitForCompositionAfterTargetStateChange()
seekToFraction()
}
}
}
// targetState가 변경될 때 컴포지션이 대기하며 올바른 애니메이션 동작을 보장하기 위함
private suspend fun waitForCompositionAfterTargetStateChange() {
val expectedState = targetState
compositionContinuationMutex.lock()
if (expectedState == composedTargetState) {
compositionContinuationMutex.unlock()
} else {
val state = suspendCancellableCoroutine { continuation ->
compositionContinuation = continuation
compositionContinuationMutex.unlock()
}
if (state != expectedState) {
lastFrameTimeNanos = AnimationConstants.UnspecifiedTime
throw CancellationException(
"snapTo() was canceled because state was changed to " +
"$state instead of $expectedState"
)
}
}
}
// targetState의 변화에 관계없이 컴포지션을 기다린다
private suspend fun waitForComposition() {
val expectedState = targetState
compositionContinuationMutex.lock()
val state = suspendCancellableCoroutine { continuation ->
compositionContinuation = continuation
compositionContinuationMutex.unlock()
}
if (state != expectedState) {
lastFrameTimeNanos = AnimationConstants.UnspecifiedTime
throw CancellationException("targetState while waiting for composition")
}
}
// 애니메이션의 상태를 초기 상태로 업데이트
private fun moveAnimationToInitialState() {
val transition = transition ?: return
val animation = currentAnimation ?: if (totalDurationNanos <= 0 || fraction == 1f ||
currentState == targetState
) {
null
} else {
SeekingAnimationState().also {
it.value = fraction
val totalDurationNanos = totalDurationNanos
it.durationNanos = totalDurationNanos
it.animationSpecDuration = (totalDurationNanos * (1.0 - fraction)).roundToLong()
it.start[0] = fraction
}
}
if (animation != null) {
animation.durationNanos = totalDurationNanos
initialValueAnimations += animation
transition.setInitialAnimations(animation)
}
currentAnimation = null
}
// targetState로 애니메이션을 실행
// animationSpec이 제공되지 않는 경우 선형 애니메이션 진행
@Suppress("DocumentExceptions")
suspend fun animateTo(
targetState: S = this.targetState,
animationSpec: FiniteAnimationSpec<Float>? = null
) {
val transition = transition ?: return
mutatorMutex.mutate {
coroutineScope {
val oldTargetState = this@SeekableTransitionState.targetState
if (targetState != oldTargetState) {
moveAnimationToInitialState()
fraction = 0f
transition.updateTarget(targetState)
transition.playTimeNanos = 0L
currentState = oldTargetState
this@SeekableTransitionState.targetState = targetState
}
val composedTargetState =
compositionContinuationMutex.withLock { composedTargetState }
if (targetState != composedTargetState) {
doOneFrame() // We have to wait a frame for the composition, so continue
// Now we shouldn't skip a frame while waiting for composition
waitForCompositionAfterTargetStateChange()
}
if (currentState != targetState) {
if (fraction < 1f) {
val runningAnimation = currentAnimation
val newSpec = animationSpec?.vectorize(Float.VectorConverter)
if (runningAnimation == null || newSpec != runningAnimation.animationSpec) {
// If there is a running animation, it has changed
val oldSpec = runningAnimation?.animationSpec
val oldVelocity: AnimationVector1D
if (oldSpec != null) {
oldVelocity = oldSpec.getVelocityFromNanos(
playTimeNanos = runningAnimation.progressNanos,
initialValue = runningAnimation.start,
targetValue = Target1,
initialVelocity =
runningAnimation.initialVelocity ?: ZeroVelocity
)
} else if (runningAnimation == null ||
runningAnimation.progressNanos == 0L
) {
oldVelocity = ZeroVelocity
} else {
val oldDurationNanos = runningAnimation.durationNanos
val oldDuration =
if (oldDurationNanos == AnimationConstants.UnspecifiedTime) {
totalDurationNanos
} else {
oldDurationNanos
} / (1000f * MillisToNanos)
if (oldDuration <= 0L) {
oldVelocity = ZeroVelocity
} else {
oldVelocity = AnimationVector1D(1f / oldDuration)
}
}
val newAnimation = runningAnimation ?: SeekingAnimationState()
newAnimation.animationSpec = newSpec
newAnimation.isComplete = false
newAnimation.value = fraction
newAnimation.start[0] = fraction
newAnimation.durationNanos = totalDurationNanos
newAnimation.progressNanos = 0L
newAnimation.initialVelocity = oldVelocity
newAnimation.animationSpecDuration = newSpec?.getDurationNanos(
initialValue = newAnimation.start,
targetValue = Target1,
initialVelocity = oldVelocity
) ?: (totalDurationNanos * (1.0 - fraction)).roundToLong()
currentAnimation = newAnimation
}
}
runAnimations()
currentState = targetState
waitForComposition()
fraction = 0f
}
}
transition.onTransitionEnd()
}
}
override fun transitionConfigured(transition: Transition<S>) {
checkPrecondition(this.transition == null || transition == this.transition) {
"An instance of SeekableTransitionState has been used in different Transitions. " +
"Previous instance: ${this.transition}, new instance: $transition"
}
this.transition = transition
}
override fun transitionRemoved() {
this.transition = null
SeekableStateObserver.clear(this)
}
// totalDuration 관찰
internal fun observeTotalDuration() {
SeekableStateObserver.observeReads(
scope = this,
onValueChangedForScope = SeekableTransitionStateTotalDurationChanged,
block = recalculateTotalDurationNanos
)
}
// totalDuration이 변경되었을 때 호출
// 에니메이션의 지속 시간을 업데이터하거나 애니메이션이 진행 중인 경우 프레임에 따라 조정
internal fun onTotalDurationChanged() {
val previousTotalDurationNanos = totalDurationNanos
observeTotalDuration()
if (previousTotalDurationNanos != totalDurationNanos) {
val animation = currentAnimation
if (animation != null) {
animation.durationNanos = totalDurationNanos
if (animation.animationSpec == null) {
animation.animationSpecDuration =
((1.0 - animation.start[0]) * totalDurationNanos).roundToLong()
}
} else if (totalDurationNanos != 0L) {
// seekTo() called with a fraction. If an animation is running, we can just wait
// for the animation to change the value. The fraction may not be the best way
// to advance a regular animation.
seekToFraction()
}
}
}
// fraction에 기반하여 애니메이션을 조정한다
private fun seekToFraction() {
val transition = transition ?: return
val playTimeNanos = (fraction.toDouble() * transition.totalDurationNanos).roundToLong()
transition.seekAnimations(playTimeNanos)
}
internal class SeekingAnimationState {
// 애니메이션의 현재 진행 상태를 나노초 단위로 저장
// animationSpec에 제동되면 그에 따라 계산된다
var progressNanos: Long = 0L
// null일 경우 선형 애니메이션
var animationSpec: VectorizedAnimationSpec<AnimationVector1D>? = null
// initialAnimation이 계속 진행되는지를 알기 위해 사용 됨
var isComplete = false
// 애니메이션의 진행 정도 0~1
var value: Float = 0f
// 애니메이션의 시작 값
var start: AnimationVector1D = AnimationVector1D(0f)
// 애니메이션의 초기 속도
var initialVelocity: AnimationVector1D? = null
// 애니메이션의 지속시간
var durationNanos: Long = 0L
// animationSpec의 총 지속 시간
// Spring 애니메이션의 지속시간을 계산하는데 시간이 걸릴 수 있기 때문에 캐시됨
var animationSpecDuration: Long = 0L
override fun toString(): String {
return "progress nanos: $progressNanos, animationSpec: $animationSpec," +
" isComplete: $isComplete, value: $value, start: $start," +
" initialVelocity: $initialVelocity, durationNanos: $durationNanos," +
" animationSpecDuration: $animationSpecDuration"
}
}
}