DEV Community

loading...
Cover image for Handling back presses in Jetpack Compose

Handling back presses in Jetpack Compose

pawegio profile image Paweł Gajda ・4 min read

The Navigation component might help us in implementing the navigation between screens in Android apps. There is also a dedicated navigation compose dependency that supports UI declared in Jetpack Compose toolkit.

We just need to create the NavHost, pass the NavController instance and define composable destinations:

val navController = rememberNavController()

NavHost(
    navController, 
    startDestination = "list"
) {
    composable("list") { List() }
    composable("detail") { Detail() }
}
Enter fullscreen mode Exit fullscreen mode

It's very intuitive and straightforward. There is no unnecessary boilerplate code if we want to implement a simple navigation.

I will assume that you already know the basics of Jetpack Compose and the Navigation component. If you don't, I suggest you to catch up first. Jetpack Compose Navigation is well described in the official docs.

Time to focus on the thread mentioned in the title - handling back presses. First of all, let's examine the following composable destination declaration:

@Composable
fun Detail() {
    val viewModel = getViewModel<DetailViewModel>()
    val state by viewModel.state.collectAsState()
    Detail(
        state = state,
        onBack = { viewModel.onAction(SelectBack) }
    )
}

@Composable
fun Detail(
    state: DetailState,
    onBack: () -> Unit,
) {
    Scaffold(
        topBar = { DetailTopAppBar(onBack) }
    ) {
        // detail content
    }
}

@Composable
fun DetailTopAppBar(onBack: () -> Unit) {
    TopAppBar(
        title = { Text("Detail") },
        navigationIcon = {
            IconButton(onClick = onBack) {
                Icon(
                    imageVector = Icons.Filled.ArrowBack,
                    contentDescription = "Back",
                )
            }
        }
    )
}
Enter fullscreen mode Exit fullscreen mode

I extracted two stateless composables. This makes the code more readable, but primarily it defers the UI declaration from the top composable function that collects the state and interacts with a resolved ViewModel instance.

Assume that the logic is extracted to the ViewModel class that extends the abstract class below. In this blog post we don't care about its implementation details.

abstract class DetailViewModel : ViewModel() {
    abstract val state: StateFlow<DetailState>
    abstract fun onAction(action: Action)
}
Enter fullscreen mode Exit fullscreen mode

It showcases a simple unidirectional data flow, which is a recommended pattern for modelling the UI state and user interaction. This pattern also fits incredibly well with Jetpack Compose.

Let's go back to the point. As you can see above, the DetailTopAppBar contains the IconButton composable function that invokes the onBack lambda. The expression passed in the top Detail() function emits a SelectBack action and it's consumed by a DetailViewModel instance. Then, depending on the business logic, it might navigate up to the parent destination or do something else. It's not the responsibility of the UI layer to think about the consequences of emitting that action. We're skipping the business logic layer.

It's a high time to answer the following question:

What if a user presses the system back button instead of the navigation icon?

Given the fact that we use Jetpack Compose Navigation (or we use the FragmentManager), we have a back stack and Android will navigate user up to the previous destination.

But what if we want to intercept the default Back behavior, emitting a SelectBack action and thus handle it in the same way as the click on the TopAppBar navigation icon?

We could emit an action in the overridden Activity.onBackPressed(), but it is ugly and not recommended solution. It is also not scalable in Single-Activity architectures.

A more elegant way is to create an OnBackPressedCallback and add it to the OnBackPressedDispatcher that controls dispatching system back presses. We have to enable this callback when we want to handle a back press, which disables other callbacks in the chain of responsibility. Sounds complicated to manage?

Hopefully, there's a BackHandler composable (added in Activity Compose in 1.3.0-alpha02) that configures it for us. Let's take a brief look at the implementation of that composable function (from 1.3.0-alpha06):

@Composable
public fun BackHandler(enabled: Boolean = true, onBack: () -> Unit) {
    // Safely update the current `onBack` lambda when a new one is provided
    val currentOnBack by rememberUpdatedState(onBack)
    // Remember in Composition a back callback that calls the `onBack` lambda
    val backCallback = remember {
        object : OnBackPressedCallback(enabled) {
            override fun handleOnBackPressed() {
                currentOnBack()
            }
        }
    }
    // On every successful composition, update the callback with the `enabled` value
    SideEffect {
        backCallback.isEnabled = enabled
    }
    val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) {
        "No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner"
    }.onBackPressedDispatcher
    val lifecycleOwner = LocalLifecycleOwner.current
    DisposableEffect(lifecycleOwner, backDispatcher) {
        // Add callback to the backDispatcher
        backDispatcher.addCallback(lifecycleOwner, backCallback)
        // When the effect leaves the Composition, remove the callback
        onDispose {
            backCallback.remove()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Under the hood, it creates an OnBackPressedCallback and enables it when composable function is successfully recomposed. The callback is added on any lifecycle owner change and it's removed on the dispose. Everything is implicitly set and all we need to do is to add the single line of code, which declared the BackHandler():

@Composable
fun Detail(
    state: DetailState,
    onBack: () -> Unit,
) {
    BackHandler(onBack = onBack)
    Scaffold(
        topBar = { DetailTopAppBar(onBack) }
    ) {
        // detail content
    }
}
Enter fullscreen mode Exit fullscreen mode

In the same way we can declare the BackHandler in different composable destinations, intercepting the default behavior of the system back press. In our case we can emit an action specific to the attached view model. We are also guaranteed that only the inner most enabled BackHandler will invoke the onBack lambda, which prevents the back press from being handled several times.

One important note: If you want to use the BackHandler, I suggest you to update the Activity Compose to at least 1.3.0-alpha06 and Navigation Compose to at least 1.0.0-alpha10 that depends on Navigation 2.3.5. In earlier versions of these androidx libraries there was a bug, which I reported. It was breaking the back press interception on Activity restart.

If you found this post useful, please tap the 💙. You can find me on Twitter.

Discussion (3)

pic
Editor guide
Collapse
rys07 profile image
rys07

Great article, thanks!

I am just curious. How do you implement navigation inside the view model (onAction)? Does it have a dependency to the navigation controller?

Collapse
pawegio profile image
Paweł Gajda Author

Glad you liked it. I tend to delegate the navigation to the navigator implementation (hidden behind the interface). It obtains a reference to the NavHostController when it's created for the NavHost.

Collapse
rys07 profile image
rys07

Good idea. The only problem I see here is that view model survives configuration change and has a reference to the old navigator when a new navigation controller is created in Composable. It can be replaced in view model, however, it could be tricky when using DI.