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 object
s 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 import
s 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))
}
}
}
}