Movies UI - Lists

Refactor Display

We can also use our new ListScaffold for the cast display in MovieDisplayUi, giving us a consistent-looking (and behaving) list...

However, MovieDisplayUi has content that appears above the list. Our ListScaffold controls the entire screen! So we need a way to pass that content into the ListScaffold and ignore it if it's not needed.

We start with a new function parameter to MovieDisplayUi:

show in full file app/src/main/java/com/androidbyexample/movies/helper/ListScaffold.kt
// ...

@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun <T: HasId> ListScaffold(
    // ...
    @StringRes itemContentDescriptionId: Int,
    modifier: Modifier = Modifier,
    fixedContentAbove: @Composable () -> Unit = {},
    cardContent: @Composable (T) -> Unit,
) {
    // ...
}

And use it in an item call in our LazyColumn

show in full file app/src/main/java/com/androidbyexample/movies/helper/ListScaffold.kt
// ...

@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun <T: HasId> ListScaffold(
    // ...
) {
    // ...
    Scaffold(
        // ...
    ) { innerPadding ->
        LazyColumn(
            modifier = Modifier.padding(innerPadding)
        ) {
            item {
                fixedContentAbove()
            }
            items(
                items = items,
                // ...
        }
    }
}

The LazyColumn now manages scrolling of the entire display screen, which increases usability if the screen height is small (such as a phone held in landscape orientation). If we used a Column that contains the display content and the cast list, there would be very little room to see the cast list. In addition, if the display information were longer that just our title and description fields, we wouldn't be able to see all the data!

Now let's use this in our MovieDisplayUi. We'll need a few new parameters (to handle the cast list):

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

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieDisplayUi(
    id: String,
    fetchMovie: suspend (String) -> MovieWithCastDto,
    onActorClicked: (String) -> Unit,
    onDeleteSelectedActors: (Set<String>) -> Unit,
    onResetDatabase: () -> Unit,
    modifier: Modifier = Modifier,
) {
    // ...
}

And then we replace the body with a call to ListScaffold

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

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieDisplayUi(
    // ...
) {
    // ...

    movieWithCast?.let { movieWithCast ->
//      Scaffold(
//          topBar = {
//              TopAppBar(
//                  title = {
//                      Text(text = movieWithCast.movie.title)
//                  }
//              )
//          },
        ListScaffold(
            title = movieWithCast.movie.title,
            items = movieWithCast.cast,
            onItemClicked = onActorClicked,
            onDeleteSelectedItems = onDeleteSelectedActors,
            onResetDatabase = onResetDatabase,
            itemIconId = R.drawable.movie_24,
            itemContentDescriptionId = R.string.movie,
            modifier = modifier,
//      ) { innerPadding ->
//          Column(
//              modifier = modifier
//                  .padding(innerPadding)
//                  .verticalScroll(rememberScrollState())
//          ) {
            fixedContentAbove = {
                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 ->
            },
            cardContent = { role ->
                Display(
                    text = stringResource(
                        R.string.cast_entry,
                        role.character,
                        role.actor.name,
                    )
                )
//                  }
//          }
//      }
            },
        )
    }
}

Finally, we need to fixup the call to MovieDisplayUi from Ui

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

@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun Ui(
    // ...
) {
    // ...
    NavDisplay(
        // ...
        entryProvider = entryProvider {
            // ...
            entry<MovieDisplay>(
                // ...
            ) { key ->
                MovieDisplayUi(
                    id = key.id,
                    fetchMovie = viewModel::getMovieWithCast,
                    onActorClicked = {},
                    onDeleteSelectedActors = { },
                    onResetDatabase = viewModel::doResetDatabase,
                )
            }
        }
    )
}

For now, we pass empty lambdas for onActorClicked and onDeleteSelectedActors.


All code changes

CHANGED: app/src/main/java/com/androidbyexample/movies/helper/ListScaffold.kt
package com.androidbyexample.movies.helper

import androidx.activity.compose.BackHandler
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
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.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.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.TopAppBarDefaults
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.androidbyexample.movies.R
import com.androidbyexample.movies.repository.HasId

@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable
fun <T: HasId> ListScaffold( title: String, items: List<T>, onItemClicked: (String) -> Unit, onDeleteSelectedItems: (Set<String>) -> Unit, onResetDatabase: () -> Unit, @DrawableRes itemIconId: Int, @StringRes itemContentDescriptionId: Int, modifier: Modifier = Modifier,
fixedContentAbove: @Composable () -> Unit = {},
cardContent: @Composable (T) -> Unit,
) { val selectedIds = rememberSaveable { mutableStateSetOf<String>()} fun onSelectionToggle(id: String) { if (id in selectedIds) { selectedIds -= id } else { selectedIds += id } } fun clearSelectedIds() { selectedIds.clear() } if (selectedIds.isNotEmpty()) { BackHandler { clearSelectedIds() } } Scaffold( topBar = { val colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primary, scrolledContainerColor = MaterialTheme.colorScheme.surfaceContainer, titleContentColor = MaterialTheme.colorScheme.onPrimary, navigationIconContentColor = MaterialTheme.colorScheme.onPrimary, actionIconContentColor = MaterialTheme.colorScheme.onSecondary ) if (selectedIds.isEmpty()) { TopAppBar( colors = colors, title = { Text(text = title) }, actions = { IconButton(onClick = onResetDatabase) { Icon( painter = painterResource(R.drawable.refresh_24), contentDescription = stringResource(R.string.reset_database), ) } } ) } else { TopAppBar( colors = colors, navigationIcon = { Icon( painter = painterResource(R.drawable.arrow_back_24), contentDescription = stringResource(R.string.clear_selections), modifier = Modifier.clickable(onClick = ::clearSelectedIds), ) }, title = { Text( text = selectedIds.size.toString(), modifier = Modifier.padding(8.dp) ) }, actions = { IconButton( onClick = { onDeleteSelectedItems(selectedIds.toSet()) // NOTE - this is a mutable set, and we need to be sure we just // pass a read-only copy out of here. If we don't, the // caller could modify the contents AND we set up a race // condition between the clear() call below and the read // of the data in the caller. selectedIds.clear() } ) { Icon( painter = painterResource(R.drawable.delete_24), contentDescription = stringResource(R.string.delete_selected_items) ) } }, ) } }, modifier = modifier, ) { innerPadding -> LazyColumn( modifier = Modifier.padding(innerPadding) ) {
item { fixedContentAbove() }
items( items = items, key = { it.id }, ) { item -> val containerColor = if (item.id in selectedIds) { MaterialTheme.colorScheme.primaryContainer } else { MaterialTheme.colorScheme.surface } val contentColor = MaterialTheme.colorScheme.contentColorFor(containerColor) Card( elevation = CardDefaults.cardElevation( defaultElevation = 8.dp, ), colors = CardDefaults.cardColors( containerColor = containerColor, contentColor = contentColor, ), modifier = Modifier .padding(8.dp) .combinedClickable(
// NOTE - use targetId for navigation, id for selections onClick = { if (selectedIds.isEmpty()) { onItemClicked(item.targetId) } else { onSelectionToggle(item.id) } }, onLongClick = { onSelectionToggle(item.id) },
) ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(8.dp), ) {
Icon( painter = painterResource(itemIconId), contentDescription = stringResource(itemContentDescriptionId), modifier = Modifier.clickable { onSelectionToggle(item.id) } )
cardContent(item)
} } } } } }
CHANGED: app/src/main/java/com/androidbyexample/movies/screens/MovieDisplayUi.kt
package com.androidbyexample.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.movies.R
import com.androidbyexample.movies.helper.Display
import com.androidbyexample.movies.helper.Label
import com.androidbyexample.movies.helper.ListScaffold
import com.androidbyexample.movies.repository.MovieWithCastDto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MovieDisplayUi(
    id: String,
    fetchMovie: suspend (String) -> MovieWithCastDto,
onActorClicked: (String) -> Unit, onDeleteSelectedActors: (Set<String>) -> Unit, onResetDatabase: () -> Unit,
modifier: Modifier = Modifier, ) { var movieWithCast by remember { mutableStateOf<MovieWithCastDto?>(null) } LaunchedEffect(key1 = id) { withContext(Dispatchers.IO) { movieWithCast = fetchMovie(id) } }
movieWithCast?.let { movieWithCast -> // Scaffold( // topBar = { // TopAppBar( // title = { // Text(text = movieWithCast.movie.title) // } // ) // }, ListScaffold( title = movieWithCast.movie.title, items = movieWithCast.cast, onItemClicked = onActorClicked, onDeleteSelectedItems = onDeleteSelectedActors, onResetDatabase = onResetDatabase, itemIconId = R.drawable.movie_24, itemContentDescriptionId = R.string.movie, modifier = modifier, // ) { innerPadding -> // Column( // modifier = modifier // .padding(innerPadding) // .verticalScroll(rememberScrollState()) // ) { fixedContentAbove = { 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 -> }, cardContent = { role -> Display( text = stringResource( R.string.cast_entry, role.character, role.actor.name, ) ) // } // } // } }, )
} }
CHANGED: 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.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,
) {
    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()
            ) {
                val movies by viewModel.moviesFlow.collectAsStateWithLifecycle(
                    initialValue = emptyList()
                )

                MovieListUi(
                    movies = movies,
onMovieClicked = { movieId -> viewModel.pushScreen(MovieDisplay(movieId)) },
onDeleteSelectedMovies = { ids -> viewModel.deleteSelectedMovies(ids) },
onResetDatabase = viewModel::doResetDatabase, ) } entry<MovieDisplay>( metadata = ListDetailSceneStrategy.detailPane() ) { key -> MovieDisplayUi( id = key.id, fetchMovie = viewModel::getMovieWithCast,
onActorClicked = {}, onDeleteSelectedActors = { }, onResetDatabase = viewModel::doResetDatabase,
) } } ) }