Blog Infos
Author
Published
Topics
,
Published

This article will teach us how to build Kotlin extensions for Activity and fragment to display bottom sheets using Jetpack compose.

 

Photo by Ziv Kesten

The target

One of the sleekest UI components out there is the Bottom sheet, a view that pops up from the bottom of the screen to display some information or represent an inner flow without fully leaving its current context.

It is used by many apps today and will probably be of use to you in your app one day as well.

Background

Displaying a bottom sheet using Jetpack compose is documented well in several articles like this one by

Ahmed Tikiwa, Senior Software Engineer @Luno, which is using ModalBottomSheetLayout.

And that one by Rasul Aghakishiyev, which focuses on BottomSheetScaffold.

In our example, we chose ModalBottomSheetLayout since it enables us to get a state change by tapping outside the bottom sheet area

The code (TL;DR)

If you just came here for the code, here it is, we will break it down later in this post.

// Extension for Activity
fun Activity.showAsBottomSheet(content: @Composable (() -> Unit) -> Unit) {
val viewGroup = this.findViewById(android.R.id.content) as ViewGroup
addContentToView(viewGroup, content)
}
// Extension for Fragment
fun Fragment.showAsBottomSheet(content: @Composable (() -> Unit) -> Unit) {
val viewGroup = requireActivity().findViewById(android.R.id.content) as ViewGroup
addContentToView(viewGroup, content)
}
// Helper method
private fun addContentToView(
viewGroup: ViewGroup,
content: @Composable (() -> Unit) -> Unit
) {
viewGroup.addView(
ComposeView(viewGroup.context).apply {
setContent {
BottomSheetWrapper(viewGroup, this, content)
}
}
)
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun BottomSheetWrapper(
parent: ViewGroup,
composeView: ComposeView,
content: @Composable (() -> Unit) -> Unit
) {
val TAG = parent::class.java.simpleName
val coroutineScope = rememberCoroutineScope()
val modalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
var isSheetOpened by remember { mutableStateOf(false) }
ModalBottomSheetLayout(
sheetBackgroundColor = Color.Transparent,
sheetState = modalBottomSheetState,
sheetContent = {
content {
// Action passed for clicking close button in the content
coroutineScope.launch {
modalBottomSheetState.hide() // will trigger the LaunchedEffect
}
}
}
) {}
BackHandler {
coroutineScope.launch {
modalBottomSheetState.hide() // will trigger the LaunchedEffect
}
}
// Take action based on hidden state
LaunchedEffect(modalBottomSheetState.currentValue) {
when (modalBottomSheetState.currentValue) {
ModalBottomSheetValue.Hidden -> {
when {
isSheetOpened -> parent.removeView(composeView)
else -> {
isSheetOpened = true
modalBottomSheetState.show()
}
}
}
else -> {
Log.i(TAG, "Bottom sheet ${modalBottomSheetState.currentValue} state")
}
}
}
}
A portal to compose world

When working in the Android view system, we often stumble on UI components we wish we could just write with Jetpack Compose and dump into our legacy views. Thanks to Jetpack Compose interoperability, We can!

To do that we declare an extension function for Activity like this

inline fun Activity.showAsBottomSheet(
    content: @Composable (() -> Unit) -> Unit
) {    
    val viewGroup = this.findViewById(
                             android.R.id.content) as ViewGroup    
    addContentToView(viewGroup, content)
}

This extension function, which can be called anywhere on any Activity, takes a composable function as a parameter:

content: @Composable (() -> Unit) -> Unit

Notice that the parameter type is a Kotlin function that takes a lambda as a parameter, this will be the action for closing the bottom sheet if we want this event to be propagated by a user event (close button tap, network response, etc).

We will extract the content viewGroup which is the top view in any activity hierarchy.

val viewGroup = this.findViewById(android.R.id.content) as ViewGroup

We will add the bottom sheet to that viewGroupso that it can be displayed on top of any content currently in the Activity.

private fun addContentToView(
    viewGroup: ViewGroup,
    content: @Composable (() -> Unit) -> Unit
) {
    viewGroup.addView(
        ComposeView(viewGroup.context).apply {
            setContent {
                BottomSheetWrapper(viewGroup, this, content)
            }
        }
    )
}
Jetpack Compose content in an Android view.

To add composable content to the viewGroup we simply add a new ComposeView to it.
The ComposeView is an Android view that can set a composable content using setContent extension just like an Activity can.

In its content block, we will place our composable, wrapped in aBottomSheetWrapper, this wrapper will contain all the bottom sheet logic and will be populated by the composable content we passed as a parameter to the extension function.

Job Offers

Job Offers


    Sr. Software Development Engineer, Last Mile Driver Assistance Technology

    Amazon
    Berlin
    • Full Time
    apply now

    Developer (m/w/d) Backend/ Mobile

    Payback GmbH
    Cologne, Germany
    • Full Time
    apply now

    API Engineer

    American Express
    Phoenix, USA
    • Full Time
    apply now
Load more listings

OUR VIDEO RECOMMENDATION

, ,

Automated migration of Android apps to Bazel build system

Migrating large projects that consist of hundreds or thousands of modules and being maintained by a large team, from Gradle to Bazel might be challenging. I would like to discuss the process of automation of…
Watch Video

Automated migration of Android apps to Bazel build system

Pavlo Stavytskyi
Software Engineer
Lyft

Automated migration of Android apps to Bazel build system

Pavlo Stavytskyi
Software Engineer
Lyft

Automated migration of Android apps to Bazel build system

Pavlo Stavytskyi
Software Engineer
Lyft

Jobs

Wrap it up! (the composables, that is)

So most of our heavy lifting is done in BottomSheetWrapper:

As is in any code that is going to be used in a Composition, we store variables as remember elements.

val coroutineScope = rememberCoroutineScope()
val modalBottomSheetState = rememberModalBottomSheetState(
    ModalBottomSheetValue.Hidden
)
val isSheetOpened = remember { mutableStateOf(false) }

So we keep three remembered values:

Coroutine scope

We want to have a remembered CoroutineScope so that we can launch slide animations in a coroutine to improve performance.

val coroutineScope = rememberCoroutineScope()
Bottom sheet state

The bottom sheet state will be passed into the content as well so that it can react to changes, it will also be observed in a LaunchedEffect to make sure that pulling the bottom sheet down all the way will initiate exit()

val modalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden)
Bottom sheet open — initial state

We will remember the initial state of the sheet as well since we want to know if the sheet is closed due to user action or is closed since we have not animated it into the view yet.

val isSheetOpened = remember { mutableStateOf(false) }
The bottom sheet composable content

Jetpack compose provides two components that can handle bottom sheet modal behavior, and as we mentioned earlier we will use ModalBottomSheetLayout for our case, since we get the behavior of hiding the bottom sheet while clicking outside the bottom sheet.

ModalBottomSheetLayout(
    sheetBackgroundColor = Color.Transparent,
    sheetState = modalBottomSheetState,
    sheetContent = { 
        content {            
          // Action passed for clicking close button in the content
          coroutineScope.launch {      
              modalBottomSheetState.hide() // trigger LaunchedEffect
          }
       }
    }
) {}

We will provide it with the sheetState we remembered earlier, and content() that is the actual content for the bottom sheet and will be provided to the extension function as a parameter.
We also provide the content with an action to take once exit flow is initiated from inside the content (like pressing an X or close button for example).

Observing the bottom sheet state

There are three scenarios in which we want to close and remove the bottom sheet.

The click event from the content was handled earlier when we declared:

sheetContent = { 
        content {            
          // Action passed for clicking close button in the content
          coroutineScope.launch {      
              modalBottomSheetState.hide() // trigger LaunchedEffect
          }
       }
    }

Clicking back on the device will execute the same code under BackHandler helper function provided by Jetpack dependencies

// clicking back on device
BackHandler {
    coroutineScope.launch {
        modalBottomSheetState.hide() // trigger the LaunchedEffect
    }
}
Observing the bottom sheet state

As we have seen, when we are done with the bottom sheet we call hide(), on the modalBottomSheetState which animates the sheet out of view.
But the bottom sheet is still attached to the viewGroup of the Activity.

Remember we called:

viewGroup.addView(ComposeView(...).apply { ... }

So we still need to remove the view from the Activity, and we can do that by listening to the bottom sheet state using LaunchedEffect

// Take action based on hidden state
LaunchedEffect(modalBottomSheetState.currentValue) {
    when (modalBottomSheetState.currentValue) {
        ModalBottomSheetValue.Hidden -> { 
           parent.removeView(composeView)
        }
        else -> { Log.i(TAG, "Bottom sheet ${...} state") }
    }
}
Edge case

There is an edge case when we first initialize the activity and the bottom sheet is hidden.
In this case, we use the isSheetOpened we remembered earlier and show the bottom sheet while initializing this variable, in all other cases, we call removeView

// Take action based on hidden state
LaunchedEffect(modalBottomSheetState.currentValue) {
    when (modalBottomSheetState.currentValue) {
        ModalBottomSheetValue.Hidden -> {
            when {
                isSheetOpened -> parent.removeView(composeView)
                else -> {
                    isSheetOpened = true
                    modalBottomSheetState.show()
                }
            }
        }
        else -> { Log.i(TAG, "Bottom sheet ${...} state") }
    }
}

And that’s it! we can now show composables as bottom sheets, on top of the legacy Android view they can be dragged up and down, display important information, or start a side flow without leaving the main flow context visually!

 

 

The full sample code is available on GitHub and can be viewed here
I hope you enjoyed the article, make sure you clap and follow!
But only if you think I deserve it 😉

This article was originally published on proandroiddev.com on June 16, 2022

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published.

Fill out this field
Fill out this field
Please enter a valid email address.

Menu