Skip to content

Mastering Screen Navigation with Kotlin – Day 10 Android 14 Masterclass

Mastering Screen Navigation with Kotlin - Day 10 Android 14 Masterclass

Mastering Screen Navigation with Kotlin – Day 10 Android 14 Masterclass

Welcome to Day 10 of our Android 14 & Kotlin Masterclass. Today’s focus is on screen navigation using Kotlin and Jetpack Compose. We’ll explore the Navigation component, delve into NavController’s functionalities, and learn about making text scrollable in Compose. Additionally, we’ll discuss the use of sealed classes, variable passing between screens, and data management during navigation.

 

1. Navigation from one screen to another

Navigating with Compose

The Navigation component for Jetpack Compose is a part of Android Jetpack, a suite of libraries to help developers follow best practices, reduce boilerplate code, and write code that works consistently across Android versions and devices.

In traditional Android development, navigation is typically handled by using fragments and activities. Jetpack Compose does not use fragments or activities for the UI but instead uses composable functions to define the UI.

To support navigation in the context of composable functions, the Navigation component for Jetpack Compose offers a simple and consistent way to implement navigation within your Compose applications.

Setup

To support Compose, use the following dependency in your app module’s build.gradle file:

dependencies {
    val nav_version = "2.7.5"

    implementation("androidx.navigation:navigation-compose:$nav_version")
}

Here are the key components of Navigation in Jetpack Compose:

  1. NavHost: This is a composable function that displays other composable functions based on the current navigation state. It’s similar to a ‘frame’ that swaps out different content based on where you are in the app.
  2. NavController: This object manages the navigation within a NavHost. It keeps track of the back stack and provides methods to navigate between composables.
  3. NavGraph: This defines the navigation structure of the application, specifying all the composables that can be navigated to and the actions that can navigate between them.
  4. NavBackStackEntry: Represents an entry in the back stack of a NavController, which holds the state for a destination.

In a Jetpack Compose application, navigation is usually set up in a single place in the UI hierarchy, typically in the main activity.

You define a NavHost composable, specify the startDestination, and for each screen or destination in your app, you create a composable within the NavHost that takes a lambda defining the UI for that screen.

Here’s a simple example:

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

In this example:

  • "home" is the route for the home screen.
  • "details/{itemId}" is the route for a details screen, which expects an itemId parameter.

You navigate between composables using the NavController like this:

navController.navigate("details/$itemId")

To go back, you use:

navController.navigateUp()

 

2. NavController in more details

Let’s break down the concept of NavController in the context of Android app development, specifically when using the Jetpack Compose toolkit for UI design.

What is NavController?

The NavController is like a guide within your app that knows how to take users from one screen to another.

Imagine you’re in a museum with many rooms (which are like different screens in your app). The NavController is the guide who knows all the paths between the rooms and helps you get from one to the other.

In technical terms, the NavController manages the navigation within a NavHost. It orchestrates changing the content on the screen when the user wants to navigate through the app.

How does NavController work?

To understand NavController, let’s consider a real-world analogy.

Imagine you are at a train station with several trains (destinations/screens) and a central control system (the NavController). This control system knows which train will take you to your destination and gives you the necessary directions (navigation actions).

In app terms:

  • Train Station (App): Your app with different places the user can visit (different screens or destinations).
  • Trains (Destinations): These are the screens within your app. Just like trains take you to different places, each destination represents a different part of your app.
  • Central Control System (NavController): The part of the app that knows and controls which

Key Responsibilities of NavController

  1. Knows the Routes: It knows all the possible screens a user can navigate to within the app.
  2. Manages the Navigation Graph: The navigation graph is a map of all destinations and actions. NavController uses it to navigate from one destination to another.
  3. Handles the Back Stack: When a user navigates through the app, NavController keeps a stack (like a stack of plates) of where they’ve been. When the user goes back, it pops the stack to return to the previous screen.
  4. Transitions Between Destinations: When you tell it to navigate to a different screen, it handles the work to replace the current screen with the new one.

Using NavController in Jetpack Compose

In Jetpack Compose, navigation is built around composable functions instead of activities or fragments. You define a NavHost that uses the NavController to swap out different composables based on user actions.

Here’s an example of how you might use NavController:

// First, you create the NavController
val navController = rememberNavController()

// Then, you define a NavHost and specify a start destination
NavHost(navController = navController, startDestination = "home") {
    composable("home") { HomeScreen(navController) }
    composable("details") { DetailScreen(navController) }
}

// In your HomeScreen composable, you could have something like this:
Button(onClick = { navController.navigate("details") }) {
    Text("Go to Details")
}

// And in your DetailScreen composable, you could have a button that goes back:
Button(onClick = { navController.navigateUp() }) {
    Text("Go Back")
}

In this code, you have two “rooms” or destinations: Home and Details. When you press the button in the HomeScreen, the NavController helps you “walk” to the DetailsScreen.

When you press the back button in the DetailsScreen, the NavController helps you “walk” back to the HomeScreen.

 

3. Making Text Scrollable in Jetpack Compose

In Jetpack Compose, you create UI elements using composable functions. These functions allow you to define your UI in a declarative way.

When you want to make text scrollable, you’re saying, “I want this text to be inside a container that can move up and down if the content is too large to fit on the screen at once.”

Theory

To make text scrollable in Jetpack Compose, you need to use a Modifier. A Modifier is like an instruction or an add-on that you attach to a UI element to change its behavior or appearance.

In this case, you’ll use the verticalScroll modifier, which tells Compose that the content should be able to scroll vertically.

Here’s how to do it step-by-step:

  1. RememberScrollState: First, you create a ScrollState, which keeps track of the scroll position. Think of it as the memory of where you have scrolled to in your content. You use rememberScrollState() to create this and remember it across recompositions.
  2. Modifier.verticalScroll: Next, you apply the verticalScroll modifier to the Text composable. This modifier takes the ScrollState you created as a parameter and makes the text inside the Text composable scrollable.
  3. Text Composable: This is the UI element that displays text on the screen. In Compose, everything you see is a composable function, and Text is the one that shows text.
  4. textAlign: This parameter of the Text composable defines how your text is aligned horizontally. By setting textAlign = TextAlign.Justify, you’re telling Compose to stretch the lines of your text so that they reach both edges of the container, giving it a clean and even look on both sides.

Now let’s put it all together in a Kotlin code example:

import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp

@Composable
fun ScrollableTextExample() {
    // This creates and remembers a scroll state for this composable
    val scrollState = rememberScrollState()

    // Here's the Text composable with a verticalScroll modifier
    Text(
        text = "Your really long text goes here...",
        textAlign = TextAlign.Justify, // This justifies the text
        modifier = Modifier
         // This makes the text vertically scrollable
            .verticalScroll(scrollState) 
    )
}

// Don't forget to call ScrollableTextExample() in your @Composable function 
// to see it on the screen

In this example, ScrollableTextExample is a composable function that creates a block of text that is vertically scrollable.

The verticalScroll modifier is attached to the Text, and it uses the scrollState to remember the current scroll position.

The text alignment is set to justify, so the text is evenly distributed from left to right.

You would call ScrollableTextExample() inside the content of your Activity or another composable, and when this composable gets displayed, you’ll see the text that you can scroll up and down if it extends beyond the bounds of the screen.

 

4. What is a Sealed Class?

A sealed class in Kotlin is a special kind of class that restricts which other classes can inherit from it. Think of it like an exclusive club where only members with an invitation can enter. In the programming world, this means you can define a closed set of subclasses.

Here’s a simple way to understand sealed classes:

  • Regular Class: Anyone can create a new subclass from it. It’s like a public park where anyone can come in.
  • Sealed Class: Only the subclasses defined in the same file can extend it. It’s like a private party where only guests on the list (defined in the same file) can join.

The major benefit of sealed classes comes into play when you use them in when expressions — they allow you to ensure that all possible cases are handled, and if you miss one, the compiler will warn you.

Syntax of a Sealed Class

Here’s how you define a sealed class in Kotlin:

sealed class Screen {
    object Home : Screen()
    object Settings : Screen()
    data class Details(val itemId: String) : Screen()
}

In this example, Screen is a sealed class with three possible types: Home, Settings, and Details.

The object keyword is used for declaring single instances (think of them as unique guests).

The data class is used for types that hold data (like a guest who brings a plus-one with a name).

Using a Sealed Class for Navigation Routes

Now, let’s apply this to set up navigation routes in an Android app using Jetpack Compose:

  1. Define a sealed class to represent all possible screens/routes in your app.
  2. Use this sealed class to manage navigation and ensure you’ve handled all possible navigation cases.

Here’s how you could define a sealed class for navigation:

sealed class Screen(val route: String) {
    object Home : Screen("home")
    object Settings : Screen("settings")
    // In this case, Details expects an ID, 
    // but we're not using vararg or any dynamic content.
    object Details : Screen("details")
}

With the sealed class in place, we can set up the navigation routes in a NavHost within Jetpack Compose:

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

    NavHost(navController = navController, startDestination = Screen.Home.route) {
        composable(Screen.Home.route) { HomeScreen(navController) }
        composable(Screen.Settings.route) { SettingsScreen(navController) }
        composable(Screen.Details.route) { DetailsScreen(navController, "staticID") }
    }
}

In this example:

  • We define a NavHost, which holds the different screens the user can navigate to.
  • For each screen, we have a composable that takes a route from our Screen sealed class.

Navigating Between Screens

Now let’s say you want to navigate from the HomeScreen to the DetailsScreen. Here’s how you could implement the HomeScreen with a button that navigates to the DetailsScreen:


@Composable
fun HomeScreen(navController: NavController) {
    // Button in the Home Screen
    Button(onClick = {
        // Navigate to the Details screen
        // We use the route from the sealed class here
        navController.navigate(Screen.Details.route)
    }) {
        Text("Go to Details")
    }
}

When the button is clicked, navController.navigate() is called with the route for the Details screen. This tells the NavController to change the current screen to the DetailsScreen.

Example of a Details Screen with a Static ID

For the DetailsScreen, even though we typically would want to pass a dynamic ID, let’s assume for simplicity we’re just using a static ID:

@Composable
fun DetailsScreen(navController: NavController, itemId: String) {
    // Details content goes here
    // For now, let's just display the itemId passed to it
    Text(text = "Item ID: $itemId")
}

In a real-world app, you’d likely have dynamic content, and you’d pass the itemId as a parameter when navigating. But to keep things straightforward for beginners, we’re using a static string here.

 

5. Passing Variables From One Screen To Another

Passing variables (like data or user input) from one screen to another is a fundamental task. This is typically done during navigation when you move from one screen to another and you want to pass some information along.

Here’s a simple step-by-step guide on how to pass a variable from one screen (Screen A) to another (Screen B):

Step 1: Define Your Navigation Routes

First, you need to define the navigation routes. Let’s modify our sealed class to include a route that can accept a variable:

sealed class Screen(val route: String) {
    object Home : Screen("home")
    object Details : Screen("details/{itemId}") {
        // This function will provide the correct route with the itemId parameter.
        fun createRoute(itemId: String) = "details/$itemId"
    }
}

In Details, we have a placeholder {itemId} in the route string which will be replaced by an actual item ID when we navigate.

Step 2: Set Up Navigation with Arguments

Next, in your navigation setup, you define how to handle the incoming argument:

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

    NavHost(navController = navController, startDestination = Screen.Home.route) {
        composable(Screen.Home.route) { HomeScreen(navController) }
        // Here we tell Compose how to extract the argument from the route
        composable(
            route = Screen.Details.route,
            arguments = listOf(navArgument("itemId") { type = NavType.StringType })
        ) { backStackEntry ->
            // Now we retrieve the argument
            val itemId = backStackEntry.arguments?.getString("itemId")
            // And pass it to the DetailsScreen
            DetailsScreen(navController, itemId ?: "No ID")
        }
    }
}

We define an argument itemId in the composable function that matches the placeholder in the route. This tells the NavController how to extract the itemId from the route string.

Step 3: Navigate with Arguments

Now, from Screen A (HomeScreen in this case), we want to navigate to Screen B (DetailsScreen) and pass the item ID:

@Composable
fun HomeScreen(navController: NavController) {
    // Example list of items
    val itemsList = listOf("Item1", "Item2", "Item3")

    // Simple UI with buttons for each item
    Column {
        for (item in itemsList) {
            Button(onClick = {
                // Navigate and pass the item as an argument
                navController.navigate(Screen.Details.createRoute(item))
            }) {
                Text(item)
            }
        }
    }
}

In this HomeScreen, we have a list of items, and for each item, we have a button. When you click a button, it navigates to the DetailsScreen and passes the item ID.

Step 4: Receive the Variable in the Destination Screen

Finally, in Screen B (DetailsScreen), you use the passed variable:

@Composable
fun DetailsScreen(navController: NavController, itemId: String) {
    // Here you can use the itemId that was passed from the HomeScreen
    Text(text = "Showing details for $itemId")
}

DetailsScreen takes the itemId as a parameter and uses it to display which item’s details are being shown.

Key Points to Remember:

  • To pass variables between screens in a Jetpack Compose app, you define navigation routes with placeholders, set up the navigation graph to extract arguments from the routes, and navigate using these routes with the variables you want to pass.
  • Then you can receive and use these variables in the destination screen. This method keeps your code clean and easy to manage, especially when dealing with multiple screens and data.

6. Saving and Retrieving Data When Navigating

Saving Data When Navigating to Another Screen

navController.currentBackStackEntry?.savedStateHandle?.set("cat", it)

Here’s what’s happening in this line:

  • navController: This is an instance of NavController that’s used to control navigation in your app. It manages the navigation stack, which keeps track of which screens (composables) have been displayed.
  • currentBackStackEntry: This refers to the current entry in the navigation stack — effectively, the current screen the user is looking at.
  • savedStateHandle: This is a storage area similar to a map or a bundle where you can save key-value pairs. It’s tied to the current navigation stack entry, meaning that the data you save here is preserved even if the user navigates away from the screen.
  • set("cat", it): The set method is used to save a piece of data. "cat" is the key, and it is the value being saved. You can think of it like putting a note inside a box where the note is labeled “cat”, and the content of the note is whatever it refers to.

In this context, it would be a variable in your composable function, likely representing some data that you want to pass to another screen. By setting this in the savedStateHandle, you ensure that when the user navigates to a new screen, this piece of data is saved and can be retrieved later.

Retrieving Data When Navigating Back to a Previous Screen

navController.previousBackStackEntry?.savedStateHandle?.get<Category>("cat")

Now, this line is about retrieving the data that was previously saved:

  • navController: Again, the same NavController instance.
  • previousBackStackEntry: This is now referring to the screen that was displayed before the current one. When the user navigates back, this entry becomes the current one.
  • savedStateHandle: Similar to before, but now it’s for the previous screen in the stack.
  • get<Category>("cat"): The get method is used to retrieve the data that was saved earlier. "cat" is the key we’re looking for, and Category is the expected type of the data. The get method will look in the saved state handle for the note labeled “cat” and give you back the content, which should be of the type Category.

In simple terms, when you navigate back to a previous screen, you’re looking for a piece of data that you expect to be there — like checking the box for the note labeled “cat”. If the note is there, great! You’ll use that data.

Putting It All Together

In the flow of an app, you might save some data when navigating forward and retrieve it when coming back. Here’s a simple scenario:

  1. Screen A wants to pass some data to Screen B. Before navigating, it saves the data with set.
  2. The user does their thing on Screen B.
  3. The user decides to go back to Screen A.
  4. Screen A uses get to retrieve the data that was passed earlier and resumes where the user left off.

This mechanism of passing and retrieving data is essential in providing a seamless user experience, where the state of the app is preserved across navigation actions.

7. Parcelization

Parcelization refers to converting an object into a format that can be easily saved and restored.

Imagine you have a toy (the object) and you want to send it through the mail (saving the state before closing the app or during configuration changes). To do so, you need to pack it into a box (parcel) in a way that it can easily be unpacked (restored) when it reaches its destination (when the app needs it again).

Parcelable objects are meant for short-term storage and are optimized for performance, unlike serializable objects which are more general-purpose and slower.

To use parcelization in Kotlin, you often use the @Parcelize annotation. This tells Kotlin to automatically generate all the necessary boilerplate code to make the object parcelable.

Here’s an example:

import android.os.Parcelable
import kotlinx.parcelize.Parcelize

@Parcelize
data class User(val name: String, val age: Int) : Parcelable

In this code:

  • @Parcelize: This is the annotation that tells Kotlin to create the necessary implementations for parceling the User object.
  • data class User: This defines a simple data class with a name and an age.
  • : Parcelable: This shows that the User class implements the Parcelable interface, making it capable of being packed into a Parcel.

@Parcelize

The @Parcelize annotation in Kotlin is part of the Kotlin Android Extensions. It simplifies the process of making an object Parcelable by auto-generating the Parcelable implementation at compile-time, which otherwise would be very tedious and error-prone to write by hand.

Serialization

Serialization is the process of converting an object into a stream of bytes to save it to memory, a file, or send it through a network.

It’s like writing a letter (the object) into a coded message (the stream of bytes) that can later be read and understood only by someone who knows the code (the app that deserializes the object).

Deserialization

Deserialization is the opposite of serialization. It takes the stream of bytes and converts it back into an object.

Continuing with the letter analogy, deserialization would be like taking the coded message you received and translating it back into the letter (the object).

We use Parcelization in the context of navigating for these key reasons:

  1. Fast Transfer of Data: Parcelization allows complex data objects to be quickly converted into a format that can be efficiently passed between screens.
  2. Optimized for Android: It’s specifically optimized for the Android platform, ensuring that data is handled in the most resource-effective manner during navigation.
  3. Easy to Implement: With @Parcelize, developers can easily make objects parcelable and reduce boilerplate code, simplifying the process of passing objects through navigation actions.

 

Conclusion: Mastering Screen Navigation with Kotlin – Day 10 Android 14 Masterclass

Concluding Day 10 of our masterclass, we’ve gained valuable insights into screen navigation in Android applications. From Jetpack Compose’s Navigation component to the intricacies of NavController and sealed classes, these skills are crucial for creating efficient and user-friendly Android apps. This knowledge forms a strong foundation for more advanced topics in our ongoing Kotlin journey.

 

 

If you want to skyrocket your Android career, check out our The Complete Android 14 & Kotlin Development Masterclass. Learn Android 14 App Development From Beginner to Advanced Developer.

Master Jetpack Compose to harness the cutting-edge of Android development, gain proficiency in XML — an essential skill for numerous development roles, and acquire practical expertise by constructing real-world applications.

 

 

Check out Day 8 of this course here.

Check out Day 9 of this course here.

Check out Day 11 of this course here.