애니메이션

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요.

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

개요

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

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

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

  • 레이아웃에서 콘텐츠 변경사항을 애니메이션 처리하는 경우:
    • 나타남과 사라짐을 애니메이션으로 처리하는 경우:
    • 상태에 따라 콘텐츠 교체:
      • 콘텐츠를 크로스페이드하는 경우:
      • 그렇지 않은 경우 AnimatedContent를 사용합니다.
    • 그렇지 않은 경우 Modifier.animateContentSize를 사용합니다.
  • 애니메이션이 상태 기반인 경우:
    • 컴포지션 시 애니메이션이 발생하는 경우:
  • 애니메이션 시간을 세밀하게 관리하려는 경우:
    • TargetBasedAnimation 또는 DecayAnimation과 같은 Animation을 사용합니다.
  • 애니메이션이 유일한 정보 소스인 경우
  • 그 외의 경우 AnimationStateanimate를 사용합니다.

상위 수준 애니메이션 API

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

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.
        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 객체를 결합할 수 있으며 각 객체에 선택적 매개변수를 사용하여 동작을 맞춤설정할 수 있습니다. 자세한 내용은 참고 자료를 확인하세요.

EnterTransitionExitTransition 예시

EnterTransition ExitTransition
fadeIn
페이드 인 애니메이션
fadeOut
페이드 아웃 애니메이션
slideIn
슬라이드 인 애니메이션
slideOut
슬라이드 아웃 애니메이션
slideInHorizontally
가로 슬라이드 인 애니메이션
slideOutHorizontally
가로 슬라이드 아웃 애니메이션
slideInVertically
세로 슬라이드 인 애니메이션
slideOutVertically
세로 슬라이드 아웃 애니메이션
scaleIn
스케일 인 애니메이션
scaleOut
스케일 아웃 애니메이션
expandIn
확장 애니메이션
shrinkOut
축소 애니메이션
expandHorizontally
가로 확장 애니메이션
shrinkHorizontally
가로 축소 애니메이션
expandVertically
세로 확장 애니메이션
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,
    enter = fadeIn(),
    exit = fadeOut()
) {
    // Fade in/out the background and the foreground.
    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을 참고하세요.

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, Offset, Rect, Int, IntOffset, IntSize에 사용할 수 있는 animate*AsState 함수를 즉시 제공합니다. 일반 유형을 취하는 animateValueAsStateTwoWayConverter를 제공하여 다른 데이터 유형의 지원 기능을 쉽게 추가할 수 있습니다.

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

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을 만들 수 있습니다. SizeTransformusing 중위 함수로 연결하여 ContentTransform에 적용할 수 있습니다.

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

updateTransition

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

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

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 스튜디오는 애니메이션 미리보기에서 전환 검사를 지원합니다.

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

애니메이션 미리보기를 시작하면 미리보기에 포함된 모든 전환을 실행할 수 있는 '애니메이션' 창이 표시됩니다. 전환 및 전환의 각 애니메이션 값에 기본 이름으로 라벨이 지정됩니다. updateTransitionanimate* 함수에서 label 매개변수를 지정하여 라벨을 맞춤설정할 수 있습니다.

애니메이션 미리보기 패널

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

하위 수준 애니메이션 API

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

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

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

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

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을 참고하세요.

애니메이션

Animation은 사용 가능한 가장 낮은 수준의 Animation API입니다. 지금까지 본 많은 애니메이션은 Animation을 기반으로 빌드되었습니다. Animation 하위유형에는 두 가지가 있습니다. TargetBasedAnimationDecayAnimation입니다.

Animation은 애니메이션 시간을 수동으로 제어하는 데만 사용해야 합니다. Animation은 스테이트리스(Stateless)이고 수명 주기 개념이 없습니다. 상위 수준 API에서 사용하는 애니메이션 계산 엔진 역할을 합니다.

TargetBasedAnimation

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

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

DecayAnimation

TargetBasedAnimation과 달리 DecayAnimation에는 targetValue를 제공할 필요가 없습니다. 대신 initialVelocityinitialValue, 제공된 DecayAnimationSpec에 의해 설정된 시작 조건에 기반하여 targetValue를 계산합니다.

감쇠 애니메이션은 플링 동작 후에 흔히 사용되어 요소가 천천히 중지되도록 합니다. 애니메이션 속도는 initialVelocityVector에서 설정한 값으로 시작하며 시간이 지남에 따라 느려집니다.

애니메이션 맞춤설정

많은 애니메이션 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 함수를 제공합니다. 시나리오에 따라 이징에서 사용해야 하는 함수에 관한 자세한 내용은 속도 - Material Design을 참고하세요.

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

애니메이션 벡터 리소스(실험용)

AnimatedVectorDrawable 리소스를 사용하려면 animatedVectorResource를 사용하여 드로어블 파일을 로드하고 boolean을 전달하여 드로어블의 시작 상태와 종료 상태를 전환합니다.

@Composable
fun AnimatedVectorDrawable() {
    val image = AnimatedImageVector.animatedVectorResource(R.drawable.ic_hourglass_animated)
    var atEnd by remember { mutableStateOf(false) }
    Image(
        painter = rememberAnimatedVectorPainter(image, atEnd),
        contentDescription = "Timer",
        modifier = Modifier.clickable {
            atEnd = !atEnd
        },
        contentScale = ContentScale.Crop
    )
}

드로어블 파일의 형식에 관한 자세한 내용은 드로어블 그래픽 애니메이션화를 참고하세요.

목록 항목 애니메이션

지연 목록 또는 그리드 내에서 항목 재정렬에 애니메이션을 적용하려면 지연 레이아웃 항목 애니메이션 문서를 참고하세요.

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

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

아래 예에서는 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.
    // `assertAgainGolden` needs to be implemented in your code.
    rule.onRoot().captureToImage().assertAgainstGolden()
}

자세히 알아보기

Jetpack Compose의 애니메이션에 관한 자세한 내용은 다음 추가 리소스를 참고하세요.

Codelab

동영상