diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/Top10MoviesList.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/Top10MoviesList.kt index 5dd1dd8f..0e77f8d5 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/Top10MoviesList.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/screens/home/Top10MoviesList.kt @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * https://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -16,13 +16,17 @@ package com.google.jetstream.presentation.screens.home +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -31,116 +35,125 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.tv.material3.ExperimentalTvMaterial3Api -import androidx.tv.material3.ImmersiveList import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text -import coil.compose.AsyncImage -import coil.request.ImageRequest import com.google.jetstream.R import com.google.jetstream.data.entities.Movie +import com.google.jetstream.data.entities.MovieList import com.google.jetstream.presentation.common.ImmersiveListMoviesRow import com.google.jetstream.presentation.common.ItemDirection +import com.google.jetstream.presentation.common.PosterImage import com.google.jetstream.presentation.screens.dashboard.rememberChildPadding +import com.google.jetstream.presentation.utils.bringIntoViewIfChildrenAreFocused -@OptIn(ExperimentalTvMaterial3Api::class) @Composable fun Top10MoviesList( + movieList: MovieList, modifier: Modifier = Modifier, - moviesState: List, + gradientColor: Color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f), onMovieClick: (movie: Movie) -> Unit ) { - var currentItemIndex by remember { mutableStateOf(0) } var isListFocused by remember { mutableStateOf(false) } - var currentYCoord: Float? by remember { mutableStateOf(null) } + var selectedMovie by remember(movieList) { mutableStateOf(movieList.first()) } - ImmersiveList( - modifier = modifier.onGloballyPositioned { currentYCoord = it.positionInWindow().y }, - background = { _, listHasFocus -> - isListFocused = listHasFocus - val gradientColor = MaterialTheme.colorScheme.surface - AnimatedVisibility( - visible = isListFocused, - enter = fadeIn() + expandVertically(), - exit = fadeOut() + shrinkVertically(), - modifier = Modifier - .height(432.dp) - .gradientOverlay(gradientColor) - ) { - val movie = remember(moviesState, currentItemIndex) { - moviesState[currentItemIndex] - } + val sectionTitle = if (isListFocused) { + null + } else { + stringResource(R.string.top_10_movies_title) + } - Crossfade( - targetState = movie.posterUri, - label = "posterUriCrossfade" - ) { posterUri -> - AsyncImage( - modifier = Modifier - .fillMaxWidth() - .height(432.dp), - model = ImageRequest.Builder(LocalContext.current) - .data(posterUri) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop + Box( + contentAlignment = Alignment.BottomStart, + modifier = modifier + .bringIntoViewIfChildrenAreFocused(paddingValues = PaddingValues(bottom = 96.dp)) + ) { + Background( + movie = selectedMovie, + visible = isListFocused, + modifier = modifier + .height(432.dp) + .gradientOverlay(gradientColor) + ) + Column { + if (isListFocused) { + MovieDescription( + movie = selectedMovie, + modifier = Modifier.padding( + start = rememberChildPadding().start, + bottom = 32.dp ) - } - - } - }, - list = { - Column { - // TODO this causes the whole vertical list to jump - if (isListFocused) { - val movie = remember(moviesState, currentItemIndex) { - moviesState[currentItemIndex] - } - Column( - modifier = Modifier.padding( - start = rememberChildPadding().start, - bottom = 32.dp - ) - ) { - Text(text = movie.name, style = MaterialTheme.typography.displaySmall) - Spacer(modifier = Modifier.padding(top = 8.dp)) - Text( - modifier = Modifier.fillMaxWidth(0.5f), - text = movie.description, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), - fontWeight = FontWeight.Light - ) - } - } - ImmersiveListMoviesRow( - itemDirection = ItemDirection.Horizontal, - movies = moviesState, - title = if (isListFocused) null - else stringResource(R.string.top_10_movies_title), - showItemTitle = !isListFocused, - onMovieClick = onMovieClick, - showIndexOverImage = true, - focusedItemIndex = { focusedIndex -> currentItemIndex = focusedIndex } ) } + + ImmersiveListMoviesRow( + movieList = movieList, + itemDirection = ItemDirection.Horizontal, + title = sectionTitle, + showItemTitle = !isListFocused, + showIndexOverImage = true, + onMovieSelected = onMovieClick, + onMovieFocused = { selectedMovie = it }, + modifier = Modifier.onFocusChanged { + isListFocused = it.hasFocus + } + ) } - ) + } +} + +@Composable +private fun Background( + movie: Movie, + visible: Boolean, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + modifier = modifier + ) { + Crossfade( + targetState = movie, + label = "posterUriCrossfade", + + ) { + PosterImage(movie = it, modifier = Modifier.fillMaxSize()) + } + } +} + +@Composable +private fun MovieDescription( + movie: Movie, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(text = movie.name, style = MaterialTheme.typography.displaySmall) + Text( + modifier = Modifier.fillMaxWidth(0.5f), + text = movie.description, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.75f), + fontWeight = FontWeight.Light + ) + } } -fun Modifier.gradientOverlay(gradientColor: Color) = this then drawWithCache { +private fun Modifier.gradientOverlay(gradientColor: Color) = this then drawWithCache { val horizontalGradient = Brush.horizontalGradient( colors = listOf( gradientColor, diff --git a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/BringIntoViewIfChildrenAreFocused.kt b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/BringIntoViewIfChildrenAreFocused.kt index f71b6e85..95f786fc 100644 --- a/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/BringIntoViewIfChildrenAreFocused.kt +++ b/JetStreamCompose/jetstream/src/main/java/com/google/jetstream/presentation/utils/BringIntoViewIfChildrenAreFocused.kt @@ -17,39 +17,50 @@ package com.google.jetstream.presentation.utils import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.relocation.BringIntoViewResponder import androidx.compose.foundation.relocation.bringIntoViewResponder -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.toSize @Suppress("IllegalExperimentalApiUsage") // TODO (b/233188423): Address before moving to beta @OptIn(ExperimentalFoundationApi::class) -internal fun Modifier.bringIntoViewIfChildrenAreFocused(): Modifier = composed( +internal fun Modifier.bringIntoViewIfChildrenAreFocused( + paddingValues: PaddingValues = PaddingValues() +): Modifier = composed( inspectorInfo = debugInspectorInfo { name = "bringIntoViewIfChildrenAreFocused" }, factory = { + val pxOffset = with(LocalDensity.current) { + val y = (paddingValues.calculateBottomPadding() - paddingValues.calculateTopPadding()) + .toPx() + Offset.Zero.copy(y = y) + } var myRect: Rect = Rect.Zero + val responder = object : BringIntoViewResponder { + // return the current rectangle and ignoring the child rectangle received. + @ExperimentalFoundationApi + override fun calculateRectForParent(localRect: Rect): Rect { + return myRect + } + + // The container is not expected to be scrollable. Hence the child is + // already in view with respect to the container. + @ExperimentalFoundationApi + override suspend fun bringChildIntoView(localRect: () -> Rect?) { + } + } + this .onSizeChanged { - myRect = Rect(Offset.Zero, Offset(it.width.toFloat(), it.height.toFloat())) + val size = it.toSize() + myRect = Rect(pxOffset, size) } - .bringIntoViewResponder( - remember { - object : BringIntoViewResponder { - // return the current rectangle and ignoring the child rectangle received. - @ExperimentalFoundationApi - override fun calculateRectForParent(localRect: Rect): Rect = myRect - - // The container is not expected to be scrollable. Hence the child is - // already in view with respect to the container. - @ExperimentalFoundationApi - override suspend fun bringChildIntoView(localRect: () -> Rect?) {} - } - } - ) + .bringIntoViewResponder(responder) } )