와챠의 우당탕탕 코딩 일기장
[Compose] 2-2. 다양한 Animation / animate*AsState / AnimatedVisibility / animateContentSize / Transition / InfiniteTransition / pointerInput 본문
[Compose] 2-2. 다양한 Animation / animate*AsState / AnimatedVisibility / animateContentSize / Transition / InfiniteTransition / pointerInput
minWachya 2025. 4. 18. 13:24더 완성도 있는 앱을 위해서는 Animation이 필수다!!
이번엔 여러가지 방법으로 애니메이션을 적용하는 방법을 배워보려고 한다.
간단한 애니메이션 구현부터 복잡한 애니메이션 구현까지 알아보자~
<목차>
- animate*AsState: state 변화에 따라 애니메이션 적용할 때 사용
- AnimatedVisibility: visibilty(Boolean값) 변화에 따라 애니메이션 적용할 때 사용
- AnimatedVisibility - Custom
- animateContentSize: 크기가 변하는 애니메이션 만들 때 사용
- Transition - updateTransition: 여러 값을 동시에 애니메이션할 때 사용
- InfiniteTransition - infiniteTransition: 애니메이션 반복
- pointerInput: 터치 기반 애니메이션
1. animate*AsState
State 변경에 Animation 적용할 때 사용된다.
아래 움짤처럼 tab의 state에 따라 배경 color를 변경하고 싶을 때
animateColorAsState를 사용해주면 된다.
// 적용 전 코드
val backgroundColor = if (tabPage == TabPage.Home) Seashell else GreenLight
// 적용 후 코드
val backgroundColor by animateColorAsState(
targetValue = if (tabPage == TabPage.Home) Seashell else GreenLight,
label = "background color")
참고로 label을 지정하는 이유는 위 바운스볼같이 생긴 아이콘(Start Animation Preview)을 를 클릭했을 때
애니메이션 별 프레임을 확인할 수 있는데, 이때 그 애니메이션의 이름을 지정해주기 위해서다.
2. AnimatedVisibility
visibilty(Boolean값) 변경에 따라 Animation을 설정할 때 사용한다.
아래처럼 스크롤 변화에 따라 FAB의 "EDIT" text가 나타났다 사라지는 것을 자연스럽게 바꾸기 위해선 AnimatedVisibility를 사용하면 된다!
// 적용 전
if (extended) {
Text(
text = stringResource(R.string.edit),
modifier = Modifier
.padding(start = 8.dp, top = 3.dp)
)
}
// 적용 후
AnimatedVisibility(extended) {
Text(
text = stringResource(R.string.edit),
modifier = Modifier
.padding(start = 8.dp, top = 3.dp)
)
}
3. AnimatedVisibility - Custom
이렇게 수직으로 내려왔다가 올라가는 애니메이션을 만들 때에도 AnimatedVisibility가 사용된다!
물론 어느정도의 커스텀이 필요함...
화면 상단은 0이고, 토스트의 높이를 height라고 할 때,
나타날 때는 -height에서 시작해서 0까지 움직이고,
사라질 때는 0에서 -height까지 움직이게 만들면 된다.
slideInVertically의 기본 동작은 -height/2에서 0까지(initalOffsetY: -height/2, targetOffsetY: 0),
slideOutVertically의 기본 동작은 0에서 -height/2까지(initalOffsetY: 0, targetOffsetY: -height/2),이므로 0 부분은 똑같으니 높이의 절반만 사용되는 것을 전체 높이를 사용하게 변경해주면 된다.
AnimatedVisibility(
visible = shown,
// 나타날 때: 수직으로 슬라이드되어 나타남
enter = slideInVertically(
// -fullHeight에서 0까지
initialOffsetY = { fullHeight -> -fullHeight },
// 150ms까지 지속
animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing)
),
// 사라질 때: 수직으로 슬라이드되어 사라짐
exit = slideOutVertically(
// 0에서 -fullHeight까지
targetOffsetY = { fullHeight -> -fullHeight },
animationSpec = tween(durationMillis = 250, easing = FastOutLinearInEasing)
)
) {
Surface(/* Edit feature is not supported~ */)
}
}
*animationSpec과 tween, easing 관련 자세한 설명은 공식 문서를 참고
4. animateContentSize
"더보기"를 누르면 공간이 커지고 "접기"를 누르면 공간이 줄어드는 애니메이션, 즉 크기를 변경하는 애니메이션을 만들 때 사용된다.
Modifier에 animateContentSize()를 추가해주면 된다. 이것도 animationSpec을 통해 커스텀 가능함...ㅎㅎ
// 적용 전
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// ... the title and the body
}
// 적용 후
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.animateContentSize()
) {
// ... the title and the body
}
5. Transition - updateTransition
여러 값을 동시에 애니메이션하기 위해 사용된다.
이번엔 아래처럼 두 가지 값을 동시에 애니메이션 해보려고 한다.
1. 선택된 Tab에 따라 border 탄성 효과
2. 선택된 Tab에 따라 color 변화
Transition은 animate*AsState 와 달리 애니메이션의 완료 시점을 추적할 수 있다.
Transition은 updateTransition를 통해 만들 수 있다.
아래 사용된 updateTransition은 다음과 같은 의미를 갖는다: 현재 선택된 탭의 index를 tabPage에 전달한다.
이런 방식으로 Transition의 인스턴스를 만들고 저장하며 상태를 업데이트할 수 있다.
// 1. 선택된 Tab에 따라 border 탄성 효과
// 현재 선택된 탭의 index를 tabPage에 전달. 탭 변경될 때마다 탭 index에 따라 애니메이션 실행됨
val transition = updateTransition(
tabPage,
label = "Tab indicator"
)
// animateDp를 통해 애니메이션 값 설정
val indicatorLeft by transition.animateDp(
transitionSpec = {
if (TabPage.Home isTransitioningTo TabPage.Work) {
// indicator가 Work 쪽으로 이동할 때(오른쪽으로 이동할 때)
// left edge가 right edge보다 느리게 이동(탄성 효과)
spring(stiffness = Spring.StiffnessVeryLow)
} else {
// indicator가 Home 쪽으로 이동할 때(왼쪽으로 이동할 때)
// left edge가 right edge보다 빠르게 이동(탄성 효과)
spring(stiffness = Spring.StiffnessMedium)
}
},
label = "Indicator left"
) { page ->
// 왼쪽 탭 테두리
tabPositions[page.ordinal].left
}
/* indicatorRight도 같은 방법으로 작성 */
// 2. 선택된 Tab에 따라 color 변화
val color by transition.animateColor(
label = "Border color"
) { page ->
if (page == TabPage.Home) PaleDogwood else Green
}
6. InfiniteTransition - infiniteTransition
반복적인 애니메이션을 만들 때 사용된다.
로딩 중일 때 더미 박스의 alpha값이 0f에서 1f까지 반복되게 해보자.
InfiniteTransition을 만들려면 rememberInfiniteTransition 함수가 필요하다.
val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
initialValue = 0f, // 0f에서
targetValue = 1f, // 1f까지
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 1000 // 지속 시간 1000ms
0.7f at 500 // 500ms에서 alpha 값을 0.7f가 되도록 설정
},
// 0->1에서 1->0으로 돌아가면서 반복되므로 reverse모드
repeatMode = RepeatMode.Reverse
),
label = "alpha"
)
7. pointerInput
터치 기반 애니메이션을 만들어보자.
아래처럼 리스트를 오른쪽으로 삭제 기준까지 swipe하면 해당 아이템이 삭제되고,
삭제 기준까지 swipe하지 않으면 해당 아이템이 다시 원래대로 돌아오도록 애니메이션을 만들어보려고 한다.
Modifier에 swipeToDismiss함수를 만들어서 이 함수를 TaskItem의 상위 Modifier에 추가해면 된다.
swipeToDismiss는 어떻게 만들어지는지 살펴보자
private fun Modifier.swipeToDismiss(
onDismissed: () -> Unit // 삭제되는 함수
): Modifier = composed {
// item의 가로 offset 상태. 기본 값 0f
val offsetX = remember { Animatable(0f) }
// pointerInput: 사용자가 드래그(swipe)하는 속도 추적
pointerInput(Unit) {
// swipe 효과를 위한 위치 계산
val decay = splineBasedDecay<Float>(this)
// 터치 이벤트와 애니메이션이 동작하는 동안 다른 작업도 가능하도록 코루틴 사용
coroutineScope {
while (true) {
// 1. 터치한 위치 반환
// awaitPointerEventScope: 사용자 입력 이벤트를 기다렸다가 응답할 수 있는 suspend func.
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
// 애니메이션 중이었다면 애니메이션 중지
offsetX.stop()
// VelocityTracker: 사용자가 왼쪽에서 오른쪽으로(가로로) swipe하는 속도 계산
val velocityTracker = VelocityTracker()
// 2. 가로로 이동 중이라면
awaitPointerEventScope {
horizontalDrag(pointerId) { change ->
// 드래그한 만큼 item도 이동
// 이동할 위치
val horizontalDragOffset = offsetX.value + change.positionChange().x
// snapTo는 (awaitPointerEventScope, horizontalDrag에서 제한되기 때문에) launch 블록에서 호출
// 이동할 위치까지 item 이동
launch {
offsetX.snapTo(horizontalDragOffset)
}
// 드래그 속도 저장
velocityTracker.addPosition(change.uptimeMillis, change.position)
// 이동 변화를 외부에 알리지 않음. == UI에 의해 처리되지 않고 무시함.
change.consumePositionChange()
}
}
// (드래그)swipe 끝. swipe한 속도 계산
val velocity = velocityTracker.calculateVelocity().x
// swipe 후 최종 위치 계산
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
// item의 가로 offset의 하한, 상한 설정
offsetX.updateBounds(
lowerBound = -size.width.toFloat(),
upperBound = size.width.toFloat()
)
launch {
// 최종 위치가 삭제 기준에 미치지 못하면: 원래 위치로 이동
if (targetOffsetX.absoluteValue <= size.width) {
offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
} else {
// 최종 위치가 삭제 기준에 미치면: 해당 item 삭제
offsetX.animateDecay(velocity, decay)
onDismissed()
}
}
}
}
}
// item에 offsetX 적용
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
}
pointerInput 겁나어렵다... 나머지는 쉽게 활용하기 조을듯
1번부터 7번까지 다 흔하게 사용되는 애니메이션같아서 열시미 주석 달아봤다...ㅋ
7번 swipe되면서 삭제되는 거... 이건 진짜 여기저기 마니 쓰여서 활용할 날이 또 오면 조켓다