By using this site, you agree to the Privacy Policy and Terms of Use.
Accept
World of SoftwareWorld of SoftwareWorld of Software
  • News
  • Software
  • Mobile
  • Computing
  • Gaming
  • Videos
  • More
    • Gadget
    • Web Stories
    • Trending
    • Press Release
Search
  • Privacy
  • Terms
  • Advertise
  • Contact
Copyright © All Rights Reserved. World of Software.
Reading: Build Cleaner Navigation in Jetpack Compose With Just a Few Lines of Code | HackerNoon
Share
Sign In
Notification Show More
Font ResizerAa
World of SoftwareWorld of Software
Font ResizerAa
  • Software
  • Mobile
  • Computing
  • Gadget
  • Gaming
  • Videos
Search
  • News
  • Software
  • Mobile
  • Computing
  • Gaming
  • Videos
  • More
    • Gadget
    • Web Stories
    • Trending
    • Press Release
Have an existing account? Sign In
Follow US
  • Privacy
  • Terms
  • Advertise
  • Contact
Copyright © All Rights Reserved. World of Software.
World of Software > Computing > Build Cleaner Navigation in Jetpack Compose With Just a Few Lines of Code | HackerNoon
Computing

Build Cleaner Navigation in Jetpack Compose With Just a Few Lines of Code | HackerNoon

News Room
Last updated: 2025/04/05 at 5:39 PM
News Room Published 5 April 2025
Share
SHARE

Navigation in Compose is all about making your app’s flow feel natural and effortless – no more juggling a bunch of XML files or wrestling with clunky code. In this article, I’ll explain how Jetpack Compose handles navigation between screens with a modern, declarative twist. You’ll learn how to set up a NavController and a NavHost to create clear, maintainable navigation paths, pass data between screens, and handle transitions with ease. Whether you’re building a multi-screen app or just trying to keep your UI organized, Compose’s navigation component lets you map out your app’s journey using simple, intuitive code. It’s like drawing a roadmap for your app, but without the headache.

Android Jetpack’s Navigation component includes the Navigation library, Safe Args Gradle plug-in, and tooling to help you implement app navigation. The Navigation component handles diverse navigation use cases – from straightforward button clicks to more complex patterns such as app bars and navigation drawers.

If you want to fully migrate your app to Compose and use Navigation Compose, there are a few concepts you need to know to get started:

NavController

In Jetpack Compose, the NavController is a central component responsible for managing navigation within an app. It is part of the Navigation component, which enables you to navigate between different destinations (screens or composables) in your app, manage the back stack, and pass data between destinations. The NavController is used to trigger navigation actions – such as moving forward or backward – and to manage the state of the navigation stack, allowing the app to handle features like deep linking, back navigation, and passing arguments to destinations.

NavHost

In Jetpack Compose, NavHost is a key component for setting up navigation within your app. It serves as the container where your composable screens (destinations) are displayed and managed. Essentially, it links the navigation graph (which defines all the possible routes and their relationships) with the UI elements that users interact with.

NavGraph

In Jetpack Compose, a NavGraph (short for Navigation Graph) is a logical container that defines the set of destinations (screens or composables) and the relationships between them. It is used in conjunction with NavHost to manage the flow of navigation within an app. A NavGraph represents the complete navigation structure of your app and helps define how users can move from one screen (destination) to another. It holds all the routes (destinations) that can be navigated to, along with their associated arguments or deep links. While NavHost is the UI component that renders the screens based on the navigation state, the NavGraph focuses on defining and organizing the available routes.

Example

@Composable
fun MyApp() {
  val navController = rememberNavController()

  NavHost(navController = navController, startDestination = "home") {
    composable("home") { HomeScreen(navController) }
    composable("details/{id}") { backStackEntry ->
      DetailsScreen(id = backStackEntry.arguments?.getString("id"))
    }
  }
}

@Composable
fun HomeScreen(navController: NavController) {
  Column {
    Text("Home Screen")
    Button(onClick = { navController.navigate("details/1") }) {
      Text("Go to Details")
    }
  }
}

@Composable
fun DetailsScreen(id: String?) {
  Text("Details Screen, ID: $id")
}
  • rememberNavController() – Creates and remembers a NavController instance for the current composable, ensuring its state is preserved across recompositions.
  • NavHost – Acts as the container where navigation occurs, defining the UI region for destination composables and specifying the start destination (e.g., startDestination = “home”).
  • NavGraph – Within the NavHost, you declare individual destinations (such as composable(“home”) and composable(“details/{id}”)). This is where you define the navigation graph that outlines all possible navigation flows in your app.

Google’s approach

Out of the box, Google suggests passing an ID for your data and storing it in a cache (in-memory, database, etc.). But what if your app doesn’t have any of these, yet you still want to migrate? In that case, there’s a trick you can use. Instead of passing an ID, you can serialize the entire data class that you need to pass to the next screen, and then deserialize it when you navigate to the destination. To simplify the process, it’s a good idea to add a couple of helper classes:

NavigationDefines.kt

const val NAVIGATION_DATA_ARGUMENT = “navigationData”

ComposeNavGraphExtensions.kt

inline fun <reified T : Parcelable> NavGraphBuilder.composableWithData(
  route: String,
  crossinline content: @Composable (T) -> Unit
) {
  composable(
    route = route,
    arguments = listOf(navArgument(NAVIGATION_DATA_ARGUMENT) { type = NavigationArgumentsNavType<T>() })
  ) {
    it.arguments?.parcelable(NAVIGATION_DATA_ARGUMENT, T::class.java)?.let {  inputData ->
      content(inputData)
    } ?: run {
      throw IllegalStateException("Cannot navigate to $route. Expected arguments - ${T::class.qualifiedName}, but the actual value is ${it.arguments}")
    }
  }
}

ComposeNavHostExtensions.kt

fun <T : Parcelable> NavHostController.navigateWithData(route: String, navigationArguments: T) {
  val routeString = navigationArguments.toRouteString()
  navigate("$route/$routeString")
}

NavigationArgumentsNavType.kt

data class NavigationArgumentsNavType<T : Parcelable>(private val type: Class<T>) : NavType<T>(isNullableAllowed = false) {

  companion object {
    inline operator fun <reified T : Parcelable> invoke(): NavigationArgumentsNavType<T> = NavigationArgumentsNavType(T::class.java)
  }

  override fun get(bundle: Bundle, key: String): T? = bundle.parcelable(key, type)
  override fun parseValue(value: String): T = fromRouteSting(value, type)
  override fun put(bundle: Bundle, key: String, value: T) = bundle.putParcelable(key, value)
}

ParcelableExtension.kt

fun <T : Parcelable> Bundle.parcelable(key: String, type: Class<T>): T? = when {
  Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getParcelable(key, type)
  else -> @Suppress("DEPRECATION") getParcelable(key) as T?
}

ParcelableSerializationUtils.kt

private const val DELIMITER = "@"

fun <T : Parcelable> T.toRouteString(): String {
  return javaClass.name + DELIMITER + toBase64()
}

fun <T : Parcelable> fromRouteSting(routeString: String, type: Class<T>): T {
  val (className, base64) = routeString.split(DELIMITER).let { it[0] to it[1] }

  val creator = if (type.isFinal) {
    // Since we have this, small optimisation to avoid additional reflection call of Class.forName
    type.parcelableCreator
  } else {
    // If our class is not final, then we must use the actual class from "className"
    parcelableClassForName<T>(className).parcelableCreator
  }

  return base64ToParcelable(base64, creator)
}

private fun Parcelable.toBase64(): String {
  val parcel = Parcel.obtain()
  writeToParcel(parcel, 0)
  val bytes = parcel.marshall()
  parcel.recycle()

  return bytes.toBase64String()
}

@SuppressLint("NewApi")
private fun ByteArray.toBase64String(): String {
  return if (shouldUseJavaUtil) {
    java.util.Base64.getUrlEncoder().encodeToString(this)
  } else {
    Base64.encodeToString(this, Base64.URL_SAFE or Base64.NO_WRAP)
  }
}

private fun <T : Parcelable> base64ToParcelable(base64: String, creator: Parcelable.Creator<T>): T {
  val bytes = base64.base64ToByteArray()
  val parcel = unmarshall(bytes)
  val result = creator.createFromParcel(parcel)
  parcel.recycle()

  return result
}

fun String.base64ToByteArray(): ByteArray {
  return if (shouldUseJavaUtil) {
    java.util.Base64.getUrlDecoder().decode(toByteArray(StandardCharsets.UTF_8))
  } else {
    Base64.decode(toByteArray(StandardCharsets.UTF_8), Base64.URL_SAFE or Base64.NO_WRAP)
  }
}

private fun unmarshall(bytes: ByteArray): Parcel {
  val parcel = Parcel.obtain()
  parcel.unmarshall(bytes, 0, bytes.size)
  parcel.setDataPosition(0)

  return parcel
}

@Suppress("UNCHECKED_CAST")
private fun <T : Parcelable> parcelableClassForName(className: String): Class<T> {
  return Class.forName(className) as Class<T>
}

private val Class<out Parcelable>.isFinal get() = !isInterface && Modifier.isFinal(modifiers)

@Suppress("UNCHECKED_CAST")
private val <T> Class<T>.parcelableCreator
  get(): Parcelable.Creator<T> {
    return try {
      val creatorField = getField("CREATOR")
      creatorField.get(null) as Parcelable.Creator<T>
    } catch (e: Exception) {
      throw BadParcelableException(e)
    } catch (t: Throwable) {
      throw BadParcelableException(t.message)
    }
  }

// Both encode/decode from java until and android util seem to do exactly the same. android.util one doesn't work on Unit tests
// and java.util doesn't work on older devices. Also, on unit tests SDK_INT is 0, but we're running on the developer's machine
// So we can still use java.util in that case
@SuppressLint("ObsoleteSdkInt")
private val isRunningOnUnitTests = Build.VERSION.SDK_INT == 0

private val shouldUseJavaUtil = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O || isRunningOnUnitTests

What will the navigation look like now? Let’s modify our previous example:

Modified example

@Composable
fun MyApp() {
  val navController = rememberNavController()

  NavHost(navController = navController, startDestination = "home") {
    composable("home") { HomeScreen(navController) }
    composableWithData<Details>("$result/{$NAVIGATION_DATA_ARGUMENT}") { navigationArguments ->
      DetailsScreen(details = navigationArguments)
    }
  }
}

@Composable
fun HomeScreen(navController: NavController) {
  Column {
    Text("Home Screen")
    val details = Details(
      title = “Hello world!”,
      details = “Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.”
    )
    Button(onClick = { navigateWithArguments(“details”, details) }) {
      Text("Go to Details")
    }
  }
}

@Composable
fun DetailsScreen(details: Details) {
  Text(title)
  Text(description)
}

@Parcelable
data class Details(
  val title: String,
  val description: String,
)

This approach simplifies data management by removing the need for a database. Once set up, the code can be reused across multiple screens, reducing development effort. Additionally, it offers flexibility; you can navigate to screens without input data using the standard composable() function. However, passing entire data classes as navigation arguments may not be suitable for large or complex objects. In such cases, consider alternative methods like using a Shared ViewModel or passing only the necessary identifiers and retrieving the data in the destination screen.

Sign Up For Daily Newsletter

Be keep up! Get the latest breaking news delivered straight to your inbox.
By signing up, you agree to our Terms of Use and acknowledge the data practices in our Privacy Policy. You may unsubscribe at any time.
Share This Article
Facebook Twitter Email Print
Share
What do you think?
Love0
Sad0
Happy0
Sleepy0
Angry0
Dead0
Wink0
Previous Article Trump Tariffs Could Push iPhone Costs as High as $2,300
Next Article Today's NYT Connections Hints, Answers for April 6, #665
Leave a comment

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Stay Connected

248.1k Like
69.1k Follow
134k Pin
54.3k Follow

Latest News

Apple Acquisition Hints at Upgraded Calendar App on iOS 19 or Beyond
News
Ring is giving away FREE outdoor cameras worth £80 – and it’s easy to apply
News
How to Use Your iPad as a Second Monitor With Your Mac
Gadget
Everything I heard at the AVCA Conference |
Computing

You Might also Like

Computing

Everything I heard at the AVCA Conference |

9 Min Read
Computing

The Best Way to Protect Your Packages and Your Ethics | HackerNoon

8 Min Read
Computing

Why Glovo thinks Nigeria is its biggest African bet yet

11 Min Read
Computing

The TechBeat: Big Monitoring, Small Budget: Powering Observability on Kubernetes with Prometheus, Grafana & Mimir (5/10/2025) | HackerNoon

5 Min Read
//

World of Software is your one-stop website for the latest tech news and updates, follow us now to get the news that matters to you.

Quick Link

  • Privacy Policy
  • Terms of use
  • Advertise
  • Contact

Topics

  • Computing
  • Software
  • Press Release
  • Trending

Sign Up for Our Newsletter

Subscribe to our newsletter to get our newest articles instantly!

World of SoftwareWorld of Software
Follow US
Copyright © All Rights Reserved. World of Software.
Welcome Back!

Sign in to your account

Lost your password?