RizTech Academy logo
RizTech Academy
Lists and NavigationLesson 3 of 535 min

Navigation between screens

A farmer who sees onion at ₹2,450 in Lasalgaon wants to know one thing next: is that high or low for this week? That needs a second screen, the price in detail with its last seven days, and a way to get there and back. Module 2 explained why that second screen is a composable in the same activity rather than a second activity. This lesson chooses the navigation library, builds the detail screen, and checks on the emulator that Back, rotation, process death and the predictive back gesture all do what a user expects.

Choosing the library

There are two Jetpack navigation libraries for Compose, and you will meet both.

Navigation Compose (androidx.navigation:navigation-compose, 2.10.2 on Google Maven on 8 November 2026) is the original. A NavController owns the back stack, a NavHost declares a graph of destinations, and in current versions routes are @Serializable classes, so arguments are type-checked. Most Compose apps written before 2026 use it, and the Navigation overview page on developer.android.com still describes it.

Navigation 3 (androidx.navigation3, stable since November 2025, 1.2.1 on 8 November 2026) is the rewrite. Google's stable-release post tells developers to use it in production apps, and tells existing Navigation Compose apps to consider migrating. The idea is small enough to say in one sentence: the back stack is a list you own, and the library draws whatever is on top of it. Navigating is add, going back is removeLast. There is no controller with its own hidden state; the list is Compose state, like everything else in module 5.

Mandi Bhav uses Navigation 3. No page on developer.android.com says "new apps: use Navigation 3" in one sentence, and the Navigation overview still describes the older library, so here is the reasoning instead. Google calls it stable and production-ready and asks existing Navigation Compose apps to consider moving to it; a new app should not start on the library other apps are being asked to leave. Its model is the state model this course has taught for a whole module. And it was built for adaptive layouts, which module 4 cares about. What it costs: it is younger, so there are fewer answered questions on the internet; and it gives you building blocks rather than a finished system, so some things Navigation Compose does for you, such as a separate back stack per tab, you assemble yourself from Google's published recipes (lesson 5 does). Navigation Compose gets a short section at the end, because the app you join may use it.

Adding the libraries

Navigation 3 keys are saved by kotlinx.serialization, which needs a compiler plugin and a runtime library. The "Get started" page on developer.android.com, read on 8 November 2026, lists an alpha of the ViewModel integration and an older Kotlin version for the plugin; the versions below are the latest stable ones on Google Maven and Maven Central on that date, and the plugin version must be your Kotlin version, so it reuses kotlin. In gradle/libs.versions.toml:

[versions]
navigation3 = "1.2.1"
kotlinxSerialization = "1.12.0"

[libraries]
androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "navigation3" }
androidx-lifecycle-viewmodel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "lifecycle" }
kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerialization" }

[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

The ViewModel integration is part of the lifecycle family, so it shares lifecycle = "2.11.0" with the other three. In the root build.gradle.kts, add alias(libs.plugins.kotlin.serialization) apply false; in app/build.gradle.kts, add alias(libs.plugins.kotlin.serialization) to plugins and four lines to dependencies:

implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.kotlinx.serialization.core)

Keys: where you can go

In Navigation 3 a destination is identified by a key, and keys are what go on the back stack. Create navigation/Destinations.kt:

package com.riztechacademy.mandibhav.navigation

import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable

// One key per destination. A key is what goes on the back stack, and the
// back stack is saved, so every key is @Serializable and holds only small
// values: an id or a name, never the data the screen shows.

/** Top-level: today's prices, searchable. The start destination. */
@Serializable
data object Prices : NavKey

/** One price in detail, by its id. */
@Serializable
data class PriceDetail(val priceId: String) : NavKey

NavKey marks a key the library can save; @Serializable makes the compiler plugin generate the code that turns it into something a Bundle holds. PriceDetail carries the price's id, the one lesson 2 added, and nothing else. The next lesson is about why.

The detail screen

The detail screen needs data the app does not have yet: the last seven days. In model/DayPrice.kt:

package com.riztechacademy.mandibhav.model

import java.time.LocalDate

/** The modal price of one commodity at one market on one day: a point in its history. */
data class DayPrice(
    val date: LocalDate,
    val modalPrice: Long,
)

In SamplePrices, after all, seven modal prices for each sample price, oldest first, keyed by id:

/**
 * The modal price over the last seven days for each price above, oldest
 * first, keyed by price id. The last value is that price's modal price.
 */
val weekOfModalPrices: Map<String, List<Long>> = mapOf(
    lasalgaonOnion.id to listOf(2_310, 2_280, 2_350, 2_400, 2_380, 2_420, 2_450),
    puneOnion.id to listOf(2_100, 2_150, 2_120, 2_180, 2_250, 2_230, 2_200),
    nashikOnion.id to listOf(2_150, 2_200, 2_180, 2_260, 2_240, 2_280, 2_300),
    puneTomato.id to listOf(1_800, 1_750, 1_700, 1_650, 1_600, 1_550, 1_500),
    nashikTomato.id to listOf(1_350, 1_300, 1_320, 1_250, 1_280, 1_220, 1_200),
    vashiPotato.id to listOf(2_050, 2_080, 2_060, 2_100, 2_090, 2_110, 2_100),
    vashiWheat.id to listOf(2_960, 2_970, 2_980, 2_980, 2_990, 3_000, 3_000),
    lasalgaonSoybean.id to listOf(4_480, 4_500, 4_520, 4_510, 4_540, 4_530, 4_550),
    puneTur.id to listOf(7_100, 7_150, 7_200, 7_180, 7_250, 7_280, 7_300),
)

Then data/PriceSource.kt, the one place screens ask for prices:

package com.riztechacademy.mandibhav.data

import com.riztechacademy.mandibhav.model.CommodityPrice
import com.riztechacademy.mandibhav.model.DayPrice

/**
 * Where the screens get prices from: for now, the sample data in memory.
 *
 * Every screen asks this for what it shows, by id, rather than being handed
 * a copy by the screen before it. Module 7 replaces it with a repository,
 * and the screens keep asking the same questions.
 */
class PriceSource(
    private val prices: List<CommodityPrice> = SamplePrices.all,
    private val weekOfModalPrices: Map<String, List<Long>> = SamplePrices.weekOfModalPrices,
) {
    /** Today's prices, every commodity at every market. */
    fun todaysPrices(): List<CommodityPrice> = prices

    /** One price by its id, or null if there is no such price (any more). */
    fun price(id: String): CommodityPrice? = prices.firstOrNull { it.id == id }

    /** Today's prices at one market. */
    fun pricesAt(market: String): List<CommodityPrice> = prices.filter { it.market == market }

    /**
     * The last seven days of modal prices for the price with [id], oldest
     * first and ending on that price's date; empty if there is no history.
     */
    fun week(id: String): List<DayPrice> {
        val price = price(id) ?: return emptyList()
        val modals = weekOfModalPrices[id] ?: return emptyList()
        return modals.mapIndexed { index, modal ->
            DayPrice(
                date = price.date.minusDays((modals.size - 1 - index).toLong()),
                modalPrice = modal,
            )
        }
    }
}

What the screen shows is worked out in a plain function, so it can be tested, in ui/PriceWeek.kt:

package com.riztechacademy.mandibhav.ui

import com.riztechacademy.mandibhav.model.CommodityPrice
import com.riztechacademy.mandibhav.model.DayPrice

/**
 * Everything the detail screen shows for one price: the price itself, its
 * last seven days, and the two numbers worked out from them.
 */
data class PriceWeek(
    val price: CommodityPrice,
    val week: List<DayPrice>,
    /** Today's modal price minus yesterday's; null without a yesterday. */
    val changeSinceYesterday: Long?,
    /** The lowest and highest modal price of the week; null without a history. */
    val weekRange: LongRange?,
)

/** Builds the detail for [price] from its [week], oldest day first. */
fun priceWeek(price: CommodityPrice, week: List<DayPrice>): PriceWeek {
    val modals = week.map { it.modalPrice }
    return PriceWeek(
        price = price,
        week = week,
        changeSinceYesterday = if (modals.size >= 2) modals.last() - modals[modals.size - 2] else null,
        weekRange = if (modals.isEmpty()) null else modals.min()..modals.max(),
    )
}

/** The change since yesterday in words: "₹30 up on yesterday". */
fun describeChange(change: Long): String = when {
    change > 0 -> "${formatRupees(change)} up on yesterday"
    change < 0 -> "${formatRupees(-change)} down on yesterday"
    else -> "Same as yesterday"
}

The ViewModel, in ui/PriceDetailViewModel.kt, takes the id and asks the source:

package com.riztechacademy.mandibhav.ui

import androidx.lifecycle.ViewModel
import com.riztechacademy.mandibhav.data.PriceSource

/**
 * Holds what the detail screen shows for one price.
 *
 * It receives only the id, from the navigation key, and asks [source] for
 * the rest. The key is saved with the back stack, so after process death a
 * new ViewModel gets the same id and loads the price again: there is
 * nothing for this class to keep in a SavedStateHandle.
 */
class PriceDetailViewModel(
    priceId: String,
    source: PriceSource = PriceSource(),
) : ViewModel() {

    /** The price and its week, or null if [priceId] is not a price (any more). */
    val detail: PriceWeek? = source.price(priceId)?.let { price ->
        priceWeek(price, source.week(priceId))
    }
}

The screen, in ui/PriceDetailScreen.kt, follows module 5's split: a route that knows the ViewModel, and a stateless screen that is previewed.

private val weekDayFormat = DateTimeFormatter.ofPattern("EEE d MMM", Locale.ENGLISH)
private val spokenDayFormat = DateTimeFormatter.ofPattern("EEEE d MMMM", Locale.ENGLISH)
private val detailDateFormat = DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH)

/**
 * The detail screen as the app uses it. [priceId] comes from the navigation
 * key; the ViewModel is created with it and is scoped to this back stack
 * entry, so it is cleared when the user goes back.
 */
@Composable
fun PriceDetailRoute(
    priceId: String,
    onBack: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: PriceDetailViewModel = viewModel { PriceDetailViewModel(priceId) },
) {
    PriceDetailScreen(detail = viewModel.detail, onBack = onBack, modifier = modifier)
}

/** One price at one market: today's numbers and the last seven days. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PriceDetailScreen(
    detail: PriceWeek?,
    onBack: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Scaffold(
        modifier = modifier,
        topBar = {
            TopAppBar(
                title = { Text(detail?.price?.commodity ?: "Price") },
                navigationIcon = { BackButton(onBack) },
            )
        },
    ) { innerPadding ->
        if (detail == null) {
            // The id came from a saved back stack, and the price it named is
            // gone. Say so, rather than crash or show an empty screen.
            Text(
                text = "This price is no longer available.",
                style = MaterialTheme.typography.bodyLarge,
                modifier = Modifier
                    .padding(innerPadding)
                    .padding(16.dp),
            )
            return@Scaffold
        }
        val price = detail.price
        LazyColumn(
            modifier = Modifier
                .padding(innerPadding)
                .consumeWindowInsets(innerPadding)
                .fillMaxWidth(),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            item(key = "today") {
                Column(
                    modifier = Modifier.widthIn(max = 600.dp).fillMaxWidth(),
                    verticalArrangement = Arrangement.spacedBy(4.dp),
                ) {
                    Text(text = price.market, style = MaterialTheme.typography.titleMedium)
                    Text(
                        text = detailDateFormat.format(price.date),
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                    )
                    Text(
                        text = "${formatRupees(price.modalPrice)} per quintal",
                        style = MaterialTheme.typography.headlineMedium,
                        color = MaterialTheme.colorScheme.primary,
                        modifier = Modifier
                            .padding(top = 8.dp)
                            .semantics {
                                contentDescription = "${spokenRupees(price.modalPrice)} per quintal"
                            },
                    )
                    Text(
                        text = "Modal (most common) price",
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                    )
                    Text(
                        text = "Min ${formatRupees(price.minPrice)} · Max ${formatRupees(price.maxPrice)}",
                        style = MaterialTheme.typography.bodyLarge,
                        modifier = Modifier
                            .padding(top = 8.dp)
                            .semantics {
                                contentDescription = "Range ${spokenRupees(price.minPrice)} to ${spokenRupees(price.maxPrice)}"
                            },
                    )
                    detail.changeSinceYesterday?.let { change ->
                        Text(text = describeChange(change), style = MaterialTheme.typography.bodyLarge)
                    }
                    HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
                    Text(
                        text = "Last 7 days, modal price",
                        style = MaterialTheme.typography.titleMedium,
                        modifier = Modifier.semantics { heading() },
                    )
                }
            }
            // Newest day first, like the rest of the app.
            items(items = detail.week.asReversed(), key = { it.date.toEpochDay() }) { day ->
                WeekRow(
                    day = day,
                    weekRange = detail.weekRange,
                    modifier = Modifier.widthIn(max = 600.dp).fillMaxWidth(),
                )
            }
        }
    }
}

/** One day: the date, a bar for where the price sits in the week, and the price. */
@Composable
private fun WeekRow(day: DayPrice, weekRange: LongRange?, modifier: Modifier = Modifier) {
    // How far along the week's range this day is, 0 to 1. A flat week
    // (every day the same) draws every bar full.
    val fraction = if (weekRange == null || weekRange.first == weekRange.last) {
        1f
    } else {
        (day.modalPrice - weekRange.first).toFloat() / (weekRange.last - weekRange.first)
    }
    Row(
        modifier = modifier
            .height(40.dp)
            .clearAndSetSemantics {
                contentDescription = "${spokenDayFormat.format(day.date)}, ${spokenRupees(day.modalPrice)}"
            },
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Text(
            text = weekDayFormat.format(day.date),
            style = MaterialTheme.typography.bodyMedium,
            modifier = Modifier.width(96.dp),
        )
        Box(
            modifier = Modifier
                .weight(1f)
                .height(12.dp),
        ) {
            Box(
                modifier = Modifier
                    // At least a sliver, so the week's lowest day still shows a bar.
                    .fillMaxWidth(0.1f + 0.9f * fraction)
                    .fillMaxHeight()
                    .background(
                        color = MaterialTheme.colorScheme.primary,
                        shape = RoundedCornerShape(6.dp),
                    ),
            )
        }
        Text(
            text = formatRupees(day.modalPrice),
            style = MaterialTheme.typography.bodyLarge,
        )
    }
}

/** The up arrow in an app bar, for screens that are not top-level destinations. */
@Composable
fun BackButton(onBack: () -> Unit) {
    IconButton(onClick = onBack) {
        Icon(
            painter = painterResource(R.drawable.ic_arrow_back),
            contentDescription = "Back",
        )
    }
}

Below these go two previews: PriceDetailScreen with priceWeek(SamplePrices.lasalgaonOnion, PriceSource().week(SamplePrices.lasalgaonOnion.id)), and with detail = null. A few things worth noticing:

  • viewModel { PriceDetailViewModel(priceId) } is the overload of module 5's viewModel() that takes an initializer, for a ViewModel whose constructor needs something other than a SavedStateHandle. Here it needs the id.
  • The bars show where each day sits between the week's lowest and highest price, not the price from zero. Wheat moving from ₹2,960 to ₹3,000 fills most of the bar; that is the point, because the trend is what the farmer reads. The numbers beside the bars are the truth.
  • widthIn(max = 600.dp) stops the text and bars stretching across a landscape tablet, module 4's lesson.
  • The up arrow is the app's first icon. Module 3 said the first icon would arrive with the networking module's refresh button; a detail screen needs a way up before that. It is a 24 dp vector drawable, res/drawable/ic_arrow_back.xml, the kind module 3 showed you how to download from Google's icon set, with android:autoMirrored="true" added to its <vector> tag so it points the other way in right-to-left languages.

The imports are the ones these names need, from androidx.compose.foundation, foundation.layout, foundation.lazy, foundation.shape, material3, ui.semantics and java.time.format, plus androidx.lifecycle.viewmodel.compose.viewModel; Android Studio offers each one on Alt + Enter (Option + Enter on a Mac).

The back stack and NavDisplay

Now the navigation itself. MainActivity shows a new composable instead of PricesRoute:

MandiBhavTheme {
    MandiBhavApp(modifier = Modifier.fillMaxSize())
}

and ui/MandiBhavApp.kt holds the back stack and draws it:

/** The app's screens and how to move between them. */
@Composable
fun MandiBhavApp(modifier: Modifier = Modifier) {
    val backStack = rememberNavBackStack(Prices)

    NavDisplay(
        backStack = backStack,
        onBack = { backStack.removeLastOrNull() },
        entryDecorators = listOf(
            rememberSaveableStateHolderNavEntryDecorator(),
            rememberViewModelStoreNavEntryDecorator(),
        ),
        entryProvider = entryProvider {
            entry<Prices> {
                PricesRoute(onPriceClick = { id -> backStack.add(PriceDetail(id)) })
            }
            entry<PriceDetail> { key ->
                PriceDetailRoute(priceId = key.priceId, onBack = { backStack.removeLastOrNull() })
            }
        },
        modifier = modifier,
    )
}
Piece What it does
rememberNavBackStack(Prices) A list of keys starting with Prices, saved across rotation and process death because every key is a @Serializable NavKey
backStack.add(key) Navigate forward
backStack.removeLastOrNull() Go back
NavDisplay Shows the entry for the last key, animates changes, and handles the system Back gesture by calling onBack
entryProvider { entry<T> { } } Turns each type of key into its screen; entry<PriceDetail> { key -> } receives the key
rememberSaveableStateHolderNavEntryDecorator() Gives each entry its own saved state, so the prices list keeps its scroll position while the detail screen is on top
rememberViewModelStoreNavEntryDecorator() Gives each entry its own ViewModels, created on arrival and cleared when the entry is popped

The imports: NavDisplay from androidx.navigation3.ui; entryProvider, rememberNavBackStack and rememberSaveableStateHolderNavEntryDecorator from androidx.navigation3.runtime; rememberViewModelStoreNavEntryDecorator from androidx.lifecycle.viewmodel.navigation3.

Screens do not get the back stack. PricesRoute gains one parameter, onPriceClick: (priceId: String) -> Unit, which it passes down to PricesScreen and on to each card; only MandiBhavApp knows what a tap means. Module 5 said a ViewModel must never hold a NavController; the same reasoning keeps the back stack out of screens, so they stay previewable and testable.

PriceCard gains an optional onClick and becomes one tap target. One detail cost a test run to find: the card's clearAndSetSemantics, from module 4, also clears the click that Card(onClick = …) adds, so TalkBack would read the card and offer no action. The click goes back into the semantics:

@Composable
fun PriceCard(
    price: CommodityPrice,
    modifier: Modifier = Modifier,
    onClick: (() -> Unit)? = null,
) {
    val description = priceCardDescription(price)
    // TalkBack reads the card as one sentence, not a string of fragments.
    // clearAndSetSemantics also removes the click the card adds, so it is
    // declared again here, with a label TalkBack reads as "double-tap to …".
    val cardModifier = modifier.clearAndSetSemantics {
        contentDescription = description
        if (onClick != null) {
            role = Role.Button
            onClick(label = "open price details") {
                onClick()
                true
            }
        }
    }
    if (onClick != null) {
        Card(onClick = onClick, modifier = cardModifier) { PriceCardContent(price) }
    } else {
        Card(modifier = cardModifier) { PriceCardContent(price) }
    }
}

The old body of PriceCard, the Column of texts, moves unchanged into private fun PriceCardContent(price: CommodityPrice). In the grid, PriceCard(price = price, onClick = { onPriceClick(price.id) }, modifier = Modifier.fillMaxWidth()). The layout inspector, or adb shell uiautomator dump, now shows each card as a clickable android.widget.Button.

Proving it on the emulator

Add two temporary log lines to PriceDetailViewModel, as module 5 did:

init {
    android.util.Log.d("Nav", "PriceDetailViewModel created viewModel=${hashCode()} priceId=$priceId")
}

override fun onCleared() {
    android.util.Log.d("Nav", "PriceDetailViewModel onCleared viewModel=${hashCode()}")
}

Open Lasalgaon onion, rotate twice, press Back; open Pune onion, press Home, kill the process (adb shell am kill com.riztechacademy.mandibhav), reopen it from Recents, press Back:

D Nav     (31283): PriceDetailViewModel created viewModel=195665820 priceId=lasalgaon-onion-2026-11-07
D Nav     (31283): PriceDetailViewModel onCleared viewModel=195665820
D Nav     (31283): PriceDetailViewModel created viewModel=224819135 priceId=pune-onion-2026-11-07
D Nav     (31476): PriceDetailViewModel created viewModel=143004044 priceId=pune-onion-2026-11-07
D Nav     (31476): PriceDetailViewModel onCleared viewModel=143004044

Two rotations created nothing: one ViewModel for the whole visit, cleared the moment Back popped its entry. After process death (pid 31283 became 31476), the app reopened on the Pune onion detail screen, not the prices list: the back stack was saved, the key brought back the id, and a new ViewModel loaded the price again. Back from there landed on the prices list, as if nothing had happened.

Back has three routes, and all must agree: the up arrow (onBack), the system Back button or gesture (NavDisplay's onBack), and, from the first screen, leaving the app, which is Android's job: with one entry left, Back on the emulator went to the home screen. All three were tried above.

Predictive back is on by default. Android's guide says so, and NavDisplay supports it. On the emulator, with gesture navigation, start a swipe from the left edge on the detail screen and hold it: the detail screen shrinks and the prices list shows behind it. Let go and it completes; drag back to the edge and it cancels, leaving the detail screen where it was. Nothing in Mandi Bhav's code made that happen, and nothing should get in its way: a BackHandler you add only "to log the back press" takes over the gesture and the preview disappears. The guide's advice for that is the one already used here: log in onCleared.

The trap: one ViewModel for every price

The entryDecorators list looks like configuration you can leave out. Delete rememberViewModelStoreNavEntryDecorator(), run it, open Lasalgaon onion, go back, and open Pune onion. The screen says "Lasalgaon APMC, ₹2,450". The log has one line:

D Nav     (31602): PriceDetailViewModel created viewModel=204921790 priceId=lasalgaon-onion-2026-11-07

Without the decorator, viewModel() asks the nearest owner, the activity, for a PriceDetailViewModel; the first call creates one for Lasalgaon, and every later call gets that same object back, whatever id you pass. It is never cleared either. The decorator is what makes a ViewModel belong to one screen visit. Put it back.

Meeting Navigation Compose

In an app on navigation-compose, the same two screens look like this (compiled against 2.10.2 for this lesson):

@Serializable
object Prices

@Serializable
data class PriceDetail(val priceId: String)

@Composable
fun MandiBhavNavHost() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = Prices) {
        composable<Prices> {
            PricesRoute(onPriceClick = { id -> navController.navigate(PriceDetail(id)) })
        }
        composable<PriceDetail> { backStackEntry ->
            val route: PriceDetail = backStackEntry.toRoute()
            PriceDetailRoute(priceId = route.priceId, onBack = { navController.popBackStack() })
        }
    }
}
Navigation 3 Navigation Compose
A NavBackStack you own A NavController that owns the stack
backStack.add(key) navController.navigate(route)
backStack.removeLastOrNull() navController.popBackStack()
NavDisplay + entryProvider NavHost + composable<T>
The key is passed to the entry backStackEntry.toRoute<T>(), or savedStateHandle.toRoute<T>() in a ViewModel
ViewModel per entry with a decorator ViewModel per entry by default

The ideas are the same: typed destinations, small arguments, one owner of the stack. If you join a Navigation Compose app, read its NavHost first; it is the map of the app.

Check your work

Why Navigation 3 for Mandi Bhav. It is stable, Google asks existing apps to consider moving to it, and its back stack is plain Compose state you own.

What a key is, and why @Serializable. What goes on the back stack; serialisable so the stack can be saved.

What rememberViewModelStoreNavEntryDecorator does. Gives each entry its own ViewModels, cleared when the entry is popped.

Why screens get onPriceClick and not the back stack. Only one place decides what a tap means, and the screens stay previewable and testable.

Why the card needed its click declared again. clearAndSetSemantics removed the click semantics that Card(onClick) adds.

What the process-death run proved. The back stack, and the id in it, survived; a new ViewModel loaded the price again.

What happened without the decorator. Every detail screen got the first price's ViewModel.

Practice 4. Each tap that reaches the card pushes another PriceDetail key. On the emulator, three taps sent with adb shell input tap in one go pushed two detail screens (the third landed on the detail screen as it appeared), and Back had to be pressed twice to reach the list. One fix is to ignore a tap whose key is already on top: if (backStack.lastOrNull() != PriceDetail(id)) backStack.add(PriceDetail(id)), which works because data class keys compare by value.

Practice

  1. Add the libraries, keys, data, ViewModel, screen, MandiBhavApp and the card's onClick. Add PriceWeekTest from the checkpoint (four tests on priceWeek and describeChange) and run all 23 tests.
  2. Run the log experiment above and compare your log with it.
  3. Try the predictive back gesture slowly, and cancel it half way. Then rotate the emulator on the detail screen and try again.
  4. Break it on purpose: tap a price card three times as fast as you can (adb shell "input tap 540 900; input tap 540 900; input tap 540 900" is faster than a finger). Count the Back presses needed to reach the list, explain why, and decide whether to fix it.
  5. Remove rememberViewModelStoreNavEntryDecorator() and reproduce the wrong-price bug. Put it back.
  6. Harder: give the detail screen a "Compare markets" TextButton that opens the same commodity at another market (the PriceDetail of, say, Nashik onion from Lasalgaon onion). Predict the back stack after two hops, and what Back does at each step. Then check your prediction.

Official documentation

Next: what to put in a navigation key, and what happens when you put in more.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship