오늘도 지난번(ThemingCodelab(Reply))에 이어서 AnimationCodelab의 내용을 둘러보고자 한다.
본 프로젝트는 애니메이션이 적용되지 않은 모듈이 있고, 애니메이션이 적용된 모듈이 있어서 개발자로 하여금 능동적으로 구현해보길 권장하고 있다.
다음은 프로젝트에 존재하는 애니메이션의 종류들이다.
코드를 순서대로 쭉 보던 앞의 프로젝트들과는 달리 이번 프로젝트에서는 애니메이션 별로 코드를 살펴보고자 한다.
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를 썼는가 안 썼는가의 차이다.
쓸 일이 있겠지..라는 생각 정도..?이다.
스크롤에 따라 플로트 아이콘의 크기와 디자인이 애니메이션 형태로 바뀌는 것을 확인할 수 있다.
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)을 통해 구현할 수 있는 것이다.
플로팅 버튼의 결과로 텍스트가 나타나는 효과가 있다.
@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)로 애니메이션의 형태를 조정할 수 있다.
리스트의 숨겨진 영역을 나타나게할 때 보여지는 애니메이션이다.
@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()로 확장되는 애니메이션을 설정하고 있다.
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로 정의한다.
회색 화면이 서서히 보이는 효과이다.
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을 설정하고 있다는 점을 주목하고 넘어가자.
이미지처럼 리스트 아이템을 스와이프하여 삭제할 수 있다.
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 {
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()
}
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
애니메이션은 확실히 사용할 때가 되서야 허겁지겁 찾아보는 맛이 있는 것 같다..
정리를 해놔도 까먹을 확률이 높기 때문에.. 하지만 compose에서 애니메이션들이 어떻게 적용되는지 다양하게 알아보기에 좋았던 것 같다.
다음 포스팅은 NavigationCodelab이다.