애니메이션

Jetpack Compose에서는 앱 UI에서 다양한 애니메이션을 쉽게 구현하도록 지원하는 강력하고 확장 가능한 API를 제공합니다. 이 문서에서는 이 API를 사용하는 방법 및 애니메이션 시나리오에 따라 사용해야 하는 API를 설명합니다.

개요

최신 모바일 앱에서 원활하고 이해하기 쉬운 사용자 환경을 실현하기 위해서는 애니메이션이 필수적입니다. 많은 Jetpack Compose 애니메이션 API는 레이아웃 및 기타 UI 요소와 마찬가지로 구성 가능한 함수로 사용할 수 있으며 Kotlin 코루틴 정지 함수로 빌드된 하위 수준 API의 지원을 받습니다. 이 가이드에서는 여러 실제 시나리오에서 유용한 상위 수준 API부터 시작하여 더욱 세밀하게 관리하고 맞춤설정할 수 있는 하위 수준 API를 설명합니다.

아래 차트는 애니메이션을 구현하는 데 사용할 API를 결정하는 데 도움이 됩니다.

  • 레이아웃에서 콘텐츠 변경사항을 애니메이션 처리하는 경우:
    • 나타남과 사라짐을 애니메이션으로 처리하는 경우:
      • AnimationVisibility를 사용합니다.
    • 상태에 따라 콘텐츠 교체:
      • 콘텐츠를 크로스페이드하는 경우:
        • Crossfade를 사용합니다.
      • 그렇지 않은 경우 AnimatedContent를 사용합니다.
    • 그렇지 않은 경우 Modifier.contentSize를 사용합니다.
  • 애니메이션이 상태 기반인 경우:
    • 컴포지션 시 애니메이션이 발생하는 경우:
      • 애니메이션이 무제한인 경우:
        • rememberInfiniteTransition을 사용합니다.
      • 여러 값을 동시에 애니메이션 처리하는 경우:
        • updateTransition을 사용합니다.
      • 그렇지 않은 경우 animate*AsState을 사용합니다.
  • 애니메이션 시간을 세밀하게 관리하려는 경우:
    • Animation을 사용합니다.
  • 애니메이션이 유일한 정보 소스인 경우
    • Animatable을 사용합니다.
  • 그 외의 경우 AnimationStateanimate를 사용합니다.

적절한 애니메이션 API를 선택하기 위한 결정 트리를 설명하는 플로 차트

상위 수준 애니메이션 API

Compose는 많은 앱에서 사용되는 여러 일반적인 애니메이션 패턴을 위한 상위 수준 애니메이션 API를 제공합니다. 이 API는 머티리얼 디자인 모션의 권장사항에 맞게 조정되었습니다.

AnimatedVisibility(실험용)

AnimatedVisibility 컴포저블은 콘텐츠 표시와 사라짐을 애니메이션 처리합니다.

var editable by remember { mutableStateOf(true) }
AnimatedVisibility(visible = editable) {
    Text(text = "Edit")
}

기본적으로는 콘텐츠는 페이드인 및 확장 방식으로 표시되고 페이드아웃 및 축소 방식으로 사라집니다. EnterTransitionExitTransition을 지정하여 전환을 맞춤설정할 수 있습니다.

var visible by remember { mutableStateOf(true) }
val density = LocalDensity.current
AnimatedVisibility(
    visible = visible,
    enter = slideInVertically(
        // Slide in from 40 dp from the top.
        initialOffsetY = { with(density) { -40.dp.roundToPx() } }
    ) + expandVertically(
        // Expand from the top.
        expandFrom = Alignment.Top
    ) + fadeIn(
        // Fade in with the initial alpha of 0.3f.
        initialAlpha = 0.3f
    ),
    exit = slideOutVertically() + shrinkVertically() + fadeOut()
) {
    Text("Hello", Modifier.fillMaxWidth().height(200.dp))
}

위의 예에서 볼 수 있듯이 + 연산자를 사용하여 여러 EnterTransition 또는 ExitTransition 객체를 결합할 수 있으며 각 객체에 선택적 매개변수를 사용하여 동작을 맞춤설정할 수 있습니다. 자세한 내용은 참고 자료를 확인하세요.

EnterTransition

fadeIn:

slideIn:

slideInHorizontally:

slideInVertically:

scaleIn:

expandIn:

expandHorizontally:

expandVertically:

ExitTransition

fadeOut:

slideOut:

slideOutHorizontally:

slideOutVertically:

scaleOut:

shrinkOut:

shrinkHorizontally:

shrinkVertically:

AnimatedVisibilityMutableTransitionState를 사용하는 변형도 제공합니다. 이를 통해 AnimatedVisibility가 컴포지션 트리에 추가되는 즉시 애니메이션을 트리거할 수 있습니다. 애니메이션 상태를 관찰하는 데도 유용합니다.

// Create a MutableTransitionState<Boolean> for the AnimatedVisibility.
val state = remember {
    MutableTransitionState(false).apply {
        // Start the animation immediately.
        targetState = true
    }
}
Column {
    AnimatedVisibility(visibleState = state) {
        Text(text = "Hello, world!")
    }

    // Use the MutableTransitionState to know the current animation state
    // of the AnimatedVisibility.
    Text(
        text = when {
            state.isIdle && state.currentState -> "Visible"
            !state.isIdle && state.currentState -> "Disappearing"
            state.isIdle && !state.currentState -> "Invisible"
            else -> "Appearing"
        }
    )
}

하위 요소의 들어가기와 나가기 애니메이션

AnimatedVisibility 내 콘텐츠(직접 또는 간접 하위 요소)는 animateEnterExit 수정자를 사용하여 각각의 다른 애니메이션 동작을 지정할 수 있습니다. 이러한 각 하위 요소의 시각적 효과는 AnimatedVisibility 컴포저블에 지정된 애니메이션과 하위 요소의 자체 들어가기 및 나가기 애니메이션의 조합입니다.

AnimatedVisibility(
    visible = visible,
    // Fade in/out the background and the foreground.
    enter = fadeIn(),
    exit = fadeOut()
) {
    Box(Modifier.fillMaxSize().background(Color.DarkGray)) {
        Box(
            Modifier
                .align(Alignment.Center)
                .animateEnterExit(
                    // Slide in/out the inner box.
                    enter = slideInVertically(),
                    exit = slideOutVertically()
                )
                .sizeIn(minWidth = 256.dp, minHeight = 64.dp)
                .background(Color.Red)
        ) {
            // Content of the notification…
        }
    }
}

상황에 따라 AnimatedVisibility가 애니메이션을 전혀 적용하지 않도록 하여 하위 요소가 각각 animateEnterExit로 고유한 자체 애니메이션을 보유할 수 있도록 할 수 있습니다. 이렇게 하려면 AnimatedVisibility 컴포저블에 EnterTransition.NoneExitTransition.None을 지정하세요.

맞춤 애니메이션 추가

기본 제공 들어가기 및 나가기 애니메이션 외에 맞춤 애니메이션 효과를 추가하려면 AnimatedVisibility 콘텐츠 람다 내의 transition 속성을 통해 기본 Transition 인스턴스에 액세스합니다. Transition 인스턴스에 추가된 애니메이션 상태는 AnimatedVisibility의 들어가기 및 나가기 애니메이션과 동시에 실행됩니다. AnimatedVisibility는 콘텐츠를 삭제하기 전에 Transition의 모든 애니메이션이 완료될 때까지 기다립니다. Transition과는 별개로 만들어진 나가기 애니메이션(예: animate*AsState 사용)의 경우 AnimatedVisibility는 이를 고려할 수 없으므로 완료되기 전에 콘텐츠 컴포저블을 삭제할 수 있습니다.

AnimatedVisibility(
    visible = visible,
    enter = fadeIn(),
    exit = fadeOut()
) { // this: AnimatedVisibilityScope
    // Use AnimatedVisibilityScope.transition() to add a custom animation
    // to the AnimatedVisibility.
    val background by transition.animateColor { state ->
        if (state == EnterExitState.Visible) Color.Blue else Color.Gray
    }
    Box(modifier = Modifier.size(128.dp).background(background))
}

Transition에 관한 자세한 내용은 updateTransition을 참고하세요.

AnimatedContent(실험용)

AnimatedContent 컴포저블은 타겟 상태에 따라 변경될 때 콘텐츠에 애니메이션을 적용합니다.

Row {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Add")
    }
    AnimatedContent(targetState = count) { targetCount ->
        // Make sure to use `targetCount`, not `count`.
        Text(text = "Count: $targetCount")
    }
}

항상 람다 매개변수를 사용하고 이를 콘텐츠에 반영해야 합니다. API는 이 값을 키로 사용하여 현재 표시되는 콘텐츠를 식별합니다.

기본적으로 초기 콘텐츠는 페이드 아웃되고 타겟 콘텐츠가 페이드 인됩니다. 이 동작을 페이드 스루라고 합니다. transitionSpec 매개변수에 ContentTransform 객체를 지정하여 이 애니메이션 동작을 맞춤설정할 수 있습니다. with 중위 함수를 사용하여 EnterTransitionExitTransition과 결합해 ContentTransform을 만들 수 있습니다. using 중위 함수로 연결하여 SizeTransformContentTransform에 적용할 수 있습니다.

AnimatedContent(
    targetState = count,
    transitionSpec = {
        // Compare the incoming number with the previous number.
        if (targetState > initialState) {
            // If the target number is larger, it slides up and fades in
            // while the initial (smaller) number slides up and fades out.
            slideInVertically({ height -> height }) + fadeIn() with
                slideOutVertically({ height -> -height }) + fadeOut()
        } else {
            // If the target number is smaller, it slides down and fades in
            // while the initial number slides down and fades out.
            slideInVertically({ height -> -height }) + fadeIn() with
                slideOutVertically({ height -> height }) + fadeOut()
        }.using(
            // Disable clipping since the faded slide-in/out should
            // be displayed out of bounds.
            SizeTransform(clip = false)
        )
    }
) { targetCount ->
    Text(text = "$targetCount")
}

EnterTransition은 타겟 콘텐츠가 표시되는 방식을 정의하고 ExitTransition은 초기 콘텐츠가 사라지는 방식을 정의합니다. AnimatedVisibility에서 사용할 수 있는 모든 EnterTransitionExitTransition 함수 외에도 AnimatedContentslideIntoContainerslideOutOfContainer를 제공합니다. 이는 초기 콘텐츠 크기와 AnimatedContent 콘텐츠의 타겟 콘텐츠에 따라 슬라이드 거리를 계산하는 slideInHorizontally/VerticallyslideOutHorizontally/Vertically의 편리한 대안입니다.

SizeTransform은 초기 콘텐츠와 타겟 콘텐츠 사이에 크기가 애니메이션되는 방식을 정의합니다. 애니메이션을 만들 때 초기 크기와 타겟 크기에 모두 액세스할 수 있습니다. SizeTransform은 애니메이션 중에 콘텐츠를 구성요소 크기로 잘라야 하는지도 제어합니다.

var expanded by remember { mutableStateOf(false) }
Surface(
    color = MaterialTheme.colors.primary,
    onClick = { expanded = !expanded }
) {
    AnimatedContent(
        targetState = expanded,
        transitionSpec = {
            fadeIn(animationSpec = tween(150, 150)) with
                fadeOut(animationSpec = tween(150)) using
                SizeTransform { initialSize, targetSize ->
                    if (targetState) {
                        keyframes {
                            // Expand horizontally first.
                            IntSize(targetSize.width, initialSize.height) at 150
                            durationMillis = 300
                        }
                    } else {
                        keyframes {
                            // Shrink vertically first.
                            IntSize(initialSize.width, targetSize.height) at 150
                            durationMillis = 300
                        }
                    }
                }
        }
    ) { targetExpanded ->
        if (targetExpanded) {
            Expanded()
        } else {
            ContentIcon()
        }
    }
}

하위 요소 들어가기/나가기 애니메이션

AnimatedVisibility와 마찬가지로 animateEnterExit 수정자는 AnimatedContent 콘텐츠 람다 내에서 사용할 수 있습니다. EnterAnimationExitAnimation을 각 직간접 하위 요소에 별도로 적용하는 데 사용합니다.

맞춤 애니메이션 추가

AnimatedVisibility와 마찬가지로 transition 필드는 AnimatedContent 콘텐츠 람다 내에서 사용할 수 있습니다. AnimatedContent 전환과 동시에 실행되는 맞춤 애니메이션 효과를 만드는 데 사용합니다. 자세한 내용은 updateTransition을 참고하세요.

animateContentSize

animateContentSize 수정자는 크기를 변경하는 애니메이션을 표시합니다.

var message by remember { mutableStateOf("Hello") }
Box(
    modifier = Modifier.background(Color.Blue).animateContentSize()
) {
    Text(text = message)
}

크로스페이드

Crossfade는 크로스페이드 애니메이션을 사용하여 두 레이아웃 사이의 전환을 애니메이션 처리합니다. current 매개변수로 전달된 값을 전환하면 콘텐츠가 크로스페이드 애니메이션을 사용하여 전환됩니다.

var currentPage by remember { mutableStateOf("A") }
Crossfade(targetState = currentPage) { screen ->
    when (screen) {
        "A" -> Text("Page A")
        "B" -> Text("Page B")
    }
}

하위 수준 애니메이션 API

이전 섹션에서 언급된 모든 상위 수준 애니메이션 API는 하위 수준 애니메이션 API를 기반으로 빌드됩니다.

animate*AsState 함수는 가장 단순한 API로 인스턴트 값 변경사항을 애니메이션 값으로 렌더링합니다. 단일 값을 애니메이션 처리하는 코루틴 기반 API인 Animatable의 지원을 받습니다. updateTransition에서는 여러 애니메이션 값을 관리하고 상태 변경에 따라 실행할 수 있는 전환 객체를 만듭니다. rememberInfiniteTransition도 비슷하지만 이 함수는 계속해서 무제한 실행되는 여러 애니메이션을 관리할 수 있는 무한 전환을 만듭니다. 이러한 API는 Animatable을 제외하고 모두 컴포저블이며, 컴포지션 외부에서 이러한 애니메이션을 만들 수 있습니다.

이러한 API는 모두 더 기본적인 Animation API를 기반으로 합니다. 대부분의 앱은 Animation과 직접 상호작용하지 않지만 Animation의 일부 맞춤설정 기능은 상위 수준 API를 통해 사용할 수 있습니다. AnimationVectorAnimationSpec에 관한 자세한 내용은 애니메이션 맞춤설정을 참고하세요.

다양한 하위 수준 애니메이션 API 간의 관계를 보여주는 다이어그램

animate*AsState

animate*AsState 함수는 Compose에서 단일 값을 애니메이션 처리하는 가장 간단한 애니메이션 API입니다. 최종 값(또는 타겟 값)만 제공하면 API가 현재 값에서 지정된 값으로 애니메이션을 시작합니다.

다음은 이 API를 사용하여 알파를 애니메이션 처리하는 예입니다. 타겟 값을 animateFloatAsState에 래핑하기만 하면 알파 값이 제공된 값(이 경우 1f 또는 0.5f) 사이의 애니메이션 값이 됩니다.

val alpha: Float by animateFloatAsState(if (enabled) 1f else 0.5f)
Box(
    Modifier.fillMaxSize()
        .graphicsLayer(alpha = alpha)
        .background(Color.Red)
)

애니메이션 클래스의 인스턴스를 생성하거나 중단을 처리할 필요가 없습니다. 내부적으로 애니메이션 객체(즉 Animatable 인스턴스)가 생성되고 첫 번째 타겟 값을 초깃값으로 하여 호출 사이트에 저장됩니다. 이후에는 이 컴포저블에 다른 타겟 값을 제공할 때마다 이 값을 향해 애니메이션이 자동으로 시작됩니다. 이미 실행 중인 애니메이션이 있는 경우 애니메이션이 현재 값(및 속도)에서 시작하고 타겟 값을 향해 애니메이션 처리됩니다. 애니메이션 처리 중에 이 컴포저블은 재구성되고 프레임마다 업데이트된 애니메이션 값을 반환합니다.

Compose는 Float, Color, Dp, Size, Bounds, Offset, Rect, Int, IntOffset, IntSize에 사용할 수 있는 animate*AsState 함수를 즉시 제공합니다. 일반 유형을 취하는 animateValueAsStateTwoWayConverter를 제공하여 다른 데이터 유형의 지원 기능을 쉽게 추가할 수 있습니다.

AnimationSpec을 제공하여 애니메이션 사양을 맞춤설정할 수 있습니다. 자세한 내용은 AnimationSpec을 참고하세요.

Animatable

Animatable은 값이 animateTo를 통해 변경될 때 값을 애니메이션으로 표시할 수 있는 값 홀더입니다. 이 API는 animate*AsState의 구현을 백업하는 API입니다. 이 API를 사용하면 일관된 지속성과 상호 배타성이 보장되므로 값 변경이 항상 지속되고 진행 중인 모든 애니메이션이 취소됩니다.

animateTo를 포함한 Animatable의 많은 기능이 정지 함수로 제공됩니다. 즉 적절한 코루틴 범위에 래핑해야 합니다. 예를 들어 LaunchedEffect 컴포저블을 사용하여 지정된 키 값의 기간에만 해당하는 범위를 만들 수 있습니다.

// Start out gray and animate to green/red based on `ok`
val color = remember { Animatable(Color.Gray) }
LaunchedEffect(ok) {
    color.animateTo(if (ok) Color.Green else Color.Red)
}
Box(Modifier.fillMaxSize().background(color.value))

위의 예에서는 Color.Gray의 초깃값을 사용하여 Animatable의 인스턴스를 만들고 저장합니다. 부울 플래그 ok의 값에 따라 색상이 Color.Green 또는 Color.Red로 애니메이션 처리됩니다. 이후 부울 값이 변경되면 다른 색상으로 애니메이션이 시작됩니다. 값이 변경될 때 진행 중인 애니메이션이 있는 경우 애니메이션이 취소되며 새로운 애니메이션이 현재 스냅샷 값에서 현재 속도로 시작됩니다.

이 구현은 이전 섹션에서 언급한 animate*AsState API를 백업하는 애니메이션 구현입니다. animate*AsState와 비교하여 Animatable을 사용하면 여러 가지 측면을 더 세밀하게 직접 관리할 수 있습니다. 첫째, Animatable에는 첫 번째 타겟 값과 다른 초깃값을 설정할 수 있습니다. 예를 들어 위의 코드 예에서 처음에는 잠시 회색 상자가 표시되었다가 곧바로 녹색 또는 빨간색으로 애니메이션 처리됩니다. 둘째, Animatable은 콘텐츠 값에 관한 추가 작업(snapToanimateDecay)을 제공합니다. snapTo는 현재 값을 즉시 타겟 값으로 설정합니다. 이 기능은 애니메이션 자체가 유일한 정보 소스가 아니고 터치 이벤트와 같은 다른 상태와 동기화해야 하는 경우 유용합니다. animateDecay는 지정된 속도보다 느려지는 애니메이션을 시작합니다. 이 기능은 플링 동작을 구현하는 데 유용합니다. 자세한 내용은 동작 및 애니메이션을 참고하세요.

Animatable은 즉시 FloatColor를 지원하지만 TwoWayConverter를 제공하면 모든 데이터 유형을 사용할 수 있습니다. 자세한 내용은 AnimationVector를 참고하세요.

AnimationSpec을 제공하여 애니메이션 사양을 맞춤설정할 수 있습니다. 자세한 내용은 AnimationSpec을 참고하세요.

updateTransition

Transition은 하나 이상의 애니메이션을 하위 요소로 관리하며 여러 상태 간에 동시에 실행합니다.

상태는 모든 데이터 유형이 될 수 있습니다. 대부분의 경우 다음 예와 같이 맞춤 enum 유형을 사용하여 유형 안전성을 보장할 수 있습니다.

private enum class BoxState {
    Collapsed,
    Expanded
}

updateTransitionTransition의 인스턴스를 만들고 저장하며 상태를 업데이트합니다.

var currentState by remember { mutableStateOf(BoxState.Collapsed) }
val transition = updateTransition(currentState)

그런 다음 animate* 확장 함수 중 하나를 사용하여 이 전환에서 하위 애니메이션을 정의할 수 있습니다. 각 상태의 타겟 값을 지정합니다. 이 animate* 함수는 전환 상태가 updateTransition으로 업데이트될 때 애니메이션의 모든 프레임에서 업데이트되는 애니메이션 값을 반환합니다.

val rect by transition.animateRect { state ->
    when (state) {
        BoxState.Collapsed -> Rect(0f, 0f, 100f, 100f)
        BoxState.Expanded -> Rect(100f, 100f, 300f, 300f)
    }
}
val borderWidth by transition.animateDp { state ->
    when (state) {
        BoxState.Collapsed -> 1.dp
        BoxState.Expanded -> 0.dp
    }
}

transitionSpec 매개변수를 전달하여 전환 상태 변경사항의 조합별로 다른 AnimationSpec을 지정할 수도 있습니다. 자세한 내용은 AnimationSpec을 참고하세요.

val color by transition.animateColor(
    transitionSpec = {
        when {
            BoxState.Expanded isTransitioningTo BoxState.Collapsed ->
                spring(stiffness = 50f)
            else ->
                tween(durationMillis = 500)
        }
    }
) { state ->
    when (state) {
        BoxState.Collapsed -> MaterialTheme.colors.primary
        BoxState.Expanded -> MaterialTheme.colors.background
    }
}

전환이 타겟 상태에 도달하면 Transition.currentStateTransition.targetState와 동일하게 됩니다. 이 기능은 전환이 완료되었는지 여부를 나타내는 신호로 사용할 수 있습니다.

초기 상태를 첫 번째 타겟 상태와 다르게 설정해야 하는 경우도 있습니다. updateTransitionMutableTransitionState와 함께 사용하여 이 목표를 달성할 수 있습니다. 예를 들어 이렇게 하면 코드가 컴포지션을 시작하는 즉시 애니메이션을 시작할 수 있습니다.

// Start in collapsed state and immediately animate to expanded
var currentState = remember { MutableTransitionState(BoxState.Collapsed) }
currentState.targetState = BoxState.Expanded
val transition = updateTransition(currentState)
// ...

여러 구성 가능한 함수가 관련된 좀 더 복잡한 전환의 경우 createChildTransition을 사용하여 하위 전환을 만들 수 있습니다. 이 기법은 복잡한 컴포저블에서 여러 하위 구성요소 간에 문제를 구분하는 데 유용합니다. 상위 전환은 하위 전환의 모든 애니메이션 값을 인식합니다.

enum class DialerState { DialerMinimized, NumberPad }

@Composable
fun DialerButton(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun NumberPad(isVisibleTransition: Transition<Boolean>) {
    // `isVisibleTransition` spares the need for the content to know
    // about other DialerStates. Instead, the content can focus on
    // animating the state change between visible and not visible.
}

@Composable
fun Dialer(dialerState: DialerState) {
    val transition = updateTransition(dialerState)
    Box {
        // Creates separate child transitions of Boolean type for NumberPad
        // and DialerButton for any content animation between visible and
        // not visible
        NumberPad(
            transition.createChildTransition {
                it == DialerState.NumberPad
            }
        )
        DialerButton(
            transition.createChildTransition {
                it == DialerState.DialerMinimized
            }
        )
    }
}

AnimatedVisibility와 AnimatedContent와 함께 전환 사용

AnimatedVisibilityAnimatedContentTransition의 확장 함수로 사용할 수 있습니다. Transition.AnimatedVisibilityTransition.AnimatedContenttargetStateTransition에서 파생되며 TransitiontargetState가 변경될 때 필요에 따라 들어가기/나가기 전환을 트리거합니다. 이러한 확장 함수를 사용하면 AnimatedVisibility/AnimatedContent 내부에 있는 모든 enter/exit/sizeTransform 애니메이션을 Transition으로 끌어올릴 수 있습니다. 이러한 확장 함수로 AnimatedVisibility/AnimatedContent의 상태 변경을 외부에서 관찰할 수 있습니다. 부울 visible 매개변수 대신 이 AnimatedVisibility 버전은 상위 전환의 타겟 상태를 부울로 변환하는 람다를 사용합니다.

자세한 내용은 AnimatedVisibilityAnimatedContent를 참고하세요.

var selected by remember { mutableStateOf(false) }
// Animates changes when `selected` is changed.
val transition = updateTransition(selected)
val borderColor by transition.animateColor { isSelected ->
    if (isSelected) Color.Magenta else Color.White
}
val elevation by transition.animateDp { isSelected ->
    if (isSelected) 10.dp else 2.dp
}
Surface(
    onClick = { selected = !selected },
    shape = RoundedCornerShape(8.dp),
    border = BorderStroke(2.dp, borderColor),
    elevation = elevation
) {
    Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
        Text(text = "Hello, world!")
        // AnimatedVisibility as a part of the transition.
        transition.AnimatedVisibility(
            visible = { targetSelected -> targetSelected },
            enter = expandVertically(),
            exit = shrinkVertically()
        ) {
            Text(text = "It is fine today.")
        }
        // AnimatedContent as a part of the transition.
        transition.AnimatedContent { targetState ->
            if (targetState) {
                Text(text = "Selected")
            } else {
                Icon(imageVector = Icons.Default.Phone, contentDescription = "Phone")
            }
        }
    }
}

전환을 캡슐화하고 재사용 가능하도록 설정

간단한 사용 사례에서는 UI와 동일한 컴포저블에서 전환 애니메이션을 정의하는 것이 완벽하게 유효한 방법입니다. 그러나 여러 애니메이션 값으로 구성된 복잡한 구성요소를 사용하는 경우 애니메이션 구현을 컴포저블 UI와 분리해야 할 수도 있습니다.

이렇게 하려면 모든 애니메이션 값을 포함하는 클래스와 이 클래스의 인스턴스를 반환하는 '업데이트' 함수를 만들면 됩니다. 전환 구현은 별도의 새 함수로 추출할 수 있습니다. 이 패턴은 애니메이션 로직을 중앙집중식으로 처리하거나 복잡한 애니메이션을 재사용 가능하도록 설정해야 하는 경우에 유용합니다.

enum class BoxState { Collapsed, Expanded }

@Composable
fun AnimatingBox(boxState: BoxState) {
    val transitionData = updateTransitionData(boxState)
    // UI tree
    Box(
        modifier = Modifier
            .background(transitionData.color)
            .size(transitionData.size)
    )
}

// Holds the animation values.
private class TransitionData(
    color: State<Color>,
    size: State<Dp>
) {
    val color by color
    val size by size
}

// Create a Transition and return its animation values.
@Composable
private fun updateTransitionData(boxState: BoxState): TransitionData {
    val transition = updateTransition(boxState)
    val color = transition.animateColor { state ->
        when (state) {
            BoxState.Collapsed -> Color.Gray
            BoxState.Expanded -> Color.Red
        }
    }
    val size = transition.animateDp { state ->
        when (state) {
            BoxState.Collapsed -> 64.dp
            BoxState.Expanded -> 128.dp
        }
    }
    return remember(transition) { TransitionData(color, size) }
}

도구 지원

Android 스튜디오는 Compose 미리보기에서 전환 검사를 지원합니다.

  • 프레임별 전환 미리보기
  • 전환 내 모든 애니메이션의 값 검사
  • 초기 상태와 타겟 상태 간 전환 미리보기

애니메이션 검사기를 시작하면 대화형 미리보기 아래 미리보기에 포함된 모든 전환을 실행할 수 있는 '애니메이션' 창이 표시됩니다. 전환 및 전환의 각 애니메이션 값에 기본 이름으로 라벨이 지정됩니다. updateTransitionanimate* 함수에서 label 매개변수를 지정하여 라벨을 맞춤설정할 수 있습니다. Compose 미리보기에 관한 자세한 내용은 레이아웃 미리보기를 참고하세요.

rememberInfiniteTransition

InfiniteTransition에는 Transition과 같은 하위 애니메이션이 하나 이상 포함되지만 컴포지션을 시작하는 즉시 애니메이션이 시작되며 삭제되지 않는 한 중단되지 않습니다. rememberInfiniteTransition을 사용하여 InfiniteTransition의 인스턴스를 만들 수 있습니다. 하위 애니메이션은 animateColor, animatedFloat 또는 animatedValue와 함께 추가할 수 있습니다. 또한 애니메이션 사양을 지정하려면 infiniteRepeatable을 지정해야 합니다.

val infiniteTransition = rememberInfiniteTransition()
val color by infiniteTransition.animateColor(
    initialValue = Color.Red,
    targetValue = Color.Green,
    animationSpec = infiniteRepeatable(
        animation = tween(1000, easing = LinearEasing),
        repeatMode = RepeatMode.Reverse
    )
)

Box(Modifier.fillMaxSize().background(color))

TargetBasedAnimation

TargetBasedAnimation은 지금까지 확인된 최저 수준의 애니메이션 API입니다. 다른 API는 대부분의 사용 사례에 적용되지만 TargetBasedAnimation을 사용하면 애니메이션 재생 시간을 직접 관리할 수 있습니다. 아래 예에서 TargetAnimation의 재생 시간은 withFrameMillis에서 제공하는 프레임 시간을 기반으로 수동으로 관리됩니다.

val anim = remember {
    TargetBasedAnimation(
        animationSpec = tween(200),
        typeConverter = Float.VectorConverter,
        initialValue = 200f,
        targetValue = 1000f
    )
}
var playTime by remember { mutableStateOf(0L) }

LaunchedEffect(anim) {
    val startTime = withFrameNanos { it }

    do {
        playTime = withFrameNanos { it } - startTime
        val animationValue = anim.getValueFromNanos(playTime)
    } while (someCustomCondition())
}

애니메이션 맞춤설정

많은 애니메이션 API에서는 일반적으로 동작을 맞춤설정하는 매개변수를 사용할 수 있습니다.

AnimationSpec

대부분의 애니메이션 API에서 개발자가 선택적 AnimationSpec 매개변수로 애니메이션 사양을 맞춤설정할 수 있습니다.

val alpha: Float by animateFloatAsState(
    targetValue = if (enabled) 1f else 0.5f,
    // Configure the animation duration and easing.
    animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
)

다양한 유형의 애니메이션을 만들 수 있는 여러 AnimationSpec 유형이 있습니다.

spring

spring은 시작 값과 끝 값 사이에 물리학 기반 애니메이션을 만들며 두 매개변수 dampingRatiostiffness를 사용합니다.

dampingRatio는 스프링의 탄성을 정의합니다. 기본값은 Spring.DampingRatioNoBouncy입니다.

여러 감쇠비의 동작을 보여주는 애니메이션 그래픽

stiffness는 스프링이 종료 값으로 이동하는 속도를 정의합니다. 기본값은 Spring.StiffnessMedium입니다.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = spring(
        dampingRatio = Spring.DampingRatioHighBouncy,
        stiffness = Spring.StiffnessMedium
    )
)

spring은 애니메이션 도중 타겟 값이 변경될 때 속도의 연속성을 보장하므로 기간 기반 AnimationSpec 유형보다 원활하게 중단을 처리할 수 있습니다. springanimate*AsState, updateTransition 등 많은 애니메이션 API에서 기본 AnimationSpec으로 사용됩니다.

tween

tween은 이징 곡선을 사용하여 지정된 durationMillis 동안 시작 값과 끝 값 간에 애니메이션을 처리합니다. 자세한 내용은 이징을 참고하세요. delayMillis를 지정하여 애니메이션 시작을 연기할 수도 있습니다.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = tween(
        durationMillis = 300,
        delayMillis = 50,
        easing = LinearOutSlowInEasing
    )
)

keyframes

keyframes는 애니메이션 기간에 여러 타임스탬프에서 지정된 스냅샷 값을 기반으로 애니메이션을 처리합니다. 언제나 애니메이션 값은 두 키프레임 값 사이에 보간됩니다. 키프레임마다 이징을 지정하여 보간 유형 곡선을 결정할 수 있습니다.

0밀리초 및 지속 시간에 값을 지정할 수도 있습니다. 이 값을 지정하지 않으면 각각 애니메이션의 시작 값과 종료 값으로 기본 설정됩니다.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = keyframes {
        durationMillis = 375
        0.0f at 0 with LinearOutSlowInEasing // for 0-15 ms
        0.2f at 15 with FastOutLinearInEasing // for 15-75 ms
        0.4f at 75 // ms
        0.4f at 225 // ms
    }
)

repeatable

repeatable은 지정된 반복 횟수에 도달할 때까지 기간 기반 애니메이션(예: tween 또는 keyframes)을 반복적으로 실행합니다. repeatMode 매개변수를 전달하여 처음(RepeatMode.Restart)부터 또는 끝(RepeatMode.Reverse)부터 시작하여 애니메이션을 반복할지 여부를 지정할 수 있습니다.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = repeatable(
        iterations = 3,
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

infiniteRepeatable

infiniteRepeatablerepeatable과 유사하지만 무한 반복됩니다.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(durationMillis = 300),
        repeatMode = RepeatMode.Reverse
    )
)

ComposeTestRule을 사용하는 테스트에서 infiniteRepeatable을 사용하는 애니메이션은 실행되지 않습니다. 구성요소는 각 애니메이션된 값의 초깃값을 사용하여 렌더링됩니다.

snap

snap은 값을 즉시 종료 값으로 변환하는 특수 AnimationSpec입니다. delayMillis를 지정하여 애니메이션 시작을 지연시킬 수 있습니다.

val value by animateFloatAsState(
    targetValue = 1f,
    animationSpec = snap(delayMillis = 50)
)

이징

기간 기반 AnimationSpec 작업(예: tween 또는 keyframes)은 Easing을 사용하여 애니메이션의 분수 값을 조정합니다. 이렇게 하면 애니메이션 값이 일정한 속도로 움직이는 대신 더 빨라지고 느려질 수 있습니다. 비율은 애니메이션의 현재 지점을 나타내는 0(시작)과 1.0(끝) 사이의 값입니다.

이징은 실제로는 0과 1.0 사이의 분수 값을 가져와 부동 소수점 수를 반환하는 함수입니다. 반환된 값은 목표를 초과하거나 목표에 미달한 값을 나타내기 위해 경계를 벗어날 수 있습니다. 아래의 코드와 같이 맞춤 이징을 만들 수 있습니다.

val CustomEasing = Easing { fraction -> fraction * fraction }

@Composable
fun EasingUsage() {
    val value by animateFloatAsState(
        targetValue = 1f,
        animationSpec = tween(
            durationMillis = 300,
            easing = CustomEasing
        )
    )
    // … …
}

Compose는 대부분의 사용 사례가 포함되는 여러 내장 Easing 함수를 제공합니다. 시나리오에 따라 이징에서 사용해야 하는 함수에 관한 자세한 내용은 속도 - 머티리얼 디자인을 참고하세요.

  • FastOutSlowInEasing
  • LinearOutSlowInEasing
  • FastOutLinearEasing
  • LinearEasing
  • CubicBezierEasing

AnimationVector

대부분의 Compose 애니메이션 API에서는 Float, Color, Dp 및 기타 기본 데이터 유형이 즉시 애니메이션 값으로 지원되지만 맞춤 데이터 유형을 포함하여 다른 데이터 유형을 애니메이션 처리해야 하는 경우도 있습니다. 애니메이션 중에 모든 애니메이션 값은 AnimationVector로 표시됩니다. 값은 핵심 애니메이션 시스템이 균일하게 처리할 수 있도록 상응하는 TwoWayConverter에 의해 AnimationVector로 변환되며 그 반대로도 변환됩니다. 예를 들어 Int는 단일 부동 소수점 값을 보유한 AnimationVector1D로 표시됩니다. IntTwoWayConverter는 다음과 같습니다.

val IntToVector: TwoWayConverter<Int, AnimationVector1D> =
    TwoWayConverter({ AnimationVector1D(it.toFloat()) }, { it.value.toInt() })

Color는 기본적으로 빨간색, 녹색, 파란색, 알파 등 네 값의 집합이므로 Color는 네 개의 부동 소수점 값을 보유한 AnimationVector4D로 변환됩니다. 애니메이션에 사용되는 모든 데이터 유형은 이러한 방식으로 차원에 따라 AnimationVector1D, AnimationVector2D, AnimationVector3D 또는 AnimationVector4D로 변환됩니다. 따라서 객체의 여러 구성요소를 각각 자체 속도 추적 기능을 사용하여 독립적으로 애니메이션 처리할 수 있습니다. Color.VectorConverter, Dp.VectorConverter 등으로 기본 데이터 유형의 내장 변환기에 액세스할 수 있습니다.

새 데이터 유형 지원 기능을 애니메이션 값으로 추가하려면 자체 TwoWayConverter를 만들어 API에 제공하면 됩니다. 예를 들어 다음과 같이 animateValueAsState를 사용하여 맞춤 데이터 유형을 애니메이션 처리할 수 있습니다.

data class MySize(val width: Dp, val height: Dp)

@Composable
fun MyAnimation(targetSize: MySize) {
    val animSize: MySize by animateValueAsState<MySize, AnimationVector2D>(
        targetSize,
        TwoWayConverter(
            convertToVector = { size: MySize ->
                // Extract a float value from each of the `Dp` fields.
                AnimationVector2D(size.width.value, size.height.value)
            },
            convertFromVector = { vector: AnimationVector2D ->
                MySize(vector.v1.dp, vector.v2.dp)
            }
        )
    )
}

동작 및 애니메이션(고급)

터치 이벤트와 애니메이션을 사용할 때는 애니메이션만 사용할 때에 비해 여러 가지 사항을 고려해야 합니다. 무엇보다도 사용자 상호작용의 우선순위가 가장 높아야 하므로 터치 이벤트가 시작될 때 진행 중인 애니메이션을 중단해야 할 수도 있습니다.

아래 예에서는 Animatable을 사용하여 원 구성요소의 오프셋 위치를 나타냅니다. 터치 이벤트는 pointerInput 수정자로 처리됩니다. 새 탭 이벤트가 감지되면 animateTo를 호출하여 오프셋 값을 탭 위치에 애니메이션 처리합니다. 애니메이션 도중에도 탭 이벤트가 발생할 수 있으며 이 경우 animateTo가 진행 중인 애니메이션을 중단하고 중단된 애니메이션의 속도를 유지하면서 새 타겟 위치로 애니메이션을 시작합니다.

@Composable
fun Gesture() {
    val offset = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) }
    Box(
        modifier = Modifier
            .fillMaxSize()
            .pointerInput(Unit) {
                coroutineScope {
                    while (true) {
                        // Detect a tap event and obtain its position.
                        val position = awaitPointerEventScope {
                            awaitFirstDown().position
                        }
                        launch {
                            // Animate to the tap position.
                            offset.animateTo(position)
                        }
                    }
                }
            }
    ) {
        Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
    }
}

private fun Offset.toIntOffset() = IntOffset(x.roundToInt(), y.roundToInt())

또 하나의 빈번한 패턴은 애니메이션 값을 드래그와 같은 터치 이벤트에서 발생하는 값과 동기화해야 한다는 것입니다. 아래 예에서는 '스와이프하여 닫기'가 SwipeToDismiss 컴포저블을 사용하는 대신 Modifier로 구현됩니다. 요소의 가로 오프셋은 Animatable로 표시됩니다. 이 API에는 동작 애니메이션에 유용한 특성이 있습니다. 특성의 값은 애니메이션은 물론 터치 이벤트에서도 변경할 수 있습니다. 터치 다운 이벤트가 수신되면 Animatablestop 메서드를 사용하여 정지되고 진행 중인 애니메이션이 중단됩니다.

드래그 이벤트 중에 snapTo를 사용하여 Animatable 값을 터치 이벤트에서 계산된 값으로 업데이트합니다. 플링의 경우 Compose는 드래그 이벤트를 기록하고 속도를 계산할 수 있도록 VelocityTracker를 제공합니다. 플링 애니메이션의 경우 animateDecay에 직접 속도를 제공할 수 있습니다. 오프셋 값을 원래 위치로 되돌리려는 경우 animateTo 메서드를 사용하여 0f의 타겟 오프셋 값을 지정합니다.

fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate fling decay.
        val decay = splineBasedDecay<Float>(this)
        // Use suspend functions for touch events and the Animatable.
        coroutineScope {
            while (true) {
                // Detect a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                val velocityTracker = VelocityTracker()
                // Stop any ongoing animation.
                offsetX.stop()
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Update the animation value with touch events.
                        launch {
                            offsetX.snapTo(
                                offsetX.value + change.positionChange().x
                            )
                        }
                        velocityTracker.addPosition(
                            change.uptimeMillis,
                            change.position
                        )
                    }
                }
                // No longer receiving touch events. Prepare the animation.
                val velocity = velocityTracker.calculateVelocity().x
                val targetOffsetX = decay.calculateTargetValue(
                    offsetX.value,
                    velocity
                )
                // The animation stops when it reaches the bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back.
                        offsetX.animateTo(
                            targetValue = 0f,
                            initialVelocity = velocity
                        )
                    } else {
                        // The element was swiped away.
                        offsetX.animateDecay(velocity, decay)
                        onDismissed()
                    }
                }
            }
        }
    }
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

테스트

Compose는 테스트 클록을 완전히 제어할 수 있는 확정적인 방식으로 애니메이션 테스트를 작성할 수 있는 ComposeTestRule을 제공합니다. 이를 통해 중간 애니메이션 값을 확인할 수 있습니다. 또한 애니메이션의 실제 기간보다 시간보다 더 빠르게 테스트를 실행할 수 있습니다.

ComposeTestRule은 테스트 클록을 mainClock으로 노출합니다. 테스트 코드에서 autoAdvance 속성을 false로 설정하여 클록을 제어할 수 있습니다. 테스트할 애니메이션을 시작한 후 advanceTimeBy를 사용하여 클록을 앞으로 이동할 수 있습니다.

참고: advanceTimeBy는 클록을 이동할 때 지정된 기간을 정확히 따르지 않습니다. 대신 프레임 시간의 배수인 가장 가까운 기간으로 반올림합니다.

@get:Rule
val rule = createComposeRule()

@Test
fun testAnimationWithClock() {
    // Pause animations
    rule.mainClock.autoAdvance = false
    var enabled by mutableStateOf(false)
    rule.setContent {
        val color by animateColorAsState(
            targetValue = if (enabled) Color.Red else Color.Green,
            animationSpec = tween(durationMillis = 250)
        )
        Box(Modifier.size(64.dp).background(color))
    }

    // Initiate the animation.
    enabled = true

    // Let the animation proceed.
    rule.mainClock.advanceTimeBy(50L)

    // Compare the result with the image showing the expected result.
    rule.onRoot().captureToImage().assertAgainstGolden()
}

자세히 알아보기

자세한 내용은 Jetpack Compose 애니메이션 Codelab을 참고하세요.