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.
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 for convenience, as we're focusing on the user interface in this module.
We use a Kotlin object
for the movie list state. Because the movie list will just
display all movies, we don't need to keep any state 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.
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 current screen. The
current screen 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.
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.
Now the placeholder screens. We define simple movie display and movie list screens.
Our MovieDisplayUi
takes a Movie
as a parameter and displays 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.
For now, we just display "Movie list", and when it's clicked,
tell the caller
that the first movie in the list was selected.
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. 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 Activity
, ending the application (because we only
have a single activity in the application.)
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.
Code Changes
CHANGED: /app/src/main/java/com/androidbyexample/movieui1/MainActivity.kt
package com.androidbyexample.movieui1 import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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.movieui1.screens.Uiimport com.androidbyexample.movieui1.ui.theme.MovieUi1Theme class MainActivity : ComponentActivity() {private val viewModel by viewModels<MovieViewModel>()override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MovieUi1Theme { // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) {} } } } }// Greeting("Android")Ui(viewModel) { finish() }//@Composable//fun Greeting(name: String, modifier: Modifier = Modifier) {// Text(// text = "Hello $name!",// modifier = modifier// )//}//@Preview(showBackground = true)//@Composable//fun GreetingPreview() {// MovieUi1Theme {// Greeting("Android")// }//}
CHANGED: /app/src/main/java/com/androidbyexample/movieui1/MovieViewModel.kt
package com.androidbyexample.movieui1 import androidx.compose.runtime.getValueimport androidx.compose.runtime.mutableStateOfimport androidx.compose.runtime.setValueimport androidx.lifecycle.ViewModel import com.androidbyexample.movieui1.screens.MovieListimport com.androidbyexample.movieui1.screens.Screenclass MovieViewModel: ViewModel() {private var screenStack = listOf<Screen>(MovieList) set(value) { field = valuecurrentScreen = value.lastOrNull()}var currentScreen by mutableStateOf<Screen?>(MovieList) private setfun 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/movieui1/screens/MovieDisplayUi.kt
package com.androidbyexample.movieui1.screensimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport com.androidbyexample.movieui1.Movie@Composablefun MovieDisplayUi( movie: Movie) { Text(text = "Movie Display: ${movie.title}")}
ADDED: /app/src/main/java/com/androidbyexample/movieui1/screens/MovieListUi.kt
package com.androidbyexample.movieui1.screensimport androidx.compose.foundation.clickableimport androidx.compose.material3.ExperimentalMaterial3Apiimport androidx.compose.material3.Textimport androidx.compose.runtime.Composableimport androidx.compose.ui.Modifierimport com.androidbyexample.movieui1.Movie@Composablefun MovieListUi( movies: List<Movie>, onMovieClicked: (Movie) -> Unit,) { Text( text = "Movie List",modifier = Modifier.clickable { onMovieClicked(movies[0]) })}
ADDED: /app/src/main/java/com/androidbyexample/movieui1/screens/Screens.kt
package com.androidbyexample.movieui1.screensimport com.androidbyexample.movieui1.Moviesealed interface Screenobject MovieList: Screendata class MovieDisplay(val movie: Movie): Screen
ADDED: /app/src/main/java/com/androidbyexample/movieui1/screens/Ui.kt
package com.androidbyexample.movieui1.screensimport androidx.activity.compose.BackHandlerimport androidx.compose.runtime.Composableimport com.androidbyexample.movieui1.MovieViewModel@Composablefun Ui( viewModel: MovieViewModel, onExit: () -> Unit,) {BackHandler { viewModel.popScreen() }when (val screen = viewModel.currentScreen) { null -> onExit() is MovieDisplay -> { MovieDisplayUi(screen.movie) } MovieList -> { MovieListUi(viewModel.movies) { movie -> viewModel.pushScreen(MovieDisplay(movie)) } } }}