Implementing Soft Navigation Requests in Jetpack Compose Navigation — Part 2 : The Back Press
In the previous article, Implementing Soft Navigation Requests in Jetpack Compose Navigation, we explored how to enhance user experience by implementing soft navigation within Jetpack Compose applications. We created a more fluid and intuitive navigation system that responds gracefully to user interactions and ensures that the user intention did not have detrimental consequences that the user was unaware of.
Building upon that foundation, this article focuses on extending soft navigation to handle one of the most fundamental user interactions: the back button press. While navigating forward in an app is essential, providing a way for users to navigate backward is equally important. Handling back button presses correctly ensures that users can easily retrace their steps without unexpected behavior, contributing to a more polished and user-friendly application.
Understanding NavHost and Customizing Back Stack Behavior
In the previous implementation, we focused on handling soft navigation when navigating forward using the navigate()
method. However, on Android, users often navigate backward by pressing the back button, which pops the back stack maintained by the NavController
. To provide a consistent soft navigation experience, we need to extend our approach to handle back stack pops caused by back button presses.
The Current Soft Navigation Implementation
Let’s revisit our current AppNavigationImpl
class:
internal class AppNavigationImpl(
private val navHostController: NavHostController,
private val dispatcher: IDispatcherProvider,
) : ApplicationNavigation {
// ...
override fun <T : Any> navigate(
route: T,
navOptions: NavOptions?,
navigatorExtras: Navigator.Extras?,
force: Boolean,
onSuccess: () -> Unit,
) {
if (force) {
navHostController.navigate(route, navOptions, navigatorExtras)
onSuccess()
} else {
MainScope().launch(dispatcher.default()) {
// Handle soft navigation request
}
}
}
override fun popBackStack() {
navHostController.popBackStack()
}
// ...
}
In this implementation, soft navigation is applied when navigating forward via the navigate()
method. However, the popBackStack()
method, which is called when the back button is pressed, directly invokes navHostController.popBackStack()
without any soft navigation handling. This means that when the back button is pressed, the back stack pops immediately, bypassing any opportunity to handle soft navigation or confirm navigation actions.
Investigating the NavController’s Back Press Mechanics
To understand how we can intercept and customize the back stack behavior, let’s look into the Android NavController
implementation, specifically how it handles back presses.
The NavController
class manages the navigation stack and provides methods like popBackStack()
to navigate backward. When the back button is pressed, the OnBackPressedCallback
is triggered, which internally calls popBackStack()
.
Here’s an excerpt from the NavController
class:
public open class NavController(
public val context: Context
) {
private val backQueue: ArrayDeque<NavBackStackEntry> = ArrayDeque()
private val onBackPressedCallback: OnBackPressedCallback =
object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
popBackStack()
}
}
@MainThread
public open fun popBackStack(): Boolean {
return if (backQueue.isEmpty()) {
false
} else {
popBackStack(currentDestination!!.id, true)
}
}
@MainThread
public open fun popBackStack(@IdRes destinationId: Int, inclusive: Boolean): Boolean {
// Logic to pop the back stack
// ...
return true
}
// Other methods and properties
}
From this code, we can observe:
- The
onBackPressedCallback
is responsible for handling the back button press. - The
popBackStack()
method is called when the back button is pressed. - The back stack (
backQueue
) is managed internally withinNavController
.
Challenges in Customizing Back Stack Behavior
The NavController
encapsulates the back stack and its manipulation methods, making it challenging to intercept or override the default back stack behavior directly. Additionally, since the back stack is managed internally, and popBackStack()
directly modifies it, we cannot easily inject our custom logic into the back navigation process.
If we’re retrieving the NavController
or our ApplicationNavigation
instance from a dependency injector or passing it around as an interface, we have limited ability to subclass or modify its internal behavior.
Handling Back Presses with BackHandler in Jetpack Compose and Kotlin Multiplatform
To intercept back button presses and integrate soft navigation when users navigate backward, we can utilize the BackHandler
composable in Jetpack Compose. However, when working with Kotlin Multiplatform projects, we need a solution that works across different platforms. By leveraging the expect/actual
mechanism in Kotlin Multiplatform, we can provide platform-specific implementations while maintaining a common interface.
Using BackHandler in Jetpack Compose
The BackHandler
composable allows us to intercept the back button press and execute custom logic. Here's how you can use it in an Android Jetpack Compose application:
@Composable
fun MyAppContent(appNavigation: ApplicationNavigation) {
val coroutineScope = rememberCoroutineScope()
BackHandler(enabled = true) {
coroutineScope.launch {
appNavigation.popBackStack()
}
}
// Rest of your composable content
}
In this example, when the back button is pressed, the BackHandler
intercepts the event, and we can execute our custom back navigation logic, such as invoking appNavigation.popBackStack()
.
Implementing BackHandler in Kotlin Multiplatform
In a Kotlin Multiplatform project, we use the expect
and actual
keywords to define common interfaces and provide platform-specific implementations.
Defining the Expected Function
In your common (shared) module, declare the expected function:
// Common module
@Composable
expect fun OnBackPress(handler: () -> Unit)
This expect
function serves as a placeholder, indicating that the actual implementation will be provided on each platform.
Providing the Actual Implementation for Android
In your Android-specific module, provide the actual implementation using the BackHandler
composable:
// Android module
@Composable
actual fun OnBackPress(handler: () -> Unit) {
androidx.activity.compose.BackHandler {
handler()
}
}
Integrating OnBackPress into Your Composables
Now, you can use OnBackPress
in your shared composable code without worrying about platform differences:
@Composable
fun MyAppContent(appNavigation: ApplicationNavigation) {
val coroutineScope = rememberCoroutineScope()
OnBackPress {
coroutineScope.launch {
appNavigation.popBackStack()
}
}
// Rest of your composable content
}
Determining the Optimal Placement of OnBackPress
for Effective Back Handling
When working with BackHandler
(or OnBackPress
in your multiplatform setup), the registration order and placement within the composable hierarchy are crucial for ensuring that back presses are intercepted at the correct layer of your application. Understanding how BackHandler
works in Jetpack Compose will help us place OnBackPress
effectively.
How BackHandler
Works in Jetpack Compose
In Jetpack Compose, the BackHandler
composable allows us to intercept back button presses. The key points to understand are:
- Registration Order Matters: The
BackHandler
that is most recently added (i.e., deepest in the composable hierarchy) gets the first opportunity to handle the back press. - Composable Hierarchy:
BackHandler
s are evaluated in the context of the composition tree. The one closest to the current composable scope takes precedence. - Cancellation of Back Presses: If a
BackHandler
consumes the back press, it prevents otherBackHandler
s higher up in the hierarchy from receiving the event.
Implications for Your Application
Given these behaviors, the placement of OnBackPress
(which uses BackHandler
under the hood) is critical. You need to decide at which layer you want to intercept the back press:
- Global Level: Placing
OnBackPress
at the root of your app will intercept all back presses unless overridden by a deeperBackHandler
. - Screen Level: Placing
OnBackPress
inside individual screens allows for screen-specific back press handling. - Component Level: Placing
OnBackPress
inside specific components (e.g., dialogs or pop-ups) enables component-specific back handling.
If you have read this article on Navigation, this will seem a little familiar.
class SettingsNavArea : INavigationArea {
override fun display(navGraphBuilder: NavGraphBuilder) {
navGraphBuilder.composable<AppScreens.Settings> {
val appNav = getKoin().get<ApplicationNavigation>()
onBackPress {
appNav.popBackStack()
}
SettingsScreen()
}
}
}
After much experimenting, this is the best place I have found to commonly put this back press detecting logic. The screen-level works like a charm.
Implementing Soft Navigation for popBackStack
In our current implementation, the navigate()
method handles soft navigation by checking if there are any pending navigation requests and possibly delaying the navigation until certain conditions are met (like confirming unsaved changes). However, when users navigate backward by pressing the back button or programmatically invoking popBackStack()
, the back stack pops immediately without any soft navigation handling. This could lead to situations where unsaved changes are lost or necessary confirmations are bypassed.
To provide a consistent soft navigation experience, we need to extend our approach to handle back stack pops caused by popBackStack()
. This involves modifying the popBackStack()
method in our AppNavigationImpl
class to include soft navigation logic similar to what we implemented in the navigate()
method.
The Current popBackStack()
Implementation
Here’s how our AppNavigationImpl
class currently handles back navigation:
internal class AppNavigationImpl(
private val navHostController: NavHostController,
private val dispatcher: IDispatcherProvider,
) : ApplicationNavigation {
// ...
override fun popBackStack() {
navHostController.popBackStack()
}
// ...
}
In this implementation, the popBackStack()
method directly calls navHostController.popBackStack()
, which immediately pops the back stack without any soft navigation handling.
Modifying popBackStack()
to Include Soft Navigation
To implement soft navigation when the back button is pressed or popBackStack()
is called, we need to modify the popBackStack()
method to include soft navigation logic. This involves:
- Checking for Pending Navigation Requests: Before popping the back stack, we check if there are any subscribers to the
_navigationRequest
flow. If there are, it means some components (like ViewModels) are interested in handling navigation requests, possibly to confirm unsaved changes. - Emitting a Navigation Request: If there are subscribers, we emit a
NavigationRequest
to the_navigationRequest
flow, allowing subscribers to respond. - Waiting for Confirmation: We wait for a response from the subscribers. If they confirm that navigation can proceed, we pop the back stack. Otherwise, we cancel the operation.
internal class AppNavigationImpl(
private val navHostController: NavHostController,
private val dispatcher: IDispatcherProvider,
) : ApplicationNavigation {
// ...
override fun popBackStack() {
// Launch a coroutine to handle the soft navigation logic
MainScope().launch(dispatcher.default()) {
// Check if any listeners are subscribed to the navigation request flow
if (_navigationRequest.subscriptionCount.value > 0) {
val channel = Channel<Boolean>(capacity = Channel.UNLIMITED)
val canNavigate = suspendCoroutine { cont ->
// Collect the response from subscribers
CoroutineScope(dispatcher.default()).launch {
channel.consumeAsFlow().collect {
cont.resume(it)
cancel()
}
}
// Emit a navigation request
CoroutineScope(dispatcher.default()).launch {
_navigationRequest.emit(ApplicationNavigation.NavigationRequest(channel))
}
}
if (canNavigate) {
// If confirmed, pop the back stack on the UI thread
withContext(dispatcher.ui()) {
navHostController.popBackStack()
}
}
} else {
// If no listeners, proceed to pop the back stack immediately
withContext(dispatcher.ui()) {
navHostController.popBackStack()
}
}
}
}
// ...
}
Understanding the Modified popBackStack()
Method
Let’s break down what’s happening in the modified method:
- Coroutine Scope: We use
MainScope().launch(dispatcher.default())
to launch a coroutine that doesn't block the main thread. - Checking for Subscribers: We check if
_navigationRequest.subscriptionCount.value > 0
to see if any components are listening for navigation requests. - Creating a Channel: We create a
Channel<Boolean>
to receive the confirmation response from subscribers. - Suspending Execution: We suspend the coroutine using
suspendCoroutine
and wait until a response is received. - Emitting the Navigation Request: We emit a
NavigationRequest
containing the channel to the_navigationRequest
flow. - Collecting the Response: We collect the response from the channel and resume the coroutine.
- Proceeding Based on Confirmation: If
canNavigate
istrue
, we proceed to pop the back stack usingnavHostController.popBackStack()
. Otherwise, we cancel the operation.
Wrap Up
In this comprehensive exploration, we successfully implemented soft navigation in an Android application using Jetpack Compose and Kotlin Multiplatform. Our primary focus was on ensuring that both forward and backward navigation actions respect the same confirmation and state-preserving mechanisms, enhancing the user experience and preventing unintended data loss.
Takeaways
- Soft Navigation Concept: Soft navigation introduces a layer where navigation actions can be delayed or confirmed based on certain conditions, such as unsaved changes or user confirmation dialogs. This approach ensures that users do not lose data or context inadvertently.
- Handling Forward Navigation: We implemented soft navigation in the
navigate()
method by checking for pending navigation requests and emitting aNavigationRequest
to which subscribers (like ViewModels) can respond. This allows components to intercept navigation and, if necessary, prompt the user before proceeding. - Extending Soft Navigation to Back Presses: Recognizing that users often navigate backward using the back button, we extended our soft navigation implementation to the
popBackStack()
method. By intercepting back presses and applying the same soft navigation logic, we provided a consistent experience across all navigation actions. - Using
BackHandler
andOnBackPress
: We utilized theBackHandler
composable in Jetpack Compose to intercept back button presses. In a Kotlin Multiplatform context, we created anOnBackPress
expect/actual function to handle back presses across different platforms effectively. - Importance of Composable Hierarchy: We emphasized the significance of the registration order and placement of
BackHandler
(orOnBackPress
). Placing it at the appropriate level in the composable hierarchy ensures that back presses are intercepted correctly and that our soft navigation logic is applied. - Asynchronous Handling with Coroutines: Leveraging Kotlin coroutines allowed us to handle navigation requests asynchronously without blocking the main thread. We used
suspendCoroutine
,Channel
, andFlow
to manage communication between components. - Reactive Flow of Navigation Requests: The use of a
SharedFlow
for navigation requests enabled multiple components to subscribe and respond to navigation events. This reactive approach ensures that all interested parties can participate in the decision-making process when a navigation action is initiated. - Encapsulating Back Handling Logic: By placing back handling logic within the screen’s main composable or ViewModel, we maintained clean separation of concerns and made the code more maintainable and scalable.
Implementation Summary
- AppNavigationImpl: Modified both
navigate()
andpopBackStack()
methods to include soft navigation handling. EmittedNavigationRequest
instances to a shared flow and awaited responses before proceeding with navigation actions. - ApplicationNavigation Interface: Defined a
navigationRequest
flow and aNavigationRequest
data class to standardize how navigation requests are communicated and handled across the application.
Benefits of the Soft Navigation Approach
- Enhanced User Experience: Users are less likely to lose data or context due to accidental navigation actions. They are given the opportunity to confirm navigation, especially when there are unsaved changes.
- Consistency Across Navigation Actions: By applying soft navigation logic to both forward and backward navigation, we provided a uniform experience regardless of how the user navigates through the app.
- Scalability and Maintainability: The reactive and modular approach allows for easy extension and maintenance of the navigation logic. New screens or components can subscribe to navigation requests as needed without affecting existing functionality.
- Cross-Platform Compatibility: The use of
expect
andactual
in Kotlin Multiplatform ensures that back press handling works seamlessly across different platforms, providing a consistent user experience.