Animated Drawer in Jetpack Compose
Introduction
In today’s story we will learn how to implement an animated drawer with a couple of different animation effects, what we are after is showcased in the 2 GIFs below, where, as the drawer slides in from the left side, the main content resizes and either moves to the right or slides behind the drawer.
We’ll start with the first animation, resizing and moving the main content to the right.
Creating the animation
When I want to figure out how to animate some content I find it useful to create a basic composable in a Preview
that I can iterate over in the IDE, so that I can tweak the animation step by step, until I get the desired outcome.
So we will start there, we will create a composable that simply has a box and a button to trigger the animation, and we will build on top of that step by step. Once we get the desired result, we will refactor it into our animated drawer composable with a state holder.
As a starting point, this is the composable that we will use:
@Preview(widthDp = 240, heightDp = 360)
@Composable
fun PreviewAnimation() {
PlaygroundTheme {
Surface(
color = MaterialTheme.colorScheme.background
) {
Box(
modifier = Modifier
.background(Color.Red)
.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Button(onClick = { /*TODO*/ }) {
Text(text = "Toggle")
}
}
}
}
}
which gives us this result
Adding the scale animation
Next we are going to add the scale animation. For this we will use a graphicsLayer
modifier. For this animation, we want to scale to 80% of the original size. We will also hook up the button to start and animation, allowing us to play it forwards and then in reverse. Let’s see what changes we need to do to accomplish this:
// 1
private const val CollapsedFaction = .2f
@Preview(widthDp = 240, heightDp = 360)
@Composable
fun PreviewAnimation() {
PlaygroundTheme {
Surface(
color = MaterialTheme.colorScheme.background
) {
// 2
var expanded by remember { mutableStateOf(false) }
// 3
val fraction by animateFloatAsState(
targetValue = if (expanded) 1f else 0f,
animationSpec = tween(durationMillis = 800),
)
Box(
modifier = Modifier
.fillMaxSize()
// 4
.graphicsLayer {
// 5
scaleX = (1f - CollapsedFaction * fraction)
scaleY = (1f - CollapsedFaction * fraction)
}
.background(Color.Red),
contentAlignment = Alignment.Center,
) {
// 6
Button(onClick = { expanded = !expanded }) {
Text(text = "Toggle")
}
}
}
}
}
- First we define the scale factor for the content once resized. We want 80%, but we are going from 100 to 80, so the value we want is
0.2f
, corresponding to 20%. - We define a flag that will indicate if we are expanded or collapsed, which we will use to drive the animation.
- To animate the content we use
animateFloatAsState
which, as the name indicates, will run an animation on afloat
towards a value that we specify as thetargetValue
— in our case, we want this to be0f
when we are collapsed (drawer closed), and1f
when expanded (drawer open). For this first phase, where we are tweaking the animation, setting a duration on the longer side, like 800ms, helps to visualize the changes we do. - We add a
graphicsLayer
modifier to the content, to apply the scale animation. - We apply an X and Y scale factor to the content, based on the animation value — we start at
1f
and will end at0.8f
. - Finally we hook-up the button to toggle the flag so that we can start the animation on click.
Now we get this result:
Transforming the origin
We can see in the animation above that, when we run it, the content remains centered in the container or, in other words, both the left and right edges move inwards. When we use this with a drawer we will want to slide the content to the right, in sync with the drawer, so having the scale animation change where the content starts will make synchronizing this with the drawer somewhat of a challenge as we would have to calculate how much the scale animation has moved the edge and then compensate for that. However, there is a simpler solution, we can modify the scale animation to change the point of origin, which changes the center of the content once it has scaled. For our use case, we want the left edge to continue to be on the left side all along the animation. Let’s make this change:
private const val CollapsedFaction = .2f
@Preview(widthDp = 240, heightDp = 360)
@Composable
fun PreviewAnimation() {
PlaygroundTheme {
Surface(
color = MaterialTheme.colorScheme.background
) {
var expanded by remember { mutableStateOf(false) }
val fraction by animateFloatAsState(
targetValue = if (expanded) 1f else 0f,
animationSpec = tween(durationMillis = 800),
)
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
// 1
transformOrigin = TransformOrigin(pivotFractionX = 0f, pivotFractionY = .5f)
scaleX = 1f - CollapsedFaction * fraction
scaleY = 1f - CollapsedFaction * fraction
}
.background(Color.Red),
contentAlignment = Alignment.Center,
) {
Button(onClick = { expanded = !expanded }) {
Text(text = "Toggle")
}
}
}
}
}
- We specify the
transformOrigin
on thegraphicsLayer
lambda, this specifies the fraction towards the left/right (for X) or towards the top/bottom (for Y) from the center for the animation. To remain centered we would use0.5f
, for left/top we would use0f
, and forright/bottom
we would use1f
.
With this change, we get this result:
Now that we have this in place, we can start to put together our drawer composable.
The animated drawer
For the drawer we will use a slots composable, that is, a composable that accepts a certain number of child composables and places them within its bounds. This is how, for instance, the Scaffold
from the Material components works, we can specify a TopBar
, a BottomBar
, a Fab
and a main content, and they will all be placed within the Scaffold
, with the Scaffold
being agnostic to the actual content in each of the slots.
For our particular case we only have 2 slots, the drawer and the main pane content (we will later add a 3rd slot).
For the animated drawer we will use a Layout
composable, which is the most flexibly option when it comes to custom components, as we have complete control over the measurement and position of the children.
Before we get to the drawer implementation, we will need some content for both the drawer and the main pane, so let’s get that out of the way. This is just for demo purposes, we will be able to provide any content we want for both the drawer and the main pane, so I won’t go over the details of both of these composables, I’ll just share them here for completeness sake.
Drawer content:
@Composable
private fun SettingsOptions(
modifier: Modifier = Modifier,
onCloseClick: () -> Unit,
) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background,
) {
val lorem = "Lorem ipsum dolor sit amet consectetur adipiscing elit"
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Settings",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier
.weight(1f)
.padding(all = 16.dp),
)
IconButton(
onClick = onCloseClick,
modifier = Modifier
.padding(all = 16.dp)
) {
Icon(imageVector = Icons.Default.Close, contentDescription = "close")
}
}
lorem.split(" ").forEach { label ->
DrawerEntry(
label = label,
modifier = Modifier
.fillMaxWidth()
.clickable { }
.padding(vertical = 16.dp),
)
Divider(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
)
}
}
}
}
@Composable
fun DrawerEntry(
label: String,
modifier: Modifier = Modifier,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyLarge,
modifier = modifier,
textAlign = TextAlign.Center,
)
}
Main pane content:
@Composable
private fun CatList(
modifier: Modifier = Modifier,
onOpenClick: () -> Unit,
) {
val urls = remember {
List(100) {
val width = 400 + 20 * Random.nextInt(20)
val height = 400 + 20 * Random.nextInt(20)
"https://placekitten.com/$width/$height"
}
}
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = {
Text("Drawer Sample")
},
navigationIcon = {
IconButton(onClick = onOpenClick) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = "Open drawer",
)
}
}
)
}
) { paddingValues ->
LazyVerticalGrid(
columns = GridCells.Adaptive(200.dp),
modifier = Modifier.padding(paddingValues),
contentPadding = PaddingValues(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(items = urls) { url ->
CardImage(
url = url,
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
@Composable
fun CardImage(
url: String,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(true)
.build(),
modifier = Modifier
.aspectRatio(1f)
.padding(all = 16.dp)
.clip(shape = RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop,
contentDescription = null,
)
}
}
With this out of the way, let’s start fleshing out the drawer.
The drawer state
The first thing we want to do is define the state for our drawer. We need to control the position and scale of the drawer content and the main pane content, so we will need these attributes as part of our state:
- drawer width
- drawer translation X
- drawer elevation (to cast a shadow)
- content scale X
- content scale Y
- content translation X
- content transform origin
Besides these attributes, our state will also expose 2 methods, one to open the drawer and one to close it.
With these requirements, our state will be defined as shown below:
@Stable
interface AnimatedDrawerState {
val drawerWidth: Float
val drawerTranslationX: Float
val drawerElevation: Float
val contentScaleX: Float
val contentScaleY: Float
val contentTranslationX: Float
val contentTransformOrigin: TransformOrigin
suspend fun open()
suspend fun close()
}
We define the state as a Stable
interface, this is a contract that tells the Compose compiler that, whenever a property in the state changes, we will notify the compose framework of the change. Other than that, the interface maps to the requirements we described above.
Implementing the state
Next we are going to implement the state. We are going to migrate the logic from our test preview composable where we scaled a Box into a concrete class. However, instead of using an animateFloatAsState
which is meant to be used in composition, we will use an animatable
to drive the animation. Let’s see it:
private const val AnimationDurationMillis = 600
private const val DrawerMaxElevation = 8f
// 1
@Stable
class AnimatedDrawerStateImpl(
// 2
override val drawerWidth: Float,
) : AnimatedDrawerState {
// 3
private val animation = Animatable(0f)
// 4
override val drawerTranslationX: Float
get() = -drawerWidth * (1f - animation.value)
// 5
override val drawerElevation: Float
get() = DrawerMaxElevation * animation.value
// 6
override val contentScaleX: Float
get() = 1f - .2f * animation.value
// 7
override val contentScaleY: Float
get() = 1f - .2f * animation.value
// 8
override val contentTranslationX: Float
get() = drawerWidth * animation.value
// 9
override val contentTransformOrigin: TransformOrigin
get() = TransformOrigin(pivotFractionX = 0f, pivotFractionY = .5f)
// 10
override suspend fun open() {
animation.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = AnimationDurationMillis,
)
)
}
// 11
override suspend fun close() {
animation.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = AnimationDurationMillis,
)
)
}
}
- Our state implementation is also annotated with
Stable
. - We take as argument the drawer width, which we will need to compute the drawer translation.
- We create an
animatable
property, we will use this to drive the animation. Similar to how we used theaniamteFloatAsState
before, we will animate this between0f
and1f
. - Here we specify the drawer translation. This is straightforward, when the drawer is closed, i.e., with an animation value of
0f
, we want the drawer to be off to the left, just outside the screen, so the translation has to be the negative of the drawer width and, as the animated value moves towards1f
, we want the drawer to be back to the default position, so with a translation value of0f
. - This is the drawer elevation, which we will use to cast a shadow. We want the shadow to be gradual, starting at no shadow and ending with a shadow corresponding to
8f
. We do this for 2 reasons, if the shadow is applied all the time, we will see the shadow when the drawer is closed, as it would spill over the content, and by animating it, we make this effect more dynamic. - Next we calculate the scale X of the content, just like we did for our preview composable — from
1f
to0.8f
. - And we do the same for the Y axis.
- Here we calculate the content translation, it mimics the drawer translation, but starts at 0 and ends at the drawer width, so the content will move in sync with the drawer.
- The transform origin is constant and does not depend on the animation value.
- Here we implement the
open
method, which simply triggers the animation towards the1f
value. - And similarly, the
close
method, which triggers the animation towards the0f
value.
The state factory
Now that we have our state in place, we will also need a method to instantiate it, so we will define a factory method, as we’ve done in other stories:
@Composable
fun rememberAnimatedDrawerState(
drawerWidth: Dp,
): AnimatedDrawerState {
val density = LocalDensity.current.density
return remember {
AnimatedDrawerStateImpl(
drawerWidth = drawerWidth.value * density,
)
}
}
Now much to tell about this snippet, we use a remember
method to wrap the state, and we convert the drawer width from Dp
to a float
representing the value in pixels.
The drawer composable
It’s now time to define our drawer composable. As we mentioned earlier, we will use slots, so our composable will accept 2 composable lambdas, one for the drawer and one for the main pane content. As is customary, we will also accept a Modifier
that we will pass to the root composable and, finally, we will also receive the drawer state, so that our composable is stateless.
Placing the content will be straightforward now that we have the state dictating where the child composables need to be placed, we just need to measure them and then place them following the properties of the state.
Let’s have a look at the implementation and we’ll describe it:
@Composable
fun AnimatedDrawer(
// 1
modifier: Modifier = Modifier,
// 2
state: AnimatedDrawerState = rememberAnimatedDrawerState(
drawerWidth = 280.dp,
),
// 3
drawerContent: @Composable () -> Unit,
// 4
content: @Composable () -> Unit,
) {
// 5
Layout(
// 6
modifier = modifier,
// 7
content = {
drawerContent()
content()
}
) { measurables, constraints ->
// 8
val (drawerContentMeasurable, contentMeasurable) = measurables
// 9
val drawerContentConstraints = Constraints.fixed(
width = state.drawerWidth.coerceAtMost(constraints.maxWidth.toFloat()).toInt(),
height = constraints.maxHeight,
)
// 10
val drawerContentPlaceable = drawerContentMeasurable.measure(drawerContentConstraints)
// 11
val contentConstraints = Constraints.fixed(
width = constraints.maxWidth,
height = constraints.maxHeight,
)
// 12
val contentPlaceable = contentMeasurable.measure(contentConstraints)
// 13
layout(
width = constraints.maxWidth,
height = constraints.maxHeight,
) {
// 14
contentPlaceable.placeRelativeWithLayer(
IntOffset.Zero,
) {
// 15
transformOrigin = state.contentTransformOrigin
scaleX = state.contentScaleX
scaleY = state.contentScaleY
translationX = state.contentTranslationX
}
// 16
drawerContentPlaceable.placeRelativeWithLayer(
IntOffset.Zero,
) {
// 17
translationX = state.drawerTranslationX
shadowElevation = state.drawerElevation
}
}
}
}
- Our composable accepts a
Modifier
to let users customize its behaviour. - It also accepts the state, providing a default.
- Next we accept the drawer content, a
Composable
lambda. - And similarly, here we have the 2nd slot, a
Composable
lambda for the main pane. - Our composable is based on a
Layout
composable. - We pass the
modifier
received to theLayout
composable. - The content for the
Layout
is the 2 slots, the drawer content and the main pane content. - We split the
Measurable
s in the lambda so that we have direct access to the drawer measurable and the content measurable. - Here we build the constraints for the drawer content. We want the drawer to fill the width we receive as argument (constrained to the overall available width), and the full height.
- Once we have the drawer constraints, we can measure the drawer content.
- We do the same with the main pane content, we want it to fill all the available space.
- Once we have the content constraints, we can measure it.
- Once we have our content measured, it’s time to place it, using the
layout
method. - We place the content first, because we want the drawer to remain above the content. Note that we use the
placeRelativeWithLayer
method — this is a variant ofplaceRelative
that allows us to apply transformations to the content as it’s been placed, which is more performant than doing transformations at measure time, as we can skip the measure phase if nothing has changed. - The
placeRelativeWithLayer
accepts a lambda with aGraphicsLayerScope
receiver so that we can apply transformations like we did in the preview. In here we apply the scale transformation based on the state, with the corresponding transform origin, and the translation on the X axis. - Next we place the drawer content, similarly, we use
placeRelativeWithLayer
so that we can apply transformations at the place stage instead of the measure one. - Here we translate the drawer on the X axis and apply a shadow elevation, based on the current state.
Now that we have this in place, we just need to call this composable with our drawer and main pane content, which can do as shown below:
PlaygroundTheme {
Surface(
color = MaterialTheme.colorScheme.background
) {
// 1
val drawerState = rememberAnimatedDrawerState(
drawerWidth = 280.dp,
)
// 2
val scope = rememberCoroutineScope()
// 3
AnimatedDrawer(
modifier = Modifier.fillMaxSize(),
// 4
state = drawerState,
// 5
drawerContent = {
SettingsOptions(
modifier = Modifier.fillMaxSize(),
onCloseClick = {
// 6
scope.launch { drawerState.close() }
}
)
},
// 7
content = {
CatList(
modifier = Modifier.fillMaxSize(),
onOpenClick = {
// 8
scope.launch { drawerState.open() }
}
)
}
)
}
- We create an instance of the drawer state — we’ll see shortly why we need a reference to it here.
- We get a coroutine scope.
- Next we place our drawer.
- We pass the state to the drawer composable.
- We provide the drawer content, using the
SettingsOptions
composable we described earlier. - On the close lambda for the
SettingsOptions
composable we trigger the drawer close animation — that’s why we needed to hoist the drawer state as this level. - Next we add the main pane content.
- And similarly, we hook-up the open callback to trigger the drawer opening animation.
If we run this, we get this result:
Tweaking the animation
There are a couple of things we can do to improve this. First, the drawer is touching the content when it’s expanded, it would look better if there is a small gap between the 2 composables. Also, the scale animation is the same on both the X and Y axis, we can make it a bit more dynamic by changing them, by delaying one of them slightly. Let’s make those changes:
@Stable
class AnimatedDrawerStateImpl(
override val drawerWidth: Float,
// 1
private val drawerGap: Float,
) : AnimatedDrawerState {
private val animation = Animatable(0f)
// 2
private val animationY = Animatable(0f)
override val drawerTranslationX: Float
get() = -drawerWidth * (1f - animation.value)
override val drawerElevation: Float
get() = DrawerMaxElevation * animation.value
override val backgroundTranslationX: Float
get() = animation.value * drawerWidth
override val backgroundAlpha: Float
get() = .25f * animation.value
override val contentScaleX: Float
get() = 1f - .2f * animation.value
// 3
override val contentScaleY: Float
get() = 1f - .2f * animationY.value
override val contentTranslationX: Float
// 4
get() = (drawerWidth + drawerGap) * animation.value
override val contentTransformOrigin: TransformOrigin
get() = TransformOrigin(pivotFractionX = 0f, pivotFractionY = .5f)
override suspend fun open() {
// 5
coroutineScope {
launch {
animation.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = AnimationDurationMillis)
)
}
launch {
animationY.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = AnimationDurationMillis,
delayMillis = AnimationDurationMillis / 4,
),
)
}
}
}
override suspend fun close() {
// 6
coroutineScope {
launch {
animation.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = AnimationDurationMillis)
)
}
launch {
animationY.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = AnimationDurationMillis,
delayMillis = AnimationDurationMillis / 4,
),
)
}
}
}
}
// 7
@Composable
fun rememberAnimatedDrawerState(
drawerWidth: Dp,
drawerGap: Dp,
): AnimatedDrawerState {
val density = LocalDensity.current.density
return remember {
AnimatedDrawerStateImpl(
drawerWidth = drawerWidth.value * density,
drawerGap = drawerGap.value * density,
)
}
}
- Our state now accepts a new parameter, to specify the gap between the drawer and the content when the drawer is open.
- We create a 2nd animatable object to drive the scale animation on the Y axis.
- When we calculate the scale on the Y axis, we use the new animatable.
- When we calculate the translation for the main pane content, we include the
drawerGap
, factored by the animation value. - When we open the drawer we need to kick off both animatables, so we wrap them in a
coroutineScope
and then launch a coroutine for each animatable, so that they run in parallel. Note that the only difference here is that the animatable on the Y axis is delayed by 1/4 the duration of the animation. Other changes we could do would be to change the easing curve. - We do the same for the close method.
- And finally we update the factory method to accept the drawer gap in
Dp
and convert it to a pixels for the state.
If we run our app with these changes, we get this result:
Refactoring the state
Next we want to refactor the state so that we can handle different kinds of animations. The way the state is currently implemented is rigid, we want to add flexibility to handle different kinds of drawer animations without having to change too much in the state implementation.
To represent the different kinds of animations we want to support we will create a sealed class
. We choose this over enum
s because each animation may have different attributes, so we need a class so that we can pass any payload we need for the animation. For instance, for the current animation we have a gap when the drawer is open, so that’ll be the payload for this one.
Let’s create our sealed class with one child class for the current animation:
sealed interface DrawerMode {
data class SlideRight(
val drawerGap: Dp
) : DrawerMode
}
Next we need to change our state and state implementation to accept the drawer mode. We will also define a set of extension functions on the DrawerMode
to retrieve the information we need to drive the animations. We could add those as members to the sealed interface
, but defining them as extensions keeps the DrawerMode
cleaner and more focused on its purpose when it comes to using the drawer, just specifying what kind of animation we want.
Let’s update the drawer state to handle the new DrawerMode
:
@Stable
interface AnimatedDrawerState {
// 1
var density: Float
val drawerWidth: Dp
val drawerTranslationX: Float
val drawerElevation: Float
val backgroundTranslationX: Float
val backgroundAlpha: Float
val contentScaleX: Float
val contentScaleY: Float
val contentTranslationX: Float
val contentTransformOrigin: TransformOrigin
suspend fun open()
suspend fun close()
}
// 2
private val DrawerMode.scaleFactor: Float
get() = when (this) {
is DrawerMode.SlideRight -> .2f
}
// 3
private fun DrawerMode.translationX(
drawerWidth: Float,
fraction: Float,
density: Float,
) = when (this) {
is DrawerMode.SlideRight -> (drawerWidth + drawerGap.value * density) * fraction
}
// 4
private val DrawerMode.transformOrigin: TransformOrigin
get() = when (this) {
is DrawerMode.SlideRight -> TransformOrigin(pivotFractionX = 0f, pivotFractionY = .5f)
}
@Stable
class AnimatedDrawerStateImpl(
// 5
override val drawerWidth: Dp,
// 6
private val drawerMode: DrawerMode,
) : AnimatedDrawerState {
private val animation = Animatable(0f)
private val animationY = Animatable(0f)
// 7
override var density by mutableStateOf(1f)
override val drawerTranslationX: Float
get() = -drawerWidth.value * density * (1f - animation.value)
override val drawerElevation: Float
get() = DrawerMaxElevation * animation.value
override val backgroundTranslationX: Float
get() = animation.value * drawerWidth.value * density
override val backgroundAlpha: Float
get() = .25f * animation.value
// 8
override val contentScaleX: Float
get() = 1f - drawerMode.scaleFactor * animation.value
// 9
override val contentScaleY: Float
get() = 1f - drawerMode.scaleFactor * animationY.value
// 10
override val contentTranslationX: Float
get() = drawerMode.translationX(
drawerWidth = drawerWidth.value * density,
fraction = animation.value,
density = density,
)
// 11
override val contentTransformOrigin: TransformOrigin
get() = drawerMode.transformOrigin
override suspend fun open() {
// omitted for brevity
}
override suspend fun close() {
// omitted for brevity
}
}
// 12
@Composable
fun rememberAnimatedDrawerState(
drawerWidth: Dp,
drawerMode: DrawerMode,
): AnimatedDrawerState = remember {
AnimatedDrawerStateImpl(
drawerWidth = drawerWidth,
drawerMode = drawerMode,
)
}
- We will need the
density
for size calculations, so we update the interface with that property. - We create an extension method on
DrawerMode
to provide the scale factor for the main pane content. - We create another one for the main pain content translation on the X axis.
- And a third one for the transform origin.
- As we now have a
density
in our state, we no longer need to receive the drawer width in pixels and can instead accept it inDp
s. - And we also receive the drawer mode to specify the animation type to run.
- We define the overridden
density
property as amutableStateOf
so that, if it changes, we will trigger a recalculation of all the properties that depend on it. - When we compute the main pane content scale X, we defer to the
DrawerMode
extension function. - We do the same for the scale on the Y axis.
- And similarly, for the translation.
- And again for the transform origin.
- Finally we update the state factory method to accept the drawer mode and pass it to the drawer state instance. Note that we no longer convert the drawer width from
Dp
s to pixels.
There is one more change not listed above, we have to update the density
in the main block for the animated drawer composable:
@Composable
fun AnimatedDrawer(
modifier: Modifier = Modifier,
state: AnimatedDrawerState = rememberAnimatedDrawerState(
drawerWidth = 280.dp,
DrawerMode.SlideRight(drawerGap = 16.dp),
),
drawerContent: @Composable () -> Unit,
content: @Composable () -> Unit,
) {
Layout(
modifier = modifier,
content = {
drawerContent()
content()
}
) { measurables, constraints ->
// 1
state.density = density
// omitted for brevity
}
}
Now we have all the main pieces in place for our animated drawer. We will next add a different animation, to make use of the refactor we just went through.
Adding the slide behind animation
Like we did for the first animation, we will dry-run this on a preview composable, so that we can tweak it. We will start with this composable, which scales the content:
private const val CollapsedFaction = .2f
@Preview(widthDp = 240, heightDp = 360)
@Composable
fun PreviewAnimation() {
PlaygroundTheme {
Surface(
color = MaterialTheme.colorScheme.background
) {
var expanded by remember { mutableStateOf(false) }
val fraction by animateFloatAsState(
targetValue = if (expanded) 1f else 0f,
animationSpec = tween(durationMillis = 800),
)
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
scaleX = (1f - CollapsedFaction * fraction)
scaleY = (1f - CollapsedFaction * fraction)
}
.background(Color.Red),
contentAlignment = Alignment.Center,
) {
Button(onClick = { expanded = !expanded }) {
Text(text = "Toggle")
}
}
}
}
}
Defining the animation
For the 2nd animation, we want the main pane content to slide to the right and then back to the left, in an arc motion, as if it is moving out of the way from the drawer, as the drawer slides in from the left. When the drawer is fully expanded we want the content to be on the right edge, so we will reuse the TransformOrigin
approach, but this time we want the content to be on the right, so instead of specifying 0f
for the X pivot fraction, we will specify 1f
:
@Preview(widthDp = 240, heightDp = 360)
@Composable
fun PreviewAnimation() {
PlaygroundTheme {
Surface(
color = MaterialTheme.colorScheme.background
) {
var collapsed by remember { mutableStateOf(false) }
val fraction by animateFloatAsState(
targetValue = if (collapsed) 1f else 0f,
animationSpec = tween(durationMillis = 800),
)
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
transformOrigin = TransformOrigin(pivotFractionX = 1f, pivotFractionY = .5f)
scaleX = 1f - CollapsedFaction * fraction
scaleY = 1f - CollapsedFaction * fraction
}
.background(Color.Red),
contentAlignment = Alignment.Center,
) {
Button(onClick = { collapsed = !collapsed }) {
Text(text = "Toggle")
}
}
}
}
}
And we get this result:
Now we want to move the content in an arc, starting centered, moving towards the right edge, then returning back to the center. This sounds like a sinusoidal curve, between 0 and π. We don’t want the content to go all the way off the screen, so we will cap the translation on the X axis to a maximum of 60% of the drawer width. So, all we need to do is add this 1 line to the graphicsLayer
block above:
translationX = (240f * density * fraction * sin(PI * fraction)).toFloat()
Note that here I’m cheating and setting the width as 240f * density
because I know the preview is 240dp wide, which is fine for a dry-run, but in the proper implementation we will use the actual drawer width.
With this line added, out drawer animates as shown here:
OK, we’re ready to port this t our drawer state.
Updating the state with the new animation
This slide behind animation does not have any additional payload, so we will define it as an Object
in the sealed class hierarchy:
sealed interface DrawerMode {
data class SlideRight(
val drawerGap: Dp
) : DrawerMode
object SlideBehind : DrawerMode
}
And next we need to update the extension methods on DrawerMode
to provide the correct values for the new child class:
private val DrawerMode.scaleFactor: Float
get() = when (this) {
is DrawerMode.SlideRight -> .2f
// 1
DrawerMode.SlideBehind -> .4f
}
private fun DrawerMode.translationX(
drawerWidth: Float,
fraction: Float,
density: Float,
) = when (this) {
is DrawerMode.SlideRight -> (drawerWidth + drawerGap.value * density) * fraction
// 2
DrawerMode.SlideBehind -> ((.6f * drawerWidth) * sin(fraction * PI)).toFloat()
}
private val DrawerMode.transformOrigin: TransformOrigin
get() = when (this) {
is DrawerMode.SlideRight -> TransformOrigin(pivotFractionX = 0f, pivotFractionY = .5f)
// 3
DrawerMode.SlideBehind -> TransformOrigin(pivotFractionX = 1f, pivotFractionY = .5f)
}
- We update the
scaleFactor
for the new animation — when we slide behind we want the content to appear as it’s moving further away from the user, so we scale it so that it appears smaller than when we just slide it to the right. - For the translation along the X axis for the main pane content we use the sinusoidal curve we got on the dry-run. Here we have the drawer width available, so we apply a
0.6f
factor to it as we did earlir. - And for the transform origin, we provide a transform that will scale the content while keeping the right edge stuck to the right side.
Thanks to the refactoring we did when moving the first animation to a sealed interface
we can very easily add a new animation to the drawer, there are no further changes required because the state implementation is delegating the calculation to the DrawerMode
, so that’s all we need to do.
To test this we just need to change how we call the AnimatedDrawer
to pass the new animation in the state:
val drawerState = rememberAnimatedDrawerState(
drawerWidth = 280.dp,
drawerMode = DrawerMode.SlideBehind,
)
that’s it, if we run this we get this result:
We are pretty much done, but we can do a small improvement. When the main content slides out of the way there is a lot of blank space behind it — we can provide another slot to our drawer to fill that in.
Adding the main pane background
We will expand our animated drawer to allow us to specify an optional background, to render behind the main pane content. We will also apply an alpha to the background, starting from fully transparent when the drawer is closed and going up to 0.25f
when the drawer is open, so that it fills the background but without being too distracting.
First we will update our state and implementation to provide the background attributes we need to apply to the background:
@Stable
interface AnimatedDrawerState {
var density: Float
val drawerWidth: Dp
val drawerTranslationX: Float
val drawerElevation: Float
// 1
val backgroundTranslationX: Float
// 2
val backgroundAlpha: Float
val contentScaleX: Float
val contentScaleY: Float
val contentTranslationX: Float
val contentTransformOrigin: TransformOrigin
suspend fun open()
suspend fun close()
}
- We add a property to specify how the background needs to move along the X axis.
- And a second property to specify its transparency.
Next we update the state implementation to provide these values:
@Stable
class AnimatedDrawerStateImpl(
override val drawerWidth: Dp,
private val drawerMode: DrawerMode,
) : AnimatedDrawerState {
// 1
override val backgroundTranslationX: Float
get() = animation.value * drawerWidth.value * density
// 2
override val backgroundAlpha: Float
get() = .25f * animation.value
// remainder omitted for brevity
}
- The background translation mimics the drawer translation, but starting at 0 (no translation) and then sliding to the right so that its left edge is always aligned with the drawer.
- The alpha starts at
0f
and progresses to0.25f
as the animation advances.
Now that we have the state in place, we need to update the animated drawer composable to draw the background. Let’s see these changes:
@Composable
fun AnimatedDrawer(
modifier: Modifier = Modifier,
state: AnimatedDrawerState = rememberAnimatedDrawerState(
drawerWidth = 280.dp,
DrawerMode.SlideRight(drawerGap = 16.dp),
),
drawerContent: @Composable () -> Unit,
// 1
background: @Composable () -> Unit = {},
content: @Composable () -> Unit,
) {
Layout(
modifier = modifier,
content = {
drawerContent()
// 2
background()
content()
}
) { measurables, constraints ->
state.density = density
val drawerWidthPx = state.drawerWidth.value * density
// 3
val (drawerContentMeasurable, backgroundMeasurable, contentMeasurable) = measurables
val drawerContentConstraints = Constraints.fixed(
width = drawerWidthPx.coerceAtMost(constraints.maxWidth.toFloat()).toInt(),
height = constraints.maxHeight,
)
val drawerContentPlaceable = drawerContentMeasurable.measure(drawerContentConstraints)
val contentConstraints = Constraints.fixed(
width = constraints.maxWidth,
height = constraints.maxHeight,
)
val contentPlaceable = contentMeasurable.measure(contentConstraints)
// 4
val backgroundPlaceable = backgroundMeasurable.measure(
Constraints.fixed(
width = constraints.maxWidth,
height = constraints.maxHeight,
)
)
layout(
width = constraints.maxWidth,
height = constraints.maxHeight,
) {
// 5
backgroundPlaceable.placeRelativeWithLayer(
IntOffset.Zero
) {
// 6
translationX = state.backgroundTranslationX
alpha = state.backgroundAlpha
}
contentPlaceable.placeRelativeWithLayer(
IntOffset.Zero,
) {
transformOrigin = state.contentTransformOrigin
scaleX = state.contentScaleX
scaleY = state.contentScaleY
translationX = state.contentTranslationX
}
drawerContentPlaceable.placeRelativeWithLayer(
IntOffset.Zero,
) {
translationX = state.drawerTranslationX
shadowElevation = state.drawerElevation
}
}
}
}
- We update the method signature to accept a background composable, which we default to an empty lambda.
- We execute this lambda as part of the content for the
Layout
composable. - We extract the background measurable from the list of measurables.
- We measure the background, forcing it to fill the full available space on the
Layout
composable. - We place the background using a
placeRelativeWithLayer
so that we can apply a transformation, like we did for the others. The background is the first element to be placed, so that it remains behind everything else. - And finally we translate and apply an alpha to the background based on the current state.
Now we can provide a background like this:
AnimatedDrawer(
modifier = Modifier.fillMaxSize(),
state = drawerState,
drawerContent = {
SettingsOptions(
modifier = Modifier.fillMaxSize(),
onCloseClick = {
scope.launch {
drawerState.close()
}
}
)
},
background = {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://placekitten.com/1200/1200")
.crossfade(true)
.build(),
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
contentDescription = null,
)
},
content = {
CatList(
modifier = Modifier.fillMaxSize(),
onOpenClick = {
scope.launch { drawerState.open() }
}
)
}
)
and if we run this, we get our final result:
On a phone the image we chose as background does not show that well, it still meets our needs of filling in the space behind the main pane content. However, on a larger device or tablet we can appreciate this better:
And here’s a slow motion version of the animation:
And this concludes this story. There are further improvements we could add to the animated drawer, for instance we could add an overlay that shows over the main content when the drawer is open, which we could also use to trap touch events while the drawer is opened, and we could add further animations. These improvements are left as an exercise to the reader.
The full code is available in this gist. I hope you found this useful, have a great one, and I’ll see you on the next story!