[Android] JetPack Compose Codelab 5 : AnimationCodelab

이석규·2023년 7월 25일
0
post-thumbnail
post-custom-banner

오늘도 지난번(ThemingCodelab(Reply))에 이어서 AnimationCodelab의 내용을 둘러보고자 한다.


본 프로젝트는 애니메이션이 적용되지 않은 모듈이 있고, 애니메이션이 적용된 모듈이 있어서 개발자로 하여금 능동적으로 구현해보길 권장하고 있다.

다음은 프로젝트에 존재하는 애니메이션의 종류들이다.

  • 탭 레이아웃의 탭 터치로 인한 색상 변경
  • 플로팅 버튼의 스크롤 방향에 따른 확대 축소
  • 텍스트 메시지의 등장 퇴장
  • 컨텐츠(리스트 아이템의) 크기 변경
  • 탭 레이아웃의 탭 이동
  • 로딩 같은 반복 효과
  • 리스트 아이템 삭제 같은 동작 효과

코드를 순서대로 쭉 보던 앞의 프로젝트들과는 달리 이번 프로젝트에서는 애니메이션 별로 코드를 살펴보고자 한다.

1. 탭 레이아웃의 탭 터치로 인한 백그라운드 색상 변경

val backgroundColor = if (tabPage == TabPage.Home) Purple100 else Green300
val backgroundColor by animateColorAsState(if (tabPage == TabPage.Home) Purple100 else Green300)

둘의 차이는 by animateColorAsState로 animate??AsState API를 썼는가 안 썼는가의 차이다.
쓸 일이 있겠지..라는 생각 정도..?이다.

2. 플로팅 버튼의 스크롤 방향에 따른 확대 축소

스크롤에 따라 플로트 아이콘의 크기와 디자인이 애니메이션 형태로 바뀌는 것을 확인할 수 있다.

 HomeFloatingActionButton(
	extended = lazyListState.isScrollingUp(),
	onClick = {
		coroutineScope.launch {
			showEditMessage()
		}
	}
)
            
AnimatedVisibility(visible = extended) {
	Text(
		text = stringResource(R.string.edit),
		modifier = Modifier
		.padding(start = 8.dp, top = 3.dp)
	)
}

코드를 확인해보면, 플로팅 버튼이 확대, 축소 되는 것은 어디까지나 extended라는 파라미터를 통해 넘겨줄 수 있다.
그리고 이 확대, 축소가 자연스럽게 애니메이팅 되는 것을 AnimatedVisibility(visible = extended)을 통해 구현할 수 있는 것이다.

3. 텍스트 메시지의 등장 퇴장

플로팅 버튼의 결과로 텍스트가 나타나는 효과가 있다.

@Composable
private fun EditMessage(shown: Boolean) {
    AnimatedVisibility(
        visible = shown,
        enter = slideInVertically(
            // Enters by sliding in from offset -fullHeight to 0.
            initialOffsetY = { fullHeight -> -fullHeight },
            animationSpec = tween(durationMillis = 150, easing = LinearOutSlowInEasing)
        ),
        exit = slideOutVertically(
            // Exits by sliding out 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)
            )
        }
    }
}

AnimatedVisibility를 통해 기본적으로 나타나게 하고, enter(slideInVertically)과 exit(slideOutVertically)로 애니메이션의 형태를 조정할 수 있다.

4. 컨텐츠(리스트 아이템의) 크기 변경

리스트의 숨겨진 영역을 나타나게할 때 보여지는 애니메이션이다.

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun TopicRow(topic: String, expanded: Boolean, onClick: () -> Unit) {
    TopicRowSpacer(visible = expanded)
    Surface(
        modifier = Modifier
            .fillMaxWidth(),
        elevation = 2.dp,
        onClick = onClick
    ) {
        Column(
            modifier = Modifier
                .fillMaxWidth()
                .padding(16.dp)
                // This `Column` animates its size when its content changes.
                .animateContentSize()
        ) {
            Row {
                Icon(
                    imageVector = Icons.Default.Info,
                    contentDescription = null
                )
                Spacer(modifier = Modifier.width(16.dp))
                Text(
                    text = topic,
                    style = MaterialTheme.typography.body1
                )
            }
            if (expanded) {
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = stringResource(R.string.lorem_ipsum),
                    textAlign = TextAlign.Justify
                )
            }
        }
    }
    TopicRowSpacer(visible = expanded)
}

TopicRowSpacer로 아이템 뷰의 상하단에 확장시의 여백을 넣어주고, Modifier.animateContentSize()로 확장되는 애니메이션을 설정하고 있다.

5. 탭 레이아웃의 탭 이동

val indicatorLeft = tabPositions[tabPage.ordinal].left
val indicatorRight = tabPositions[tabPage.ordinal].right
val color = if (tabPage == TabPage.Home) Purple700 else Green800
Box(
	Modifier
	.fillMaxSize()
	.wrapContentSize(align = Alignment.BottomStart)
    .offset(x = indicatorLeft)
    .width(indicatorRight - indicatorLeft)
    .padding(4.dp)
    .fillMaxSize()
    .border(
    	BorderStroke(2.dp, color),
    	RoundedCornerShape(4.dp)
    )
)

기본 탭의 코드이다. 이를 애니메이션 효과로 딱딱한 느낌을 없애주려면 indicatorLeft, indicatorRight, color에 변화를 줌으로써 구현할 수 있다.
아래처럼

val transition = updateTransition(
    tabPage,
    label = "Tab indicator"
)
val indicatorLeft by transition.animateDp(
    transitionSpec = {
        if (TabPage.Home isTransitioningTo TabPage.Work) {
            // Indicator moves to the right.
            // Low stiffness spring for the left edge so it moves slower than the right edge.
            spring(stiffness = Spring.StiffnessVeryLow)
        } else {
            // Indicator moves to the left.
            // Medium stiffness spring for the left edge so it 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
            // Medium stiffness spring for the right edge so it moves faster than the left edge.
            spring(stiffness = Spring.StiffnessMedium)
        } else {
            // Indicator moves to the left.
            // Low stiffness spring for the right edge so it 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
}

변화가 일어날 영역을 transition로 지정하고,
indicatorLeft와 indicatorRight는 animateDp로
color는 animateColor로 정의한다.

6. 로딩 같은 반복 효과

회색 화면이 서서히 보이는 효과이다.

val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    // `infiniteRepeatable` repeats the specified duration-based `AnimationSpec` infinitely.
    animationSpec = infiniteRepeatable(
        // The `keyframes` animates the value by specifying multiple timestamps.
        animation = keyframes {
            // One iteration is 1000 milliseconds.
            durationMillis = 1000
            // 0.7f at the middle of an iteration.
            0.7f at 500
        },
        // When the value finishes animating from 0f to 1f, it repeats by reversing the
        // animation direction.
        repeatMode = RepeatMode.Reverse
    )
)

개발자마다 원하는 효과는 제각각일 것이기 때문에, rememberInfiniteTransition를 통해서 animateFloat을 설정하고 있다는 점을 주목하고 넘어가자.

7. 리스트 아이템 삭제 같은 동작 효과

이미지처럼 리스트 아이템을 스와이프하여 삭제할 수 있다.

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
                        if (change.positionChange() != Offset.Zero) change.consume()
                    }
                }
                // 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) }
}
  • 수평 오프셋을 저장한다.
offset
  • 플링 애니메이션이 멈추는 포지션을 계산 하기 위해 사용
decay
  • 애니메이션이 실행되고 있는 경우, 이것을 가로채야 하기 때문에 사용된다. 만일 애니메이션이 별도로 실행 중이 아니라면 무시된다.
offsetX.stop()
  • awaitPointerEventScope에서 사용자의 드래그가 끝나기를 기다리고, 터치 이벤트의 위치를 애니메이션 값과 동기화해야 하므로, 이를 위해 Animateable에서 snapTo를 사용할 수 있다.
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)
		}
    }
}
  • 아래 로직을 위해 이용될 계산한 값 + 사라지고 난 후 아이템 뷰가 채워지는
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()
)
  • 아이템 뷰가 슬라이드 되고 일정 지점에서 사용자가 멈췄을 때, 그것을 삭제시킬 것인지 원래자리로 되돌릴 것인지를 계산한 값을 이용하여 실행하는 로직
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()
}
  • 최종적으로 요소가 가지게 될 보여질 x, y 좌표 위치 설정
.offset { IntOffset(offsetX.value.roundToInt(), 0) }

애니메이션은 확실히 사용할 때가 되서야 허겁지겁 찾아보는 맛이 있는 것 같다..
정리를 해놔도 까먹을 확률이 높기 때문에.. 하지만 compose에서 애니메이션들이 어떻게 적용되는지 다양하게 알아보기에 좋았던 것 같다.

다음 포스팅은 NavigationCodelab이다.


출처 :

  1. https://www.charlezz.com/?p=45738
  2. https://developer.android.com/codelabs/jetpack-compose-animation?hl=ko#7
profile
안드안드안드
post-custom-banner

0개의 댓글