All Articles

Custom Side Drawer In Jetpack Compose

One of my side projects, that uses Jetpack compose, needed a side drawer to support filtering functionality for a list view. androidx.compose.material.Scaffold supports adding a Drawer and handling opening and closing it but it doesn’t allow you to customise the drawer behaviour.

For example, I wanted my drawer to open and close from the right hand of the screen, under the topBar and pushing the content to the left when the drawer is open. Here’s a GIF to explain.

jetpack compose right-side drawer

Breaking It Down

Let’s first understand what we are aiming for. We will be breaking down into small steps:

  1. We need to display the main content and it needs to take the full size of the screen
  2. We need a side panel that will take roughly half the screen width
  3. When the side panel is in the closed state, it shouldn’t be seen by the user
  4. When the side panel is in the open state, it should take half the screen and not overlap with the main content
  5. Animating opening and closing the side panel

Displaying The Main Content

This part can be easily achieved by using `Scaffold`. Something like this would suffice

@Composable
fun MainContent() {
    return Column {
        // Some content here
    }
}

@Composable
fun ScaffoldWithPanel() {
    Scaffold(
        topBar = { ... },
        bottomBar = { ... },
        bodyContent = {
            MainContent()
        }
    )
}

Adding The Side Panel

For the panel, we can create a composable to return the body of the panel and then we can use Box to wrap both the main content and the side drawer panel.

@Composable
fun SidePanel() {
    return Column {
        // Some content here
    }
}

@Composable
fun MainContent() {
    return Column {
        // Some content here
    }
}

@Composable
fun ScaffoldWithPanel() {
    Scaffold(
        topBar = { ... },
        bottomBar = { ... },
        bodyContent = {
            Box {
                MainContent()
                SidePanel()
            }
        }
    )
}

Box is a layout composable that allows us to stack children layouts on top of each other. This means that as it stands, you would end up with something looking like this image:

Effect of using Box

Hiding The Side Panel When Closed

To be able to hide the panel when it is closed and display it when it’s open, we need to represent its state and store in the ScaffoldWithPanel composable.

We can easily do that either by creating a new enum SidePanelState:

enum class SidePanelState {
    Open, Closed
}

or we can use an existing enum provided by androidx.compose.material package. In this case, we are going to use the one provided to us. It’s called DrawerValue, which is part of DrawerState used by androidx.compose.material.Scaffold. It is defined as follows:

/**
 * Possible values of [DrawerState].
 */
enum class DrawerValue {
    /**
     * The state of the drawer when it is closed.
     */
    Closed,

    /**
     * The state of the drawer when it is open.
     */
    Open
}

We can wrap DrawerValues in a MutableState object and read/write to it from our ScaffoldWithPanel composable.

@Composable
fun ScaffoldWithPanel() {

    val drawerState = remember { mutableStateOf(DrawerValue.Closed) }
    
    Scaffold(
        topBar = { ... },
        bottomBar = { ... },
        bodyContent = {
            Box {
                MainContent()
                SidePanel()
            }
        }
    )
}

Wrapping the call to SidePanel around if condition would guarantee that the side panel is visible only if the state value is equal to DrawerValue.Open

@Composable
fun ScaffoldWithPanel() {

    val drawerState = remember { mutableStateOf(DrawerValue.Closed) }
    
    Scaffold(
        topBar = { ... },
        bottomBar = { ... },
        bodyContent = {
            Box {
                MainContent()
                if(drawerState.value != DrawerValue.Open) {
                    SidePanel()    
                }
            }
        }
    )
}

In the topBar we can add a button to toggle drawerState. For example, in here we are adding a TextButton:

@Composable
fun ScaffoldWithPanel() {
        val drawerState = ...
        Scaffold(
        topBar = {
            TopAppBar(
                title = ...,
                actions = {
                    TextButton(
                        onClick = {
                            if (drawerState.value == DrawerValue.Closed) {
                                drawerState.value = DrawerValue.Open
                            } else {
                                drawerState.value = DrawerValue.Closed
                            }
                        }
                    ) {
                        Text(
                            text = "Filter"
                        )
                      }
                },
                backgroundColor = ....
            )
        },
        ....
    )
}

Avoiding Overlap Between Main Content and Side Panel

To avoid overlapping the main content and the side panel, we need to size the panel correctly and move the main content out of the way when needed.

The side panel should fill half the width of its parent and to do that we can use WithConstraints which will allow us to get details about the parent size and direction. For now, we are only interested in width:

@Composable
fun ScaffoldWithPanel() {
    Scaffold(
        topBar = { ... },
        bottomBar = { ... },
        bodyContent = {
            WithConstraints {
                val parentWidth = with(AmbientDensity.current) {
                    constraints.maxWidth.toDp()
                }
                val parentHeight = with(AmbientDensity.current) {
                    constraints.maxHeight.toDp() 
                }
                Box {
                    MainContent()
                    if(drawerState.value != DrawerValue.Open) {
                        Box(modifier = Modifier.size(parentWidth / 2, height = parentHeight)) {
                            SidePanel()   
                        }
                    }
                }
            }
        }
    )
}

With this logic so far, we should be to show/hide the side panel. When the side panel is visible we are seeing something like this.

Side Panel opening from the left with width of half the screen

We can offset the location of the panel by half of its parent which will cause to fill the right side of the parent instead of the left side:

@Composable
fun ScaffoldWithPanel() {
    Scaffold(
        topBar = { ... },
        bottomBar = { ... },
        bodyContent = {
            WithConstraints {
                val parentWidth = with(AmbientDensity.current) {
                    constraints.maxWidth.toDp()
                }
                val parentHeight = with(AmbientDensity.current) {
                    constraints.maxHeight.toDp() 
                }
                Box {
                    MainContent()
                    if(drawerState.value != DrawerValue.Open) {
                        Box(
                            modifier = Modifier
                                .size(parentWidth / 2, height = parentHeight)
                                .offest(x = parentWidth / 2)
                        ) {
                            SidePanel()   
                        }
                    }
                }
            }
        }
    )
}

So we end up with the following:

Side Panel opening displaying on the right with width of half the screen

Finally, to avoid the overlap between the side panel and the main content, we can offset the main content in the negative by direction half the parent width. Basically shifting it to the left. That’s only if drawerState says that the side panel is open:

@Composable
fun ScaffoldWithPanel() {
    Scaffold(
        topBar = { ... },
        bottomBar = { ... },
        bodyContent = {
            WithConstraints {
                val parentWidth = with(AmbientDensity.current) {
                    constraints.maxWidth.toDp()
                }
                val parentHeight = with(AmbientDensity.current) {
                    constraints.maxHeight.toDp() 
                }
                val mainContentModifier = if (drawerState.value == DrawerValue.Open) {
                    Modifier.offset(x = -parentWidth / 2)
                } else {
                    Modifier
                }
                Box {
                    Box(
                        modifier = mainContentModifier
                    ) {
                        MainContent()
                    }
                    if(drawerState.value != DrawerValue.Open) {
                        Box(
                            modifier = Modifier
                                .size(parentWidth / 2, height = parentHeight)
                                .offest(x = parentWidth / 2)
                        ) {
                            SidePanel()   
                        }
                    }
                }
            }
        }
    )
}

This means that our screen should look as follows:

Side panel displayed on the right not overlapping main content

Animating Opening and Closing Side Panel

The animation we will be looking at is gradually increasing/decreasing the offsets when opening and closing the side panel. This means that the animation is a fire-once animation that doesn’t repeat itself.

So using animate(target = Offset()) should be enough to animate the main content offset changes:

@Composable
fun ScaffoldWithPanel() {
    Scaffold(
        topBar = { ... },
        bottomBar = { ... },
        bodyContent = {
            WithConstraints {
                val parentWidth = with(AmbientDensity.current) {
                    constraints.maxWidth.toDp()
                }
                val parentHeight = with(AmbientDensity.current) {
                    constraints.maxHeight.toDp() 
                }
                val mainContentAnimation = animate(
                    target = Offset(
                        x = if (drawerState.value == DrawerValue.Open) {
                            -parentWidth.value / 2
                        } else {
                            0f
                        },
                        y = 0f
                    ),
                )
                Box {
                    Box(
                        modifier = Modifier.offset(
                            x = mainContentAnimation.x.dp,
                            y = mainContentAnimation.y.dp
                        )
                    ) {
                        MainContent()
                    }
                    if(drawerState.value != DrawerValue.Open) {
                        Box(
                            modifier = Modifier
                                .size(parentWidth / 2, height = parentHeight)
                                .offest(x = parentWidth / 2)
                        ) {
                            SidePanel()   
                        }
                    }
                }
            }
        }
    )
}

To sum it all up, we used WithConstraints to get the parent width, stacked both the main content and the side panel using Box and animated offset changes with animate to avoid any overlapping them.