swipe on list item cards

This commit is contained in:
Owen LeJeune
2023-03-23 19:00:21 -04:00
parent a9fb0cc9ac
commit 077e51f3cd
4 changed files with 404 additions and 136 deletions

View File

@@ -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"

View File

@@ -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<Float> = 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
// )
}

View File

@@ -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

View File

@@ -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)