Post

Abstract your Android Navigation for Compose, part 2

Intro


Welcome to the second part of the navigation abstraction in Compose using Google’s navigation component, in this blog post we’ll see the abstraction in code.

Abstraction


In order to understand this code, it’s highly recommended to read the Part #1.

We have to create an abstraction for:

  • Graph (with starting destination and unique route/ID)
  • Destination (with animations, arguments, deep links)
  • How to connect destinations to a graph
  • Navigation graph entries connected with destinations that have logic and Composable UI render ability
  • How to connect navigation entries to a graph
  • Arguments (to and from)

Destination


We will start with our basic setup, every destination, doesn’t matter if it’s a dialog, bottom sheet or a screen would need one of the following:

  • a required destination string a.k.a route
  • list of arguments (optional)
  • list of deep links (optional)
1
2
3
4
5
6
7
8
9
10
11
@Immutable
interface NavigationDestination {

    fun destination(): String

    val arguments: List<NamedNavArgument>
        get() = emptyList()

    val deepLinks: List<NavDeepLink>
        get() = emptyList()
}

Animated destination


We also would want to create one common place for our animatable destinations (at this time only screens support animations, but hopefully in the future dialogs and bottom sheets).

On Android via the navigation component we have 4 types of transitions:

  • enterTransition
  • exitTransition
  • popEnterTransition
  • popExitTransition

Translated into the Compose world, we have access to the AnimatedContentTransitionScope, for example: You have access to the initial and target transitionary state, additionaly you can even utilize NavigationDestination’s List<NamedNavArgument> arguments to drive the screen’s transitions.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Stable
interface AnimatedDestination {

    val enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)?

    val exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)?

    val popEnterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)?
        get() = enterTransition

    val popExitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)?
        get() = exitTransition
}

For example

1
2
3
4
5
6
7
 override val enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?) = {
        when (initialState.arguments?.getInt(ON_OPEN_DIRECTIONS)) {
            FROM_WALK_THROUGH -> slideInHorizontally { it }
            FROM_SETTINGS -> fadeIn()
            else -> slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Up)
        }
    }

Screen destination


This type of destination supports Animations, hence we’ll just implement AnimatedDestination.

1
2
3
4
5
6
7
8
9
@Stable
interface ScreenDestination : NavigationDestination, AnimatedDestination {

    override val enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)?
        get() = null

    override val exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)?
        get() = null
}

Dialog destination


This type of destination requires of us to provide DialogProperties which can modify the appearance of a dialog, do keep in mind that this type of a destination doesn’t have a surface (i.e like a rounded corner background, you have to create one common modifier or a wrapped composable around your UI, otherwise you’ll be placing your UI elements on a transparent background).

1
2
3
4
@Stable
interface DialogDestination : NavigationDestination {
    val dialogProperties: DialogProperties
}

Bottom sheet destination


This type of destination is for our Bottom Sheets that will live in a modal bottom sheet layout.

1
2
3
4
5
6
/**
 * Keep in mind that the parent of this type is a
 * [androidx.compose.foundation.layout.Column]
 */
@Stable
interface BottomSheetDestination : NavigationDestination

Graph


Each graph has a starting destination and a unique route/id, we can utilize the commonly used NavigationDestination which every type implements already.

1
2
3
4
5
@Immutable
interface NavigationGraph {
    val startingDestination: NavigationDestination
    val route: String
}

Graph entry


Each navigation graph entry is added to a parent (Graph), to provide a better developer experience, each navigation graph entry should have the NavigationDestination it belongs to, where with just one click you can see the arguments, deep links, animations etc…

The Render function is where you’ll host your logic, it’s stability is kinda hacked so that it doesn’t recompose unnecessarily, only when the StableHolder notifies that the NavHostController changed.

1
2
3
4
5
6
@Immutable
interface NavigationGraphEntry {
    val navigationDestination: NavigationDestination
    @Composable
    fun Render(controller: StableHolder<NavHostController>)
}

Navigation graph entry arguments


Each navigation graph entry can receive arguments, for our arguments we’ll use this brilliant repo and won’t try to reinvent the wheel, but for our type safety we would need the following.

1
2
3
4
5
@Immutable
interface NavigationEntryArguments {
    val currentNavBackStackEntry: NavBackStackEntry
    val arguments: Bundle? get() = currentNavBackStackEntry.arguments
}

For obtaining the arguments in an easier way, we’d use context receivers, take a look at this class.

This implementation would help you obtain the arguments you’ve sent to the screen inside the @Composable fun Render().


Each navigation entry if accompanied by a view model can receive a type safe arguments with a simple implementation of

1
2
3
interface ViewModelNavigationArguments {
    val savedStateHandle: SavedStateHandle
}

For obtaining the arguments in an easier way, we’d use context receivers again, take a look at this class.

Arguments to previous destination


We want to have callbacks too, usually ones we send to the previous screen.

To support them one can approach this in the following way (due note that you might change the naming to your own suitable way).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
 * Name should start ArgumentsFromXDestination where X destination is the one you're sending arguments from
 */
@Immutable
interface AddArgumentsToPreviousDestination {
    val navHostController: StableHolder<NavHostController>
    fun consumeArgumentsAtReceivingDestination()
    private val currentBackStackEntrySavedStateHandle: SavedStateHandle?
        get() = navHostController.item.currentBackStackEntry?.savedStateHandle
    private val previousBackStackEntrySavedStateHandle: SavedStateHandle?
        get() = navHostController.item.previousBackStackEntry?.savedStateHandle

    fun <T> addArgumentToNavEntry(route: String, key: String, value: T?) {
        navHostController.item.getBackStackEntry(route).savedStateHandle[key] = value
    }

    fun consumeArgument(key: String) {
        currentBackStackEntrySavedStateHandle?.set(key, null)
    }

    fun <T> set(key: String, value: T?) {
        previousBackStackEntrySavedStateHandle?.set(key, value)
    }

    fun consumeArguments(vararg key: String) {
        key.forEach(::consumeArgument)
    }
}

The required navHostController takes care for adding the arguments to and from, this class supports everything that can be stored in a Bundle/SavedStateHandle, it’s use case is single shot event which you must consumeArgumentsAtReceivingDestination.

In order for us to send arguments to the previous destination we have to set those and consume them, we set them FROM the current entry to the PREVIOUS entry.

We can also build a helper function to observe the callback, with more at this link

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Composable
fun <T> AddArgumentsToPreviousDestination.OnSingleCallbackArgument(
    key: String,
    initialValue: T? = null,
    consumeWhen: (T?) -> Boolean = { it != null },
    onValue: (T?) -> Unit
) {
    val currentEntryHandle = navHostController.currentEntry.savedStateHandle
    val value by currentEntryHandle.getStateFlow(key, initialValue).collectAsState(initial = initialValue)
    val currentOnValue by rememberUpdatedState(newValue = onValue)
    LaunchedEffect(value) {
        currentOnValue(value)
        if (consumeWhen(value)) {
            consumeArgument(key)
        }
    }
}

The idea is whenever the key changes and if we provide an initial value, we react upon the value and then we consume it if it’s been received as a non null or a custom predicate.

Inside the Render function

1
2
3
4
5
6
7
8
9
val argumentsFromConfirmationDialog = remember { ArgumentsFromConfirmationDialog(controller) }
    ArgumentsFromConfirmationDialog.OnSingleBooleanCallbackArgument(
            key = ArgumentsFromConfirmationDialog.REQUEST_RESULT_KEY_CONFIRMATION,
            onValue = { refreshSettingsClick ->
                if (refreshSettingsClick == true) {
                    settingsViewModel.fetchSettings()
                }
            }
        )

We would need to send commands to navigate, we can model this pretty easily.

We have:

  • Navigating away from a screen (you might know it as a navigate up)
  • Popping the current backstack (additionally with a custom logic)
  • Top level destination (usually known from bottom navigation in case you’re not familiar with the term)
  • Directions (that cover everything else)

All of that in code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Stable
sealed interface NavigatorIntent {

    object NavigateUp : NavigatorIntent

    object PopCurrentBackStack : NavigatorIntent

    data class PopBackStack(
        val route: String,
        val inclusive: Boolean,
        val saveState: Boolean = false,
    ) : NavigatorIntent

    class Directions(
        val destination: String,
        val builder: NavOptionsBuilder.() -> Unit = {},
    ) : NavigatorIntent {
        override fun toString(): String = "destination=$destination"
    }

    data class NavigateTopLevel(val route: String) : NavigatorIntent
}

We would want to transform this navigator intent into directions so we would split this into two interfaces, one that’s only the commands and the other one that’s having these commands transformed into a consumable Flow.

1
2
3
4
@Immutable
interface NavigatorDirections {
    val directions: Flow<NavigatorIntent>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Immutable
interface Navigator {

    fun navigateUp()

    fun navigateSingleTop(
        destination: String,
        builder: NavOptionsBuilder.() -> Unit = { launchSingleTop = true },
    )

    fun navigate(
        destination: String,
    )

    fun navigateTopLevel(
        destination: String,
    )

    fun navigate(
        destination: String,
        builder: NavOptionsBuilder.() -> Unit,
    )

    fun popBackStack(
        destination: String,
        inclusive: Boolean,
        saveState: Boolean = false,
    )

    fun popCurrentBackStack()

    fun navigate(navigatorIntent: NavigatorIntent)
}

We include most of the helper functions you’ll need in case you use this Navigator directly or if accessed from a deepend layer there’s the fun navigate(navigatorIntent: NavigatorIntent).

Closing notes


This will be the end of this blog post, thanks for your attention, continue to Part #3 where we’ll wrap up the implementation.

This post is licensed under CC BY 4.0 by the author.