Post

Abstract your Android Navigation for Compose, part 3

Intro


Welcome to the last part of the navigation abstraction in Compose using Google’s navigation component, in this blog post we’ll actually see the implementation of the abstracted code from Part #2.

Implementation


For the implementation we would utilize Hilt to help us, why?

  • Compile time checks
  • We can easily navigate through the code and it’ll help us scale when app gets large, but it will be writing some boilerplate code to set it up of course.

We had our Navigator defined in the previous part, the implementation follows

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
34
35
36
37
38
@Singleton
internal class NavigatorImpl @Inject constructor() : Navigator, NavigatorDirections {

    private val _directions = Channel<NavigatorIntent>(Channel.BUFFERED)
    override val directions = _directions.receiveAsFlow()

    override fun popCurrentBackStack() {
        _directions.trySend(NavigatorIntent.PopCurrentBackStack)
    }

    override fun navigate(navigatorIntent: NavigatorIntent) {
        _directions.trySend(navigatorIntent)
    }

    override fun navigateUp() {
        _directions.trySend(NavigatorIntent.NavigateUp)
    }

    override fun popBackStack(destination: String, inclusive: Boolean, saveState: Boolean) {
        _directions.trySend(NavigatorIntent.PopBackStack(destination, inclusive, saveState))
    }

    override fun navigateTopLevel(destination: String) {
        _directions.trySend(NavigatorIntent.NavigateTopLevel(destination))
    }

    override fun navigate(destination: String, builder: NavOptionsBuilder.() -> Unit) {
        _directions.trySend(NavigatorIntent.Directions(destination, builder))
    }

    override fun navigateSingleTop(destination: String, builder: NavOptionsBuilder.() -> Unit) {
        _directions.trySend(NavigatorIntent.Directions(destination, builder))
    }

    override fun navigate(destination: String) {
        _directions.trySend(NavigatorIntent.Directions(destination))
    }
}

We want it singleton so that we’ll be able to re-use it everywhere, also we had it split into two, so we would just provide it as separates

1
2
3
4
5
6
7
8
9
10
@Module
@InstallIn(SingletonComponent::class)
internal abstract class NavigatorModule {

    @Binds
    abstract fun bindNavigator(navigatorImpl: NavigatorImpl): Navigator

    @Binds
    abstract fun bindNavigatorDestination(navigatorImpl: NavigatorImpl): NavigatorDirections
}

Bottom navigation


1
2
3
4
5
6
7
8
@Immutable
class BottomNavigationEntry(
    val destination: NavigationDestination,
    val text: String,
    val selectedIcon: ImageVector,
    val unselectedIcon: ImageVector,
    val route: String
)

Everything is pretty self explanatory, except desination and route, we would use destination to know whether current one is selected or not and the route is for the top level navigation upon click (matching the Graph’s route/ID).

Our BottomNavigation composable will look like

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Composable
internal fun BottomNavigation(
    modifier: Modifier = Modifier,
    navBackStackEntry: NavBackStackEntry?,
    hideBottomNav: Boolean,
    bottomNavigationEntries: ImmutableHolder<List<BottomNavigationEntry>>,
    onTopLevelClick: (route: String) -> Unit
) {
    AnimatedVisibility(
        visible = !hideBottomNav,
        enter = slideInVertically { it },
        exit = slideOutVertically { it },
        modifier = modifier.fillMaxWidth(),
    ) {
        BottomAppBar(modifier = Modifier.fillMaxWidth()) {
            bottomNavigationEntries.item.forEach { bottomEntry ->
                val isSelected = navBackStackEntry?.destination?.route == bottomEntry.destination.destination()
                NavigationBarItem(
                    selected = isSelected,
                    alwaysShowLabel = true,
                    onClick = {
                        onTopLevelClick(bottomEntry.route)
                    },
                    label = {
                        Text(
                            modifier = Modifier
                                .wrapContentSize(unbounded = true),
                            softWrap = false,
                            maxLines = 1,
                            textAlign = TextAlign.Center,
                            text = bottomEntry.text,
                        )
                    },
                    icon = {
                        Crossfade(targetState = isSelected, label = "bottom-nav-icon") { isSelectedIcon ->
                            if (isSelectedIcon) {
                                Icon(imageVector = bottomEntry.selectedIcon, contentDescription = bottomEntry.text)
                            } else {
                                Icon(imageVector = bottomEntry.unselectedIcon, contentDescription = bottomEntry.text)
                            }
                        }
                    },
                )
            }
        }
    }
}

Graph entries


Each graph with it’s entries would be added accordingly to the types we created

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
fun NavGraphBuilder.addGraphs(
    navController: StableHolder<NavHostController>,
    navigationGraphs: Map<NavigationGraph, Set<NavigationGraphEntry>>,
    showAnimations: Boolean,
) {
    navigationGraphs.forEach { (navigatorGraph, destinationGraphEntries) ->
        navigation(startDestination = navigatorGraph.startingDestination.destination(), route = navigatorGraph.route) {
            destinationGraphEntries.forEach { destinationGraphEntry ->
                addDestinationBasedOnType(destinationGraphEntry, navController, showAnimations)
            }
        }
    }
}

private fun NavGraphBuilder.addDestinationBasedOnType(
    navigationGraphEntry: NavigationGraphEntry,
    navController: StableHolder<NavHostController>,
    showAnimations: Boolean,
) {
    when (navigationGraphEntry.navigationDestination) {
        is DialogDestination -> addDialogDestinations(navController, navigationGraphEntry)
        is ScreenDestination -> addComposableDestinations(navController, navigationGraphEntry, showAnimations)
        is BottomSheetDestination -> addBottomSheetDestinations(navController, navigationGraphEntry)
    }
}

@OptIn(ExperimentalMaterialNavigationApi::class)
private fun NavGraphBuilder.addBottomSheetDestinations(navController: StableHolder<NavHostController>, entry: NavigationGraphEntry) {
    val destination = entry.navigationDestination
    bottomSheet(destination.destination(), destination.arguments, destination.deepLinks) {
        entry.Render(navController)
    }
}

private fun NavGraphBuilder.addDialogDestinations(navController: StableHolder<NavHostController>, entry: NavigationGraphEntry) {
    val destination = entry.navigationDestination as DialogDestination
    dialog(
        destination.destination(),
        destination.arguments,
        destination.deepLinks,
        destination.dialogProperties,
    ) {
        entry.Render(navController)
    }
}

private fun NavGraphBuilder.addComposableDestinations(
    navController: StableHolder<NavHostController>,
    entry: NavigationGraphEntry,
    showAnimations: Boolean,
) {
    val destination = entry.navigationDestination
    if (destination is AnimatedDestination && showAnimations) {
        composable(
            destination.destination(),
            destination.arguments,
            destination.deepLinks,
            destination.enterTransition,
            destination.exitTransition,
            destination.popEnterTransition,
            destination.popExitTransition,
        ) {
            entry.Render(navController)
        }
    } else {
        composable(
            destination.destination(),
            destination.arguments,
            destination.deepLinks,
        ) {
            entry.Render(navController)
        }
    }
}

Reacting to the navigator commands


We created a way to send commands, we should be able to utilize them

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
34
35
36
37
38
@Composable
private fun NavHostControllerEvents(
    navigator: NavigatorDirections,
    navController: NavHostController,
) {
    LaunchedEffect(navController) {
        navigator.directions.collectLatest { navigatorEvent ->
            when (navigatorEvent) {
                is NavigatorIntent.NavigateUp -> navController.navigateUp()
                is NavigatorIntent.Directions -> navController.navigate(navigatorEvent.destination, navigatorEvent.builder)
                NavigatorIntent.PopCurrentBackStack -> navController.popBackStack()
                is NavigatorIntent.PopBackStack -> navController.popBackStack(
                    navigatorEvent.route,
                    navigatorEvent.inclusive,
                    navigatorEvent.saveState,
                )

                is NavigatorIntent.NavigateTopLevel -> {
                    val topLevelNavOptions = navOptions {
                        // Pop up to the start destination of the graph to
                        // avoid building up a large stack of destinations
                        // on the back stack as users select items
                        popUpTo(navController.graph.findStartDestination().id) {
                            saveState = true
                        }
                        // Avoid multiple copies of the same destination when
                        // reselecting the same item
                        launchSingleTop = true
                        // Restore state when reselecting a previously selected item
                        restoreState = true
                    }
                    navController.navigate(navigatorEvent.route, topLevelNavOptions)
                }
            }
            Log.d("NavDirections", navigatorEvent.toString())
        }
    }
}

Graphs factory


We have so many Graphs in our application, we should be able to aggregate and connect them with their entries through their destinations.

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
/**
 * Responsible for concatenating your destinations and graphs
 */
@Suppress("UNCHECKED_CAST")
@Singleton
class GraphFactory @Inject constructor(
    private val destinations: Map<Class<*>, @JvmSuppressWildcards Set<NavigationGraphEntry>>,
    private val graphs: Map<@JvmSuppressWildcards Class<*>, @JvmSuppressWildcards NavigationGraph>,
) {

    fun <T : NavigationGraph> getGraphByRoute(uniqueRoute: Class<*>): T {
        val graph = graphs[uniqueRoute] as? T
        when {
            graph == null -> throw IllegalArgumentException("Graph with $uniqueRoute is not registered")
            graph.javaClass != uniqueRoute -> throw IllegalArgumentException("Graph should be of a type ${uniqueRoute.javaClass}")
            else -> return graph
        }
    }

    val graphsWithDestinations: Map<NavigationGraph, Set<NavigationGraphEntry>> =
        (destinations.keys.plus(graphs.keys).asSequence())
            .associate {
                graphs[it] to destinations[it]
            }.filter {
                it.key != null && it.value != null
            } as Map<NavigationGraph, Set<NavigationGraphEntry>>
}

In this example we’ll utilize the ClassKey for connecting the navigation graph entries to the graph, of course you can use a StringKey which would be of the same effect and you won’t need to bind the ClassKey to a map and have one less module, I’ll demonstrate this later down the road.

Why do we use MultiMap bindings


The goal is to never re-use the same key, why?

  • You’re splitting features into modules, each feature is its own entity
  • Each feature has a flow, a flow can consits of more than one destination (can be 3-4 or more)
  • One complete flow should not be scattered across multiple modules, it should stay in one

Multi bindings prevent us from using same Key twice, that means you can’t contribute to the Graph from your auth feature and at the same time from your home feature.

Login feature example


So, let’s say we want to implement a “Login” feature, for that we’ll need a LoginGraph, LoginScreenDestination, LoginScreenGraphEntry and of course UI for the entry.

Let’s say that this is a multi modular project and you’re approaching the setup with a feature based modularization.

You would have the following

  1. Graph
    1
    2
    3
    
    class LoginGraph @Inject constructor(override val startingDestination: LoginScreenDestination) : NavigationGraph {
     override val route: String = "LoginGraph"
    }
    
  2. Destination
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    
    class LoginScreenDestination @Inject constructor() : ScreenDestination {
     override fun destination(): String = "LoginScreenDestination"
    
     override val enterTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)
         get() = {
             slideIntoContainer(AnimatedContentTransitionScope.SlideDirection.Up)
         }
    
     override val exitTransition: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)
         get() =  {
             slideOutOfContainer(AnimatedContentTransitionScope.SlideDirection.Down)
         }
    }
    
  3. Graph entry
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    internal class LoginScreenGraphEntry @Inject constructor(
     override val navigationDestination: LoginScreenDestination,
     private val navigator: Navigator
    ) : NavigationGraphEntry {
    
     @Composable
     override fun Render(controller: StableHolder<NavHostController>) {
         LoginScreen(navigator::navigateUp)
     }
    }
    
  4. UI
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    
    @Composable
    internal fun LoginScreen(onClick: () -> Unit) {
     Column(
         modifier = Modifier
             .fillMaxSize()
             .background(Color.DarkGray.copy(alpha = 0.65f)),
         horizontalAlignment = Alignment.CenterHorizontally,
         verticalArrangement = Arrangement.Center
     ) {
         Button(onClick = onClick) {
             Text(text = "Go back")
         }
     }
    }
    @Composable
    @Preview
    private fun LoginScreenPreview(){
     ComposedLibThemeSurface {
         LoginScreen {
    
         }
     }
    }
    
  5. Module binding the entries to the graph
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    @InstallIn(SingletonComponent::class)
    @Module
    internal object LoginModule {
    
     @Provides
     @IntoMap
     @ClassKey(LoginGraph::class)
     fun loginEntries(loginScreenGraphEntry: LoginScreenGraphEntry): Set<NavigationGraphEntry> = setOf(loginScreenGraphEntry)
    }
    
  6. Adding the graph to the global graph map
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    
    @Module
    @InstallIn(SingletonComponent::class)
    internal abstract class GraphsModule {
    
     @Binds
     @IntoMap
     @ClassKey(FavoritesGraph::class)
     abstract fun favoritesGraph(graph: FavoritesGraph): NavigationGraph
    
     @Binds
     @IntoMap
     @ClassKey(HomeGraph::class)
     abstract fun homeGraph(graph: HomeGraph): NavigationGraph
    
     @Binds
     @IntoMap
     @ClassKey(SettingsGraph::class)
     abstract fun settingsGraph(graph: SettingsGraph): NavigationGraph
    
     @Binds
     @IntoMap
     @ClassKey(LoginGraph::class)
     abstract fun loginGraph(graph: LoginGraph): NavigationGraph
    }
    

You can always choose to use a StringKey to drop the 6th step and use a companion object where you won’t need to create a module for your graphs.

1
2
3
4
5
6
7
class LoginGraph @Inject constructor(override val startingDestination: LoginScreenDestination) : NavigationGraph {
    companion object {
        const val ID = "LoginGraph"
    }

    override val route: String = ID
}

Upon modification

1
2
3
4
5
6
7
8
9
@InstallIn(SingletonComponent::class)
@Module
internal object LoginModule {

    @Provides
    @IntoMap
    @StringKey(LoginGraph.ID)
    fun loginEntries(loginScreenGraphEntry: LoginScreenGraphEntry): Set<NavigationGraphEntry> = setOf(loginScreenGraphEntry)
}

and of course, you would change the GraphFactory to provide you a String based map. This is the actual solution I started with, but later changed it with a GraphsModule so that I can keep track of which Graphs are actually included into the global Map of graphs.

Top level destinations


We want to have our top level destinations provided with a help from GraphFactory, we’re getting a graph by it’s unique ClassKey.

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
34
35
36
37
@ActivityRetainedScoped
class TopLevelDestinationsProvider @Inject constructor(
    private val graphFactory: GraphFactory,
) {

    private val homeGraph get() = graphFactory.getGraphByRoute<HomeGraph>(HomeGraph::class.java)
    private val favoritesGraph get() = graphFactory.getGraphByRoute<FavoritesGraph>(FavoritesGraph::class.java)
    private val settingsGraph get() = graphFactory.getGraphByRoute<SettingsGraph>(SettingsGraph::class.java)

    fun getStartingDestination(): String = homeGraph.route


    val bottomNavigationEntries = listOf(
        BottomNavigationEntry(
            destination = HomeScreenBottomNavDestination,
            text = "Home",
            selectedIcon = Icons.Filled.Home,
            unselectedIcon = Icons.Outlined.Home,
            route = homeGraph.route
        ),

        BottomNavigationEntry(
            destination = FavoritesScreenBottomNavDestination,
            text = "Favorites",
            selectedIcon = Icons.Default.Favorite,
            unselectedIcon = Icons.Outlined.FavoriteBorder,
            route = favoritesGraph.route
        ),
        BottomNavigationEntry(
            destination = SettingsScreenBottomNavDestination,
            text = "Settings",
            selectedIcon = Icons.Default.Settings,
            unselectedIcon = Icons.Outlined.Settings,
            route = settingsGraph.route
        ),
    )
}

Connecting it all


All in all, our MainActivity will be looking like this

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject lateinit var navigatorDirections: NavigatorDirections
    @Inject lateinit var navigator: Navigator
    @Inject lateinit var graphFactory: GraphFactory
    @Inject lateinit var topLevelDestinationsProvider: TopLevelDestinationsProvider

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            ComposedLibThemeSurface {
                AppScaffold(
                    startingDestination = topLevelDestinationsProvider.getStartingDestination(),
                    graphs = {
                        graphFactory.graphsWithDestinations
                    },
                    showAnimations = true,
                    navigatorDirections = navigatorDirections,
                    bottomNavigationEntries = topLevelDestinationsProvider.bottomNavigationEntries.asImmutable,
                    navigator = navigator
                )
            }
        }
    }
}

@OptIn(ExperimentalMaterialNavigationApi::class)
@Composable
private fun AppScaffold(
    startingDestination: String,
    graphs: () -> Map<NavigationGraph, Set<NavigationGraphEntry>>,
    showAnimations: Boolean,
    navigatorDirections: NavigatorDirections,
    bottomNavigationEntries: ImmutableHolder<List<BottomNavigationEntry>>,
    navigator: Navigator,
) {
    val bottomSheetNavigator: BottomSheetNavigator = rememberBottomSheetNavigator()
    val navController: NavHostController = rememberAnimatedNavController(bottomSheetNavigator)

    val navBackStackEntry: NavBackStackEntry? by navController.currentBackStackEntryFlow.collectAsStateWithLifecycle(null)
    val hideBottomNav: Boolean by remember {
        derivedStateOf { navBackStackEntry.hideBottomNavigation }
    }
    val currentRoute by remember {
        derivedStateOf { navBackStackEntry?.destination?.route }
    }
    LaunchedEffect(currentRoute) {
        Log.d(
            "Current destination", "${navBackStackEntry?.destination} with arguments ${navBackStackEntry?.arguments}",
        )
    }

    ModalBottomSheetLayout(
        modifier = Modifier.fillMaxSize(),
        bottomSheetNavigator = bottomSheetNavigator,
        sheetShape = MaterialTheme.shapes.large.copy(topStart = CornerSize(16.dp), topEnd = CornerSize(16.dp)),
        sheetElevation = 0.dp,
        sheetBackgroundColor = MaterialTheme.colorScheme.background
    ) {
        Scaffold(
            modifier = Modifier.fillMaxSize(),
        ) { paddingValues ->
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(paddingValues),
            ) {
                AnimatedNavHost(
                    navController = navController,
                    startDestination = startingDestination,
                    enterTransition = { fadeIn() },
                    exitTransition = { fadeOut() },
                ) {
                    addGraphs(navController.asStable, graphs(), showAnimations)
                }

                BottomNavigation(
                    bottomNavigationEntries = bottomNavigationEntries,
                    modifier = Modifier.align(Alignment.BottomStart),
                    hideBottomNav = hideBottomNav,
                    navBackStackEntry = navBackStackEntry,
                    onTopLevelClick = navigator::navigateTopLevel
                )
            }
        }
    }
    NavHostControllerEvents(
        navigator = navigatorDirections,
        navController = navController,
    )
}

@Composable
private fun NavHostControllerEvents(
    navigator: NavigatorDirections,
    navController: NavHostController,
) {
    LaunchedEffect(navController) {
        navigator.directions.collectLatest { navigatorEvent ->
            when (navigatorEvent) {
                is NavigatorIntent.NavigateUp -> navController.navigateUp()
                is NavigatorIntent.Directions -> navController.navigate(navigatorEvent.destination, navigatorEvent.builder)
                NavigatorIntent.PopCurrentBackStack -> navController.popBackStack()
                is NavigatorIntent.PopBackStack -> navController.popBackStack(
                    navigatorEvent.route,
                    navigatorEvent.inclusive,
                    navigatorEvent.saveState,
                )

                is NavigatorIntent.NavigateTopLevel -> {
                    val topLevelNavOptions = navOptions {
                        // Pop up to the start destination of the graph to
                        // avoid building up a large stack of destinations
                        // on the back stack as users select items
                        popUpTo(navController.graph.findStartDestination().id) {
                            saveState = true
                        }
                        // Avoid multiple copies of the same destination when
                        // reselecting the same item
                        launchSingleTop = true
                        // Restore state when reselecting a previously selected item
                        restoreState = true
                    }
                    navController.navigate(navigatorEvent.route, topLevelNavOptions)
                }
            }
            Log.d("NavDirections", navigatorEvent.toString())
        }
    }
}

Arguments feature


Let’s create a YellowDialog and RedDialog.

You will go from YellowDialog to RedDialog with arguments.

You will also send arguments from RedDialog to YellowDialog.

YellowDialog


Our starting point is always a Graph.

1
2
3
class DialogsGraph @Inject constructor(override val startingDestination: YellowDialogDestination) : NavigationGraph {
    override val route: String = "DialogsGraph"
}

We should not forget to add it to all of our available graphs if you’re using the approach of ClassKey.

1
2
3
4
5
6
7
8
9
10
@Module
@InstallIn(SingletonComponent::class)
internal abstract class GraphsModule {
//the others 
    @Binds
    @IntoMap
    @ClassKey(DialogsGraph::class)
    abstract fun dialogsGraph(graph: DialogsGraph): NavigationGraph

}

What we would do without a destination (in life) and even in code?

1
2
3
4
5
6
class YellowDialogDestination @Inject constructor() : DialogDestination {
    override val dialogProperties: DialogProperties
        get() = DialogProperties(usePlatformDefaultWidth = false)

    override fun destination(): String = "YellowDialogDestination"
}

For this purpose we would create directions class to safely navigate from one to the other

1
2
3
4
5
6
7
8
class YellowDialogDirections @Inject constructor(
    private val navigator: Navigator,
    private val redDialogDestination: RedDialogDestination
) {
    fun openRedDialog(
        texts : Array<String>
    ) = navigator.navigateSingleTop(redDialogDestination.openDestination(texts))
}

Our graph entry

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
34
35
36
37
38
39
40
41
42
43
44
internal class YellowDialogGraphEntry @Inject constructor(
    override val navigationDestination: YellowDialogDestination,
    private val yellowDialogDirections: YellowDialogDirections
) : NavigationGraphEntry {
    @Composable
    override fun Render(controller: StableHolder<NavHostController>) {
        var callbackString by remember { mutableStateOf<String?>(null) }
        val argumentsFromRedDialog = remember { ArgumentsFromRedDialog(controller) }
        argumentsFromRedDialog
            .OnSingleStringCallbackArgument(key = ArgumentsFromRedDialog.TEXT, onValue = {
                callbackString = it
            })
        Column(
            modifier = Modifier
                .fillMaxHeight(0.6f)
                .fillMaxWidth(0.6f)
                .background(Color.Yellow.copy(alpha = 0.9f), RoundedCornerShape(16.dp)),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Button(onClick = {
                yellowDialogDirections.openRedDialog(
                    arrayOf(
                        "Navigation",
                        "Compose",
                        "Something",
                        "Dialog",
                    )
                )
            }) {
                Text(text = "Open other dialog")
            }
            AnimatedVisibility(
                visible = !callbackString.isNullOrEmpty(),
                modifier = Modifier.fillMaxWidth()
            ) {
                Text(
                    text = "Callback string $callbackString",
                    modifier = Modifier.padding(horizontal = 16.dp)
                )
            }
        }
    }
}

RedDialog


The arguments that we’ll use for the callback

1
2
3
4
5
6
7
8
9
10
11
12
13
class ArgumentsFromRedDialog(override val navHostController: StableHolder<NavHostController>) : AddArgumentsToPreviousDestination {

    companion object {
        const val TEXT = "text"
    }

    fun addText(text :String){
        set(TEXT, text)
    }
    override fun consumeArgumentsAtReceivingDestination() {
        consumeArgument(TEXT)
    }
}

We also need the destination which has a receiving arguments texts: Array<String>, we create the destination using the helper function createDestination() where the first argument is the starting route, followed by a vararg of either optional or required arguments.

When we are trying to navigate to a destination we use applyArgumentsToDestination, again the first argument is the starting route, followed by a one of:

  • fun String.asNonNullDestinationValue()
  • fun String.asNullWithDestinationValue(value: String?)
  • fun String.asNullDestinationValue()

Where String, the receiver object is the name of the argument when you’re using null values or for non null values, the actual value you would pass to the screen.

When you’re passing value and you use it in form of String.asNonNullDestinationValue() you can use one of the following functions to add the argument safely.

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
class RedDialogDestination @Inject constructor() : DialogDestination {

    companion object {
        const val ROUTE = "RedDialogDestination"
        const val TEXTS = "texts"
    }

    override val dialogProperties: DialogProperties
        get() = DialogProperties()

    override fun destination(): String = createDestination(ROUTE, TEXTS.asOptionalArgument())

    fun openDestination(texts: Array<String>) = applyArgumentsToDestination(
        ROUTE, TEXTS.asNullWithDestinationValue(
            addStringArrayArgument(texts)
        ),
    )

    override val arguments: List<NamedNavArgument>
        get() = listOf(
            navArgument(TEXTS) {
                type = DestinationsStringArrayNavType
                nullable = true
                defaultValue = null
            },
        )
}

We want the arguments to be consumed inside a ViewModel

1
2
3
4
5
@ViewModelScoped
class RedDialogViewModelArguments @Inject constructor(override val savedStateHandle: SavedStateHandle) : ViewModelNavigationArguments {

    val texts = getStringArrayArgument(RedDialogDestination.TEXTS)
}

Accordingly the ViewModel

1
2
3
4
5
6
7
8
9
@HiltViewModel
class RedDialogViewModel @Inject constructor(
    redDialogViewModelArguments: RedDialogViewModelArguments
) : ViewModel() {

    private val _texts = MutableStateFlow(redDialogViewModelArguments.texts)
    val texts = _texts.asStateFlow()

}

and finally the graph entry

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
34
35
36
37
38
39
40
internal class RedDialogGraphEntry @Inject constructor(
    override val navigationDestination: RedDialogDestination,
    private val navigator: Navigator
) :
    NavigationGraphEntry {
    @Composable
    override fun Render(controller: StableHolder<NavHostController>) {
        val redDialogViewModel = hiltViewModel<RedDialogViewModel>()
        val texts by redDialogViewModel.texts.collectAsStateWithLifecycle()
        val argumentsFromRedDialog = remember { ArgumentsFromRedDialog(controller) }
        Column(
            modifier = Modifier
                .fillMaxSize()
                .background(Color.Red.copy(alpha = 0.8f), RoundedCornerShape(24.dp)),
        ) {
            LazyColumn(
                contentPadding = PaddingValues(16.dp),
                content = {
                    item {
                        Button(
                            onClick = {
                                argumentsFromRedDialog.addText("Text argument sent back to Yellow")
                                navigator.navigateUp()
                            },
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(horizontal = 16.dp)
                        ) {
                            Text(
                                text = "Send arguments back", color = Color.White,
                            )
                        }
                    }
                    items(texts.orEmpty(), key = { it }) {
                        Text(text = it, color = Color.White)
                    }
                })
        }
    }
}

Which we finally concatenate in the graph by doing

1
2
3
4
5
6
7
8
9
10
11
12
@InstallIn(SingletonComponent::class)
@Module
internal object LoginModule {

    @Provides
    @IntoMap
    @ClassKey(DialogsGraph::class)
    fun dialogEntries(
        yellowDialogGraphEntry: YellowDialogGraphEntry,
        redDialogGraphEntry: RedDialogGraphEntry
    ): Set<NavigationGraphEntry> = setOf(yellowDialogGraphEntry, redDialogGraphEntry)
}

You can check out the sample app which also acts as a library for many of these abstraction, there you can also find how to use NavEntryArguments, Create destinations etc.

Thank you


Thanks for your wholehearted attention and reading up until this lengthy article, I hope this will help you modularize the app and make ease of use with some abstraction on top of the Compose Navigation component from Google, which works amazingly well, of course with a little bit of help, as you know Compose is new and the Navigation Component was XML based up until recently, so it’s normal that this article exists because of this.

Special thanks to my friend who helped me proof read this artcle.

This is an article that might not fit your case, it solved my problem, might not work in your situation.

A capybara to make your day more chill, as they’re one of the chillest animals.

At last, a video of the sample app and demonstration of everything so far and everything else that did not fit in these series of blog posts (as some parts were skipped because they can be looked into the code and understood as they’re straight forward)

This is now redundant, please use the type-safe solution.

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