Movies UI - Lists
Refactor time!
Our app bakes the list support in with the Movie UI. We can make it much more reusable!
We're going to have two types of lists in the application:
- Top-level lists of all movies, actors and ratings
- Nested lists, such as actors starring in a movie (on the movie's display screen)
To create consistent list support, we need to separate the LazyColumn
from the Scaffold
and the MovieListUi
. We want to keep all of the selection management, but make it more generic.
But there's a problem. If we make a generic List
composable, something like
fun <T> List(
items: List<T>,
...
) {
...
}
we have several spots that access the item's id
. When the item type was explicitly MovieDto
,
we knew it had an id, but if the item type is a generic parameter T
, we can no longer make
that assumption.
To fix this, we create a HasId
interface" in the repository
module. (You could create it in
the data
module, but we don't have a need for it that low.)
show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/HasId.kt
package com.androidbyexample.compose.movies.repository
interface HasId {
val id: String
}
We apply it to our MovieDto
, ActorDto
, and RatingDto
.
show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieDto.kt
// ...
data class MovieDto(
// val id: String,
override val id: String,
val title: String,
val description: String,
val ratingId: String,
//)
): HasId
internal fun MovieEntity.toDto() =
// ...
show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/ActorDto.kt
// ...
data class ActorDto(
// val id: String,
override val id: String,
val name: String,
//)
): HasId
internal fun ActorEntity.toDto() =
// ...
show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/RatingDto.kt
// ...
data class RatingDto(
// val id: String,
override val id: String,
val name: String,
val description: String,
//)
): HasId
internal fun RatingEntity.toDto() =
// ...
We can now create a generic List
composable.
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/List.kt
// ...
import com.androidbyexample.compose.movies.repository.HasId
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T: HasId> List(
items: List<T>,
onItemClicked: (T) -> Unit,
selectedIds: Set<String>,
onSelectionToggle: (id: String) -> Unit,
onClearSelections: () -> Unit,
modifier: Modifier = Modifier,
itemContent: @Composable ColumnScope.(T) -> Unit,
) {
LazyColumn(
modifier = modifier
) {
items(
items = items,
key = { it.id },
) { item ->
val containerColor =
if (item.id in selectedIds) {
MaterialTheme.colorScheme.secondary
} else {
MaterialTheme.colorScheme.surface
}
val contentColor = MaterialTheme.colorScheme.contentColorFor(containerColor)
if (selectedIds.isNotEmpty()) {
BackHandler {
onClearSelections()
}
}
Card(
elevation = CardDefaults.cardElevation(
defaultElevation = 8.dp,
),
colors = CardDefaults.cardColors(
containerColor = containerColor,
contentColor = contentColor,
),
modifier = Modifier
.padding(8.dp)
.combinedClickable(
onClick = {
if (selectedIds.isEmpty()) {
onItemClicked(item)
} else {
onSelectionToggle(item.id)
}
},
onLongClick = {
onSelectionToggle(item.id)
},
)
) {
itemContent(item)
}
}
}
}
We can now replace the common list function in MovieListUi
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
// ...
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) // for TopAppBar
@Composable
fun MovieListUi(
// ...
) {
Scaffold(
// ...
modifier = modifier,
) { innerPadding ->
// LazyColumn (
List(
items = movies,
onItemClicked = onMovieClicked,
selectedIds = selectedIds,
onSelectionToggle = onSelectionToggle,
onClearSelections = onClearSelections,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
// ) {
// items(
// items = movies,
// key = { it.id }
) { movie ->
// val containerColor =
// if (movie.id in selectedIds) {
// MaterialTheme.colorScheme.secondary
// } else {
// MaterialTheme.colorScheme.surface
// }
// val contentColor = MaterialTheme.colorScheme.contentColorFor(containerColor)
//
// if (selectedIds.isNotEmpty()) {
// BackHandler {
// onClearSelections()
// }
// }
//
// Card (
// elevation = CardDefaults.cardElevation(
// defaultElevation = 8.dp,
// ),
// colors = CardDefaults.cardColors(
// containerColor = containerColor,
// contentColor = contentColor,
// ),
// modifier = Modifier.padding(8.dp)
// .combinedClickable(
// onClick = {
// if (selectedIds.isEmpty()) {
// onMovieClicked(movie)
// } else {
// onSelectionToggle(movie.id)
// }
// },
// onLongClick = {
// onSelectionToggle(movie.id)
// },
// )
// ) {
Row (
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = stringResource(id = R.string.movie),
modifier = Modifier.clickable {
onSelectionToggle(movie.id)
}
)
Display(text = movie.title)
}
}
// }
// }
}
}
Because this list manages scrolling, we remove the verticalScroll
from the surrounding
column.
We can also use this list for the cast display in MovieDisplayUi
, giving us a
consistent-looking (and behaving) list...
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieDisplay.kt
// ...
@OptIn(ExperimentalMaterial3Api::class) // for TopAppBar
@Composable
fun MovieDisplayUi(
// ...
) {
// ...
Scaffold(
// ...
) { innerPadding ->
movieWithCast?.let { movieWithCast ->
Column (
modifier = Modifier
.padding(innerPadding)
// .verticalScroll(rememberScrollState())
) {
Label (textId = R.string.title)
// ...
// .forEach { role ->
List(
items = movieWithCast.cast.sortedBy { it.orderInCredits },
onItemClicked = onActorClicked,
selectedIds = emptySet(),
onSelectionToggle = {},
onClearSelections = {},
modifier = Modifier.weight(1f)
) { role ->
Display(
text = stringResource(
R.string.cast_entry,
role.character,
role.actor.name,
)
)
}
}
}
}
}
But there are a couple of hitches:
RoleWithActorDto
doesn't implementHasId
MovieDisplayUi
doesn't have a parameter to handle when an actor has been clicked- We don't want selections in the cast list, just clicks.
First, the HasId
. We can add a derived id
to RoleWithActorDto
. The id here is a combination
of movie id & character. (This might not be unique, but for our example purposes it's
good enough).
show in full file repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieDto.kt
// ...
data class RoleWithActorDto(
val actor: ActorDto,
val character: String,
val orderInCredits: Int,
//)
): HasId {
override val id: String
get() = "${actor.id}:$character"
}
internal fun RoleWithActor.toDto() =
RoleWithActorDto(
// ...
Next, we add a parameter to handle clicks on roles
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/MovieDisplay.kt
// ...
@OptIn(ExperimentalMaterial3Api::class) // for TopAppBar
@Composable
fun MovieDisplayUi(
id: String,
fetchMovie: suspend (String) -> MovieWithCastDto,
onActorClicked: (RoleWithActorDto) -> Unit,
modifier: Modifier = Modifier,
) {
// ...
}
and, just for now, an empty handler in Ui
. (We'll add that navigation after we set up the other
screens later.)
show in full file app/src/main/java/com/androidbyexample/compose/movies/screens/Ui.kt
// ...
@Composable
fun Ui(
// ...
) {
// ...
when (val screen = viewModel.currentScreen) {
// ...
is MovieDisplay -> {
MovieDisplayUi(
id = screen.id,
fetchMovie = viewModel::getMovieWithCast,
onActorClicked = { }, // empty for now. we'll fix later
modifier = modifier,
)
}
// ...
}
}
All code changes
ADDED: app/src/main/java/com/androidbyexample/compose/movies/screens/List.kt
package com.androidbyexample.compose.movies.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.androidbyexample.compose.movies.repository.HasId
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T: HasId> List(
items: List<T>,
onItemClicked: (T) -> Unit,
selectedIds: Set<String>,
onSelectionToggle: (id: String) -> Unit,
onClearSelections: () -> Unit,
modifier: Modifier = Modifier,
itemContent: @Composable ColumnScope.(T) -> Unit,
) {
LazyColumn(
modifier = modifier
) {
items(
items = items,
key = { it.id },
) { item ->
val containerColor =
if (item.id in selectedIds) {
MaterialTheme.colorScheme.secondary
} else {
MaterialTheme.colorScheme.surface
}
val contentColor = MaterialTheme.colorScheme.contentColorFor(containerColor)
if (selectedIds.isNotEmpty()) {
BackHandler {
onClearSelections()
}
}
Card(
elevation = CardDefaults.cardElevation(
defaultElevation = 8.dp,
),
colors = CardDefaults.cardColors(
containerColor = containerColor,
contentColor = contentColor,
),
modifier = Modifier
.padding(8.dp)
.combinedClickable(
onClick = {
if (selectedIds.isEmpty()) {
onItemClicked(item)
} else {
onSelectionToggle(item.id)
}
},
onLongClick = {
onSelectionToggle(item.id)
},
)
) {
itemContent(item)
}
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/screens/MovieDisplay.kt
package com.androidbyexample.compose.movies.screens
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.androidbyexample.compose.movies.R
import com.androidbyexample.compose.movies.components.Display
import com.androidbyexample.compose.movies.components.Label
import com.androidbyexample.compose.movies.repository.MovieDto
import com.androidbyexample.compose.movies.repository.MovieWithCastDto
import com.androidbyexample.compose.movies.repository.RoleWithActorDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class) // for TopAppBar
@Composable
fun MovieDisplayUi(
id: String,
fetchMovie: suspend (String) -> MovieWithCastDto,
onActorClicked: (RoleWithActorDto) -> Unit,
modifier: Modifier = Modifier,
) {
var movieWithCast by remember { mutableStateOf<MovieWithCastDto?>(null) }
LaunchedEffect(key1 = id) {
withContext(Dispatchers.IO) {
movieWithCast = fetchMovie(id)
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = movieWithCast?.movie?.title ?: stringResource(R.string.loading))
}
)
},
modifier = modifier,
) { innerPadding ->
movieWithCast?.let { movieWithCast ->
Column (
modifier = Modifier
.padding(innerPadding)
// .verticalScroll(rememberScrollState())
) {
Label (textId = R.string.title)
Display(text = movieWithCast.movie.title)
Label(textId = R.string.description)
Display(text = movieWithCast.movie.description)
Label(textId = R.string.cast)
// movieWithCast
// .cast
// .sortedBy { it.orderInCredits }
// .forEach { role ->
List(
items = movieWithCast.cast.sortedBy { it.orderInCredits },
onItemClicked = onActorClicked,
selectedIds = emptySet(),
onSelectionToggle = {},
onClearSelections = {},
modifier = Modifier.weight(1f)
) { role ->
Display(
text = stringResource(
R.string.cast_entry,
role.character,
role.actor.name,
)
)
}
}
}
}
}
CHANGED: app/src/main/java/com/androidbyexample/compose/movies/screens/MovieList.kt
package com.androidbyexample.compose.movies.screens
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.androidbyexample.compose.movies.R
import com.androidbyexample.compose.movies.components.Display
import com.androidbyexample.compose.movies.repository.MovieDto
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) // for TopAppBar
@Composable
fun MovieListUi(
movies: List<MovieDto>,
modifier: Modifier = Modifier,
onMovieClicked: (MovieDto) -> Unit,
selectedIds: Set<String>,
onSelectionToggle: (id: String) -> Unit,
onClearSelections: () -> Unit,
onDeleteSelectedMovies: () -> Unit,
onResetDatabase: () -> Unit,
) {
Scaffold(
topBar = {
if (selectedIds.isEmpty()) {
TopAppBar(
title = { Text(text = stringResource(R.string.movies)) },
actions = {
IconButton(onClick = onResetDatabase) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.reset_database)
)
}
}
)
} else {
TopAppBar(
navigationIcon = {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.clear_selections),
modifier = Modifier.clickable(onClick = onClearSelections),
)
},
title = {
Text(
text = selectedIds.size.toString(),
modifier = Modifier.padding(8.dp)
)
},
actions = {
IconButton(onClick = onDeleteSelectedMovies) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.delete_selected_items)
)
}
},
)
}
},
modifier = modifier,
) { innerPadding ->
// LazyColumn (
List(
items = movies,
onItemClicked = onMovieClicked,
selectedIds = selectedIds,
onSelectionToggle = onSelectionToggle,
onClearSelections = onClearSelections,
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
// ) {
// items(
// items = movies,
// key = { it.id }
) { movie ->
// val containerColor =
// if (movie.id in selectedIds) {
// MaterialTheme.colorScheme.secondary
// } else {
// MaterialTheme.colorScheme.surface
// }
// val contentColor = MaterialTheme.colorScheme.contentColorFor(containerColor)
//
// if (selectedIds.isNotEmpty()) {
// BackHandler {
// onClearSelections()
// }
// }
//
// Card (
// elevation = CardDefaults.cardElevation(
// defaultElevation = 8.dp,
// ),
// colors = CardDefaults.cardColors(
// containerColor = containerColor,
// contentColor = contentColor,
// ),
// modifier = Modifier.padding(8.dp)
// .combinedClickable(
// onClick = {
// if (selectedIds.isEmpty()) {
// onMovieClicked(movie)
// } else {
// onSelectionToggle(movie.id)
// }
// },
// onLongClick = {
// onSelectionToggle(movie.id)
// },
// )
// ) {
Row (
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = stringResource(id = R.string.movie),
modifier = Modifier.clickable {
onSelectionToggle(movie.id)
}
)
Display(text = movie.title)
}
}
// }
// }
}
}
CHANGED: 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.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.androidbyexample.compose.movies.MovieViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun Ui(
viewModel: MovieViewModel,
modifier: Modifier = Modifier,
onExit: () -> Unit,
) {
BackHandler {
viewModel.popScreen()
}
val scope = rememberCoroutineScope()
when (val screen = viewModel.currentScreen) {
null -> onExit()
is MovieDisplay -> {
MovieDisplayUi(
id = screen.id,
fetchMovie = viewModel::getMovieWithCast,
modifier = modifier,
)
}
MovieList -> {
val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(
initialValue = emptyList()
)
val selectedIds by viewModel
.selectedIdsFlow
.collectAsStateWithLifecycle(initialValue = emptySet())
MovieListUi(
movies = movies,
modifier = modifier,
selectedIds = selectedIds,
onClearSelections = viewModel::clearSelectedIds,
onSelectionToggle = viewModel::toggleSelection,
onDeleteSelectedMovies = viewModel::deleteSelectedMovies,
onResetDatabase = {
scope.launch (Dispatchers.IO) {
viewModel.resetDatabase()
}
},
onMovieClicked = { movie ->
viewModel.pushScreen(MovieDisplay(movie.id))
}
)
}
}
}
CHANGED: repository/src/main/java/com/androidbyexample/compose/movies/repository/ActorDto.kt
package com.androidbyexample.compose.movies.repository
import com.androidbyexample.compose.movies.data.ActorEntity
import com.androidbyexample.compose.movies.data.ActorWithFilmography
import com.androidbyexample.compose.movies.data.RoleWithMovie
data class ActorDto(
// val id: String,
override val id: String,
val name: String,
//)
): HasId
internal fun ActorEntity.toDto() =
ActorDto(id = id, name = name)
internal fun ActorDto.toEntity() =
ActorEntity(id = id, name = name)
data class ActorWithFilmographyDto(
val actor: ActorDto,
val filmography: List<RoleWithMovieDto>,
)
data class RoleWithMovieDto(
val movie: MovieDto,
val character: String,
val orderInCredits: Int,
)
internal fun RoleWithMovie.toDto() =
RoleWithMovieDto(
movie = movie.toDto(),
character = role.character,
orderInCredits = role.orderInCredits,
)
internal fun ActorWithFilmography.toDto() =
ActorWithFilmographyDto(
actor = actor.toDto(),
filmography =
rolesWithMovies.map {
it.toDto()
}
)
ADDED: repository/src/main/java/com/androidbyexample/compose/movies/repository/HasId.kt
package com.androidbyexample.compose.movies.repository
interface HasId {
val id: String
}
CHANGED: repository/src/main/java/com/androidbyexample/compose/movies/repository/MovieDto.kt
package com.androidbyexample.compose.movies.repository
import com.androidbyexample.compose.movies.data.MovieEntity
import com.androidbyexample.compose.movies.data.MovieWithCast
import com.androidbyexample.compose.movies.data.RoleWithActor
data class MovieDto(
// val id: String,
override val id: String,
val title: String,
val description: String,
val ratingId: String,
//)
): HasId
internal fun MovieEntity.toDto() =
MovieDto(id = id, title = title, description = description, ratingId = ratingId)
internal fun MovieDto.toEntity() =
MovieEntity(id = id, title = title, description = description, ratingId = ratingId)
data class MovieWithCastDto(
val movie: MovieDto,
val cast: List<RoleWithActorDto>,
)
data class RoleWithActorDto(
val actor: ActorDto,
val character: String,
val orderInCredits: Int,
//)
): HasId {
override val id: String
get() = "${actor.id}:$character"
}
internal fun RoleWithActor.toDto() =
RoleWithActorDto(
actor = actor.toDto(),
character = role.character,
orderInCredits = role.orderInCredits,
)
internal fun MovieWithCast.toDto() =
MovieWithCastDto(
movie = movie.toDto(),
cast =
rolesWithActors.map {
it.toDto()
}
)
CHANGED: repository/src/main/java/com/androidbyexample/compose/movies/repository/RatingDto.kt
package com.androidbyexample.compose.movies.repository
import com.androidbyexample.compose.movies.data.RatingEntity
import com.androidbyexample.compose.movies.data.RatingWithMovies
data class RatingDto(
// val id: String,
override val id: String,
val name: String,
val description: String,
//)
): HasId
internal fun RatingEntity.toDto() =
RatingDto(id = id, name = name, description = description)
internal fun RatingDto.toEntity() =
RatingEntity(id = id, name = name, description = description)
data class RatingWithMoviesDto(
val rating: RatingDto,
val movies: List<MovieDto>,
)
// only need the toDto(); we don't use this to do database updates
internal fun RatingWithMovies.toDto() =
RatingWithMoviesDto(
rating = rating.toDto(),
movies = movies.map { it.toDto() },
)