From 077e51f3cd37da7aa63d8c81098884394e0a8645 Mon Sep 17 00:00:00 2001 From: Owen LeJeune Date: Thu, 23 Mar 2023 19:00:21 -0400 Subject: [PATCH] swipe on list item cards --- app/build.gradle | 2 + .../owenlejeune/tvtime/ui/components/Cards.kt | 307 +++++++++++++++++- .../tvtime/ui/screens/main/ListDetailView.kt | 229 +++++++------ .../com/owenlejeune/tvtime/ui/theme/Color.kt | 2 + 4 files changed, 404 insertions(+), 136 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index cae82ca..0f32466 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -133,6 +133,8 @@ dependencies { def markdown = "0.2.1" implementation "org.jetbrains:markdown:$markdown" + implementation 'de.charlex.compose:revealswipe:1.0.0' + // testing def junit = "4.13.2" def androidx_junit = "1.1.3" diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Cards.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Cards.kt index 566a74a..7b5bcbe 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/components/Cards.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/components/Cards.kt @@ -1,25 +1,43 @@ package com.owenlejeune.tvtime.ui.components +import android.annotation.SuppressLint import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.tween +import androidx.compose.animation.core.* +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.magnifier import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FractionalThreshold +import androidx.compose.material.ThresholdConfig +import androidx.compose.material.rememberSwipeableState +import androidx.compose.material.swipeable +import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.owenlejeune.tvtime.R +import kotlinx.coroutines.launch +import kotlin.math.roundToInt +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ContentCard( modifier: Modifier = Modifier, @@ -31,10 +49,10 @@ fun ContentCard( Card( modifier = modifier .fillMaxWidth() - .wrapContentHeight(), + .wrapContentHeight() + .background(color = backgroundColor), shape = RoundedCornerShape(10.dp), - backgroundColor = backgroundColor, - elevation = 8.dp + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) ) { Column(modifier = Modifier.fillMaxSize()) { title?.let { @@ -50,6 +68,7 @@ fun ContentCard( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ExpandableContentCard( modifier: Modifier = Modifier, @@ -66,6 +85,7 @@ fun ExpandableContentCard( modifier = modifier .fillMaxWidth() .wrapContentHeight() + .background(color = backgroundColor) .animateContentSize( animationSpec = tween( durationMillis = 300, @@ -73,8 +93,7 @@ fun ExpandableContentCard( ) ), shape = RoundedCornerShape(10.dp), - backgroundColor = backgroundColor, - elevation = 8.dp + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) ) { Column(modifier = Modifier.fillMaxSize()) { title() @@ -95,6 +114,7 @@ fun ExpandableContentCard( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun LazyListContentCard( modifier: Modifier = Modifier, @@ -104,10 +124,10 @@ fun LazyListContentCard( content: LazyListScope.() -> Unit ) { Card( - modifier = modifier, + modifier = modifier + .background(color = backgroundColor), shape = RoundedCornerShape(10.dp), - backgroundColor = backgroundColor, - elevation = 8.dp + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) ) { Column( modifier = Modifier @@ -129,6 +149,7 @@ fun LazyListContentCard( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ListContentCard( modifier: Modifier = Modifier, @@ -138,10 +159,10 @@ fun ListContentCard( content: @Composable ColumnScope.() -> Unit ) { Card( - modifier = modifier, + modifier = modifier + .background(color = backgroundColor), shape = RoundedCornerShape(10.dp), - backgroundColor = backgroundColor, - elevation = 8.dp + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp) ) { Column( modifier = Modifier @@ -202,4 +223,258 @@ fun TwoLineImageTextCard( ) } } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun SwipeableActionCard( + modifier: Modifier = Modifier, + leftSwipeCard: (@Composable () -> Unit)? = null, + rightSwipeCard: (@Composable () -> Unit)? = null, + leftSwiped: () -> Unit = {}, + rightSwiped: () -> Unit = {}, + animationSpec: AnimationSpec = tween(250), + velocityThreshold: Dp = 125.dp, +// shape: Shape = RectangleShape, + mainContent: @Composable () -> Unit +) { + val coroutineScope = rememberCoroutineScope() + + val thresholds = { _: SwipeCardState, _: SwipeCardState -> + FractionalThreshold(0.6f) + } + + Box( + modifier = modifier//.clip(shape) + ) { + val swipeableState = rememberSwipeableState( + initialValue = SwipeCardState.DEFAULT, + animationSpec = animationSpec + ) + + val swipeLeftCardVisible = remember { mutableStateOf(false) } + val swipeEnabled = remember { mutableStateOf(true) } + val maxWidthInPx = with(LocalDensity.current) { + LocalConfiguration.current.screenWidthDp.dp.toPx() + } + + val anchors = hashMapOf(0f to SwipeCardState.DEFAULT) + leftSwipeCard?.let { anchors[-maxWidthInPx] = SwipeCardState.LEFT } + rightSwipeCard?.let { anchors[maxWidthInPx] = SwipeCardState.RIGHT } + + Surface( + color = Color.Transparent, + content = if (swipeLeftCardVisible.value) { + leftSwipeCard + } else { + rightSwipeCard + } ?: {} + ) + + Surface( + color = Color.Transparent, + modifier = Modifier + .fillMaxWidth() + .offset { + var offset = swipeableState.offset.value.roundToInt() + if (offset < 0 && leftSwipeCard == null) offset = 0 + if (offset > 0 && rightSwipeCard == null) offset = 0 + IntOffset(offset, 0) + } + .swipeable( + state = swipeableState, + anchors = anchors, + orientation = Orientation.Horizontal, + enabled = swipeEnabled.value, + thresholds = thresholds, + velocityThreshold = velocityThreshold + ) + ) { + val resetStateToDefault = { + coroutineScope.launch { + swipeEnabled.value = false + swipeableState.animateTo(SwipeCardState.DEFAULT) + swipeEnabled.value = true + } + } + when { + swipeableState.currentValue == SwipeCardState.LEFT && !swipeableState.isAnimationRunning -> { + leftSwiped() + LaunchedEffect(Unit) { + resetStateToDefault() + } + } + swipeableState.currentValue == SwipeCardState.RIGHT && !swipeableState.isAnimationRunning -> { + rightSwiped() + LaunchedEffect(Unit) { + resetStateToDefault() + } + } + } + + swipeLeftCardVisible.value = swipeableState.offset.value <= 0 + + mainContent() + } + } +} + +@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@Composable +fun DraggableCard( + modifier: Modifier = Modifier, + cardOffset: Float, + isRevealed: Boolean, + cardElevation: CardElevation = CardDefaults.cardElevation(defaultElevation = 10.dp), + backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant, + shape: Shape = RectangleShape, + onExpand: () -> Unit = {}, + onCollapse: () -> Unit = {}, + backgroundContent: @Composable () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + val coroutineScope = rememberCoroutineScope() + + val animationDuration = 500 + val velicityThreshold = 125.dp + + val thresholds: (from: SwipeCardState, to: SwipeCardState) -> ThresholdConfig = { _, _ -> + FractionalThreshold(0.6f) + } + + val swipeableState = rememberSwipeableState( + initialValue = SwipeCardState.DEFAULT, + animationSpec = tween(animationDuration) + ) + + val maxWidthPx = with(LocalDensity.current) { + LocalConfiguration.current.screenWidthDp.dp.toPx() + } + + val anchors = hashMapOf(0f to SwipeCardState.DEFAULT) + anchors[-maxWidthPx] = SwipeCardState.LEFT + + val swipeEnabled = remember { mutableStateOf(true) } + + Box ( + modifier = Modifier.clip(shape = shape) + ) { + backgroundContent() + + Card( + shape = shape, + elevation = cardElevation, + modifier = modifier + .background(color = backgroundColor) + .offset { + var offset = swipeableState.offset.value.roundToInt() + if (offset < 0) offset = 0 + IntOffset(offset, 0) + } + .swipeable( + state = swipeableState, + anchors = anchors, + orientation = Orientation.Horizontal, + enabled = swipeEnabled.value, + thresholds = thresholds, + velocityThreshold = velicityThreshold + ) + ) { + if (swipeableState.currentValue == SwipeCardState.LEFT && !swipeableState.isAnimationRunning) { + onExpand() + LaunchedEffect(key1 = Unit) { + coroutineScope.launch { + swipeEnabled.value = false + swipeableState.animateTo(SwipeCardState.DEFAULT) + swipeEnabled.value = true + } + } + } + content() + } +// DraggableCardInternal( +// modifier = modifier, +// cardOffset = cardOffset, +// isRevealed = isRevealed, +// cardElevation = cardElevation, +// backgroundColor = backgroundColor, +// shape = shape, +// onExpand = onExpand, +// onCollapse = onCollapse, +// content = content +// ) + } +} + +private enum class SwipeCardState { + DEFAULT, + LEFT, + RIGHT +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) +@SuppressLint("UnusedTransitionTargetStateParameter") +@Composable +fun DraggableCardInternal( + modifier: Modifier = Modifier, + cardOffset: Float, + isRevealed: Boolean, + cardElevation: CardElevation = CardDefaults.cardElevation(defaultElevation = 10.dp), + backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant, + shape: Shape = RectangleShape, + onExpand: () -> Unit = {}, + onCollapse: () -> Unit = {}, + content: @Composable ColumnScope.() -> Unit +) { + val animationDuration = 500 + + val swipeableState = rememberSwipeableState( + initialValue = SwipeCardState.DEFAULT, + animationSpec = tween(animationDuration) + ) + + val maxWidthPx = with(LocalDensity.current) { + LocalConfiguration.current.screenWidthDp.dp.toPx() + } + + val anchors = hashMapOf(0f to SwipeCardState.DEFAULT) + anchors[-maxWidthPx] = SwipeCardState.LEFT + + Card( + modifier = modifier + .background(color = backgroundColor), + shape = shape, + elevation = cardElevation + ){} +// val animationDuration = 500 +// val minDragAmount = 5 +// +// val transitionState = remember { +// MutableTransitionState(isRevealed).apply { +// targetState = !isRevealed +// } +// } +// val transition = updateTransition(targetState = transitionState, "cardTransition") +// val offsetTransition by transition.animateFloat( +// label = "cardOffsetTransition", +// transitionSpec = { tween(durationMillis = animationDuration) }, +// targetValueByState = { if (isRevealed) cardOffset else 0f }, +// ) +// +// Card( +// modifier = modifier +// .background(color = backgroundColor) +// .offset { IntOffset(offsetTransition.roundToInt(), 0) } +// .pointerInput(Unit) { +// detectHorizontalDragGestures { _, dragAmount -> +// when { +// dragAmount >= minDragAmount -> onExpand() +// dragAmount < -minDragAmount -> onCollapse() +// } +// } +// }, +// shape = shape, +// content = content +// ) + } \ No newline at end of file diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/main/ListDetailView.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/main/ListDetailView.kt index be5a394..94f0368 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/screens/main/ListDetailView.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/screens/main/ListDetailView.kt @@ -1,5 +1,4 @@ package com.owenlejeune.tvtime.ui.screens.main - import android.content.Context import android.content.Intent import android.widget.Toast @@ -8,14 +7,12 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur @@ -44,12 +41,11 @@ import com.owenlejeune.tvtime.extensions.WindowSizeClass import com.owenlejeune.tvtime.extensions.unlessEmpty import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.ui.navigation.MainNavItem -import com.owenlejeune.tvtime.ui.theme.FavoriteSelected -import com.owenlejeune.tvtime.ui.theme.RatingSelected -import com.owenlejeune.tvtime.ui.theme.WatchlistSelected -import com.owenlejeune.tvtime.ui.theme.actionButtonColor +import com.owenlejeune.tvtime.ui.theme.* import com.owenlejeune.tvtime.utils.SessionManager import com.owenlejeune.tvtime.utils.TmdbUtils +import de.charlex.compose.RevealDirection +import de.charlex.compose.RevealSwipe import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -252,136 +248,129 @@ private fun RowScope.OverviewStatCard( } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable private fun ListItemView( appNavController: NavController, listItem: ListItem ) { - Card( - shape = RoundedCornerShape(10.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 10.dp), - modifier = Modifier - .background(color = MaterialTheme.colorScheme.background) - .fillMaxWidth() - .clickable( - onClick = { - appNavController.navigate( - "${MainNavItem.DetailView.route}/${listItem.mediaType}/${listItem.id}" - ) - } - ) - ) { - Box( - modifier = Modifier.height(112.dp) - ) { - listItem.backdropPath?.let { - AsyncImage( - model = TmdbUtils.getFullBackdropPath(listItem.backdropPath), - contentDescription = null, - contentScale = ContentScale.FillWidth, - modifier = Modifier - .blur(radius = 10.dp) - .fillMaxWidth() - .wrapContentHeight() - ) + val context = LocalContext.current - Box(modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.4f)) - .blur(radius = 10.dp) + RevealSwipe ( + directions = setOf(RevealDirection.EndToStart), + hiddenContentEnd = { + IconButton( + modifier = Modifier.padding(horizontal = 15.dp), + onClick = { Toast.makeText(context, "Remove from list", Toast.LENGTH_SHORT).show() } + ) { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = stringResource(id = R.string.remove_from_list_cd), + tint = Color.White ) } - - ConstraintLayout( - modifier = Modifier - .padding(8.dp) - .fillMaxSize() - ) { - val (poster, content, ratingView) = createRefs() - - AsyncImage( - modifier = Modifier - .constrainAs(poster) { - start.linkTo(parent.start) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - height = Dimension.fillToConstraints - } - .aspectRatio(0.7f) - .clip(RoundedCornerShape(10.dp)), - model = TmdbUtils.getFullPosterPath(listItem.posterPath) ?: R.drawable.placeholder_transparent, - contentDescription = listItem.title - ) - - Column( - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier - .constrainAs(content) { - end.linkTo(ratingView.start, margin = 12.dp) - start.linkTo(poster.end, margin = 12.dp) - width = Dimension.fillToConstraints - height = Dimension.matchParent - } - ) { - Spacer(modifier = Modifier.weight(1f)) - val textColor = if (listItem.backdropPath != null || isSystemInDarkTheme()) { - Color.White - } else { - Color.Black + }, + backgroundCardEndColor = SwipeRemoveBackground + ) { + Card( + shape = RoundedCornerShape(10.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 10.dp), + modifier = Modifier + .background(color = MaterialTheme.colorScheme.background) + .fillMaxWidth() + .clickable( + onClick = { + appNavController.navigate( + "${MainNavItem.DetailView.route}/${listItem.mediaType}/${listItem.id}" + ) } - Text( - modifier = Modifier.padding(bottom = 8.dp), - text = listItem.title, - color = textColor, - fontSize = 18.sp, - fontWeight = FontWeight.Bold + ) + ) { + Box( + modifier = Modifier.height(112.dp) + ) { + listItem.backdropPath?.let { + AsyncImage( + model = TmdbUtils.getFullBackdropPath(listItem.backdropPath), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .blur(radius = 10.dp) + .fillMaxWidth() + .wrapContentHeight() + ) + + Box(modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.4f)) + .blur(radius = 10.dp) ) - ActionButtonRow(listItem) - Spacer(modifier = Modifier.weight(1f)) } - - val rating = SessionManager.currentSession?.getRatingForId(listItem.id, listItem.mediaType) ?: 0f - RatingView( - progress = rating / 10f, + ConstraintLayout( modifier = Modifier - .constrainAs(ratingView) { - end.linkTo(parent.end) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) + .padding(8.dp) + .fillMaxSize() + ) { + val (poster, content, ratingView) = createRefs() + + AsyncImage( + modifier = Modifier + .constrainAs(poster) { + start.linkTo(parent.start) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + height = Dimension.fillToConstraints + } + .aspectRatio(0.7f) + .clip(RoundedCornerShape(10.dp)), + model = TmdbUtils.getFullPosterPath(listItem.posterPath) ?: R.drawable.placeholder_transparent, + contentDescription = listItem.title + ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier + .constrainAs(content) { + end.linkTo(ratingView.start, margin = 12.dp) + start.linkTo(poster.end, margin = 12.dp) + width = Dimension.fillToConstraints + height = Dimension.matchParent + } + ) { + Spacer(modifier = Modifier.weight(1f)) + val textColor = if (listItem.backdropPath != null || isSystemInDarkTheme()) { + Color.White + } else { + Color.Black } - ) + Text( + modifier = Modifier.padding(bottom = 8.dp), + text = listItem.title, + color = textColor, + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ) + ActionButtonRow(listItem) + Spacer(modifier = Modifier.weight(1f)) + } + + + RatingView( + progress = listItem.voteAverage / 10f, + modifier = Modifier + .constrainAs(ratingView) { + end.linkTo(parent.end) + top.linkTo(parent.top) + bottom.linkTo(parent.bottom) + } + ) + } } } } } -@Composable -private fun DeleteButton( - modifier: Modifier -) { - Box( - modifier = modifier - .clip(CircleShape) - .size(48.dp) - .background(color = MaterialTheme.colorScheme.actionButtonColor) - .clickable( - onClick = { - } - ) - ) { - Icon( - modifier = Modifier - .clip(CircleShape) - .align(Alignment.Center), - imageVector = Icons.Filled.Delete, - contentDescription = stringResource(id = R.string.remove_from_list_cd), - tint = Color.Red - ) - } -} - @Composable private fun ActionButtonRow(listItem: ListItem) { val session = SessionManager.currentSession diff --git a/app/src/main/java/com/owenlejeune/tvtime/ui/theme/Color.kt b/app/src/main/java/com/owenlejeune/tvtime/ui/theme/Color.kt index 8bdedf8..ec893da 100644 --- a/app/src/main/java/com/owenlejeune/tvtime/ui/theme/Color.kt +++ b/app/src/main/java/com/owenlejeune/tvtime/ui/theme/Color.kt @@ -55,5 +55,7 @@ val WatchlistSelected = Color(0xFFBF3F39) val RatingSelected = Color(0xFFE7C343) val FavoriteSelected = Color(0xFFDD57B2) +val SwipeRemoveBackground = WatchlistSelected + val LightActionButton = Color(0xFF48C9B0) val DarkActionButton = Color(0xFF17A589) \ No newline at end of file