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" def markdown = "0.2.1"
implementation "org.jetbrains:markdown:$markdown" implementation "org.jetbrains:markdown:$markdown"
implementation 'de.charlex.compose:revealswipe:1.0.0'
// testing // testing
def junit = "4.13.2" def junit = "4.13.2"
def androidx_junit = "1.1.3" def androidx_junit = "1.1.3"

View File

@@ -1,25 +1,43 @@
package com.owenlejeune.tvtime.ui.components package com.owenlejeune.tvtime.ui.components
import android.annotation.SuppressLint
import androidx.compose.animation.animateContentSize import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.*
import androidx.compose.animation.core.tween import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.magnifier
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material.FractionalThreshold
import androidx.compose.material3.Text 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.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.owenlejeune.tvtime.R import com.owenlejeune.tvtime.R
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ContentCard( fun ContentCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -31,10 +49,10 @@ fun ContentCard(
Card( Card(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight(), .wrapContentHeight()
.background(color = backgroundColor),
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
backgroundColor = backgroundColor, elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
elevation = 8.dp
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
title?.let { title?.let {
@@ -50,6 +68,7 @@ fun ContentCard(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ExpandableContentCard( fun ExpandableContentCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -66,6 +85,7 @@ fun ExpandableContentCard(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentHeight() .wrapContentHeight()
.background(color = backgroundColor)
.animateContentSize( .animateContentSize(
animationSpec = tween( animationSpec = tween(
durationMillis = 300, durationMillis = 300,
@@ -73,8 +93,7 @@ fun ExpandableContentCard(
) )
), ),
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
backgroundColor = backgroundColor, elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
elevation = 8.dp
) { ) {
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
title() title()
@@ -95,6 +114,7 @@ fun ExpandableContentCard(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LazyListContentCard( fun LazyListContentCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -104,10 +124,10 @@ fun LazyListContentCard(
content: LazyListScope.() -> Unit content: LazyListScope.() -> Unit
) { ) {
Card( Card(
modifier = modifier, modifier = modifier
.background(color = backgroundColor),
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
backgroundColor = backgroundColor, elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
elevation = 8.dp
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -129,6 +149,7 @@ fun LazyListContentCard(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ListContentCard( fun ListContentCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -138,10 +159,10 @@ fun ListContentCard(
content: @Composable ColumnScope.() -> Unit content: @Composable ColumnScope.() -> Unit
) { ) {
Card( Card(
modifier = modifier, modifier = modifier
.background(color = backgroundColor),
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
backgroundColor = backgroundColor, elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
elevation = 8.dp
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -203,3 +224,257 @@ 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 package com.owenlejeune.tvtime.ui.screens.main
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.widget.Toast import android.widget.Toast
@@ -8,14 +7,12 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.blur 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.extensions.unlessEmpty
import com.owenlejeune.tvtime.preferences.AppPreferences import com.owenlejeune.tvtime.preferences.AppPreferences
import com.owenlejeune.tvtime.ui.navigation.MainNavItem import com.owenlejeune.tvtime.ui.navigation.MainNavItem
import com.owenlejeune.tvtime.ui.theme.FavoriteSelected import com.owenlejeune.tvtime.ui.theme.*
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.utils.SessionManager import com.owenlejeune.tvtime.utils.SessionManager
import com.owenlejeune.tvtime.utils.TmdbUtils import com.owenlejeune.tvtime.utils.TmdbUtils
import de.charlex.compose.RevealDirection
import de.charlex.compose.RevealSwipe
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -252,12 +248,30 @@ private fun RowScope.OverviewStatCard(
} }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable @Composable
private fun ListItemView( private fun ListItemView(
appNavController: NavController, appNavController: NavController,
listItem: ListItem listItem: ListItem
) { ) {
val context = LocalContext.current
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
)
}
},
backgroundCardEndColor = SwipeRemoveBackground
) {
Card( Card(
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 10.dp), elevation = CardDefaults.cardElevation(defaultElevation = 10.dp),
@@ -342,9 +356,8 @@ private fun ListItemView(
} }
val rating = SessionManager.currentSession?.getRatingForId(listItem.id, listItem.mediaType) ?: 0f
RatingView( RatingView(
progress = rating / 10f, progress = listItem.voteAverage / 10f,
modifier = Modifier modifier = Modifier
.constrainAs(ratingView) { .constrainAs(ratingView) {
end.linkTo(parent.end) end.linkTo(parent.end)
@@ -355,30 +368,6 @@ private fun ListItemView(
} }
} }
} }
}
@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
)
} }
} }

View File

@@ -55,5 +55,7 @@ val WatchlistSelected = Color(0xFFBF3F39)
val RatingSelected = Color(0xFFE7C343) val RatingSelected = Color(0xFFE7C343)
val FavoriteSelected = Color(0xFFDD57B2) val FavoriteSelected = Color(0xFFDD57B2)
val SwipeRemoveBackground = WatchlistSelected
val LightActionButton = Color(0xFF48C9B0) val LightActionButton = Color(0xFF48C9B0)
val DarkActionButton = Color(0xFF17A589) val DarkActionButton = Color(0xFF17A589)