1. 소개
최종 업데이트: 2021년 1월 25일
이 Codelab에서는 Jetpack Compose에서 애니메이션 API를 사용하는 방법을 알아봅니다.
Jetpack Compose는 UI 개발을 간소화하도록 설계된 최신 UI 도구 키트입니다. Jetpack Compose를 처음 사용하는 경우 이 Codelab 전에 시도해 볼 수 있는 여러 Codelab이 있습니다.
학습할 내용
- 여러 기본 애니메이션 API 사용 방법
- API 사용 시기
기본 요건
- 기본 Kotlin 지식
- 다음과 같은 기본적인 Compose 지식:
- 간단한 레이아웃 (열, 행, 상자 등)
- 간단한 UI 요소 (버튼, 텍스트 등)
- 상태 및 재구성
필요한 항목
2. 설정
Codelab 코드를 다운로드합니다. 다음과 같이 저장소를 클론할 수 있습니다.
$ git clone git@github.com:googlecodelabs/android-compose-codelabs.git
또는 ZIP 파일을 다운로드할 수 있습니다.
Android 스튜디오에서 AnimationCodelab
프로젝트를 가져옵니다.
프로젝트에는 여러 모듈이 있습니다.
start
는 Codelab의 시작 상태입니다.finished
는 이 Codelab을 완료한 후의 최종 앱 상태입니다.
start
선택되어 있는지 확인합니다.
다음 장에서 몇 가지 애니메이션 시나리오에 대한 작업을 시작합니다. 이 Codelab에서 작업하는 모든 코드 스니펫은 // TODO
주석으로 표시됩니다. 한 가지 깔끔한 방법은 Android 스튜디오에서 TODO 도구 창을 열고 해당 장에 대한 각 TODO 주석으로 이동하는 것입니다.
3. 간단한 값 변경 애니메이션 처리
Compose에서 가장 간단한 애니메이션 API로 시작하겠습니다.
start
구성을 실행하고 상단의 '홈' 버튼을 클릭하여 탭을 전환해 보세요. 실제로 탭 콘텐츠가 전환되지는 않지만 콘텐츠의 배경 색상이 변경되는 것을 볼 수 있습니다.
TODO 도구 창에서 TODO 1을 클릭하여 구현 방법을 확인합니다. Home
컴포저블에 있습니다.
val backgroundColor = if (tabPage == TabPage.Home) Purple100 else Green300
여기에서 tabPage
는 State
객체에서 지원하는 Int
입니다. 값에 따라 배경 색상은 보라색과 녹색 간에 전환됩니다. 이 값 변경사항을 애니메이션으로 보여 주려고 합니다.
이처럼 간단한 값 변경을 애니메이션하기 위해 animate*AsState
API를 사용할 수 있습니다. 이 경우 animate*AsState
컴포저블의 상응하는 변형(animateColorAsState
)으로 변경 값을 래핑하는 것만으로 애니메이션 값을 만들 수 있습니다. 반환된 값은 State<T>
객체이므로 by
선언과 함께 로컬 위임된 속성을 사용하여 일반 변수로 처리할 수 있습니다.
val backgroundColor by animateColorAsState(if (tabPage == TabPage.Home) Purple100 else Green300)
앱을 다시 실행하고 탭을 전환해 보세요. 이제 색상 변경이 애니메이션으로 표시됩니다.
4. 가시성 애니메이션
앱의 콘텐츠를 스크롤하면 스크롤 방향에 따라 플로팅 작업 버튼이 펼쳐집니다.
TODO 2-1을 찾아 어떻게 작동하는지 알아보세요. HomeFloatingActionButton
컴포저블에 있습니다. "EDIT"라는 텍스트는 if
문을 사용하여 표시하거나 숨깁니다.
if (extended) {
Text(
text = stringResource(R.string.edit),
modifier = Modifier
.padding(start = 8.dp, top = 3.dp)
)
}
이 공개 상태 변경을 애니메이션 처리하는 것은 간단히 if
를 AnimatedVisibility
컴포저블로 대체하기만 하면 됩니다.
AnimatedVisibility(extended) {
Text(
text = stringResource(R.string.edit),
modifier = Modifier
.padding(start = 8.dp, top = 3.dp)
)
}
앱을 실행하고 이제 FAB가 확장 및 축소되는 방식을 확인합니다.
AnimatedVisibility
는 지정된 Boolean
값이 변경될 때마다 애니메이션을 실행합니다. 기본적으로 AnimatedVisibility
은 페이드인 및 확장 방식으로 요소를 표시하고 페이드아웃 및 축소 방식으로 요소를 숨깁니다. 이 동작은 FAB를 사용하여 이 예시에서 잘 작동하지만 동작을 맞춤설정할 수도 있습니다.
FAB를 클릭하면 '편집 기능이 지원되지 않습니다'라는 메시지가 표시됩니다. 또한 AnimatedVisibility
를 사용하여 모양과 사라짐을 애니메이션으로 보여줍니다. 이 상단에서 요소를 슬라이드하여 상단으로 슬라이드하도록 애니메이션을 맞춤설정하는 방법을 살펴보겠습니다.
TODO 2-2를 찾고 EditMessage
컴포저블의 코드를 확인합니다.
AnimatedVisibility(
visible = shown
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colors.secondary,
elevation = 4.dp
) {
Text(
text = stringResource(R.string.edit_message),
modifier = Modifier.padding(16.dp)
)
}
}
애니메이션을 맞춤설정하려면 enter
및 exit
매개변수를 AnimatedVisibility
컴포저블에 추가하세요.
enter
매개변수는 EnterTransition
의 인스턴스여야 합니다. 이 예에서는 slideInVertically
함수를 사용하여 EnterTransition
를 만들 수 있습니다. 이 함수를 사용하면 initialOffsetY
및 animationSpec
매개변수를 기준으로 추가로 맞춤설정할 수 있습니다. initialOffsetY
은 초기 위치를 반환하는 람다여야 합니다. 람다는 요소의 높이인 요소의 높이를 수신하므로 단순히 음수 값을 반환할 수 있습니다. slideInVertically
를 사용하는 경우 슬라이드 후의 타겟 오프셋은 항상 0
(픽셀)입니다. initialOffsetY
은 람다 함수를 통해 절댓값 또는 요소의 전체 높이 비율로 지정할 수 있습니다.
animationSpec
은 EnterTransition
, ExitTransition
등 많은 애니메이션 API의 공통 매개변수입니다. 다양한 AnimationSpec
유형 중 하나를 전달하여 시간 경과에 따른 애니메이션 값을 어떻게 변경할지 지정할 수 있습니다. 이 예에서 let은 간단한 기간 기반 AnimationSpec
를 사용합니다. tween
함수를 사용하여 만들 수 있습니다. 지속 시간은 150밀리초이며 이징은 LinearOutSlowInEasing
입니다.
마찬가지로 exit
매개변수에 slideOutVertically
함수를 사용할 수 있습니다. slideOutVertically
는 초기 오프셋이 0이라고 가정하므로 targetOffsetY
만 지정하면 됩니다. animationSpec
매개변수에 동일한 tween
함수를 사용하되 길이는 250밀리초이며 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.colors.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
}
앱을 실행하고 주제 중 하나를 클릭합니다. 애니메이션으로 확장 및 축소되는 것을 볼 수 있습니다.
6. 다중 값 애니메이션
이제 몇 가지 기본 애니메이션 API에 익숙해졌으니 더 복잡한 애니메이션을 만들 수 있는 Transition
API를 살펴보겠습니다. 이 예에서는 탭 표시기를 맞춤설정합니다. 현재 선택된 탭에 표시되는 직사각형입니다.
HomeTabIndicator
컴포저블에서 TODO 4를 찾고 탭 표시기가 구현되는 방식을 확인합니다.
val indicatorLeft = tabPositions[tabPage.ordinal].left
val indicatorRight = tabPositions[tabPage.ordinal].right
val color = if (tabPage == TabPage.Home) Purple700 else Green800
여기서 indicatorLeft
는 탭 행에 있는 표시기 왼쪽 가장자리의 가로 위치입니다. indicatorRight
는 표시기 오른쪽 가장자리의 가로 위치입니다. 색상도 보라색과 녹색 간에 변경됩니다.
이러한 여러 값을 동시에 애니메이션 처리하기 위해 Transition
를 사용할 수 있습니다. updateTransition
함수를 사용하여 Transition
를 만들 수 있습니다. 현재 targetState
매개변수로 선택된 탭의 색인을 전달합니다.
각 애니메이션 값을 Transition
의 animate*
확장 함수로 선언할 수 있습니다. 이 예에서는 animateDp
와 animateColor
를 사용합니다. 람다 블록을 사용하며, 각 상태의 타겟 값을 지정할 수 있습니다. 타겟 값이 무엇일지 이미 알고 있으므로 아래와 같이 값을 래핑할 수 있습니다. by
선언을 사용하면 다시 로컬 위임 속성으로 만들 수 있습니다. animate*
함수가 State
객체를 반환하기 때문입니다.
val transition = updateTransition(tabPage)
val indicatorLeft by transition.animateDp { page ->
tabPositions[page.ordinal].left
}
val indicatorRight by transition.animateDp { page ->
tabPositions[page.ordinal].right
}
val color by transition.animateColor { page ->
if (page == TabPage.Home) Purple700 else Green800
}
이제 앱을 실행하면 탭 스위치가 훨씬 더 흥미로운 것을 볼 수 있습니다. 탭을 클릭하면 tabPage
상태 값이 변경되면 transition
와 연결된 모든 애니메이션 값이 타겟 상태에 지정된 값으로 애니메이션을 적용하기 시작합니다.
또한 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) Purple700 else Green800
}
앱을 다시 실행하고 탭을 전환해 봅니다.
Android 스튜디오는 Compose 미리보기에서 전환 검사를 지원합니다. 애니메이션 미리보기를 사용하려면 미리보기에서 컴포저블의 오른쪽 상단에 있는 '대화형 모드 시작' 아이콘을 클릭하여 대화형 모드를 시작하세요. 아이콘을 찾을 수 없는 경우 여기의 안내에 따라 실험 설정에서 이 기능을 사용 설정해야 합니다. PreviewHomeTabBar
컴포저블의 아이콘을 클릭해 보세요. 그런 다음 대화형 모드의 오른쪽 상단에서 '애니메이션 검사 시작' 아이콘을 클릭합니다. 새 quos;Animations" 창이 열립니다.
'재생' 아이콘 버튼을 클릭하여 애니메이션을 실행할 수 있습니다. 탐색 막대를 드래그하여 각 애니메이션 프레임을 볼 수도 있습니다. 애니메이션 값에 대한 자세한 설명을 위해 updateTransition
및 animate*
메서드에서 label
매개변수를 지정하면 됩니다.
7. 반복되는 애니메이션
현재 온도 옆의 새로고침 아이콘 버튼을 클릭해 보세요. 앱이 최신 날씨 정보를 로드하기 시작합니다 (역할을 의미함). 로드가 완료될 때까지 로드 표시기, 즉 회색 원과 막대가 표시됩니다. 이 표시기의 알파 값에 애니메이션을 적용하여 프로세스가 진행 중임을 더 명확히 해 줍니다.
LoadingRow
컴포저블에서 TODO 5를 찾습니다.
val alpha = 1f
이 값을 0f~1f 간에 반복적으로 애니메이션으로 표시하려고 합니다. 이러한 목적으로 InfiniteTransition
를 사용할 수 있습니다. 이 API는 이전 섹션의 Transition
API와 유사합니다. 둘 다 여러 값에 애니메이션을 적용하지만 Transition
는 상태 변경에 따라 값에 애니메이션을 적용하는 반면, InfiniteTransition
에는 무기한으로 값이 애니메이션 처리됩니다.
InfiniteTransition
를 생성하려면 rememberInfiniteTransition
함수를 사용합니다. 그런 다음 각 애니메이션 값 변경을 InfiniteTransition
의 animate*
확장 함수 중 하나로 선언할 수 있습니다. 이 경우 알파 값을 애니메이션으로 표시하므로 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
)
)
앱을 실행하고 새로고침 버튼을 클릭합니다. 이제 로드 표시기에 애니메이션이 표시됩니다.
8. 동작 애니메이션
이 마지막 섹션에서는 터치 입력을 기반으로 애니메이션을 실행하는 방법을 알아봅니다. 이 상황에서는 몇 가지 고유한 사항을 고려해야 합니다. 첫째, 진행 중인 애니메이션을 터치 이벤트로 가로챌 수 있습니다. 둘째, 애니메이션 값이 유일한 정보 소스가 아닐 수도 있습니다. 즉, 애니메이션 값을 터치 이벤트에서 발생하는 값과 동기화해야 할 수 있습니다.
swipeToDismiss
수정자에서 TODO 6-1을 찾습니다. 여기서는 터치로 요소를 스와이프할 수 있는 수정자를 만들려고 합니다. 요소가 화면 가장자리에 플링되면 onDismissed
콜백을 호출하여 요소를 제거할 수 있습니다.
Animatable
는 지금까지 확인된 최저 수준의 API입니다. 동작 시나리오에 유용한 여러 기능을 제공하므로, Animatable
의 인스턴스를 만들고 이를 사용하여 스와이프할 수 있는 요소의 가로 오프셋을 나타냅니다.
val offsetX = remember { Animatable(0f) } // Add this line
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
를 호출하면 됩니다. 애니메이션이 실행되고 있지 않으면 호출이 무시됩니다.
// Wait for a touch down event.
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
offsetX.stop() // Add this line
// Prepare for drag events and record velocity of a fling.
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {
TODO 6-3에서는 드래그 이벤트를 지속적으로 수신하고 있습니다. 터치 이벤트의 위치를 애니메이션 값에 동기화해야 합니다. 이 경우 Animatable
에 snapTo
를 사용할 수 있습니다.
horizontalDrag(pointerId) { change ->
// Add these 4 lines
val horizontalDragOffset = offsetX.value + change.positionChange().x
launch {
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()
}
TODO 6-4는 요소가 방금 분리되었으며 플링된 위치입니다. 플링이 결정되는 최종 위치를 계산하여 요소를 원래 위치로 다시 슬라이드할지, 아니면 요소를 슬라이드하여 콜백을 호출해야 하는지 결정합니다.
// Dragging finished. Calculate the velocity of the fling.
val velocity = velocityTracker.calculateVelocity().x
// Add this line
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
TODO 6-5에서 애니메이션이 시작하려고 합니다. 하지만 그 전에 값과 상한을 Animatable
로 설정하여 값이 도달하는 즉시 중지되도록 하려고 합니다. 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 애니메이션 API를 배웠습니다.
animateContentSize
및 AnimatedVisibility
와 같은 상위 수준 애니메이션 API를 사용하여 여러 일반적인 애니메이션 패턴을 빌드하는 방법을 알아보았습니다. 또한 단일 값을 애니메이션 처리하는 데 animate*AsState
를 사용하고, 여러 값을 애니메이션 처리하는 데 updateTransition
를 사용하고, 무기한 값에 애니메이션을 적용하는 데는 infiniteTransition
를 사용할 수 있다는 사실도 알게 되었습니다. 또한 Animatable
를 사용하여 터치 동작과 함께 맞춤 애니메이션을 빌드했습니다.
다음 단계
Compose 과정에 관한 다른 Codelab을 확인하세요.
자세한 내용은 Compose 애니메이션과 다음 참조 문서를 확인하세요.