Implementing Soft Navigation Requests in Jetpack Compose Navigation — Part 2 : The Back Press

Kerry Bisset
9 min readNov 4, 2024

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 within NavController.

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: BackHandlers 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 other BackHandlers 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 deeper BackHandler.
  • 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:

  1. 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.
  2. Emitting a Navigation Request: If there are subscribers, we emit a NavigationRequest to the _navigationRequest flow, allowing subscribers to respond.
  3. 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 is true, we proceed to pop the back stack using navHostController.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

  1. 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.
  2. Handling Forward Navigation: We implemented soft navigation in the navigate() method by checking for pending navigation requests and emitting a NavigationRequest to which subscribers (like ViewModels) can respond. This allows components to intercept navigation and, if necessary, prompt the user before proceeding.
  3. 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.
  4. Using BackHandler and OnBackPress: We utilized the BackHandler composable in Jetpack Compose to intercept back button presses. In a Kotlin Multiplatform context, we created an OnBackPress expect/actual function to handle back presses across different platforms effectively.
  5. Importance of Composable Hierarchy: We emphasized the significance of the registration order and placement of BackHandler (or OnBackPress). 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.
  6. Asynchronous Handling with Coroutines: Leveraging Kotlin coroutines allowed us to handle navigation requests asynchronously without blocking the main thread. We used suspendCoroutine, Channel, and Flow to manage communication between components.
  7. 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.
  8. 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() and popBackStack() methods to include soft navigation handling. Emitted NavigationRequest instances to a shared flow and awaited responses before proceeding with navigation actions.
  • ApplicationNavigation Interface: Defined a navigationRequest flow and a NavigationRequest 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 and actual in Kotlin Multiplatform ensures that back press handling works seamlessly across different platforms, providing a consistent user experience.

--

--