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.
- This document assumes an MVVM architecture using Dagger / Hilt.
- This document only delivers data through the navigation route → ViewModel → UI path, NOT navigation route → UI.
- This document requires prior knowledge of the Android Navigation back stack.
- This document does not consider UX issues such as rapid consecutive clicks on navigation buttons.
- This document does not cover deep links.
- This document does not cover handling back gestures supported at the Android OS level.
- This document does not cover strict argument validation or performance optimization.
- 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.
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
.
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.
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.
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
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:
- The
Sub#1
screen requires either thedraft
parameter or the combination ofparam1
,param2
,param3
, andparam4
parameters to open. - To navigate from the
Sub#1
screen to theSub#2
screen, you must wait until the long-runningsave
function completes. - To navigate from the
Sub#1
screen to theTransactional#1
screen, you must wait until the long-runningstart
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:
- Registering screens.
- Declaring and registering the list of parameters for screen routes.
- Creating
route
strings for screen navigation. - Encapsulating
NavController.navigate
. - Executing the screen's UI and passing the encapsulated
NavController.navigate
logic.
/**
* 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
- Long Navigation Graph Code :
NavigationGraph
code becomes lengthy as the number of screens, arguments for each screen, connected screens and back stack manipulations increase. - 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.
- 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.
Navigator
Introducing 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:
- Grouping Screens Navigable from
Sub#1
into an Object : The navigator object encapsulates the navigation logic for screens that can navigate from theSub#1
screen. - Creating Navigation
route
: The navigator object is responsible for generating theroute
strings required for navigation. - Manipulating the Back Stack : The navigator object handles back stack operations such as popping and pushing screens to ensure proper navigation flow.
@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:
- Registering screens.
- Declaring and registering the list of parameters for screen routes.
companion object
Navigator + 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 :
- Define the routes and arguments in the
companion object
of the navigator class. - Use these definitions in the
NavigationGraph
function.
@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}¶m2=${item.param2}¶m3=${item.param3}¶m4=${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.
- Encapsulate the
route
itself and composition. - Implement object-based navigation.
@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 :
- Inconsistent Route Definitions : If the route and arguments are not defined consistently, the navigation may fail or behave unexpectedly.
- 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) })
}
companion object
and Standardizing Destination Handling
Abstracting Navigator, You can abstract the navigator and the navigator's companion object
and define the following properties.
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 :
- Creating navigator instances.
- 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
Global Navigation
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.
/**
* 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 ->
// ...
}
}
}
Navigation Graph Configuration Utility
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,
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.
@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.
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.
@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.
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.
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 betweenApplication
,Activity
, and the navigation graph, implements common/global navigation.NavGraphBuilder.page
: Connects the navigation graph and each page(Navigator
).@Composable fun NavigationGraph
: Encapsulatesnavigation-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.
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.
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
関連記事 | Related Posts
A Kotlin Engineer’s Journey Building a Web Application with Flutter in Just One Month
KotlinエンジニアがFlutterに入門して1ヶ月でWebアプリケーションを作った話
Jetpack Compose in myroute Android App
Mobile App Development Using Kotlin Multiplatform Mobile (KMM) and Compose Multiplatform
SwiftUI in Compose Multiplatform of KMP
Android View から Jetpack Compose への移行の第一歩
We are hiring!
【プロジェクトマネージャー】モバイルアプリ開発G/大阪
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。
【iOSエンジニア】モバイルアプリ開発G/大阪
モバイルアプリ開発GについてKINTOテクノロジーズにおける、モバイルアプリ開発のスペシャリストが集まっているグループです。KINTOやmy routeなどのサービスを開発・運用しているグループと協調しながら品質の高いモバイルアプリを開発し、サービスの発展に貢献する事を目標としています。