와챠의 우당탕탕 코딩 일기장

[Compose] 2-2. 다양한 Animation / animate*AsState / AnimatedVisibility / animateContentSize / Transition / InfiniteTransition / pointerInput 본문

코딩 일기장/Android(Kotlin)

[Compose] 2-2. 다양한 Animation / animate*AsState / AnimatedVisibility / animateContentSize / Transition / InfiniteTransition / pointerInput

minWachya 2025. 4. 18. 13:24
반응형

더 완성도 있는 앱을 위해서는 Animation이 필수다!!

이번엔 여러가지 방법으로 애니메이션을 적용하는 방법을 배워보려고 한다.

간단한 애니메이션 구현부터 복잡한 애니메이션 구현까지 알아보자~

 

<목차>

  1. animate*AsState: state 변화에 따라 애니메이션 적용할 때 사용
  2. AnimatedVisibility: visibilty(Boolean값) 변화에 따라 애니메이션 적용할 때 사용
  3. AnimatedVisibility - Custom
  4. animateContentSize: 크기가 변하는 애니메이션 만들 때 사용
  5. Transition - updateTransition: 여러 값을 동시에 애니메이션할 때 사용
  6. InfiniteTransition - infiniteTransition: 애니메이션 반복 
  7. 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되면서 삭제되는 거... 이건 진짜 여기저기 마니 쓰여서 활용할 날이 또 오면 조켓다

반응형
Comments