KINTO Tech Blog
Android

Android Compose Object-Oriented Navigation

Cover Image for Android Compose Object-Oriented Navigation

Prerequisites and Scope

In this post, we will explore how to handle Android Compose navigation from an object-oriented perspective. We will cover methods to encapsulate underlying technologies and develop features that enhance development convenience and preventing lock-in to specific technologies.

Through this, we can not only write high-quality navigation code for Android Compose apps but also acquire the chain-of-thought of object-oriented development.

  1. This document assumes an MVVM architecture using Dagger / Hilt.
  2. This document only delivers data through the navigation route → ViewModel → UI path, NOT navigation route → UI.
  3. This document requires prior knowledge of the Android Navigation back stack.
  4. This document does not consider UX issues such as rapid consecutive clicks on navigation buttons.
  5. This document does not cover deep links.
  6. This document does not cover handling back gestures supported at the Android OS level.
  7. This document does not cover strict argument validation or performance optimization.
  8. The terms and names used in this document are arbitrary and may differ from established technical or academic terminology.

This article is the 16th day of the KINTO Technologies Advent Calendar 2024. 🎅🎄

Navigation Types

Detail

When navigating to a screen that provides detailed information, the transition typically involves moving from a general list to a specific item screen, such as from a news feed to a news detail or from a menu to a menu item. Each navigation action increases the back stack by one. You can return to the previous screen by removing(pop) the top of the back stack.

navigation-detail

You can call the NavController.navigate function with the route string, like navController.navigate("sub3_1").

@Composable
fun NavigationGraph() {
    val navController = rememberNavController()
    NavHost(navController, "splash") {
        composable("main3") {
            val viewModel: Main3ViewModel = hiltViewModel()
            Main3Screen(
                id = viewModel.id,
                navMain1 = { /* ... */ },
                navMain2 = { /* ... */ },
                navMain3 = { /* ... */ },
                navSub31 = { navController.navigate("sub3_1") },
                navSub32 = { navController.navigate("sub3_2") },
                navSub33 = { navController.navigate("sub3_3") }
            )
        }
    }
}

Switching

In this type of navigation, the user perceives the content change within the same screen rather than navigating to a different screen. This is typically used with tabs or a bottom navigation bar.

In the case of a bottom navigation bar, the back stack height does not change. The bottom of the back stack must always be one of Main#1, Main#2, or Main#3.

navigation-switching

To remove itself from the back stack, a popUpTo call is required, and saving and restoring the UI state may be necessary as needed.

@Composable
fun NavigationGraph() {
    val navController = rememberNavController()
    NavHost(navController, "splash") {
        composable("main3") {
            val viewModel: Main3ViewModel = hiltViewModel()
            Main3Screen(
                id = viewModel.id,
                navMain1 = {
                    navController.navigate("main1") {
                        popUpTo("main1") {
                            inclusive = true
                            saveState = true
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                },
                navMain2 = {
                    navController.navigate("main2") {
                        popUpTo("main2") {
                            inclusive = true
                            saveState = true
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                },
                navMain3 = {
                    navController.navigate("main3") {
                        popUpTo("main3") {
                            inclusive = true
                            saveState = true
                        }
                        launchSingleTop = true
                        restoreState = true
                    }
                },
                navSub31 = { /* ... */ },
                navSub32 = { /* ... */ },
                navSub33 = { /* ... */ }
            )
        }
    }
}

One-Way

This type of navigation involves moving to a screen from which you cannot return to the previous screen. It removes(pop) itself from the back stack and adds(push) the destination screen. Examples include cases where you cannot return to the form screen after submitting a form or navigating away from a splash screen.

navigation-oneway

If you only need to prevent returning to itself, you can simply handle it with popBackStack, but if necessary, you may need to use popUpTo to remove multiple screens from the back stack.

@Composable
fun NavigationGraph() {
    val navController = rememberNavController()
    NavHost(navController, "splash") {
        composable("splash") {
            val viewModel: SplashViewModel = hiltViewModel()
            SplashScreen(
                timeout = viewModel.timeout,
                navMain1 = {
                    navController.popBackStack()
                    navController.navigate("main1")
                }
            )
        }
        
        composable("transactional3") {
            val viewModel: Transactional3ViewModel = hiltViewModel()
            Transactional3Screen(
                onClickSave = { /* ... */ },
                onClickSubmit = {
                    viewModel.onClickSubmit {
                        navController.navigate("transactional1") {
                            popUpTo("sub1") {
                                inclusive = true
                            }
                        }
                    }
                }
            )
        }
    }
}

Transactional(Split)

This type of navigation involves splitting a very complex or long single screen into multiple steps. It is used to improve UX by reducing user stress when there is a lot of information to convey or actions to request from the user. The user can exit the flow midway, but must start from the beginning when re-entering. If the user completes the task or navigates away from the flow, the entire flow is removed from the back stack. This approach can be combined with one-way navigation to prevent users from abandoning complex UI, include forms.

navigation-transactional

Within the flow, you can freely navigate back and forth, but when exiting the flow, you need to popUpTo to remove all screens of the flow from the back stack.

@Composable
fun NavigationGraph() {
    val navController = rememberNavController()
    NavHost(navController, "splash") {
        composable(
            route = "transactional1",
            arguments = listOf(
                navArgument("draft") {
                    type = NavType.IntType
                }
            )
        ) {
            val viewModel: Transactional1ViewModel = hiltViewModel()
            Transactional1Screen(
                id = viewModel.id,
                onClickBack = {
                    viewModel.onClickBack {
                        navController.popBackStack()
                    }
                },
                onClickSave = {
                    viewModel.onClickSave {
                        navController.navigate("sub2") {
                            popUpTo("transactional1") {
                                inclusive = true
                            }
                        }
                    }
                },
                onClickNext = {
                    navController.navigate("transactional2")
                }
            )
        }
    }
}

Navigation Management

navigation-oo-navigation-sample.png

We will explain how to manage navigation in an object-oriented manner using the Sub#1 screen and related transitions, highlighted in red. We will introduce object-oriented elements step by step.

The Sub#1 screen has the following conditions:

  1. The Sub#1 screen requires either the draft parameter or the combination of param1, param2, param3, and param4 parameters to open.
  2. To navigate from the Sub#1 screen to the Sub#2 screen, you must wait until the long-running save function completes.
  3. To navigate from the Sub#1 screen to the Transactional#1 screen, you must wait until the long-running start function completes.

The ViewModel should be independent of the navigation graph or UI. It should use the arguments received from the navigation route directly or use them to fetch data and set properties required by the UI.

@HiltViewModel
class Sub1ViewModel @Inject constructor(
    handle: SavedStateHandle,
    private val draftModel: DraftModel
) : ViewModel() {
    val id: UUID = UUID.randomUUID()

    val draft: Draft? = handle.get<Int?>("draft")?.let {
        runBlocking {
            if (null != draft && 0 < draft) {
                draftModel.get(draft)!!.also {
                    param1 = it.param1
                    param2 = it.param2
                    param3 = it.param3
                    param4 = it.param4
                }
            }
        }
    }

    var param1: String? = handle["param1"]
        private set
    var param2: String? = handle["param2"]
        private set
    var param3: String? = handle["param3"]
        private set
    var param4: String? = handle["param4"]
        private set

    fun onClickSave(callback: () -> Unit) {
        viewModelScope.launch {
            // Long save task.
            delay(2_000)
            callback()
        }
    }

    fun onClickStart(callback: () -> Unit) {
        viewModelScope.launch {
            // Long start task.
            delay(2_000)
            callback()
        }
    }
}

Using Only the Basic Features of the Guide Document

Based on the navigation written using the Design your navigation graph / Minimal example guide, the NavigationGraph has the following roles:

  1. Registering screens.
  2. Declaring and registering the list of parameters for screen routes.
  3. Creating route strings for screen navigation.
  4. Encapsulating NavController.navigate.
  5. Executing the screen's UI and passing the encapsulated NavController.navigate logic.

Using Only the Basic Features of the Guide Document

/**
 * Guide document style navigation graph.
 */
@Composable
fun NavigationGraph() {
    val navController = rememberNavController()
    NavHost(navController, "splash") {
        composable(
            route = "sub1?${
                listOf(
                    "draft={draft}",
                    "param1={param1}",
                    "param2={param2}",
                    "param3={param3}",
                    "param4={param4}"
                ).joinToString("&")
            }",
            arguments = listOf(
                navArgument("draft") {
                    type = NavType.IntType
                    defaultValue = 0
                },
                navArgument("param1") {
                    type = NavType.StringType
                    nullable = true
                },
                navArgument("param2") {
                    type = NavType.StringType
                    nullable = true
                },
                navArgument("param3") {
                    type = NavType.StringType
                    nullable = true
                },
                navArgument("param4") {
                    type = NavType.StringType
                    nullable = true
                }
            )
        ) {
            Sub1Screen(
                navBack = navController::popBackStack,
                navSub2 = {
                    navController.navigate("sub2") {
                        popUpTo("sub1") {
                            inclusive = true
                        }
                    }
                },
                navTransactional1 = { draft ->
                    if (null == draft) {
                        navController.navigate("transactional1")
                    } else {
                        navController.navigate("transactional1?draft=${draft.id}")
                    }
                }
            )
        }
    }
}
/**
 * Bridge between navigation(encapsule navigation), `ViewModel`(state hoisting) and UI.
 */
@Composable
fun Sub1Screen(
    viewModel: Sub1ViewModel = hiltViewModel(),
    navBack: () -> Unit = {},
    navSub2: () -> Unit = {},
    navTransactional1: (Draft?) -> Unit = {}
) {
    Sub1Content(
        id = viewModel.id,
        param1 = viewModel.param1!!,
        param2 = viewModel.param2!!,
        param3 = viewModel.param3!!,
        param4 = viewModel.param4!!,
        onClickBack = navBack,
        onClickSave = { 
            viewModel.onClickSave(callback = navSub2)
        },
        onClickStart = {
            viewModel.onClickStart(callback = { navTransactional1(viewModel.draft) })
        }
    )
}

/**
 * Display state(arguments) only.
 */
@Composable
private fun Sub1Content(
    id: UUID,
    param1: String,
    param2: String,
    param3: String,
    param4: String,
    onClickBack: () -> Unit = {},
    onClickSave: () -> Unit = {},
    onClickStart: () -> Unit = {}
) {
    IconButton(onClick = onClickBack) {
        Icon(Icons.AutoMirrored.Filled.ArrowBack, "back")
    }
            
    // ...

    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.Center,
        verticalAlignment = Alignment.CenterVertically
    ) {
        OutlinedButton(
            onClick = onClickSave,
            modifier = Modifier.padding(16.dp)
        ) {
            Text("SAVE", style = MaterialTheme.typography.labelLarge)
        }

        Button(
            onClick = onClickStart,
            modifier = Modifier.padding(16.dp)
        ) {
            Text("START", style = MaterialTheme.typography.labelLarge)
        }
    }
}

Ongoing Challenges

  1. Long Navigation Graph Code : NavigationGraph code becomes lengthy as the number of screens, arguments for each screen, connected screens and back stack manipulations increase.
  2. Mismatch Between Code Order and Execution Order : The order in which the code is written becomes the order in which it is read, which not match the execution order. This discrepancy forces developers to constantly determine whether the code they are reading is relevant to the current context, increasing cognitive overhead and making maintenance difficult.
  3. Too Many Parameters in UI Functions : The number of parameters in UI functions (Sub1Screen) increases with the number of connected screens. For example, in a settings screen with many detailed settings, the number of parameters grows accordingly.

Introducing Navigator

The purpose of introducing a navigator is to reduce the number of parameters in UI functions. Navigation-related parameters in UI functions are grouped into a navigator object.

Roles of the Navigator:

  1. Grouping Screens Navigable from Sub#1 into an Object : The navigator object encapsulates the navigation logic for screens that can navigate from the Sub#1 screen.
  2. Creating Navigation route : The navigator object is responsible for generating the route strings required for navigation.
  3. Manipulating the Back Stack : The navigator object handles back stack operations such as popping and pushing screens to ensure proper navigation flow.

Introducing

@Immutable
class Sub1Navigator(
    private val navController: NavController
) {
    fun back() {
        navController.popBackStack()
    }

    fun sub2() {
        navController.navigate("sub2") {
            popUpTo("sub1") {
                inclusive = true
            }
        }
    }

    fun transactional1(draft: Drift? = null) {
        if (null == draft) {
            navController.navigate("transactional1")
        } else {
            navController.navigate("transactional1?draft=${draft.id}")
        }
    }
}

The NavigationGraph has become simpler by introducing the Navigator, which separates the navigation route creation and back stack manipulation code.

composable(
    route = "sub1?${
        listOf(
            "draft={draft}",
            "param1={param1}",
            "param2={param2}",
            "param3={param3}",
            "param4={param4}"
        ).joinToString("&")
    }",
    arguments = listOf(
        navArgument("draft") {
            type = NavType.IntType
            defaultValue = 0
        },
        navArgument("param1") {
            type = NavType.StringType
            nullable = true
        },
        navArgument("param2") {
            type = NavType.StringType
            nullable = true
        },
        navArgument("param3") {
            type = NavType.StringType
            nullable = true
        },
        navArgument("param4") {
            type = NavType.StringType
            nullable = true
        }
    )
) {
    Sub1Screen(navigator = remember(navController) { Sub1Navigator(navController) })
}

The Sub1Screen has fewer parameters by grouping navigation-related parameters into a single object, making the call code clearer by encapsulating it as object members.

@Composable
fun Sub1Screen(
    navigator: Sub1Navigator,
    viewModel: Sub1ViewModel = hiltViewModel()
) {
    Sub1Content(
        id = viewModel.id,
        param1 = viewModel.param1!!,
        param2 = viewModel.param2!!,
        param3 = viewModel.param3!!,
        param4 = viewModel.param4!!,
        onClickBack = navigator::back,
        onClickSave = { 
            viewModel.onClickSave(callback = navigator::sub2)
        },
        onClickStart = { 
            viewModel.onClickStart(callback = { navigator.transactional1(viewModel.draft) })
        }
    )
}

Ongoing Challenges

Although the UI functions have been simplified, the navigation graph remains complex and developers still experience continuous context switching due to the following roles:

  1. Registering screens.
  2. Declaring and registering the list of parameters for screen routes.

To simplify the NavigationGraph function and centralize navigation information, you can use a companion object within the navigator class. This approach allows you to define routes and arguments in one place. Here's how you can do it :

  1. Define the routes and arguments in the companion object of the navigator class.
  2. Use these definitions in the NavigationGraph function.

Navigator +

@Immutable
class Sub1Navigator(
    private val navController: NavController
) {
    @Suppress("MemberVisibilityCanBePrivate")
    companion object {
        const val ARG_DRAFT = "draft"
        const val ARG_PARAM1 = "param1"
        const val ARG_PARAM2 = "param2"
        const val ARG_PARAM3 = "param3"
        const val ARG_PARAM4 = "param4"

        const val ROUTE = "sub1?" +
                "$ARG_DRAFT={draft}&" +
                "$ARG_PARAM1={param1}&" +
                "$ARG_PARAM2={param2}&" +
                "$ARG_PARAM3={param3}&" +
                "$ARG_PARAM4={param4}"

        val ARGUMENTS = listOf(
            navArgument(ARG_DRAFT) {
                type = NavType.LongType
                defaultValue = 0
            },
            navArgument(ARG_PARAM1) {
                type = NavType.StringType
                nullable = true
            },
            navArgument(ARG_PARAM2) {
                type = NavType.StringType
                nullable = true
            },
            navArgument(ARG_PARAM3) {
                type = NavType.StringType
                nullable = true
            },
            navArgument(ARG_PARAM4) {
                type = NavType.StringType
                nullable = true
            }
        )
    }
    // ...
}

By using the companion object in the navigator class, the navigation graph can be simplified to focus on screen registration and UI function calls. The UI function remains unchanged.

composable(
    route = Sub1Navigator.ROUTE,
    arguments = Sub1Navigator.ARGUMENTS
) {
    Sub1Screen(navigator = remember(navController) { Sub1Navigator(navController) })
}

Ongoing Challenges

Considering only the Sub#1 screen, this is sufficient. However, expanding the scope to include the navigators for Main#1 and Sub#2, which need to navigate to the Sub#1 screen, is as follows:

@Immutable
class Main1Navigator(
    private val navController: NavController
) {
    // ...

    fun sub1(item: Main1Item) {
        navController.navigate("sub1?param1=${item.param1}&param2=${item.param2}&param3=${item.param3}&param4=${item.param4}")
    }
}
@Immutable
class Sub2Navigator(
    private val navController: NavController
) {
    // ...

    fun sub1(draft: Draft) {
        navController.navigate("sub1?draft=${draft.id}") {
            popUpTo(Main2Navigator.ROUTE) {
                inclusive = true
            }
        }
    }
}

Sub1Navigator manages valid route formats, but the actual route composition is handled by Main1Navigator and Sub2Navigator, resulting in an inconsistent state where the responsibility for the Sub#1 route is distributed to users rather than being centralized in Sub#1.

Shared Destination Handling

It is reasonable to manage valid route formats and the logic for composing valid route values together. By moving the route creation logic to each companion object, it can be standardized.

  1. Encapsulate the route itself and composition.
  2. Implement object-based navigation.

Shared Destination Handling

@Immutable
class Sub1Navigator(
    private val navController: NavController
) {
    companion object {
        // ...

        const val ARG_DRAFT = "draft"
        const val ARG_PARAM1 = "param1"
        const val ARG_PARAM2 = "param2"
        const val ARG_PARAM3 = "param3"
        const val ARG_PARAM4 = "param4"

        fun route(item: Main1Item) = "sub1?$ARG_PARAM1=${item.param1}&$ARG_PARAM2=${item.param2}&$ARG_PARAM3=${item.param3}&$ARG_PARAM4=${item.param4}"
        
        fun route(draft: Draft) = "sub1?draft=${draft.id}"
    }

    // ...
}
@Immutable
class Main1Navigator(
    private val navController: NavController
) {
    // ...

    fun sub1(item: Main1Item) {
        navController.navigate(Sub1Navigator.route(item))
    }
}
@Immutable
class Sub2Navigator(
    private val navController: NavController
) {
    // ...

    fun sub1(draft: Draft) {
        navController.navigate(Sub1Navigator.route(draft)) {
            popUpTo(Main2Navigator.ROUTE) {
                inclusive = true
            }
        }
    }
}

Ongoing Challenges

When registering a screen, using the same class for route, arguments, and navigator instance creation is crucial. If a mistake occurs, it can lead to logical errors such as :

  1. Inconsistent Route Definitions : If the route and arguments are not defined consistently, the navigation may fail or behave unexpectedly.
  2. Incorrect Navigator Instance : Using a different navigator instance can lead to navigation logic errors, causing the app to navigate to incorrect screens or fail to navigate.
composable(
    route = Main1Navigator.ROUTE,
    arguments = Transactional1Navigator.ARGUMENTS
) {
    Sub1Screen(navigator = remember(navController) { Sub1Navigator(navController) })
}

Abstracting Navigator, companion object and Standardizing Destination Handling

You can abstract the navigator and the navigator's companion object and define the following properties.

Abstracting Navigator,  and Standardizing Destination Handling

interface Navigator {
    val destination: Destination
}

interface Destination {
    val routePattern: String
    
    val arguments: List<NamedNavArgument>
    
    fun route(varargs arguments: Any?): String
}

If the navigator and companion object each implement Navigator and Destination, the navigation graph configuration is standardized as follows.

@Immutable
class Sub1Navigator(
    private val navController: NavController
): Navigator {
  companion object: Destination {
      const val ARG_DRAFT = "draft"
      const val ARG_PARAM1 = "param1"
      const val ARG_PARAM2 = "param2"
      const val ARG_PARAM3 = "param3"
      const val ARG_PARAM4 = "param4"
      
      override val routePattern = "sub1?$ARG_DRAFT={draft}&$ARG_PARAM1={param1}&$ARG_PARAM2={param2}&$ARG_PARAM3={param3}&$ARG_PARAM4={param4}"
      
      override val arguments = listOf(
            navArgument(ARG_DRAFT) {
                type = NavType.LongType
                defaultValue = 0
            },
            navArgument(ARG_PARAM1) {
                type = NavType.StringType
                nullable = true
            },
            navArgument(ARG_PARAM2) {
                type = NavType.StringType
                nullable = true
            },
            navArgument(ARG_PARAM3) {
                type = NavType.StringType
                nullable = true
            },
            navArgument(ARG_PARAM4) {
                type = NavType.StringType
                nullable = true
            }
        )
            
      override fun route(varargs arguments: Any?): String = when {
          1 == arguments.size && arguments[0] is Main1Item -> route(arguments[0] as Main1Item)
          1 == arguments.size && arguments[0] is Draft -> route(arguments[0] as Draft)
          else -> throw IllegalArgumentException("Invalid arguments : arguments=$arguments")
      }
      
      fun route(item: Main1Item) = "sub1?$ARG_PARAM1=${item.param1}&$ARG_PARAM2=${item.param2}&$ARG_PARAM3=${item.param3}&$ARG_PARAM4=${item.param4}"
      
      fun route(draft: Draft) = "sub1?draft=${draft.id}"
  }
    
  override val destination = Companion
}

The responsibilities of NavigationGraph are summarized as follows :

  1. Creating navigator instances.
  2. Connecting the abstracted navigation object with the UI function.
Main1Navigator(navController).let { navigator ->
    composable(navigator.destination.routePattern, navigator.destination.arguments) {
        Main1Screen(navigator)
    }
}

Sub1Navigator(navController).let { navigator ->
    composable(navigator.destination.routePattern, navigator.destination.arguments) {
        Sub1Screen(navigator)
    }
}

Sub2Navigator(navController).let { navigator ->
    composable(navigator.destination.routePattern, navigator.destination.arguments) {
        Sub2Screen(navigator)
    }
}

Improving Development Productivity

Open a web browser, making a phone call, open app settings, restarting the app and reloading the UI are sometimes necessary regardless of the screen. It is more efficient to share a single implementation of code for these universal functions rather than implementing them individually on each screen where needed.

@Immutable
class Sub1Navigator(
    private val navController: NavController
): Navigator {
    fun web(uri: Uri) { /* Indivisual impl */ }
    
    fun call(phoneNumber: String) { /* Indivisual impl */ }
}
@Immutable
class Sub31Navigator(
    private val navController: NavController
): Navigator {
    fun web(uri: Uri) { /* Indivisual impl */ }
    
    fun settings() { /* Indivisual impl */ }
}

Solution

You can declare common functions in the Navigator interface, implement the universal functions, and then delegate them to the individual screen navigators to achieve commonality.

Global Navigation

/**
 * Define common navigation functions.
 */
interface Navigator {
    val destination: Destination

    fun web(uri: Uri)

    fun call(phoneNumber: String)

    fun settings()

    fun reopen()

    fun restart()
}
/**
 * Implement common navigation functions.
 */
open class BaseNavigator(
    private val activity: Activity,
    val navController: NavController
): Navigator {
    override fun web(uri: Uri) {
        activity.startActivity(Intent(ACTION_VIEW, uri))
    }

    // ...

    override fun reopen(){
        activity.finish()
        activity.startActivity(Intent(activity, activity::class.java))
    }
    
    override fun restart() {
        activity.startActivity(Intent(activity, activity::class.java))
        exitProcess(0)
    }
}
/**
 * Delegate common navigation functions to individual screen navigators.
 */
@Immutable
class Sub1Navigator(
    private val baseNavigator: BaseNavigator
): Navigator by baseNavigagor {
    fun sub2() {
        baseNavigator.navController.navigate(Sub2Navigator.route()) {
            popUpTo(routePattern) {
                inclusive = true
            }
        }
    }
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        enableEdgeToEdge()
        setContent {
            NavigationGraph(this@MainActivity)
        }
    }
}
/**
 * Replace the `NavController` with the base navigator instance.
 *
 * @param activity The activity that owns the navigation graph.
 */
@Composable
fun NavigationGraph(activity: Activity) {
    val baseNavigator = BaseNavigator(activity, rememberNavController())

    NavHost(baseNavigator.navController, SplahNavigator.routePattern) {
        Sub1Navigator(baseNavigator).let { navigator ->
            // ...
        }
    }
}

There is still a risk of logical errors as follows.

Sub1Navigator(baseNavigator).let { navigator ->
    composable(Main1Navigator.routePattern, Transactional1Navigator.arguments) {
        Sub1Screen(navigator)
    }
}

Solution

By adding a utility function to handle navigator instances and screen registration,

Navigation Graph Configuration Utility

fun <N : Navigator> NavGraphBuilder.composable(
    navigator: N,
    content: @Composable AnimatedContentScope.(NavBackStackEntry, N) -> Unit
) {
    composable(
        route = navigator.destination.routePattern,
        arguments = navigator.destination.arguments
    ) { backStackEntry ->
        content(backStackEntry, navigator)
    }
}

the navigation graph configuration becomes simpler, eliminating the possibility of logical errors.

composable(Main1Navigator(baseNavigator)) { _, navigator ->
    Main1Screen(navigator)
}

composable(Sub1Navigator(baseNavigator)) { backStackEntry, navigator ->
    Sub1Screen(navigator)
}

composable(Sub2Navigator(baseNavigator)) { _, navigator ->
    Sub2Screen(navigator)
}

@Preview Support

For screens that display static resource in bundle, you can write navigation code by passing only the navigator instead of passing a separate event handler as an argument. Here is an example :

@Composable
fun StaticResourceListScreen(navigator: StaticResourceListNavigator) {
    Column {
        Button(onClick = navigator::static1) {
            Text("Static#1")
        }
        Button(onClick = navigator::static2) {
            Text("Static#2")
        }
        Button(onClick = navigator::static2) {
            Text("Static#2")
        }
    }
}

@Preview code repeats BaseNavigator(PreviewActivity(), rememberNavController()) for each navigator instance. As the types and number of previews increase, it becomes inconvenient to write previews. And this overhead force developer to skip the preview.

@Composable
@Preview(showSystemUi = true)
fun PreviewStaticResourceListScreen() {
    MaterialTheme {
        StaticResourceListScreen(StaticResourceListNavigator(BaseNavigator(PreviewActivity(), rememberNavController())))
    }
}

@Composable
@Preview(showSystemUi = true)
fun PreviewStatic1Screen() {
    MaterialTheme {
        Static1Screen(Static1Navigator(BaseNavigator(PreviewActivity(), rememberNavController())))
    }
}

@Composable
@Preview(showSystemUi = true)
fun PreviewStatic2Screen() {
    MaterialTheme {
        Static2Screen(Static2Navigator(BaseNavigator(PreviewActivity(), rememberNavController())))
    }
}

@Composable
@Preview(showSystemUi = true)
fun PreviewStatic3Screen() {
    MaterialTheme {
        Static3Screen(Static3Navigator(BaseNavigator(PreviewActivity(), rememberNavController())))
    }
}

Solution

Create a utility function to instantiate BaseNavigator and implement it to handle real apps and previews separately.

 Support

@Composable
fun baseNavigator(
    activity: Activity = if (LocalInspectionMode.current) {
        PreviewActivity()
    } else {
        LocalContext.current as Activity
    }
): BaseNavigator {
    val navHostController = rememberNavController()
    val base = remember(activity) {
        BaseNavigator(activity, navHostController)
    }
    return base
}
@Composable
fun NavigationGraph(activity: Activity) {
    val baseNavigator = baseNavigator(activity)

    NavHost(navController, SplahNavigator.routePattern) {
        // ...
    }
}
@Composable
@Preview(showSystemUi = true)
fun PreviewStaticResourceListScreen() {
    MaterialTheme {
        StaticResourceListScreen(StaticResourceListNavigator(baseNavigator()))
    }
}

@Composable
@Preview(showSystemUi = true)
fun PreviewStatic1Screen() {
    MaterialTheme {
        Static1Screen(Static1Navigator(baseNavigator()))
    }
}

@Composable
@Preview(showSystemUi = true)
fun PreviewStatic2Screen() {
    MaterialTheme {
        Static2Screen(Static2Navigator(baseNavigator()))
    }
}

@Composable
@Preview(showSystemUi = true)
fun PreviewStatic3Screen() {
    MaterialTheme {
        Static3Screen(Static3Navigator(baseNavigator()))
    }
}

Custom Start Screen

The NavigationGraph configuration function used so far can change the start screen but can only use a single navigation graph. This means that it is not possible to develop a demo application that utilizes only part of the existing functionality.

@AndroidEntryPoint
class DemoActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        enableEdgeToEdge()
        setContent {
            NavigationGraph(this@DemoActivity) // FIXED start screen.
        }
    }
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        enableEdgeToEdge()
        setContent {
            NavigationGraph(this@MainActivity)
        }
    }
}
@Composable
fun NavigationGraph(activity: Activity) {
    val baseNavigator = baseNavigator(activity)

    NavHost(navController, SplahNavigator.routePattern) {
        Sub1Navigator(baseNavigator).let { navigator ->
            composable(navigator.destination.routePattern, navigator.destination.arguments) {
                Sub1Screen(navigator)
            }
        }
    }
}

Solution

Implement destination in BaseNavigator, then pass the hoisted BaseNavigator instance from the activity to the navigation graph.

Custom Start Screen

open class BaseNavigator(
    private val activity: Activity,
    val navController: NavController,
    override val destination: Desination
): Navigator {
    // ...
}
/**
 * @param startDestination Default value available only in preview.
 */
@Composable
fun baseNavigator(
    activity: Activity = if (LocalInspectionMode.current) {
        PreviewActivity()
    } else {
        LocalContext.current as Activity
    },
    startDestination: Destination = if(LocalInspectionMode.current || activity is PreviewActivity) {
	      object: Destination {
	          override val routePattern = "preview"
	          // ...
	      }
    } else {
        throw IllegalArgumentException("When running the app in real mode, you must provide a startDestination.")
    }
): BaseNavigator {
    val navHostController = rememberNavController()
    val base = remember(activity) {
        BaseNavigator(activity, navHostController, startDestination)
    }
    return base
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        enableEdgeToEdge()
        setContent {
            NavigationGraph(baseNavigator(destination = SplashNavigator.Companion))
        }
    }
}
@Composable
fun NavigationGraph(baseNavigator: BaseNavigator = baseNavigator()) {
    // Implement additional and common navigation features here.

    NavHost( // Encapsulate the `navigation-compose` dependency in activity(`MainActivity`) and UI(`UiRoot`).
        baseNavigator.navController,
        baseNavigator.destination.routePattern
    ) {
        // ...
    }
}

DEMO App Development Support

The navigation graph configuration function(NavigationGraph) can change the start screen but can only use a single navigation graph & start screen. This means that it is not possible to develop a demo application that use only part of the existing screen and functionality.

@Composable
fun NavigationGraph(baseNavigator: BaseNavigator = rememberBaseNavigator()) {
    NavHost(
        navController = baeNavigator.navController,
        startDestination = baseNavigator.destination.routePattern
    ) {
        // ...
    }
}

Solution

Separate the navigation graph configuration code from the navigation-compose call. This allows for the separation of navigation graph building, the connection between navigators and UI functions, and common navigation features, while additionally encapsulating dependencies.

DEMO App Development Support

@Composable
fun NavigationGraph(baseNavigator: BaseNavigator = rememberBaseNavigator(), builder: NavGraphBuilder.() -> Unit) {
    NavHost(
        navController = baeNavigator.navController,
        startDestination = baseNavigator.destination.routePattern,
        builder = builder
    )
}

Production App

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        enableEdgeToEdge()
        setContent {
            UiRoot(baseNavigator(destination = SplashNavigator.Companion))
        }
    }
}
@Composable
fun UiRoot(baseNavigator: BaseNavigator) {
    NavigationGraph(baseNavigator) {
        // ...
        composable(Sub1Navigator(baseNavigator)) { _, navigator ->
	          Sub1Screen(navigator)
        }
        // ...
    }
}

DEMO App

@AndroidEntryPoint
class DemoActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        enableEdgeToEdge()
        setContent {
            DemoUiRoot(baseNavigator(destination = Transactional1Navigator.Companion))
        }
    }
}
@Composable
fun DemoUiRoot(baseNavigator: BaseNavigator) {
    NavigationGraph(baseNavigator) {
        composable(Transactional1Navigator(baseNavigator)) { _, navigator ->
	          Transactional1Screen(navigator)
        }
    }
}

Terminology Unification

In the guide document, the term screen is used to refer to navigation targets. However, many UI design systems—such as Atomic Design, Carbon Design System, Ant Design, Shopify Polaris— use page to refer to navigation targets, and screen is used to refer to the physical device display.

Additionally, when registering a screen in the navigation graph, the function name composable is used to indicate that the @Composable function is called.

Changing the developer-centric terminology to consider related fields outside of development can prevent confusion or the need to confirm meanings, ensuring smooth and quick communication. Therefore, it is advisable to unify the terminology.

fun <N : Navigator> NavGraphBuilder.composable(
    navigator: N,
    content: @Composable AnimatedContentScope.(NavBackStackEntry, N) -> Unit
) {
    composable(
        route = navigator.destination.routePattern,
        arguments = navigator.destination.arguments
    ) { entry ->
        content(entry, navigator)
    }
}
@Composable
fun UiRoot(baseNavigator: BaseNavigator) {
    NavigationGraph(baseNavigator) {
        composable(Sub1Navigator(baseNavigator)) { _, navigator ->
	          Sub1Screen(navigator)
        }
    }
}
@Composable
fun Sub1Screen(navigator: Sub1Navigator, viewModel: Sub1ViewModel = hiltViewModel()) {
		// ...
}

Solution

Assuming the adoption of Atomic Design as the design system, unify the terminology by using page instead of screen for navigation targets.

Terminology Unification

fun <N : Navigator> NavGraphBuilder.page(
    navigator: N,
    content: @Composable AnimatedContentScope.(NavBackStackEntry, N) -> Unit
) {
    composable(
        route = navigator.destination.routePattern,
        arguments = navigator.destination.arguments
    ) { entry ->
        content(entry, navigator)
    }
}
@Composable
fun UiRoot(baseNavigator: BaseNavigator) {
    NavigationGraph(baseNavigator) {
        page(Sub1Navigator(baseNavigator)) { _, navigator ->
	          Sub1Page(navigator)
        }
    }
}
@Composable
fun Sub1Page(navigator: Sub1Navigator, viewModel: Sub1ViewModel = hiltViewModel()) {
		// ...
}

Conclusion

At first, using only the basic features of the guide document, the structure was simple.

Object : Guide

Introducing object-oriented navigation design results in multiple generic types and functions that are not affected by specific navigation spec, each responsible for specialized functionality.

  • Navigator: Groups navigation related to the UI, defines properties on how it is registered in the navigation graph.
  • Destination: Defines properties for registering pages in the navigation graph.
  • BaseNavigator: Defines the integration between Application, Activity, and the navigation graph, implements common/global navigation.
  • NavGraphBuilder.page: Connects the navigation graph and each page(Navigator).
  • @Composable fun NavigationGraph: Encapsulates navigation-compose, implements common navigation features, separates common features and the concrete navigation graph.
  • @Composable fun baseNavigator: Provides development convenience.

The structure of the generic object is as follows.

Shared Object

Using generic objects to construct the navigation graph creates complex structure. Instead, if there are already generic features, the application-level features require less development and fewer considerations when using them, allowing for faster development. Therefore, as more screens are added, the average development cost per screen decreases.

Object : Shared & App

Depending on the field, product, development environment and individuals, this design approach may not be necessary. Even if needed, it might have a lower priority and the solutions could differ. The object-oriented design applied in this document is merely one example. However, the recognized issues and their design directions could be applicable to other software as well.

The Meaning of Object-Oriented Design

Object-oriented design does not necessarily result in short code or simple structure. The code appears shorter or simpler only when looking at already divided code by role. Even when following the guide document, the function that build the entire navigation graph and each screen function are written separately. In an extreme case, all screens can be implemented within the NavigationGraph or without NavigationGraph function; MainActivity can implement all screens while calling setContent.

Object-oriented design starts with analyzing what the code does and what responsibilities it requires to achieve that functionality. It then determines what should be grouped together and what should be separated into types or files. Each code connected through import or object references. Finally, it leads to which developer takes the responsibility of maintain the code and overhead of study to handle that code.

In the IT industry, developers are very important means of production. Therefore, cost and productivity are determined by how developers spend their time. Thus, a developer's cognitive capacity becomes expensive resource. The developer who has higher technical skills, the deeper understanding of the product and the more knowledge of the work history, the more valuable the developer's focus becomes. The cognitive overhead of determining whether the code being viewed is necessary for the current task consumes the most focus (cognitive capacity) in the least valuable way. Lower focus leads to lower productivity.

Applying object-oriented design inevitably results in complex structure and requires deep technical and historical knowledge to properly understand the code. Object-Oriented Design is a development methodology that involves a large amount of development and learning in advance to create an environment where surface-level functionality can be developed quickly and simply.

Risks and Costs of Object-Oriented Design

Object-oriented design is a development methodology that achieves high productivity based on a large amount of generic functionality and complex structure. However, generic functionality and structure has their limit. Some changes that exceed these limit can occur at any time. It is nearly impossible for planners, managers and designers, who are not developers regularly acquiring and utilizing tacit knowledge (or domain knowledge), to understand why some changes looks so simple but so difficult and time-consuming. Even among developers, those who lack an understanding of tacit knowledge will find it equally incomprehensible. It is not a solution that requires them to acquire tacit knowledge or to request developers to repeatedly explain until they understand. Instead, this problem requires a systematical and cultural solution by the organization or company, rather than an individual one.

Especially when object-oriented design is introduced for high productivity in a company or organization, managing conflicts arising from differences in tacit knowledge between developers or between developers and other positions becomes important. And this is where risks (or costs) arise. Of course, this is a problem when object-oriented design is done well. Before that, the issues are who will take responsibility for object-oriented design and how the responsible person will be decided at the organization or company level. If this decision is wrong, the most valuable resource of the organization or company, teamwork will be sacrificed.

References

  1. Design your navigation graph / Minimal example
  2. android/compose-samples > Jetnews > HomeScreens
  3. Atomic Design
  4. Carbon Design System
  5. Ant Design
  6. Shopify Polaris
Facebook

関連記事 | Related Posts

We are hiring!

【プロジェクトマネージャー】モバイルアプリ開発G/大阪

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。

【iOSエンジニア】モバイルアプリ開発G/大阪

モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。