Initial Movies UI

Create initial screens

Let's create a starter user interface!

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

Note

We'll be using a trivial custom navigation scheme for this class. It's not robust, though; you should look at available navigation frameworks (such as Navigating with Compose or others. These can be complex, and everyone has their own preferences on which are the best. I didn't want to spend a lot of time on navigation in this class, so I'm keeping it to a simple stack-based approach.

To implement our simple navigation, we need to

  • Define state representing our screens
  • Create a private screen stack in the view model
  • Expose the current top-of-stack screen for the UI to consume
  • Choose the screen to display based on the screen state

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.

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

sealed interface Screen
data object MovieList: Screen
data class MovieDisplay(val movie: Movie): Screen

Note

We're creating a file to hold all of these 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.

In the view model, we add a screen stack to track screens that the user has visited. We keep this stack private; the IU doesn't need to know about it. This stack defines a set() function that updates the backing field, and sets the currentScreen property.

The current screen property is delegated to a Compose MutableState so it can be accessed and observed for changes from the UI.

Note the private set on currentScreen - this allows us to modify it from inside the view model, but it cannot be modified from outside.

show in full file app/src/main/java/com/androidbyexample/compose/movies/MovieViewModel.kt
// ...

class MovieViewModel: ViewModel() {
    private var screenStack = listOf<Screen>(MovieList)
        set(value) {
            field = value
            currentScreen = value.lastOrNull()
        }

    var currentScreen by mutableStateOf<Screen?>(MovieList)
        private set

    fun pushScreen(screen: Screen) {
        // ...
}

Note

When adding mutableStateOf, you'll end up with two errors. First, you'll need to import the function mutableStateOf by pressing ++alt+Enter++ on it. But it will stay red! Kotlin property delegation requires getValue() and setValue() functions be defined for the delegate, and MutableState doesn't have these functions. Instead, Compose defines getValue() and setValue() extension functions, but you need to explicitly import them. Press ++alt+Enter++ a second time on mutableStateOf to import these extension functions. You'll see them added to the imports at the top of the file.

The last thing we need to do in the view model, is add external stack support so the UI can navigate to a new screen or go back to an existing screen. When these functions modify the stack, its set() updates currentScreen automatically.

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

    fun pushScreen(screen: Screen) {
        screenStack = screenStack + screen
    }
    fun popScreen() {
        screenStack = screenStack.dropLast(1)
    }

    val movies: List<Movie> = listOf(
        // ...
}

Now let's define the placeholder user interfaces (UIs). We define simple movie display and movie list UIs as Composable functions.

Note

When you define Composable functions, you should always pass and respect a Modifier. 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!

show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieDisplay.kt
// ...
import com.androidbyexample.compose.movies.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/compose/movies/screens/MovieList.kt
// ...
import com.androidbyexample.compose.movies.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)

show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
// ...

@Composable
fun MovieListUi(
    // ...
) {
    Text(
        text = "Movie List",
        modifier = modifier.clickable {
            onMovieClicked(movies[0])
        }
    )
}

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/compose/movies/screens/Ui.kt
// ...
import com.androidbyexample.compose.movies.MovieViewModel

@Composable
fun Ui(
    viewModel: MovieViewModel,
    modifier: Modifier = Modifier,
    onExit: () -> Unit,
) {
    BackHandler {
        viewModel.popScreen()
    }
    when (val screen = viewModel.currentScreen) {
        null -> onExit()
        is MovieDisplay -> {
            MovieDisplayUi(
                movie = screen.movie,
                modifier = modifier,
            )
        }
        MovieList -> {
            MovieListUi(
                movies = viewModel.movies,
                modifier = modifier,
            ) { movie ->
                viewModel.pushScreen(MovieDisplay(movie))
            }
        }
    }
}

If the current screen is null, that means we've popped all screens off the stack and should exit the application. This is done by passing an event function that calls finish() in the MainActivity. This pops the activity off the system's Activity stack. Because we only have a single activity on the system stack for this application, the system exits the application.

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.

show in full file app/src/main/java/com/androidbyexample/compose/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),
                    ) {
                        finish()
                }
            }
        }
    }
    }
}
// ...

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.

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.


All code changes

CHANGED: app/src/main/java/com/androidbyexample/compose/movies/MainActivity.kt
package com.androidbyexample.compose.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.compose.movies.screens.Ui
import com.androidbyexample.compose.movies.ui.theme.MoviesTheme
//import com.androidbyexample.compose.movieui1.MovieViewModel

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), ) { finish() }
} } } } } // //@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/compose/movies/Movie.kt
//package com.androidbyexample.compose.movieui1
package com.androidbyexample.compose.movies

data class Movie( val title: String, val description: String, )
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/MovieViewModel.kt
//package com.androidbyexample.compose.movieui1
package com.androidbyexample.compose.movies

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.androidbyexample.compose.movies.screens.MovieList
import com.androidbyexample.compose.movies.screens.Screen

class MovieViewModel: ViewModel() {
private var screenStack = listOf<Screen>(MovieList) set(value) { field = value currentScreen = value.lastOrNull() } var currentScreen by mutableStateOf<Screen?>(MovieList) private set
fun pushScreen(screen: Screen) { screenStack = screenStack + screen } fun popScreen() { screenStack = screenStack.dropLast(1) }
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"), ) }
ADDED: app/src/main/java/com/androidbyexample/compose/movies/screens/MovieDisplay.kt
package com.androidbyexample.compose.movies.screens

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

@Composable fun MovieDisplayUi( movie: Movie, modifier: Modifier = Modifier, ) { Text( text = "Movie Display: ${movie.title}", modifier = modifier, ) }
ADDED: app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
package com.androidbyexample.compose.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.compose.movies.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/compose/movies/screens/Screens.kt
package com.androidbyexample.compose.movies.screens

import com.androidbyexample.compose.movies.Movie

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

import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.androidbyexample.compose.movies.MovieViewModel

@Composable fun Ui( viewModel: MovieViewModel, modifier: Modifier = Modifier, onExit: () -> Unit, ) { BackHandler { viewModel.popScreen() } when (val screen = viewModel.currentScreen) { null -> onExit() is MovieDisplay -> { MovieDisplayUi( movie = screen.movie, modifier = modifier, ) } MovieList -> { MovieListUi( movies = viewModel.movies, modifier = modifier, ) { movie -> viewModel.pushScreen(MovieDisplay(movie)) } } } }