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
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:
- 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.
- NavController: This object manages the navigation within a
NavHost
. It keeps track of the back stack and provides methods to navigate between composables. - 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.
- 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 anitemId
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
- Knows the Routes: It knows all the possible screens a user can navigate to within the app.
- 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. - 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. - 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:
- 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 userememberScrollState()
to create this and remember it across recompositions. - Modifier.verticalScroll: Next, you apply the
verticalScroll
modifier to theText
composable. This modifier takes theScrollState
you created as a parameter and makes the text inside theText
composable scrollable. - 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. - textAlign: This parameter of the
Text
composable defines how your text is aligned horizontally. By settingtextAlign = 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:
- Define a sealed class to represent all possible screens/routes in your app.
- 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 ourScreen
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 ofNavController
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)
: Theset
method is used to save a piece of data."cat"
is the key, andit
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 whateverit
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 sameNavController
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")
: Theget
method is used to retrieve the data that was saved earlier."cat"
is the key we’re looking for, andCategory
is the expected type of the data. Theget
method will look in the saved state handle for the note labeled “cat” and give you back the content, which should be of the typeCategory
.
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:
- Screen A wants to pass some data to Screen B. Before navigating, it saves the data with
set
. - The user does their thing on Screen B.
- The user decides to go back to Screen A.
- 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 theUser
object.data class User
: This defines a simple data class with aname
and anage
.: Parcelable
: This shows that theUser
class implements theParcelable
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:
- Fast Transfer of Data: Parcelization allows complex data objects to be quickly converted into a format that can be efficiently passed between screens.
- Optimized for Android: It’s specifically optimized for the Android platform, ensuring that data is handled in the most resource-effective manner during navigation.
- 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.