Skip to content

Initial Movies UI

Create initial screens

Let's create a starter user interface!

In this step, we'll create placeholder screens and simple navigation.

To implement our navigation, we need to

  • Define state representing our screens
  • Add the Android Navigation 3 dependencies
  • Add code for the back stack, display of the proper screens, and pushing new screens

Let's take care of our dependencies for the Navigation 3 library.

We add navigation dependencies in the version catalog.

show in full file gradle/libs.versions.toml
[versions]
// ...
kotlin = "2.4.0"
composeBom = "2026.06.01"
navigation = "1.1.4"
material3AdaptiveNav3 = "1.3.0-rc01"

[libraries]
// ...
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }

androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation" }
androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" }

[plugins]
// ...

Sync the project via the banner or the elephant icon on the toolbar so the dependency variables will be available in the build scripts.

Next, we need to use these dependencies in the build. Update the app/build.gradle.kts file to include the navigation dependencies:

show in full file app/build.gradle.kts
// ...

dependencies {
    implementation(libs.androidx.navigation3.ui)
    implementation(libs.androidx.navigation3.runtime)
    implementation(libs.androidx.material3.adaptive.navigation3)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.activity.compose)
    // ...
}

Sync the project again so the new dependencies will be available in your code.

We'll come back to the navigation code once we've defined our screens.

Screen data setup

First, we create screen state using a Kotlin sealed interface. Sealed interfaces limit possible implementing classes or objects to only those defined in the current module. This is useful in applications or libraries because they know exhaustively which possible subclasses exist, and, in the case of a library, no external users can create new subclasses or implementations.

We create a "screens" subpackage containing a Screens.kt file:

show in full file app/src/main/java/com/androidbyexample/movies/screens/Screens.kt
// ...
import kotlinx.parcelize.Parcelize

sealed interface Screen: Parcelable

@Parcelize
data object MovieList: Screen

@Parcelize
data class MovieDisplay(val movie: Movie): Screen

Note

The sub-packages are here only for organization; you don't really need them. We're creating a single file to hold all of these screen definitions together. In Kotlin, you can define multiple public types inside a single file (vs Java, where you can only have a single public type per file.)

We're using a data class to represent a movie-display screen. Data classes automatically generate equals(), hashCode(), toString(), and some other interesting functions for all properties defined in their primary constructor. We'll use this to hold onto the movie that was selected.

Holding onto the movie is not a good idea. If it changes in the data store, we won't be able to automatically load the changes in the UI. We'll fix this when we set up our database. For now, we pass the movie itself for convenience, as we're focusing on the user interface in this module.

We use a Kotlin data object for the movie list state. Because the movie list will just display all movies, we don't need to keep any state (such as a specific movie id) in the screen instance for it.

Kotlin objects are singletons; you never create instances of them and can access them globally. We could use a class here (without data) and create instances, but that doesn't do anything helpful, as all instances would be effectively equal. The data keyword adds the same generated functions for the object as it did the class above.

The MovieDisplay and MovieList are marked @Parcelize, so the Kotlin-Parcelize plugin will generate code to convert them to a simple binary form. This will allow Android to save/restore their data across application hibernation.

Placeholder screens

Now let's define the placeholder user interface screens (UIs). We define simple movie display and movie list UIs as Composable functions that just display text.

Note

When you define Composable functions, you should almost always pass and respect a Modifier. (The only time I don't pass a Modifier is when defining the top-level user interface function.) This allows callers to tweak the appearance and behavior of the Composable you're defining. For example, in this application, we start with a top-level Scaffold that places other Composables on the screen. The Scaffold passes a parameter that defines the padding you must use in your main component, which you can pass along using Modifier.padding().

Modifiers should always be the first optional parameter to a Composable function, and usually default to Modifier.

Warning

Be sure to import androidx.compose.ui.Modifier and NOT java.lang.reflect.Modifier!

We're placing these Kotlin files under the "screens" sub-package.

show in full file app/src/main/java/com/androidbyexample/movies/screens/MovieDisplayUi.kt
// ...
import com.androidbyexample.movies.data.Movie

@Composable
fun MovieDisplayUi(
    movie: Movie,
    modifier: Modifier = Modifier,
) {
    Text(
        text = "Movie Display: ${movie.title}",
        modifier = modifier,
    )
}
show in full file app/src/main/java/com/androidbyexample/movies/screens/MovieListUi.kt
// ...
import com.androidbyexample.movies.data.Movie

@Composable
fun MovieListUi(
    movies: List<Movie>,
    modifier: Modifier = Modifier,
    onMovieClicked: (Movie) -> Unit,
) {
    Text(
        text = "Movie List",
        modifier = modifier.clickable {
            onMovieClicked(movies[0])
        }
    )
}

Our MovieDisplayUi() composable function takes a Movie as a parameter and emits a single Text() to temporarily represent the screen. MovieListUi() takes a list of movies and a onMovieClicked() callback to indicate to the caller that a movie was selected by the user.

For now, we just display "Movie list", and tell the caller that the first movie was clicked. (We'll flesh this out in a moment)

The Navigation Back Stack

The Navigation 3 library allows us to completely manage the back stack ourselves, or use support functions that it provides. We could use

val backStack = rememberNavBackStack(MovieList)

inside a Composable function to manage the stack. This creates and manages a MutableStateList that contains our screens. If Android decides to hibernate our application (after the user goes to the home screen or another app), it will save the state of the back stack and reload it if the user returns. Note that for this to work, our screen data must implement NavKey as well as be serializable.

But we'd encounter a problem with this approach. In a later module, we'll create a home screen widget for our application, where clicking a movie in the widget takes us to that movie. Unfortunately, if we use rememberNavBackStack(), it would be impossible to distinguish between using the saved back stack and a new explicit back stack to jump to a movie.

To make navigation work properly later, we'll explicitly manage the back stack.

!!! note

    Android can hibernate an app at any time to reclaim RAM. This typically can happen when a
    user switches to the home screen or another app (but doesn't _have_ to happen). You may
    not see it when running your apps. Unless you turn on the "Don't Keep Activities" option
    under Developer Options. I recommend you test apps with this option, go to the home screen
    and then click on the app icon to go back in.

First, we need to give the MovieViewModel a way to save/restore the back stack state when an app is hibernated.

show in full file app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
// ...
import com.androidbyexample.movies.screens.Screen

//class MovieViewModel: ViewModel() {
class MovieViewModel(
    private val savedStateHandle: SavedStateHandle,
): ViewModel() {
    val movies: List<Movie> = listOf(
        Movie("The Transporter", "Jason Statham kicks a guy in the face"),
        // ...
}

When the viewModels() function is called by MainActivity, Android will pass in a SavedStateHandle that we can use to manage our state.

Now we'll use the handle to create a Flow that saves the data when the app is hibernating. We'll talk more about Flows later, but for now, think of it as a bucket that contains data and informs you when new data is available. You'll call a "collect" function to watch for changes and pull the new value.

show in full file app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
// ...
class MovieViewModel(
    // ...
): ViewModel() {
    // ...
    )

    val backStackFlow = savedStateHandle.getMutableStateFlow<List<Screen>>(
        key = "back_stack",
        initialValue = listOf(MovieList)
    )

    fun pushScreen(screen: Screen) {
        backStackFlow.value += screen
    }

    fun popScreen() {
        backStackFlow.value = backStackFlow.value.dropLast(1)
    }
}

We start with a stack (which is just a list) that only contains MovieList. We must explicitly specify its generic type, List<Screen> because if we let it infer, it would only allow the list to contain MovieList and not MovieDisplay.

We manage the contents of the stack via "push" and "pop" functions in the view model.

Starter UI

Finally, we define a starter UI. I like to define a top-level composable function as a starting point. All it does is collect data from the view model and pass whatever is needed to the current screen.

show in full file app/src/main/java/com/androidbyexample/movies/screens/Ui.kt
// ...
import com.androidbyexample.movies.MovieViewModel

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
    viewModel: MovieViewModel,
    modifier: Modifier = Modifier,
) {
    val listDetailStrategy = rememberListDetailSceneStrategy<Screen>()

    val backStack by viewModel.backStackFlow.collectAsStateWithLifecycle(listOf(MovieList))

    NavDisplay(
        backStack = backStack,
        onBack = viewModel::popScreen,
        sceneStrategies = listOf(listDetailStrategy),
        entryProvider = entryProvider {
            entry<MovieList>(
                metadata = ListDetailSceneStrategy.listPane()
            ) {
                MovieListUi(
                    movies = viewModel.movies,
                    onMovieClicked = { movie ->
                        viewModel.pushScreen(MovieDisplay(movie))
                    },
                    modifier = modifier,
                )
            }
            entry<MovieDisplay>(
                metadata = ListDetailSceneStrategy.detailPane()
            ) { key ->
                MovieDisplayUi(
                    movie = key.movie,
                    modifier = modifier,
                )
            }
        }
    )
}

We take in parameters for the back stack and its push/pop functions.

The navigation library exposes NavDisplay composable function to set up navigation. It requires a back stack to keep track of which screens have been seen so pressing "back" will be able to navigate backwards.

The key element in the NavDisplay is its entryProvider parameter, which acts like a Kotlin "when" expression to choose which screen to display. Each entry is passed a generic type argument of the type of screen data at the top of the stack. If it matches, the entry's lambda is executed, emitting nodes to the compose UI tree.

Note the onMovieClicked lambda passed to MovieListUi. MovieListUi sets up a Text composable that has an onClicked modifier. This watches for the user clicking on the displayed Text, and calls the passed-in onMovieClicked lambda. In this case, we push a new MovieDisplay onto the back stack.

Because the back stack is a Compose MutableStateList, it sees the change, triggering recomposition.

Using the Starter UI

Back in the MainActivity class, we replace the Greeting call with a call to our Ui.

show in full file app/src/main/java/com/androidbyexample/movies/MainActivity.kt
// ...
class MainActivity : ComponentActivity() {
    // ...
    override fun onCreate(savedInstanceState: Bundle?) {
        // ...
        setContent {
            MoviesTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
//                  Greeting(
//                      name = "Android",
//                      modifier = Modifier.padding(innerPadding)
                    Ui(
                        viewModel = viewModel,
                        modifier = Modifier.padding(innerPadding),
                    )
                }
            }
        }
    }
}
// ...

Note that because we're inside a Scaffold, we take the inner padding it defines and pass it as a Modifier.padding() to our Ui() function. This is a great example why it's important to define a Modifier parameter in your Composable functions, as it allows us to adjust the Ui's appearance.

Note

Following this approach, the top-level UI function should be the only composable function to be passed the view model. This ensures that lower-level composable functions are more easily testable, as you can pass them just the data they need, and not a view model that needs to be set up.

Delete the Greeting and GreetingPreview functions.

This application can now be run, showing the placeholder movie list screen. When clicked, the placeholder movie display screen is pushed on the stack and becomes visible. When back is pressed, we return to the movie list screen. Pressing back again exits the application.

When run, if the device screen is fairly narrow (like a phone held in portrait orientation), you'll only see the Movie List screen:

Movie List Screen

Clicking on the text "Movie List" takes us to the Movie Display screen:

Movie Display Screen

If however, we run on a device with a wider screen (like a phone run in landscape orientation), we can see both list and detail screens side-by-side (after clicking the "Movie List" text)

Both Screens

The NavDisplay manages which screens are shown.


All code changes

CHANGED: app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.compose)
id("kotlin-parcelize")
} android { namespace = "com.androidbyexample.movies" compileSdk { version = release(37) } defaultConfig { applicationId = "com.androidbyexample.movies" minSdk = 24 targetSdk = 37 versionCode = 1 versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { optimization { enable = false } } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } buildFeatures { compose = true } } dependencies {
implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.material3.adaptive.navigation3)
implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) testImplementation(libs.junit) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.junit) debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.tooling) }
CHANGED: app/src/main/java/com/androidbyexample/movies/MainActivity.kt
package com.androidbyexample.movies

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
//import androidx.compose.material3.Text
//import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
//import androidx.compose.ui.tooling.preview.Preview
import com.androidbyexample.movies.screens.Ui
import com.androidbyexample.movies.ui.theme.MoviesTheme

class MainActivity : ComponentActivity() {
private val viewModel by viewModels<MovieViewModel>()
override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { MoviesTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
// Greeting( // name = "Android", // modifier = Modifier.padding(innerPadding) Ui( viewModel = viewModel, modifier = Modifier.padding(innerPadding), )
} } } } } // //@Composable //fun Greeting(name: String, modifier: Modifier = Modifier) { // Text( // text = "Hello $name!", // modifier = modifier // ) //} // //@Preview(showBackground = true) //@Composable //fun GreetingPreview() { // MoviesTheme { // Greeting("Android") // } //}
CHANGED: app/src/main/java/com/androidbyexample/movies/MovieViewModel.kt
package com.androidbyexample.movies

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.androidbyexample.movies.data.Movie
import com.androidbyexample.movies.screens.MovieList
import com.androidbyexample.movies.screens.Screen

//class MovieViewModel: ViewModel() { class MovieViewModel( private val savedStateHandle: SavedStateHandle, ): ViewModel() {
val movies: List<Movie> = listOf( Movie("The Transporter", "Jason Statham kicks a guy in the face"), Movie("Transporter 2", "Jason Statham kicks a bunch of guys in the face"), Movie("Hobbs and Shaw", "Cars, Explosions and Stuff"), Movie("Jumanji - Welcome to the Jungle", "The Rock smolders"), )
val backStackFlow = savedStateHandle.getMutableStateFlow<List<Screen>>( key = "back_stack", initialValue = listOf(MovieList) ) fun pushScreen(screen: Screen) { backStackFlow.value += screen } fun popScreen() { backStackFlow.value = backStackFlow.value.dropLast(1) }
}
ADDED: app/src/main/java/com/androidbyexample/movies/screens/MovieDisplayUi.kt
package com.androidbyexample.movies.screens

import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.androidbyexample.movies.data.Movie

@Composable fun MovieDisplayUi( movie: Movie, modifier: Modifier = Modifier, ) { Text( text = "Movie Display: ${movie.title}", modifier = modifier, ) }
ADDED: app/src/main/java/com/androidbyexample/movies/screens/MovieListUi.kt
package com.androidbyexample.movies.screens

import androidx.compose.foundation.clickable
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.androidbyexample.movies.data.Movie

@Composable fun MovieListUi( movies: List<Movie>, modifier: Modifier = Modifier, onMovieClicked: (Movie) -> Unit, ) { Text( text = "Movie List", modifier = modifier.clickable { onMovieClicked(movies[0]) } ) }
ADDED: app/src/main/java/com/androidbyexample/movies/screens/Screens.kt
package com.androidbyexample.movies.screens

import android.os.Parcelable
import com.androidbyexample.movies.data.Movie
import kotlinx.parcelize.Parcelize

sealed interface Screen: Parcelable @Parcelize data object MovieList: Screen @Parcelize data class MovieDisplay(val movie: Movie): Screen
ADDED: app/src/main/java/com/androidbyexample/movies/screens/Ui.kt
package com.androidbyexample.movies.screens

import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy
import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.ui.NavDisplay
import com.androidbyexample.movies.MovieViewModel

@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun Ui( viewModel: MovieViewModel, modifier: Modifier = Modifier, ) { val listDetailStrategy = rememberListDetailSceneStrategy<Screen>() val backStack by viewModel.backStackFlow.collectAsStateWithLifecycle(listOf(MovieList)) NavDisplay( backStack = backStack, onBack = viewModel::popScreen, sceneStrategies = listOf(listDetailStrategy), entryProvider = entryProvider { entry<MovieList>( metadata = ListDetailSceneStrategy.listPane() ) { MovieListUi( movies = viewModel.movies, onMovieClicked = { movie -> viewModel.pushScreen(MovieDisplay(movie)) }, modifier = modifier, ) } entry<MovieDisplay>( metadata = ListDetailSceneStrategy.detailPane() ) { key -> MovieDisplayUi( movie = key.movie, modifier = modifier, ) } } ) }
CHANGED: gradle/libs.versions.toml
[versions]
agp = "9.2.1"
coreKtx = "1.19.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.13.0"
kotlin = "2.4.0"
composeBom = "2026.06.01"
navigation = "1.1.4" material3AdaptiveNav3 = "1.3.0-rc01"
[libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation" } androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation" } androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" }
[plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }