How to animate BottomSheet content using Jetpack Compose

Blog Infos
Author
Published
Topics
, , ,
Author
Published
Posted by: Yahor Urbanovich

Note: Everything in this post related to Compose 1.0.1 and maybe in the future Google will introduce a new API and approaches like Compose MotionLayout ?

Early this year I started a new pet project for listening to random radio from over the world. The UI at the beginning was very simple and clear, just a logo with the station name and necessary controls. The main functionality is integration with Android Auto, but it is a topic for a separate story (just ask in the comments if you are interested in this theme).

Using the application, it became necessary to see user liked stations, other categories separated by genre and country. The idea is to make a Home screen with a list of different information and player which can be shown and hidden on the screen.

Find available solutions

As a normal developer, I started to research existing compose projects to find already implemented functionality.

The official and community samples contain only the UI part and don’t include any interaction.

For example, the Spotify UI demo application for me implies a presence player screen, but in source code, it’s just a Row with content.

Another interesting repository I found is a Podcast App. According to the gif looks like what I was looking for.

After inspecting the source code I understood how it works. The PodcastPlayerScreen is a Root composable for the player and it added to the composition of the main screen. By default, it’s not visible for users.

Then if the user clicks on some podcast item it changes the showPlayerFullScreen boolean. Recomposition triggers the AnimatedVisibility logic and the player appears.

Further interaction refers to listening swipe offset and move the player along the Y-axis on the screen.

From my perspective, it’s not an ideal solution, because it is tied to the ViewModel state, the unobvious showPlayerFullScreen variable which can be changed in different places, also we need manually operate with offset, and so on.

Let’s stop research and try to make a more simple and clear solution.

BottomSheet story

Out of the box, Compose contains two types of BottomSheet implementations:

  1. ModalBottomSheetLayout
  2. BottomSheetScaffold

To make a simple screen with ModalBottomSheetLayout we just need to initialize rememberModalBottomSheetState with initial Hidden state, create coroutine scope for future interaction with visibility and that’s all.

@Composable
@ExperimentalMaterialApi
fun ModalBottomSheet() {
val modalBottomSheetState = rememberModalBottomSheetState(
initialValue = ModalBottomSheetValue.Hidden
)
val scope = rememberCoroutineScope()
ModalBottomSheetLayout(
sheetState = modalBottomSheetState,
sheetContent = {
// BottomSheet content
}
) {
// Content behind BottomSheet
}
}
view raw Modal.kt hosted with ❤ by GitHub

Unfortunately, ModalBottomSheetLayout doesn’t have the ability to set peekHeight.

peekHeight — he height of the bottom sheet when it is collapsed. Simply speaking bottom sheet will be on the screen permanently, but in the collapsed state.

BottomSheetScaffold is trying to solve this issue and introduce this customization. The simple screen setup is pretty the same, but we are able to set peekHeight as a parameter.

Important note:
In different posts I saw wrong initialization of 
rememberBottomSheetScaffoldState without remember block for bottomSheetState.

It can lead unexpected bottomsheet behaviour after recomposition like non responding to touches, able to drag in different direction and so on.

The correct one should be:

val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState(BottomSheetValue.Collapsed)
)
view raw correct.kt hosted with ❤ by GitHub
Working with BottomSheetScaffold

In the sample project, I have added the demo UI for the radio player with collapsed and expanded state. The main goal now is to animate from one state to another one.

Job Offers

Job Offers

There are currently no vacancies.

tired of reading

Take a break an watch a video

No results found.

At the first step, we need to know the current BottomSheet state.

The good news for us is that BottomSheetState inherited from SwipeableState and we can try to access fields currentValue andtargetValue.

@ExperimentalMaterialApi
@Stable
class BottomSheetState(
initialValue: BottomSheetValue,
animationSpec: AnimationSpec<Float> = SwipeableDefaults.AnimationSpec,
confirmStateChange: (BottomSheetValue) -> Boolean = { true }
) : SwipeableState<BottomSheetValue>(
initialValue = initialValue,
animationSpec = animationSpec,
confirmStateChange = confirmStateChange
) {
}

The code will look pretty simple, we just read the necessary data from bottomSheetState and show it on the screen.

@Composable
@ExperimentalMaterialApi
fun DebugScreen(
scaffoldState: BottomSheetScaffoldState
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) {
val targetValue = scaffoldState.bottomSheetState.targetValue
val currentValue = scaffoldState.bottomSheetState.currentValue
Text("target = $targetValue")
Text("current = $currentValue")
}
}
view raw DebugScreen.kt hosted with ❤ by GitHub

Well, now we know the state of our screen and animation direction. But that’s not enough for animation, we need intermediate values between Collapsed and Expanded states.

Digging through the source code I found that Swipeable storing information about the ongoing swipe or animation in a special progress field.

[]gist id=”d8f79e47a1cd7bac5bc996b01a318258″]

SwipeProgress is a class with from/to parameters and fractionThe fraction represents the current position between from and to.

@Immutable
@ExperimentalMaterialApi
class SwipeProgress<T>(
val from: T,
val to: T,
/*@FloatRange(from = 0.0, to = 1.0)*/
val fraction: Float
) {
}

Let’s use this. Updated DebugScreen will look like this.

@Composable
@ExperimentalMaterialApi
fun DebugScreen(
scaffoldState: BottomSheetScaffoldState
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally
) {
val fraction = scaffoldState.bottomSheetState.progress.fraction
val targetValue = scaffoldState.bottomSheetState.targetValue
val currentValue = scaffoldState.bottomSheetState.currentValue
Text("fraction = $fraction")
Text("target = $targetValue")
Text("current = $currentValue")
}
}
view raw DebugScreen.kt hosted with ❤ by GitHub

Looks nice, but unusable now. If you look closely you will see that fraction starts from 0 every time and finishes with 1.

For beautiful animation, we should definitely assign fraction for states:

  • Expanded → 1f
  • Collapsed → 0f

A little math and we get this extension.

@OptIn(ExperimentalMaterialApi::class)
val BottomSheetScaffoldState.currentFraction: Float
get() {
val fraction = bottomSheetState.progress.fraction
val targetValue = bottomSheetState.targetValue
val currentValue = bottomSheetState.currentValue
return when {
currentValue == Collapsed && targetValue == Collapsed -> 0f
currentValue == Expanded && targetValue == Expanded -> 1f
currentValue == Collapsed && targetValue == Expanded -> fraction
else -> 1f - fraction
}
}

Super, this is already 90% of the result. Now we can apply the calculated fraction to any composable function and dynamically change the alpha.

In this code snippet, I change the alpha of the root collapsed element relatively to a fraction.

@Composable
fun SheetCollapsed(
currentFraction: Float,
content: @Composable RowScope.() -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(72.dp)
.background(MaterialTheme.colors.primary)
.graphicsLayer(alpha = 1f - currentFraction),
verticalAlignment = Alignment.CenterVertically
) {
content()
}
}

Looks pretty, doesn’t it?

Bonus

We can try to animate BottomSheet corner radius relatively to a fraction.

For example, for the Collapsed state, we won’t have any rounding, but for Expanded we want to have 30.dp.

The below code allows achieving dynamic corner radius change.

Menu