Jetpack Compose의 요소 애니메이션

1. 소개

Jetpack Compose 로고

최종 업데이트: 2023년 11월 21일

이 Codelab에서는 Jetpack Compose에서 Animation API를 사용하는 방법을 알아봅니다.

Jetpack Compose는 UI 개발을 간소화하기 위해 설계된 최신 UI 툴킷입니다. Jetpack Compose를 처음 사용하는 경우 다음 Codelab을 진행한 후 이 Codelab을 진행하세요.

학습할 내용

  • 여러 기본 Animation API 사용 방법

기본 요건

필요한 항목

2. 설정

Codelab 코드를 다운로드합니다. 다음과 같이 저장소를 클론할 수 있습니다.

$ git clone https://github.com/android/codelab-android-compose.git

또는 저장소를 ZIP 파일로 다운로드할 수 있습니다.

Android 스튜디오에서 AnimationCodelab 프로젝트를 가져옵니다.

Android 스튜디오에 Animation Codelab 가져오기

프로젝트에는 여러 모듈이 있습니다.

  • start는 이 Codelab의 시작 상태입니다.
  • finished는 이 Codelab을 완료한 후 앱의 최종 상태입니다.

실행 구성의 드롭다운에서 start가 선택되었는지 확인합니다.

Android 스튜디오에서 start가 선택된 것을 보여줌

다음 챕터에서 몇 가지 애니메이션 시나리오를 작업해 보겠습니다. 이 Codelab에서 작업하는 모든 코드 스니펫은 // TODO 주석으로 표시됩니다. 한 가지 유용한 방법은 Android 스튜디오에서 TODO 도구 창을 열고 챕터의 각 TODO 주석으로 이동하는 것입니다.

Android 스튜디오에 표시된 TODO 목록

3. 간단한 값 변경 애니메이션

Compose에서 가장 간단한 Animation API 중 하나인 animate*AsState API부터 시작해 보겠습니다. 이 API는 State 변경에 애니메이션을 적용할 때 사용해야 합니다.

start 구성을 실행하고 상단의 'Home' 및 'Work' 버튼을 클릭하여 탭을 전환해 봅니다. 탭 콘텐츠가 실제로 바뀌지는 않지만 콘텐츠의 배경 색상이 변경되는 것을 확인할 수 있습니다.

Home 탭이 선택됨

Work 탭이 선택됨

TODO 도구 창에서 TODO 1을 클릭하여 구현 방법을 확인합니다. Home 컴포저블에 있습니다.

val backgroundColor = if (tabPage == TabPage.Home) Seashell else GreenLight

여기서 tabPageState 객체로 지원되는 TabPage입니다. 값에 따라 배경 색상은 복숭아색과 녹색 간에 전환됩니다. 이 값 변경을 애니메이션으로 처리해 보겠습니다.

이러한 간단한 값 변경을 애니메이션으로 처리하려면 animate*AsState API를 사용하면 됩니다. 변경되는 값을 상응하는 animate*AsState 컴포저블의 변형(여기서는 animateColorAsState)으로 래핑하여 애니메이션 값을 만들 수 있습니다. 반환된 값은 State<T> 객체이므로 by 선언과 함께 로컬 위임 속성을 사용하여 일반 변수처럼 처리할 수 있습니다.

val backgroundColor by animateColorAsState(
        targetValue = if (tabPage == TabPage.Home) Seashell else GreenLight,
        label = "background color")

앱을 다시 실행하고 탭을 전환해 보세요. 이제 색상 변경이 애니메이션으로 표시됩니다.

탭 간에 실행되는 색상 변경 애니메이션

4. 가시성 애니메이션

앱의 콘텐츠를 스크롤하면 스크롤 방향에 따라 플로팅 작업 버튼이 확장되거나 축소되는 것을 알 수 있습니다.

확장된 Edit 플로팅 작업 버튼

축소된 Edit 플로팅 작업 버튼

TODO 2-1을 찾아 작동 방식을 확인하세요. HomeFloatingActionButton 컴포저블에 있습니다. 'EDIT'이라는 텍스트는 if 문을 사용하여 표시하거나 숨깁니다.

if (extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

이 가시성 변경에 애니메이션 효과를 적용하는 것은 ifAnimatedVisibility 컴포저블로 대체하는 것만큼 간단합니다.

AnimatedVisibility(extended) {
    Text(
        text = stringResource(R.string.edit),
        modifier = Modifier
            .padding(start = 8.dp, top = 3.dp)
    )
}

앱을 실행하여 이제 FAB가 확장 및 축소되는 방식을 확인합니다.

Edit 플로팅 작업 버튼 애니메이션

AnimatedVisibility는 지정된 Boolean 값이 변경될 때마다 애니메이션을 실행합니다. 기본적으로 AnimatedVisibility는 요소를 페이드 인 및 확장하여 표시하고 페이드 아웃 및 축소를 통해 요소를 숨깁니다. 이 동작은 FAB를 사용하는 이 예에서 잘 작동하지만 동작을 맞춤설정할 수도 있습니다.

FAB를 클릭해 보면 'Edit feature is not supported'라는 메시지가 표시됩니다. 또한 AnimatedVisibility를 사용하여, 나타나고 사라질 때 애니메이션을 적용합니다. 이제 이 동작을 맞춤설정하여 메시지가 위에서 슬라이드 인되고 위로 슬라이드 아웃되도록 합니다.

수정 기능이 지원되지 않는다는 내용의 세부정보

TODO 2-2를 찾아 EditMessage 컴포저블의 코드를 확인합니다.

AnimatedVisibility(
    visible = shown
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colorScheme.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

애니메이션을 맞춤설정하려면 enterexit 매개변수를 AnimatedVisibility 컴포저블에 추가합니다.

enter 매개변수는 EnterTransition의 인스턴스여야 합니다. 이 예에서는 slideInVertically 함수를 사용하여 나가기 전환을 위한 EnterTransitionslideOutVertically를 만들 수 있습니다. 다음과 같이 코드를 변경합니다.

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(),
    exit = slideOutVertically()
)

앱을 다시 실행하고 수정 버튼을 클릭하면 애니메이션이 더 나아진 것처럼 보이긴 하지만 뭔가 잘못된 것 같습니다. 이는 slideInVerticallyslideOutVertically의 기본 동작이 항목 높이의 절반을 사용하기 때문입니다.

세로로 슬라이드 아웃하면 절반에서 잘림

들어가기 전환의 경우 initialOffsetY 매개변수 설정을 통해 항목의 전체 높이를 사용하도록 기본 동작을 조정하여 올바르게 애니메이션이 적용되도록 할 수 있습니다. initialOffsetY는 초기 위치를 반환하는 람다여야 합니다.

람다는 인수 하나(요소 높이)를 수신합니다. 항목이 화면 상단에서 슬라이드 인되도록 음수 값을 반환합니다. 화면 상단의 값이 0이기 때문입니다. 애니메이션이 -height에서 0(마지막 휴지 위치)으로 시작되어 위부터 애니메이션으로 나타나도록 하려고 합니다.

slideInVertically를 사용하는 경우 슬라이드 인 후의 타겟 오프셋은 항상 0(픽셀)입니다. initialOffsetY는 절댓값으로 지정하거나 람다 함수를 통해 요소 전체 높이의 비율로 지정할 수 있습니다.

마찬가지로 slideOutVertically는 초기 오프셋이 0이라고 가정하므로 targetOffsetY만 지정하면 됩니다.

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(
        // Enters by sliding down from offset -fullHeight to 0.
        initialOffsetY = { fullHeight -> -fullHeight }
    ),
    exit = slideOutVertically(
        // Exits by sliding up from offset 0 to -fullHeight.
        targetOffsetY = { fullHeight -> -fullHeight }
    )
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colorScheme.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

앱을 다시 실행하면 애니메이션이 예상대로 잘 동작하는 것을 알 수 있습니다.

오프셋이 작동하는 슬라이드 인 애니메이션

animationSpec 매개변수를 사용하여 애니메이션을 추가로 맞춤설정할 수 있습니다. animationSpecEnterTransition, ExitTransition 등 여러 Animation API의 공통 매개변수입니다. 다양한 AnimationSpec 유형 중 하나를 전달하여 시간이 지남에 따라 애니메이션 값을 어떻게 변경할지 지정할 수 있습니다. 이 예에서는 간단한 지속 시간 기반 AnimationSpec을 사용합니다. tween 함수를 사용하여 만들 수 있습니다. 지속 시간은 150ms이고 이징은 LinearOutSlowInEasing입니다. 나가기 애니메이션의 경우 animationSpec 매개변수에 동일한 tween 함수를 사용하지만 지속 시간은 250ms이고 이징은 FastOutLinearInEasing입니다.

결과 코드는 다음과 같습니다.

AnimatedVisibility(
    visible = shown,
    enter = slideInVertically(
        // Enters by sliding down from offset -fullHeight to 0.
        initialOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing)
    ),
    exit = slideOutVertically(
        // Exits by sliding up from offset 0 to -fullHeight.
        targetOffsetY = { fullHeight -> -fullHeight },
        animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing)
    )
) {
    Surface(
        modifier = Modifier.fillMaxWidth(),
        color = MaterialTheme.colorScheme.secondary,
        elevation = 4.dp
    ) {
        Text(
            text = stringResource(R.string.edit_message),
            modifier = Modifier.padding(16.dp)
        )
    }
}

앱을 실행하고 FAB를 다시 클릭합니다. 이제 메시지가 다양한 이징 함수와 지속 시간으로 위에서 슬라이드 인되었다가 위로 슬라이드 아웃되는 것을 확인할 수 있습니다.

위에서 슬라이드 인하는 수정 메시지를 보여주는 애니메이션

5. 콘텐츠 크기 변경 애니메이션

앱 콘텐츠에는 여러 주제가 표시됩니다. 이 중 하나를 클릭해 보면 해당 주제의 본문 텍스트가 열려 표시됩니다. 텍스트가 포함된 카드는 본문이 표시되거나 숨겨질 때 확장 및 축소됩니다.

축소된 주제 목록

확장된 주제 목록

TopicRow 컴포저블에서 TODO 3 코드를 확인합니다.

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
) {
    // ... the title and the body
}

여기서 이 Column 컴포저블은 콘텐츠가 변경되면 크기를 변경합니다. animateContentSize 수정자를 추가하여 크기 변경에 애니메이션을 적용할 수 있습니다.

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
        .animateContentSize()
) {
    // ... the title and the body
}

앱을 실행하고 주제 중 하나를 클릭합니다. 애니메이션이 적용되어 확장 및 축소되는 것을 확인할 수 있습니다.

주제 목록 확장 및 축소 애니메이션

animateContentSize도 맞춤 animationSpec으로 맞춤설정할 수 있습니다. 스프링에서 트윈 등으로 애니메이션 유형을 변경하는 옵션을 제공할 수 있습니다. 자세한 내용은 애니메이션 맞춤설정 문서를 참고하세요.

6. 여러 값 애니메이션

몇 가지 기본 Animation API를 알아봤으니 이제 더 복잡한 애니메이션을 만들 수 있는 Transition API를 살펴보겠습니다. Transition API를 사용하면 Transition의 모든 애니메이션이 완료된 시점을 추적할 수 있습니다. 이는 이전에 본 개별 animate*AsState API를 사용하는 경우에는 불가능한 일입니다. Transition API를 사용하면 여러 상태 간에 전환할 때 다양한 transitionSpec을 정의할 수도 있습니다. 이를 사용할 수 있는 방법을 살펴보겠습니다.

이 예에서는 탭 표시기를 맞춤설정합니다. 현재 선택된 탭에 표시되는 직사각형입니다.

Home 탭이 선택됨

Work 탭이 선택됨

HomeTabIndicator 컴포저블에서 TODO 4를 찾아 탭 표시기가 구현되는 방식을 확인합니다.

val indicatorLeft = tabPositions[tabPage.ordinal].left
val indicatorRight = tabPositions[tabPage.ordinal].right
val color = if (tabPage == TabPage.Home) PaleDogwood else Green

여기에서 indicatorLeft는 탭 행에 있는 표시기 왼쪽 가장자리의 가로 위치입니다. indicatorRight는 표시기 오른쪽 가장자리의 가로 위치입니다. 색상도 복숭아색과 녹색 간에 변경됩니다.

이러한 여러 값에 동시에 애니메이션을 적용하려면 Transition을 사용하면 됩니다. TransitionupdateTransition 함수를 사용하여 만들 수 있습니다. 현재 선택된 탭의 색인을 targetState 매개변수로 전달합니다.

각 애니메이션 값은 Transitionanimate* 확장 함수를 사용하여 선언할 수 있습니다. 이 예에서는 animateDpanimateColor를 사용합니다. 람다 블록을 사용하므로 각 상태의 타겟 값을 지정할 수 있습니다. 타겟 값은 이미 알고 있으므로 아래와 같이 값을 래핑할 수 있습니다. animate* 함수가 State 객체를 반환하므로 여기서 다시 by 선언을 사용하고 이를 로컬 위임 속성으로 만들 수 있습니다.

val transition = updateTransition(tabPage, label = "Tab indicator")
val indicatorLeft by transition.animateDp(label = "Indicator left") { page ->
   tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(label = "Indicator right") { page ->
   tabPositions[page.ordinal].right
}
val color by transition.animateColor(label = "Border color") { page ->
   if (page == TabPage.Home) PaleDogwood else Green
}

이제 앱을 실행하면 탭 전환이 훨씬 더 흥미롭게 진행되는 것을 확인할 수 있습니다. 탭을 클릭하면 tabPage 상태 값이 변경되므로 transition과 연결된 모든 애니메이션 값이 타겟 상태에 지정된 값으로 애니메이션 처리됩니다.

Home 탭과 Work 탭 간의 애니메이션

또한 transitionSpec 매개변수를 지정하여 애니메이션 동작을 맞춤설정할 수 있습니다. 예를 들어 대상에 더 가까운 가장자리가 다른 가장자리보다 빠르게 움직이게 하여 표시기의 탄력 효과를 얻을 수 있습니다. transitionSpec 람다에서 isTransitioningTo 중위 함수를 사용하여 상태 변경 방향을 결정할 수 있습니다.

val transition = updateTransition(
    tabPage,
    label = "Tab indicator"
)
val indicatorLeft by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right.
            // The left edge moves slower than the right edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        } else {
            // Indicator moves to the left.
            // The left edge moves faster than the right edge.
            spring(stiffness = Spring.StiffnessMedium)
        }
    },
    label = "Indicator left"
) { page ->
    tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right
            // The right edge moves faster than the left edge.
            spring(stiffness = Spring.StiffnessMedium)
        } else {
            // Indicator moves to the left.
            // The right edge moves slower than the left edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        }
    },
    label = "Indicator right"
) { page ->
    tabPositions[page.ordinal].right
}
val color by transition.animateColor(
    label = "Border color"
) { page ->
    if (page == TabPage.Home) PaleDogwood else Green
}

앱을 다시 실행하여 탭을 전환해 보세요.

탭 전환 시 맞춤 탄력 효과

Android 스튜디오는 Compose 미리보기에서 전환 검사를 지원합니다. Animation Preview를 사용하려면 미리보기에서 컴포저블의 오른쪽 상단에 있는 'Start Animation Preview' 아이콘(애니메이션 미리보기 아이콘 아이콘)을 클릭하여 대화형 모드를 시작하세요. PreviewHomeTabBar 컴포저블의 아이콘을 클릭해 보세요. 그러면 새로운 'Animations' 창이 열립니다.

'Play' 아이콘 버튼을 클릭하여 애니메이션을 실행할 수 있습니다. 탐색바를 드래그하여 각 애니메이션 프레임을 확인할 수도 있습니다. 애니메이션 값을 더 잘 설명하려면 updateTransitionanimate* 메서드에서 label 매개변수를 지정하면 됩니다.

Android 스튜디오에서 애니메이션 탐색

7. 애니메이션 반복

현재 온도 옆에 있는 새로고침 아이콘 버튼을 클릭해 보세요. 앱에서 최신 날씨 정보를 로드하기 시작합니다(가장함). 로드가 완료될 때까지 로드 표시기(회색 원과 막대)가 표시됩니다. 이 표시기의 알파 값에 애니메이션을 적용하여 프로세스가 진행 중임을 더 명확하게 나타내 보겠습니다.

아직 애니메이션이 적용되지 않은 자리표시자 정보 카드의 정적 이미지

LoadingRow 컴포저블에서 TODO 5를 찾습니다.

val alpha = 1f

이 값이 0f와 1f 사이에서 반복적으로 애니메이션 처리되도록 해 보겠습니다. 이를 위해 InfiniteTransition을 사용할 수 있습니다. 이 API는 이전 섹션의 Transition API와 유사합니다. 둘 다 여러 값에 애니메이션을 적용하지만 Transition은 상태 변경에 따라 값에 애니메이션을 적용하고 InfiniteTransition은 값에 무기한으로 애니메이션을 적용합니다.

InfiniteTransition을 만들려면 rememberInfiniteTransition 함수를 사용합니다. 그런 다음 각 애니메이션 값 변경을 InfiniteTransitionanimate* 확장 함수 중 하나를 사용하여 선언할 수 있습니다. 여기서는 알파 값에 애니메이션을 적용하므로 animatedFloat를 사용하겠습니다. initialValue 매개변수는 0f여야 하고 targetValue 1f여야 합니다. 이 애니메이션의 AnimationSpec을 지정할 수도 있지만 이 API는 InfiniteRepeatableSpec만 사용합니다. infiniteRepeatable 함수를 사용하여 만듭니다. 이 AnimationSpec은 지속 시간 기반 AnimationSpec을 래핑하여 반복 가능하게 합니다. 예를 들어 결과 코드는 다음과 같습니다.

val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = keyframes {
            durationMillis = 1000
            0.7f at 500
        },
        repeatMode = RepeatMode.Reverse
    ),
    label = "alpha"
)

기본 repeatModeRepeatMode.Restart입니다. 이 경우, 애니메이션이 initialValue에서 targetValue로 전환되고 initialValue에서 다시 시작됩니다. repeatModeRepeatMode.Reverse로 설정하면 애니메이션이 initialValue에서 targetValue로 진행된 후 targetValue에서 initialValue로 진행됩니다. 애니메이션은 0에서 1까지 진행된 후 1에서 0으로 진행됩니다.

keyFrames 애니메이션은 다른 밀리초 단위에서 진행 중인 값을 변경할 수 있는 또 다른 유형의 animationSpec(일부는 tweenspring)입니다. 처음에는 durationMillis를 1,000ms로 설정합니다. 그런 다음 애니메이션에서 키프레임을 정의할 수 있습니다. 예를 들어 애니메이션의 500ms에서 알파 값을 0.7f가 되도록 설정하려고 합니다. 그러면 애니메이션의 진행률이 변경됩니다. 애니메이션의 500ms 내에 0에서 0.7까지 빠르게 진행되고 애니메이션의 500ms에서 1000ms까지는 0.7에서 1.0까지 천천히 속도를 줄이며 끝을 향해 진행됩니다.

두 개 이상의 키프레임을 원하는 경우 다음과 같이 keyFrames를 여러 개 정의할 수 있습니다.

animation = keyframes {
   durationMillis = 1000
   0.7f at 500
   0.9f at 800
}

앱을 실행하고 새로고침 버튼을 클릭해 보세요. 이제 로드 표시기에 애니메이션이 적용된 것을 확인할 수 있습니다.

애니메이션 자리표시자 콘텐츠 반복

8. 동작 애니메이션

이 마지막 섹션에서는 터치 입력을 기반으로 애니메이션을 실행하는 방법을 알아봅니다. swipeToDismiss 수정자를 처음부터 빌드합니다.

swipeToDismiss 수정자에서 TODO 6-1을 찾습니다. 여기서는 터치로 요소를 스와이프할 수 있도록 하는 수정자를 만들려고 합니다. 요소가 화면 가장자리로 플링되면 요소가 삭제될 수 있도록 onDismissed 콜백을 호출합니다.

swipeToDismiss 수정자를 빌드하려면 몇 가지 주요 개념을 알아야 합니다. 먼저 사용자가 화면에 손가락을 대면 x 및 y 좌표가 있는 터치 이벤트가 생성되고 손가락을 오른쪽이나 왼쪽으로 이동하면 이동에 따라 x 및 y 좌표도 이동합니다. 사용자가 터치하는 항목은 손가락에 따라 이동해야 하므로 터치 이벤트의 위치와 속도에 따라 항목의 위치가 업데이트됩니다.

Compose 동작 문서에 설명된 여러 개념을 활용할 수 있습니다. pointerInput 수정자를 사용하면 수신되는 포인터 터치 이벤트에 대한 하위 수준 액세스 권한을 얻고 동일한 포인터를 사용하여 사용자가 드래그하는 속도를 추적할 수 있습니다. 닫기 위한 경계를 항목이 지나기 전에 사용자가 손을 떼면 항목은 위치로 다시 돌아옵니다.

이 시나리오에서는 고려해야 할 몇 가지 고유한 사항이 있습니다. 첫째, 진행 중인 애니메이션이 터치 이벤트로 중단될 수 있습니다. 둘째, 애니메이션 값이 유일한 정보 소스가 아닐 수도 있습니다. 즉, 애니메이션 값을 터치 이벤트에서 발생하는 값과 동기화해야 할 수도 있습니다.

Animatable은 지금까지 살펴본 API 중 최저 수준의 API입니다. 동작 시나리오에서 유용한 여러 기능이 있습니다. 예를 들어 동작에서 비롯되는 새로운 값으로 즉시 스냅하고 새 터치 이벤트가 트리거될 때 진행 중인 애니메이션을 중지하는 기능입니다. Animatable의 인스턴스를 만들고 이를 사용하여 스와이프할 수 있는 요소의 가로 오프셋을 나타내 보겠습니다. androidx.compose.animation.Animatable이 아닌 androidx.compose.animation.core.Animatable에서 Animatable을 가져와야 합니다.

val offsetX = remember { Animatable(0f) } // Add this line
// used to receive user touch events
pointerInput {
    // Used to calculate a settling position of a fling animation.
    val decay = splineBasedDecay<Float>(this)
    // Wrap in a coroutine scope to use suspend functions for touch events and animation.
    coroutineScope {
        while (true) {
            // ...

TODO 6-2는 방금 터치 다운 이벤트를 수신한 곳입니다. 현재 실행 중인 경우 애니메이션을 중단해야 합니다. Animatable에서 stop을 호출하면 됩니다. 애니메이션이 실행되지 않는 경우 호출은 무시됩니다. VelocityTracker는 사용자가 왼쪽에서 오른쪽으로 이동하는 속도를 계산하는 데 사용됩니다. awaitPointerEventScope는 사용자 입력 이벤트를 기다렸다가 이에 응답할 수 있는 정지 함수입니다.

// Wait for a touch down event. Track the pointerId based on the touch
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop() // Add this line to cancel any on-going animations
// Prepare for drag events and record velocity of a fling gesture
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {

TODO 6-3에서는 드래그 이벤트를 계속 수신하고 있습니다. 터치 이벤트의 위치를 애니메이션 값에 동기화해야 합니다. Animatable에서 snapTo를 사용하면 됩니다. snapTo는 다른 launch 블록 내에서 호출해야 합니다. awaitPointerEventScopehorizontalDrag가 제한된 코루틴 범위이기 때문입니다. 즉, awaitPointerEvents의 경우에만 suspend될 수 있습니다. snapTo는 포인터 이벤트가 아닙니다.

horizontalDrag(pointerId) { change ->
    // Add these 4 lines
    // Get the drag amount change to offset the item with
    val horizontalDragOffset = offsetX.value + change.positionChange().x
    // Need to call this in a launch block in order to run it separately outside of the awaitPointerEventScope
    launch {
        // Instantly set the Animable to the dragOffset to ensure its moving
        // as the user's finger moves
        offsetX.snapTo(horizontalDragOffset)
    }
    // Record the velocity of the drag.
    velocityTracker.addPosition(change.uptimeMillis, change.position)

    // Consume the gesture event, not passed to external
    if (change.positionChange() != Offset.Zero) change.consume()

}

TODO 6-4에서 요소가 방금 해제되고 플링되었습니다. 요소를 원래 위치로 다시 슬라이드해야 할지, 아니면 슬라이드하여 없애고 콜백을 호출해야 할지 결정하려면 플링이 정착한 최종 위치를 계산해야 합니다. 앞에서 만든 decay 객체를 사용하여 targetOffsetX를 계산합니다.

// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Add this line to calculate where it would end up with
// the current velocity and position
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)

TODO 6-5에서 애니메이션을 시작하려고 합니다. 하지만 그 전에 상한값과 하한값을 Animatable에 설정하여 경계(-size.widthsize.width임. offsetX가 이 두 값을 지나 확장할 수 없도록)에 도달하는 즉시 중지되도록 합니다. pointerInput 수정자를 사용하면 size 속성으로 요소의 크기에 액세스할 수 있으므로 이를 사용하여 경계를 가져와 보겠습니다.

offsetX.updateBounds(
    lowerBound = -size.width.toFloat(),
    upperBound = size.width.toFloat()
)

TODO 6-6에서 마침내 애니메이션을 시작할 수 있습니다. 먼저 앞서 계산한 플링의 정착 위치와 요소의 크기를 비교합니다. 정착 위치가 크기보다 작다면 플링의 속도가 충분하지 않은 것입니다. animateTo를 사용하여 값을 다시 0f로 애니메이션 처리할 수 있습니다. 크기보다 작지 않은 경우 animateDecay를 사용하여 플링 애니메이션을 시작합니다. 애니메이션이 완료되면(대부분 앞에서 설정한 경계에 따라) 콜백을 호출할 수 있습니다.

launch {
    if (targetOffsetX.absoluteValue <= size.width) {
        // Not enough velocity; Slide back.
        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
    } else {
        // Enough velocity to slide away the element to the edge.
        offsetX.animateDecay(velocity, decay)
        // The element was swiped away.
        onDismissed()
    }
}

마지막으로 TODO 6-7을 참고하세요. 모든 애니메이션과 동작이 설정되어 있으므로 요소에 오프셋을 적용해야 합니다. 그러면 화면의 요소가 동작 또는 애니메이션으로 생성된 값으로 이동합니다.

.offset { IntOffset(offsetX.value.roundToInt(), 0) }

이 섹션의 결과로 다음과 같은 코드가 만들어집니다.

private fun Modifier.swipeToDismiss(
    onDismissed: () -> Unit
): Modifier = composed {
    // This Animatable stores the horizontal offset for the element.
    val offsetX = remember { Animatable(0f) }
    pointerInput(Unit) {
        // Used to calculate a settling position of a fling animation.
        val decay = splineBasedDecay<Float>(this)
        // Wrap in a coroutine scope to use suspend functions for touch events and animation.
        coroutineScope {
            while (true) {
                // Wait for a touch down event.
                val pointerId = awaitPointerEventScope { awaitFirstDown().id }
                // Interrupt any ongoing animation.
                offsetX.stop()
                // Prepare for drag events and record velocity of a fling.
                val velocityTracker = VelocityTracker()
                // Wait for drag events.
                awaitPointerEventScope {
                    horizontalDrag(pointerId) { change ->
                        // Record the position after offset
                        val horizontalDragOffset = offsetX.value + change.positionChange().x
                        launch {
                            // Overwrite the Animatable value while the element is dragged.
                            offsetX.snapTo(horizontalDragOffset)
                        }
                        // Record the velocity of the drag.
                        velocityTracker.addPosition(change.uptimeMillis, change.position)
                        // Consume the gesture event, not passed to external
                        change.consumePositionChange()
                    }
                }
                // Dragging finished. Calculate the velocity of the fling.
                val velocity = velocityTracker.calculateVelocity().x
                // Calculate where the element eventually settles after the fling animation.
                val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
                // The animation should end as soon as it reaches these bounds.
                offsetX.updateBounds(
                    lowerBound = -size.width.toFloat(),
                    upperBound = size.width.toFloat()
                )
                launch {
                    if (targetOffsetX.absoluteValue <= size.width) {
                        // Not enough velocity; Slide back to the default position.
                        offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
                    } else {
                        // Enough velocity to slide away the element to the edge.
                        offsetX.animateDecay(velocity, decay)
                        // The element was swiped away.
                        onDismissed()
                    }
                }
            }
        }
    }
        // Apply the horizontal offset to the element.
        .offset { IntOffset(offsetX.value.roundToInt(), 0) }
}

앱을 실행하고 작업 항목 중 하나를 스와이프해 보세요. 플링의 속도에 따라 요소가 기본 위치로 다시 슬라이드되거나 밖으로 슬라이드되어 삭제되는 것을 확인할 수 있습니다. 애니메이션이 실행되는 동안 요소를 포착할 수도 있습니다.

스와이프하여 항목 닫기 동작 애니메이션

9. 마무리

축하합니다. 기본적인 Compose Animation API를 배웠습니다.

이 Codelab에서는 다음을 사용하는 방법을 알아봤습니다.

상위 수준 애니메이션 API:

  • animatedContentSize
  • AnimatedVisibility

하위 수준 애니메이션 API:

  • animate*AsState: 단일 값에 애니메이션 적용
  • updateTransition: 여러 값에 애니메이션 적용
  • infiniteTransition: 무기한으로 값에 애니메이션 적용
  • Animatable: 터치 동작으로 맞춤 애니메이션 빌드

다음 단계

Compose 개발자 과정의 다른 Codelab을 확인하세요.

자세한 내용은 Compose 애니메이션과 다음 참조 문서를 확인하세요.