[Jetpack Compose] TransitionState(feat. mutable, seekable)

유민국·2024년 10월 20일

TransitionState

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

MutableTransitionState

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")
                }
            }
        }
    }
}

SeekableTrasitionState

seekTo또는 animateTo로 애니메이션을 조작할 수 있다
SeekableTransitionState는 할당된 이후 transition을 변경할 수 없다
애니메이션의 특정지점으로 이동하고 싶은경우(비디오 타임라인 이동 같은)나 사용자가 애니메이션을 직접 조작할 수 있게 하는 UI에서 활용할 수 있다

snapTo, seekTo, animateTo

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"
        }
    }
    
}
profile
안녕하세요 😊

0개의 댓글