backdrop gallery overlay on tap

This commit is contained in:
Owen LeJeune
2023-06-08 16:49:21 -04:00
parent 88f6932140
commit 6c504f4084
6 changed files with 537 additions and 150 deletions

View File

@@ -1,7 +1,14 @@
package com.owenlejeune.tvtime.extensions
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
fun Float.dpToPx(context: Context): Float {
return this * context.resources.displayMetrics.density
}
@Composable
fun Int.toDp(): Dp = (this / LocalContext.current.resources.displayMetrics.density).toInt().dp

View File

@@ -0,0 +1,273 @@
package com.owenlejeune.tvtime.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ChevronLeft
import androidx.compose.material.icons.outlined.ChevronRight
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
import com.owenlejeune.tvtime.extensions.toDp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.seconds
@OptIn(ExperimentalPagerApi::class)
@Composable
fun Gallery(
pagerState: PagerState,
models: List<Any?>,
modifier: Modifier = Modifier,
contentDescriptions: List<String> = emptyList()
) {
HorizontalPager(
count = models.size,
state = pagerState,
modifier = modifier
) { page ->
AsyncImage(
model = models[page],
contentDescription = contentDescriptions[page],
contentScale = ContentScale.FillWidth
)
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun TapGallery(
pagerState: PagerState,
models: List<Any?>,
modifier: Modifier = Modifier
) {
val scope = rememberCoroutineScope()
var showControls by remember { mutableStateOf(false) }
var lastTappedTime by remember { mutableStateOf(System.currentTimeMillis()) }
var job: Job? = null
LaunchedEffect(lastTappedTime) {
if (showControls) {
job = scope.launch {
delay(5.seconds)
withContext(Dispatchers.Main) {
showControls = false
}
}
}
}
Box(
modifier = modifier
.clickable {
job?.cancel()
showControls = true
lastTappedTime = System.currentTimeMillis()
}
) {
val sizeImage = remember { mutableStateOf(IntSize.Zero) }
HorizontalPager(
count = models.size,
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.onGloballyPositioned { sizeImage.value = it.size }
) { page ->
AsyncImage(
model = models[page],
contentDescription = null,
contentScale = ContentScale.FillWidth
)
}
AnimatedVisibility(
visible = showControls,
enter = fadeIn(),
exit = fadeOut()
) {
val gradient = Brush.horizontalGradient(
colors = listOf(Color.Black, Color.Transparent),
startX = 0f,
endX = (sizeImage.value.width/2).toFloat(),
tileMode = TileMode.Mirror
)
Box(
modifier = Modifier
.background(brush = gradient)
.size(
width = sizeImage.value.width.toDp(),
height = sizeImage.value.height.toDp()
)
) {
IconButton(
modifier = Modifier
.fillMaxHeight()
.width(width = 48.dp)
.align(Alignment.CenterStart),
onClick = {}
) {
Icon(
imageVector = Icons.Outlined.ChevronLeft,
contentDescription = null,
modifier = Modifier.align(Alignment.Center),
tint = MaterialTheme.colorScheme.surface
)
}
IconButton(
modifier = Modifier
.fillMaxHeight()
.width(width = 48.dp)
.align(Alignment.CenterEnd),
onClick = {}
) {
Icon(
imageVector = Icons.Outlined.ChevronRight,
contentDescription = null,
modifier = Modifier.align(Alignment.Center),
tint = MaterialTheme.colorScheme.surface
)
}
// Box(
// modifier = Modifier
// .background(brush = leftGradient)
// .height(height = sizeImage.value.height.toDp())
// .width((sizeImage.value.width/2).toDp())
// .align(Alignment.CenterStart)
// .onGloballyPositioned { leftSizeImage.value = it.size }
// .clickable {
// val target =
// if (pagerState.currentPage == 0) models.size - 1 else pagerState.currentPage - 1
// scope.launch { pagerState.animateScrollToPage(target) }
// }
// ) {
// Icon(
// imageVector = Icons.Outlined.ChevronLeft,
// contentDescription = null,
// modifier = Modifier
// .size(48.dp)
// .padding(start = 24.dp)
// .align(Alignment.CenterStart),
// tint = MaterialTheme.colorScheme.surface
// )
// }
// Box(
// modifier = Modifier
// .background(brush = rightGradient)
// .height(height = sizeImage.value.height.toDp())
// .width((sizeImage.value.width/2).toDp())
// .align(Alignment.CenterEnd)
// .onGloballyPositioned { rightSizeImage.value = it.size }
// .clickable {
// val target =
// if (pagerState.currentPage == models.size - 1) 0 else pagerState.currentPage + 1
// scope.launch { pagerState.animateScrollToPage(target) }
// }
// ) {
// Icon(
// imageVector = Icons.Outlined.ChevronRight,
// contentDescription = null,
// modifier = Modifier
// .size(48.dp)
// .padding(end = 24.dp)
// .align(Alignment.CenterEnd),
// tint = MaterialTheme.colorScheme.surface
// )
// }
}
}
// AnimatedVisibility(
// visible = showControls.value,
// enter = fadeIn(),
// exit = fadeOut()
// ) {
// val leftSizeImage = remember { mutableStateOf(IntSize.Zero) }
// val leftGradient = Brush.horizontalGradient(
// colors = listOf(Color.Black, Color.Transparent),
// startX = 0f,
// endX = leftSizeImage.value.width.toFloat()
// )
// Box(
// modifier = Modifier
// .background(brush = leftGradient)
// .fillMaxHeight()
// .width(100.dp)
// .align(Alignment.CenterStart)
// .onGloballyPositioned { leftSizeImage.value = it.size }
// .clickable {
// val target =
// if (pagerState.currentPage == 0) models.size - 1 else pagerState.currentPage + 1
// scope.launch { pagerState.animateScrollToPage(target) }
// }
// ) {
// Icon(
// imageVector = Icons.Outlined.ChevronLeft,
// contentDescription = null,
// modifier = Modifier.size(48.dp)
// )
// }
//
// val rightSizeImage = remember { mutableStateOf(IntSize.Zero) }
// val rightGradient = Brush.horizontalGradient(
// colors = listOf(Color.Black, Color.Transparent),
// startX = leftSizeImage.value.width.toFloat(),
// endX = 0f
// )
// Box(
// modifier = Modifier
// .background(brush = rightGradient)
// .fillMaxHeight()
// .width(100.dp)
// .align(Alignment.CenterEnd)
// .onGloballyPositioned { rightSizeImage.value = it.size }
// .clickable {
// val target =
// if (pagerState.currentPage == models.size - 1) 0 else pagerState.currentPage - 1
// scope.launch { pagerState.animateScrollToPage(target) }
// }
// ) {
// Icon(
// imageVector = Icons.Outlined.ChevronRight,
// contentDescription = null,
// modifier = Modifier.size(48.dp)
// )
// }
// }
}
}

View File

@@ -0,0 +1,41 @@
package com.owenlejeune.tvtime.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.rememberPagerState
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection
import com.owenlejeune.tvtime.utils.TmdbUtils
@OptIn(ExperimentalPagerApi::class)
@Composable
fun ImageGalleryOverlay(
imageCollection: ImageCollection,
selectedImage: Int,
onDismissRequest: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Black.copy(alpha = 0.7f))
.clickable(onClick = onDismissRequest)
) {
val pagerState = rememberPagerState(initialPage = selectedImage)
TapGallery(
pagerState = pagerState,
models = imageCollection.backdrops.map { TmdbUtils.getFullBackdropPath(it) },
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.align(Alignment.Center)
)
}
}

View File

@@ -2,6 +2,7 @@ package com.owenlejeune.tvtime.ui.screens.main
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
@@ -20,6 +21,7 @@ import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.rememberPagerState
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.ImageCollection
@@ -31,15 +33,18 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalPagerApi::class)
@Composable
fun DetailHeader(
modifier: Modifier = Modifier,
showGalleryOverlay: MutableState<Boolean>? = null,
imageCollection: ImageCollection? = null,
backdropUrl: String? = null,
posterUrl: String? = null,
backdropContentDescription: String? = null,
posterContentDescription: String? = null,
rating: Float? = null
rating: Float? = null,
pagerState: PagerState? = null
) {
ConstraintLayout(modifier = modifier
.fillMaxWidth()
@@ -56,8 +61,12 @@ fun DetailHeader(
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
.clickable {
showGalleryOverlay?.value = true
},
imageCollection = imageCollection
imageCollection = imageCollection,
state = pagerState
)
} else {
Backdrop(
@@ -148,15 +157,17 @@ private fun Backdrop(
@OptIn(ExperimentalPagerApi::class)
@Composable
private fun BackdropGallery(
fun BackdropGallery(
modifier: Modifier,
imageCollection: ImageCollection?
imageCollection: ImageCollection?,
delayMillis: Long = 5000,
state: PagerState? = null
) {
BackdropContainer(
modifier = modifier
) { sizeImage ->
if (imageCollection != null) {
val pagerState = rememberPagerState()
val pagerState = state ?: rememberPagerState(initialPage = 0)
HorizontalPager(
count = imageCollection.backdrops.size,
state = pagerState,
@@ -173,10 +184,11 @@ private fun BackdropGallery(
}
// fixes an issue where using pagerState.current page breaks paging animations
if (delayMillis > 0) {
var key by remember { mutableStateOf(false) }
LaunchedEffect(key1 = key) {
launch {
delay(5000)
delay(delayMillis)
with(pagerState) {
val target = if (currentPage < pageCount - 1) currentPage + 1 else 0
animateScrollToPage(target)
@@ -187,6 +199,7 @@ private fun BackdropGallery(
}
}
}
}
@Composable
fun RatingView(

View File

@@ -1,6 +1,7 @@
package com.owenlejeune.tvtime.ui.screens.main
import android.content.Context
import android.media.MediaActionSound
import android.widget.Toast
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
@@ -31,6 +32,9 @@ import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.PagerState
import com.google.accompanist.pager.rememberPagerState
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.api.v3.AccountService
import com.owenlejeune.tvtime.api.tmdb.api.v3.DetailService
@@ -51,16 +55,17 @@ import com.owenlejeune.tvtime.utils.TmdbUtils
import kotlinx.coroutines.*
import org.json.JSONObject
import org.koin.java.KoinJavaComponent
import org.koin.java.KoinJavaComponent.get
import java.text.DecimalFormat
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class)
@Composable
fun MediaDetailView(
appNavController: NavController,
itemId: Int?,
type: MediaViewType,
windowSize: WindowSizeClass,
preferences: AppPreferences = KoinJavaComponent.get(AppPreferences::class.java)
preferences: AppPreferences = get(AppPreferences::class.java)
) {
val service = when (type) {
MediaViewType.MOVIE -> MoviesService()
@@ -75,12 +80,25 @@ fun MediaDetailView(
}
}
val images = remember { mutableStateOf<ImageCollection?>(null) }
itemId?.let {
if (preferences.showBackdropGallery && images.value == null) {
fetchImages(itemId, service, images)
}
}
val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val topAppBarScrollState = rememberTopAppBarScrollState()
val scrollBehavior = remember(decayAnimationSpec) {
TopAppBarDefaults.pinnedScrollBehavior(topAppBarScrollState)
}
val pagerState = rememberPagerState(initialPage = 0)
Box(
modifier = Modifier.fillMaxSize()
) {
val showGalleryOverlay = remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
@@ -107,6 +125,45 @@ fun MediaDetailView(
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
MediaViewContent(
appNavController = appNavController,
itemId = itemId,
mediaItem = mediaItem,
images = images,
service = service,
type = type,
windowSize = windowSize,
showImageGallery = showGalleryOverlay,
pagerState = pagerState
)
}
}
if (showGalleryOverlay.value) {
images.value?.let {
ImageGalleryOverlay(
imageCollection = it,
selectedImage = pagerState.currentPage,
onDismissRequest = { showGalleryOverlay.value = false }
)
}
}
}
}
@OptIn(ExperimentalPagerApi::class)
@Composable
private fun MediaViewContent(
appNavController: NavController,
itemId: Int?,
mediaItem: MutableState<DetailedItem?>,
images: MutableState<ImageCollection?>,
service: DetailService,
type: MediaViewType,
windowSize: WindowSizeClass,
showImageGallery: MutableState<Boolean>,
pagerState: PagerState
) {
Row(
modifier = Modifier
.background(color = MaterialTheme.colorScheme.background),
@@ -119,19 +176,14 @@ fun MediaDetailView(
.verticalScroll(state = rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
val images = remember { mutableStateOf<ImageCollection?>(null) }
itemId?.let {
if (preferences.showBackdropGallery && images.value == null) {
fetchImages(itemId, service, images)
}
}
DetailHeader(
posterUrl = TmdbUtils.getFullPosterPath(mediaItem.value?.posterPath),
posterContentDescription = mediaItem.value?.title,
backdropUrl = TmdbUtils.getFullBackdropPath(mediaItem.value?.backdropPath),
rating = mediaItem.value?.voteAverage?.let { it / 10 },
imageCollection = images.value
imageCollection = images.value,
showGalleryOverlay = showImageGallery,
pagerState = pagerState
)
Column(
@@ -178,8 +230,6 @@ fun MediaDetailView(
}
}
}
}
}
@Composable
private fun MiscTvDetails(mediaItem: MutableState<DetailedItem?>, service: TvService) {
@@ -616,6 +666,7 @@ private fun OverviewCard(
}
mediaItem.value?.let { mi ->
if (!mi.tagline.isNullOrEmpty() || keywordResponse.value?.keywords?.isNotEmpty() == true || !mi.overview.isNullOrEmpty()) {
ContentCard(
modifier = modifier
) {
@@ -670,6 +721,7 @@ private fun OverviewCard(
}
}
}
}
@Composable
private fun AdditionalDetailsCard(

View File

@@ -19,6 +19,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.google.accompanist.pager.ExperimentalPagerApi
import com.owenlejeune.tvtime.R
import com.owenlejeune.tvtime.api.tmdb.api.v3.PeopleService
import com.owenlejeune.tvtime.api.tmdb.api.v3.model.DetailPerson
@@ -33,7 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPagerApi::class)
@Composable
fun PersonDetailView(
appNavController: NavController,