Navigation 3 - Part 3 - Muscle Car Example

Title Page Screenshot

Part 3

As mentioned in Part 2, this series is a little longer than I had originally hoped. In this third (and hopefully final) part, we will cover ViewModels with Navigation3 and make something that looks a little nicer.

The code listing is quite long so I will avoid explaining the UI elements here and focus on the sections relevant to Navigation3.

If you haven't already read the previous entries and are not already familiar with Navigation3, I strongly recommend starting with Part 1.

The main goal here is to give you a working sample to play with. This post will not go into too much detail on each of these points so you will need to read around any new subjects that come up to learn more.

Notes - Saving Data

One compromise I chose to make was not saving any data. You will be able to take notes for each car, but those notes will vanish when you change to another page. If you want to store those notes, please take a look at my DataStore post or my Room3 post. That should give you a good place to start.

The app will temporarily store favorites. These are lost when the app is fully terminated for the reason above.

The App is Process Death safe.

Notes - Images

I have not provided any of the images I used for the app. These were generated with AI using the car year and model names. I have also provided links to Wikipedia for all the cars if you want to add your own images. I will explain that later.

Notes - Code Formatting

I've kept the horizontal size very short to better support mobile users. You will see a lot of lines that would look better on a single line of code below. These are formatted differently on the Gist. You should not copy the line break style below as a template for easy to read code, but it will run just fine.

The App

We will build a simple list / detail App with a set of classic American muscle cars. These are all well-known classics for any interested in cars and that time period.
  • Chevrolet Chevelle SS 454 1970
  • Dodge Charger R/T 1969
  • Plymouth Barracuda 1970
  • Ford Mustang Mach 1 1971
  • Pontiac Firebird Trans Am 1973
  • Buick GSX Stage 1 1970
  • Oldsmobile 442 W-30 1970
  • Chevrolet Camaro Z/28 1969
Each item contains some details about the car, allows the user to favorite, and gives some space to type notes. As explained earlier, nothing is saved and the notes are lost when changing page. This is by design.

Here are two short animations to demonstrate the app. The first landscape one has been shrunk to half size to save on data for the blog. The second has been kept very short but left at the original resolution to show the details of the animation.


Dependencies & Support Classes

Dependencies and support classes are available as a separate blog entry to keep this clean.

Parts 1, 2, and 3 Dependencies & Support Classes

You will also need the CarList.kt.

If you want to add images, you can generate these yourself with AI or download suitable images from the internet.

Car Class

Just wanted to cover this quickly. This is a pretty standard data class but we need to @Parcelize the data so we can make everything Process Death safe.
@Parcelize
data class Car(
val id: Int, val make: String, val model: String,
val year: Int, val engine: String, val horsepower: Int,
val blurb: String,
val imageId: Int? = null
) : Parcelable
Charger

ViewModels

I don't want to go into much detail on these, but we have two ViewModels for our app. I specifically wanted two so we could see how Navigation3 manages the Lifecycle.

CarViewModel is used for our list and CarDetailViewModel is used for the detail.

CarViewModel

First look at the Companion object. You will notice our custom get that links to a factory method to supply the ViewModel. Take a look at my ViewModel Factory post if you want to know more.

We also have two keys here which we use along with our savedStateHandle. This saves allows us to save the ViewModel state to deal with Process Death. You can learn more about those in the Process Death posts.

This ViewModel holds a list of cars and a list of favorites. Both start empty. The list of cars is loaded after a short delay (1.5 seconds). This is to simulate loading data from a repository (e.g. a download).

The ViewModel takes in a name, but that name is never used. I left that so you can see how to add data to the ViewModel using the factory method. That was intentional.
class CarsViewModel(
private val name: String,
val savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val delayTimerMs = 1500L

var cars = savedStateHandle.getStateFlow(
key = KEY_CAR_LIST,
initialValue = listOf<Car>()
)

var favorites = savedStateHandle
.getStateFlow(
key = KEY_FAVORITE,
initialValue = listOf<Int>()
)

init { delayedInitialDataLoad() }

private fun delayedInitialDataLoad() {
if (savedStateHandle
.get<List<Car>>(KEY_CAR_LIST)?.
isNotEmpty() == true) return

viewModelScope.launch {
delay(delayTimerMs)
savedStateHandle[KEY_CAR_LIST] =
CarList.cars
}
}

fun toggleFavorite(id: Int) {
val current = savedStateHandle
.get<List<Int>>(KEY_FAVORITE) ?:
emptyList()
savedStateHandle[KEY_FAVORITE] =
if (id in current) current - id
else current + id
}

override fun onCleared() {
println("$NAME onCleared")
super.onCleared()
}

companion object {
private const val NAME = "CarsViewModel"
private const val KEY_CAR_LIST = "carList"
private const val KEY_FAVORITE = "favorite"

@VisibleForTesting
internal fun factory(name: String) =
viewModelFactory {
initializer {
CarsViewModel(
name = name,
savedStateHandle =
createSavedStateHandle()
)
}
}

@Composable
fun get(name: String = NAME): CarsViewModel {
return viewModel(factory = factory(name))
}
}
}

CarDetailViewModel

This takes in a car, id, and the savedStateHandle to protect against Process Death). 

The main purpose of this ViewModel is to take notes about a specific car. As explained in the introduction, these notes are not saved. These will vanish as soon as you change to another page, but the note will survive Process Death.
class CarDetailViewModel(
val car: Car,
val id: Int,
val savedStateHandle: SavedStateHandle,
) : ViewModel() {
@OptIn(SavedStateHandleSaveableApi::class)
var note by savedStateHandle.saveable(
key = KEY_NOTE + id
) { mutableStateOf("") }
private set

fun updateNote(value: String) { note = value }

override fun onCleared() {
println("$NAME $id onCleared")
super.onCleared()
}

companion object {
private const val NAME = "CarDetailViewModel"
private const val KEY_NOTE = "note"

@VisibleForTesting
internal fun factory(
car: Car,
id: Int
) = viewModelFactory {
println("factory $id")
initializer {
println("initializer $id")
CarDetailViewModel(
car = car,
id = id,
savedStateHandle =
createSavedStateHandle()
)
}
}

@Composable
fun get(
car: Car,
id: Int
): CarDetailViewModel {
println("get id: $id")
return viewModel(factory = factory(car, id))
}
}
}
You may also notice all the println statements. These can of course be removed, but these will allow you to see the ViewModel Lifecycle as you change between different page once everything is running.

UI Code

Mustang Mach 1

Check out the full code listing Gist to see see the UI code. I don't think I am doing anything special there and definitely nothing that furthers understanding of Navigation3. I will omit that from this post.

The only point of note is we have a guard before we NavigationHost to ensure our cars have loaded. It will sit on a background image for a second and a half before cars loads and we can then proceed to our main NavigationHost page. You would ideally make a proper loading screen with some kind of animation, but I'm skipping that for this sample.

No Images?

If you are able to supply your own images (highly recommended), you can simply remove the comment markers from the image lines in CarList.kt. The app safely runs without them as it safely defaults to null.
        Car(0, "Chevrolet", "Chevelle SS 454",
1970, "454 cu in V8",450,
"The benchmark of the muscle car era. Brute " +
"force in a long hood.",
// imageId = R.drawable.c0_chevrolet_chevelle_ss_454_1970
),
As you won't have a background either, just change it to the app icon for now from cars_bg:
@Composable
private fun Background(
imageId: Int = R.drawable.ic_launcher_foreground // cars_bg
) = CarImage(imageId)

Camaro doing dramatic jump

But it seems a shame to run it with no images!

NavigationHost

This is the part we really need to break down to understand how everything works. We can break this into sections.

The Top - Simple

We have our backstack (explained in Part 1), guardedNavLambda, and listDetailStrategy (both from Part 2). Nothing new here.

Before we get to this point, we have already created our CarViewModel and are passing down the cars, favorites, and toggleFavorites lambda as arguments.
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
private fun NavigationHost(
cars: List<Car>,
favorites: List<Int>,
toggleFavorite: (Int) -> Unit
) {
val backStack =
rememberNavBackStack(NavigationScreen.Home)

val guardedNavLambda =
rememberDualPaneNavLambda(backStack)

val listDetailStrategy =
rememberListDetailSceneStrategy<NavKey>()

CompositionLocalProvider / NavDisplay Options

Next we have CompositionLocalProvider as used in Part 2. backstack, onback, the transitions, and the sceneStrategy are all the same. Nothing new here.
CompositionLocalProvider(
LocalNavigate provides guardedNavLambda
) {
NavDisplay(
backStack = backStack,
onBack = { backStack.removeLastOrNull() },
transitionSpec = TransitionAnimations
.forwardTransition,
popTransitionSpec = TransitionAnimations
.popTransition,
predictivePopTransitionSpec = TransitionAnimations
.predictivePopTransitionSpec,
sceneStrategies = listOf(listDetailStrategy),
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
The new part is entryDecorators and this is essential for the ViewModel Lifecycle control.

Lifecycle Managment

Lifecycle management is one of the main reasons you would use Navigation3 instead of rolling your own navigation library (as I did with my LightNav). Without the Lifecycle management, you end up with ViewModels staying alive and holding data when they should have been killed off. Do not roll your own for anything other than testing convenience unless you intend to go very deep on ViewModels.

We have two entries in our list, rememberSaveableStateHolderNavEntryDecorator and rememberViewModelStoreNavEntryDecorator. The first is the default entry and should generally always be included as it handles things like saving screen positions etc.

rememberViewModelStoreNavEntryDecorator is used to child ViewModel lifecycles so we need that as we are planning to use a ViewModel for each of our detail pages. Without that, they will not get killed (onCleared()) correctly.

Note: rememberViewModelStoreNavEntryDecorator is the reason we needed to add  our implementation(libs.androidx.lifecycle.viewmodel.navigation3) dependency.

entryProvider

Finally, we have the entryProvider. Some of the code is similar to what you saw in Part 2.

First, you will notice we selectedListId. The only reason we have this is so we can track the current item selected and highlight the list item in the list UI if we have dual pane. You can see this is passed as the last argument to ListScreen. We also pass favorites to ListScreen so we can show the 
entryProvider = entryProvider<NavKey> {
entry<NavigationScreen.Home> {
HomeScreen(cars.size)
}
entry<NavigationScreen.List>(
metadata = ListDetailSceneStrategy
.listPane(
detailPlaceholder = {
DetailPlaceholder()
}
)
) {
val selectedListId = backStack
.filterIsInstance<NavigationScreen
.Detail>()
.firstOrNull()?.id
ListScreen(
cars,
favorites,
selectedListId
)
}
entry<NavigationScreen.Detail>(
metadata =
ListDetailSceneStrategy.detailPane()
) { key ->
val car = cars.first {
it.id == key.id }

val detailVm =
CarDetailViewModel.get(car, key.id)

DetailScreen(
detailVm,
isFavorite = key.id in favorites
) { toggleFavorite(key.id) }
}
}
The most complex part of this is the Detail section. From here, we get the car from our cars list and then create detailVm (our CarDetailViewModel) by passing in the car and the id. The id is only referenced in the debug messages to show what is happening with the Lifecycle, so that could be cut.

Magic!

Old Wizard
The magic happens here! Navigation3, thanks to rememberViewModelStoreNavEntryDecorator, will manage our ViewModels so it creates a new ViewModel for each list item selected. Without this, it will only accept the first list item selected and fail to respond to any further clicks. 

This is where I would suggest you test in dual pane mode and watch LogCat to see what is happening as you select the different items.
LogCat results

442 W30

Journey Over!

That's pretty much it! Everything else is UI code and that can be seen in the full Gist.

We have covered far more than I originally planned for in this series. In Part 1, I introduced Navigation3 and gave some basic examples. I almost forgot about the Part 1 spinoff with my buggy code. Part 2, the added part, focused on dual pane support and smooth animations. This final section, Part 3, gave us a basic pretty app to show everything off using the beauty of classic American muscle.

For those that have read all the way from the start, I would like to thank you for taking the time and hope you have learned something interesting along the way.

Bonus - Main Menu

Ahh, I almost forgot! With all these examples, it seems only natural we add a menu.

I've kept the UI pretty simple here to keep the code listing short. You can get the full Gist here.

If you didn't complete all the examples, you can just remove the entries you don't need from the enum class at the top. Of course, you can also add your own entries very easily, too.

Shows all examples from series

If you click the Muscle Car App, you will noticed the delayed load. Return to the main menu and then head back into the Muscle Car App. You will see the delayed load again. This is a good way to check the ViewModels are correctly being killed by the main menu.

If you used my LightNav for this, all child ViewModels will persist. This can be useful for testing, but you do not want this for production. This, again, highlights why you would use a proper navigation framework.

Links

My links

GitHub Gists

Official links

Google:

Kotlin:

Comments

Popular Posts