This article answers:
-
How to handle back press for:
ModalBottomSheet, AlertDialog
or any other modal window. - What to do if there are several modals on the screen.
The proposed approach is universal and can be applied to other similar cases.
Let’s dive into it. Here is the main utility class:
/** * This class helps to unitize back navigation. There can be several folded BackNavElements. * * Each time user presses the Back button, the whole chain is asked one-by-one starting from the most child node * whether any handler needs to process itself. In case it needs - it processes and returns the Result.CANNOT_GO_BACK. * Whole chain do not proceed if ANY of the elements returns the Result.CANNOT_GO_BACK. * * So, the chain is processed only if ALL of its sub handles returned the Result.CAN_GO_BACK. * * This utilize the cases when user has several dialogs on the screen that needs to be closed * one-by-one. */ class BackNavElement private constructor( private var child: BackNavElement? = null, private val handler: () -> Result ) { enum class Result { CANNOT_GO_BACK, CAN_GO_BACK } /** * Adds element to the END of the chain. */ fun add(element: BackNavElement?) { this.child?.let { it.add(element) } ?: run { this.child = element } } fun tryGoBack(): Result { if (child?.tryGoBack() == Result.CANNOT_GO_BACK) { return Result.CANNOT_GO_BACK } return handler() } companion object { fun default(child: BackNavElement? = null, handler: () -> Unit) = BackNavElement( child = child, handler = { handler() BackNavElement.Result.CAN_GO_BACK }) fun needsProcessing(child: BackNavElement? = null, handler: () -> Result) = BackNavElement(child = child, handler = handler) } }
Main takeaway points:
BackNavElement
class allows organizing a linked list of handlers.- Handler — is a callback that must return Result.
Result
can beCANNOT_GO_BACK or
CAN_GO_BACK.
add
function adds a new element to the end of the chain.companion object
has convenient fabric methods forBackNavElement
creation.
Now let’s look at the example to understand how we can take advantage of it.
Confirmation dialog
Use case:
- The user is on the screen doing some editing/other important operation.
- The user presses the Back button.
- The app shows a confirmation dialog.
- If the user confirms the exit — the app navigates back.
- If the user dismisses the dialog or cancels the exit — the app hides the dialog and stays on the screen.
ExitConfirmDialog.kt
@Composable fun ExitDialog(@StringRes bodyResId: Int, onNavIconClicked: () -> Unit): BackNavElement { val showDialog = rememberMutableStateOf(value = false) val defaultBackHandler = BackNavElement.needsProcessing { if (!showDialog.value) { showDialog.value = true BackNavElement.Result.CANNOT_GO_BACK } else { BackNavElement.Result.CAN_GO_BACK } } if (showDialog.value) { DefaultAlertDialog( onDismiss = { showDialog.value = false }, titleResId = R.string.exit_confirmation_title, bodyResId = bodyResId, positiveButtonResId = R.string.confirm, onPositiveClick = { showDialog.value = false onNavIconClicked() }, negativeButtonResId = R.string.cancel ) } return defaultBackHandler }
Client Composable
content:
val rootBackHandler = ExitDialog( bodyResId = R.string.exit_confirmation_body, onNavIconClicked = onNavIconClicked ) DefaultBackHandler(rootBackHandler) // other views such as Text, Icon, etc.
Here onNavIconClicked
is a callback that does navController.popBackStack
() on a higher level.
DefaultBackHandler:
@Composable fun DefaultBackHandler(backNavElement: BackNavElement) = BackHandler { backNavElement.tryGoBack() }
Where BackHandler
is the default Composable
provided by the framework.
What happens here?
- The user presses the Back button.
- The app checks if the dialog is not shown — it shows it.
- If the dialog is already shown — it will be dismissed.
So the only way to go back is to press theConfirm
button on AlertDialog
.
Let’s look at another example.
ModalBottomSheet dialog
Use case:
There is a ModalBottomSheet
dialog on the screen. It may be hidden or shown.
- The user presses the Back button while the dialog is shown — dialog changes its state to hidden.
- The user presses the Back button while the dialog is hidden —
onNavIconClicked
is invoked.
To archive it the only thing that needs to be added to the client code is:
DefaultBackHandler( BackNavElement.default( child = modalBackNavElement(modalState, coroutineScope), handler = onNavIconClicked ) )
Where modalBackNavElement() is a helper method:
@OptIn(ExperimentalMaterialApi::class) fun modalBackNavElement( state: ModalBottomSheetState, coroutineScope: CoroutineScope, callback: (() -> Unit)? = null ) = BackNavElement.needsProcessing { if (state.isVisible) { state.hideAnd(coroutineScope = coroutineScope, thenCallback = { callback?.invoke() }) BackNavElement.Result.CANNOT_GO_BACK } else { BackNavElement.Result.CAN_GO_BACK } }
hideAnd()
:
@OptIn(ExperimentalMaterialApi::class) fun ModalBottomSheetState.hideAnd( coroutineScope: CoroutineScope, thenCallback: () -> Unit ) { coroutineScope.launch { if (isVisible) { hide() thenCallback() } } }
That’s it! The next example shows how to build chains.
Chains
Imagine that the screen has several states. They are represented as separate Composable
functions. The appropriate Composable
function is shown depending on the view state.
The Composable
ChildComposable2
has 2 modals. They need to be correctly handled when the user presses the Back button.
So the screen root Composable contains:
val rootBackHandler = ExitDialog( bodyResId = R.string.exit_confirmation_body, onNavIconClicked = onNavIconClicked ) DefaultBackHandler(rootBackHandler)
Job Offers
Then depending on the state ChildComposable2
may be added into the composition.
@Composable fun ChildComposable2( rootBackHandler: BackNavElement, // other parameters ) { rootBackHandler.apply { add(modalBackNavElement(addWalletModalState, coroutineScope)) add(modalBackNavElement(walletsListModalState, coroutineScope)) // other views such as Text, Icon, etc. } }
As you see, the only thing that is needed is to pass rootBackHandler
to the child Composable
. This Composable
adds its handlers via add()
method.
The whole chain will be processed in the next priority — walletsListModalState
, addWalletModalState
, rootBackHandler
.
This can be scaled to any number of child Composables
or modal windows.
This is an example how several modals on the same screen can be properly handled
Conclusion
Although you may think that the solution is complex actually it’s already simplified. Previously I had children
instead of child
in the BackNavElement
class. However, with some time passed I realized that it was overkill for my app. But if you have heavy and complex screens — you may find it useful for your use cases.
I think that maybe some libraries exist to utilize all the work. If not — write a comment, perhaps I will do some.
If you know simpler (but still flexible) solutions — let me know.
Happy coding! Follow me on Twitter.
This article was originally published on proandroiddev.com on March 10, 2022