Everything became easier when I realized that compose animations are just and effect when the state changes. How do the animate* funcrtions work? they just the value in a small increment everytime they are called. That changes the state of the composable so the compose engine would render another frame of the animation.
Animations like container transform can be break down into small steps, for the following animation:
The steps would be:
- Elevate the row
- Expand the row
- Expand the bottom sheet
- Make the corners of the bottom sheet squared
- Hide the floating action button
- Replace the content
Steps 3-6 run together and they have to start and finish at the same time of 1 and 2.
For steps 1 and 2 I created a transition based on animation state:
private enum class AnimationState {
NOT_STARTED, // not elevated nor expanded state
ELEVATING, // start elevating when the user has clicked
EXPANDING, // start expaning when the elevate animation has finished
ENDED // elevated and expanded state
}
The transition is created based on AnimationState
val transitionState = remember { MutableTransitionState(AnimationState.NOT_STARTED) }
val transition = updateTransition(transitionState, label = "transition")
The elevate animation from 0 to 14
val elevation by transition.animateDp(
label = "elevation",
transitionSpec = { tween(containerAnimationInMillis / 2) },
targetValueByState = { if (it.hasStarted()) 14.dp else 0.dp }
)
The expand animation from 56 dp
to screen height
val height by transition.animateDp(
label = "height",
transitionSpec = { tween(containerAnimationInMillis / 2) },
targetValueByState = { if (it.hasElevated()) LocalConfiguration.current.screenHeightDp.dp else 56.dp }
)
Both animations have to last the same time as the other four. That's why the transitionSpec
is condifured to last half of the total time.
The next four animations animations are a little bit simpler, when the user clicks a row, all of them are fired and configured to last containerAnimationInMillis
.
We created a transition as well but this time we only care if the user has click a row or not
val transition = updateTransition(selectedRow != null, label = "transition")
To expad the bottom sheet we change the height from 80%
to 100%
of the screen height
val height by transition.animateDp(
label = "height",
transitionSpec = { tween(containerAnimationInMillis) },
targetValueByState = { isItSelected ->
configuration.screenHeightDp.dp * if (isItSelected) 1.0f else 0.8f
}
)
To make the corners square, we change it from 12 dp
to 0 dp
val roundCornerRadio by transition.animateDp(
label = "roundCorners",
transitionSpec = { tween(containerAnimationInMillis) },
targetValueByState = { isItSelected ->
if (isItSelected) 0.dp else 12.dp
}
)
To hide the Floating Action Button we use AnimatedVisibility
with visible = selectedRow == null
as its state
AnimatedVisibility(
visible = selectedRow == null,
enter = fadeIn(animationSpec = tween(containerAnimationInMillis)) + scaleIn(animationSpec = tween(containerAnimationInMillis)),
exit = scaleOut(animationSpec = tween(containerAnimationInMillis)) + fadeOut(animationSpec = tween(containerAnimationInMillis)),
) {
FloatingActionButton(contentColor = Color.White, onClick = { }) {
Icon(painter = painterResource(R.drawable.ic_add), contentDescription = null)
}
}
And to replace the content we use Crossfade
Crossfade(targetState = selectedExpenseId, animationSpec = tween(containerAnimationInMillis)) { expenseId ->
if (expenseId != null) {
EditExpenseScreen(expenseId)
} else {
ViewExpensesScreen()
}
}