I am trying to achieve something like this but with Jetpack Compose. In other words swipe to delete like we could do in RecyclerView with ItemTouchHelper and class DiffCallBack : DiffUtil.ItemCallback<RvModel>() where we could see enter - exit animations and then the list moving gracefully up or down where the item has been inserted or removed.
This is what I have tried:
LazyColumn(state = listState) {
items(products, {listItem:InventoryEntity -> listItem.inventoryId}) { item ->
var unread by remember { mutableStateOf(false) }
val dismissState = rememberDismissState(
confirmStateChange = {
if (it == DismissValue.DismissedToEnd) unread = !unread
it != DismissValue.DismissedToEnd
}
)
val isDismissed = dismissState.isDismissed(DismissDirection.EndToStart)
if (dismissState.isDismissed(DismissDirection.EndToStart)){
LaunchedEffect(Unit) {
delay(300)
viewModel.deleteProduct(item.inventoryId)
}
}
var itemAppeared by remember { mutableStateOf(!columnAppeared) }
LaunchedEffect(Unit) {
itemAppeared = true
}
AnimatedVisibility(
visible = itemAppeared && !isDismissed,
exit = shrinkVertically(
animationSpec = tween(
durationMillis = 300,
)
),
enter = expandVertically(
animationSpec = tween(
durationMillis = 300
)
)
) {
SwipeToDismiss(
state = dismissState,
modifier = Modifier.padding(vertical = 4.dp),
directions = setOf(
DismissDirection.StartToEnd,
DismissDirection.EndToStart
),
dismissThresholds = { direction ->
FractionalThreshold(if (direction == DismissDirection.StartToEnd) 0.25f else 0.5f)
},
background = {
val direction =
dismissState.dismissDirection ?: return#SwipeToDismiss
val color by animateColorAsState(
when (dismissState.targetValue) {
DismissValue.Default -> Color.LightGray
DismissValue.DismissedToEnd -> Color.Green
DismissValue.DismissedToStart -> Color.Red
}
)
val alignment = when (direction) {
DismissDirection.StartToEnd -> Alignment.CenterStart
DismissDirection.EndToStart -> Alignment.CenterEnd
}
val icon = when (direction) {
DismissDirection.StartToEnd -> Icons.Default.Done
DismissDirection.EndToStart -> Icons.Default.Delete
}
val scale by animateFloatAsState(
if (dismissState.targetValue == DismissValue.Default) 0.75f else 1f
)
Box(
Modifier
.fillMaxSize()
.background(color)
.padding(horizontal = 20.dp),
contentAlignment = alignment
) {
Icon(
icon,
contentDescription = "Localized description",
modifier = Modifier.scale(scale)
)
}
},
dismissContent = {
Card(
elevation = animateDpAsState(
if (dismissState.dismissDirection != null) 4.dp else 0.dp
).value
) {
ProductRow(product = item, number = item.inventoryId)
}
}
)
}
}
}
Even though it works. Scrolling is not smooth, and when I scroll up it jumps to the top. What is the right way to implement this function?
Recently google anounced compose Version 1.1.0-beta03. Now we have a new way on how we can animate items. They have introduced a new modifier: Modifier.animateItemPlacement(). You may find latest compose version in this link.
I will try to post an example with minimum code so that you can reproduce it and see how you could achieve a SwipeToDismiss inside LazyColumn with animation.
Data Class To store information:
data class DataSet(
val itemId: Int,
val itemName: String,
val itemQty: String
)
Comparator to compare list items:
private val ListComparator = Comparator<DataSet> { left, right ->
left.itemId.compareTo(right.itemId)
}
The row of each of our items:
#Composable
fun ItemRow(
modifier: Modifier = Modifier,
product: DataSet,
number: Int
) {
Card(
shape = RoundedCornerShape(4.dp),
modifier = modifier
.padding(8.dp)
.fillMaxWidth(),
backgroundColor = Color.LightGray
) {
Row(modifier = modifier) {
Text(
text = "$number.", modifier = Modifier
.weight(2f)
.padding(start = 8.dp, end = 4.dp)
)
Text(
text = product.itemName, modifier = Modifier
.weight(10f)
.padding(end = 4.dp)
)
Text(
text = product.itemQty, modifier = Modifier
.weight(2f)
.padding(end = 4.dp)
)
}
}
}
Putting all together to our composable:
#ExperimentalMaterialApi
#ExperimentalFoundationApi
#Composable
fun helloWorld() {
var list by remember { mutableStateOf(listOf<DataSet>()) }
val comparator by remember { mutableStateOf(ListComparator) }
LazyColumn {
item {
Button(onClick = {
list = list + listOf(DataSet((0..1111).random(), "A random item", "100"))
}) {
Text("Add an item to the list")
}
}
val sortedList = list.sortedWith(comparator)
items(sortedList, key = { it.itemId }) { item ->
val dismissState = rememberDismissState()
if (dismissState.isDismissed(DismissDirection.EndToStart)) {
list = list.toMutableList().also { it.remove(item) } // remove
}
SwipeToDismiss(
state = dismissState,
modifier = Modifier
.padding(vertical = 1.dp)
.animateItemPlacement(),
directions = setOf(DismissDirection.StartToEnd, DismissDirection.EndToStart),
dismissThresholds = { direction ->
FractionalThreshold(if (direction == DismissDirection.StartToEnd) 0.25f else 0.5f)
},
background = {
val direction = dismissState.dismissDirection ?: return#SwipeToDismiss
val color by animateColorAsState(
when (dismissState.targetValue) {
DismissValue.Default -> Color.LightGray
DismissValue.DismissedToEnd -> Color.Green
DismissValue.DismissedToStart -> Color.Red
}
)
val alignment = when (direction) {
DismissDirection.StartToEnd -> Alignment.CenterStart
DismissDirection.EndToStart -> Alignment.CenterEnd
}
val icon = when (direction) {
DismissDirection.StartToEnd -> Icons.Default.Done
DismissDirection.EndToStart -> Icons.Default.Delete
}
val scale by animateFloatAsState(
if (dismissState.targetValue == DismissValue.Default) 0.75f else 1f
)
Box(
Modifier
.fillMaxSize()
.background(color)
.padding(horizontal = 20.dp),
contentAlignment = alignment
) {
Icon(
icon,
contentDescription = "Localized description",
modifier = Modifier.scale(scale)
)
}
},
dismissContent = {
Card(
elevation = animateDpAsState(
if (dismissState.dismissDirection != null) 4.dp else 0.dp
).value
) {
ItemRow(
product = item,
number = item.itemId
)
}
}
)
}
}
}
For reference and see also other ways on how you could use Modifier.animateItemPlacement() you may check this example posted from Google.
Related
I saw many examples about SwipeToDismiss working on LazyColumn but I did not see one implementing it on LazyRow. Here is my current implementation -
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun DashboardDataState(dashboardCardModels: List<DashboardCardModel>) {
Column(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = listOf(
backgroundStartColor,
backgroundEndColor
)
)
),
verticalArrangement = Arrangement.Center
) {
LazyRow(modifier = Modifier.wrapContentSize()) {
items(dashboardCardModels) { model ->
val dismissState = rememberDismissState()
when {
dismissState.isDismissed(DismissDirection.EndToStart) ->{
}
dismissState.isDismissed(DismissDirection.StartToEnd) ->{
}
}
SwipeToDismiss(
state = rememberDismissState(),
background = {
val color by animateColorAsState(
when (dismissState.targetValue) {
DismissValue.Default -> Color.White
DismissValue.DismissedToEnd -> Color.Blue
DismissValue.DismissedToStart -> Color.Red
}
)
val alignment = Alignment.CenterEnd
val icon = Icons.Default.Delete
val scale by animateFloatAsState(
if (dismissState.targetValue == DismissValue.Default) 0.75f else 1f
)
Box(
Modifier
.fillMaxSize()
.background(color)
.padding(horizontal = Dp(20f)),
contentAlignment = alignment
) {
Icon(
icon,
contentDescription = "Delete Icon",
modifier = Modifier.scale(scale)
)
}
},
dismissContent = {
DashboardCard(model = model)
}
)
}
}
}
}
What happens is that I am able to swipe the LazyRow horizontally, but obviously I want to swipe it vertically. How can I achieve such behavior?
Going deeper into the SwipeToDismiss code and learning it's behavior, I made some simple modifications, changing the height variable to width, the swipe orientation to Orientation.Vertical and the offset of the dismissContent variable to go on the y axis instead of the x axis. I named my modified composable SwipeToDismissVertical and it works perfectly 🤩
#Composable
#ExperimentalMaterialApi
fun SwipeToDismissVertical(
state: DismissState,
modifier: Modifier = Modifier,
directions: Set<DismissDirection> = setOf(DismissDirection.EndToStart, DismissDirection.StartToEnd),
dismissThresholds: (DismissDirection) -> ThresholdConfig = {
FixedThreshold(DISMISS_THRESHOLD)
},
background: #Composable RowScope.() -> Unit,
dismissContent: #Composable RowScope.() -> Unit
) = BoxWithConstraints(modifier) {
val width = constraints.maxWidth.toFloat()
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
val anchors = mutableMapOf(0f to DismissValue.Default)
if (DismissDirection.StartToEnd in directions) anchors += width to DismissValue.DismissedToEnd
if (DismissDirection.EndToStart in directions) anchors += -width to DismissValue.DismissedToStart
val thresholds = { from: DismissValue, to: DismissValue ->
dismissThresholds(getDismissDirection(from, to)!!)
}
val minFactor =
if (DismissDirection.EndToStart in directions) SwipeableDefaults.StandardResistanceFactor else SwipeableDefaults.StiffResistanceFactor
val maxFactor =
if (DismissDirection.StartToEnd in directions) SwipeableDefaults.StandardResistanceFactor else SwipeableDefaults.StiffResistanceFactor
Box(
Modifier.swipeable(
state = state,
anchors = anchors,
thresholds = thresholds,
orientation = Orientation.Vertical,
enabled = state.currentValue == DismissValue.Default,
reverseDirection = isRtl,
resistance = ResistanceConfig(
basis = width,
factorAtMin = minFactor,
factorAtMax = maxFactor
)
)
) {
Row(
content = background,
modifier = Modifier.matchParentSize()
)
Row(
content = dismissContent,
modifier = Modifier.offset { IntOffset(0, state.offset.value.roundToInt(), ) }
)
}
}
private val DISMISS_THRESHOLD = 56.dp
private fun getDismissDirection(from: DismissValue, to: DismissValue): DismissDirection? {
return when {
// settled at the default state
from == to && from == DismissValue.Default -> null
// has been dismissed to the end
from == to && from == DismissValue.DismissedToEnd -> DismissDirection.StartToEnd
// has been dismissed to the start
from == to && from == DismissValue.DismissedToStart -> DismissDirection.EndToStart
// is currently being dismissed to the end
from == DismissValue.Default && to == DismissValue.DismissedToEnd -> DismissDirection.StartToEnd
// is currently being dismissed to the start
from == DismissValue.Default && to == DismissValue.DismissedToStart -> DismissDirection.EndToStart
// has been dismissed to the end but is now animated back to default
from == DismissValue.DismissedToEnd && to == DismissValue.Default -> DismissDirection.StartToEnd
// has been dismissed to the start but is now animated back to default
from == DismissValue.DismissedToStart && to == DismissValue.Default -> DismissDirection.EndToStart
else -> null
}
}
The usage is exactly the same as before, but now I use my new composable instead of the material one -
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun DashboardDataState(dashboardCardModels: List<DashboardCardModel>) {
Column(
modifier = Modifier
.fillMaxSize()
.background(
brush = Brush.verticalGradient(
colors = listOf(
backgroundStartColor,
backgroundEndColor
)
)
),
verticalArrangement = Arrangement.Center
) {
LazyRow(modifier = Modifier.wrapContentSize()) {
items(dashboardCardModels) { model ->
val dismissState = rememberDismissState()
when {
dismissState.isDismissed(DismissDirection.EndToStart) ->{
}
dismissState.isDismissed(DismissDirection.StartToEnd) ->{
}
}
SwipeToDismissVertical( //Here is the new one
state = dismissState,
background = {
val color by animateColorAsState(
when (dismissState.targetValue) {
DismissValue.Default -> Color.Transparent
DismissValue.DismissedToEnd -> Color.Blue
DismissValue.DismissedToStart -> Color.Red
}
)
val alignment = Alignment.CenterEnd
val icon = Icons.Default.Delete
val scale by animateFloatAsState(
if (dismissState.targetValue == DismissValue.Default) 0.75f else 1f
)
Box(
Modifier
.fillMaxSize()
.background(color)
.padding(horizontal = Dp(20f)),
contentAlignment = alignment
) {
Icon(
icon,
contentDescription = "Delete Icon",
modifier = Modifier.scale(scale)
)
}
},
dismissContent = {
DashboardCard(model = model)
}
)
}
}
}
}
I have a string list of texts, when I click one of them I should color it in one color, currently my implementation colors all of the texts, what I'm doing wrong ?
var isPressed by remember { mutableStateOf(false) }
val buttonColor: Color by animateColorAsState(
targetValue = when (isPressed) {
true -> FreshGreen
false -> PastelPeach
},
animationSpec = tween()
)
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(25.dp)
) {
items(filterList) { filterName ->
Text(
text = filterName,
modifier = Modifier
.background(shape = RoundedCornerShape(24.dp), color = buttonColor)
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
isPressed = !isPressed
onFilterClick(filterName)
}
)
}
}
You are using the same state (isPressed) for all the items.
As alternative to z.y's answer you can just move the isPressed declaration inside the items block:
LazyRow(
horizontalArrangement = Arrangement.spacedBy(25.dp)
) {
items(itemsList) { filterName->
var isPressed by remember { mutableStateOf(false) }
val buttonColor: Color by animateColorAsState(
targetValue = when (isPressed) {
true -> Color.Green
false -> Color.Red
},
animationSpec = tween()
)
Text(
//your code
)
}
}
For those who wants only to keep selected only one item at the time, here is the way I went for
#Composable
fun BrandCategoryFilterSection(
modifier: Modifier,
uiState: BrandFilterUiState,
onBrandCategoryClick: (String) -> Unit
) {
var selectedIndex by remember { mutableStateOf(-1) }
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(25.dp)
) {
itemsIndexed(uiState.categoryList) { index, categoryName ->
CategoryText(
categoryName = categoryName,
isSelected = index == selectedIndex,
onBrandCategoryClick = {
selectedIndex = index
onBrandCategoryClick(it)
}
)
}
}
}
#Composable
private fun CategoryText(categoryName: String, onBrandCategoryClick: (String) -> Unit, isSelected: Boolean) {
val buttonColor: Color by animateColorAsState(
targetValue = when (isSelected) {
true -> FreshGreen
false -> PastelPeach
},
animationSpec = tween()
)
Text(
text = categoryName,
modifier = Modifier
.background(shape = RoundedCornerShape(24.dp), color = buttonColor)
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
onBrandCategoryClick(categoryName)
}
)
}
I modified your code, where I lowered down the animation and the pressed state so the parent composable won't suffer from its own re-composition
#Composable
fun MyScreen(
modifier: Modifier = Modifier,
filterList: SnapshotStateList<String>
) {
LazyRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(25.dp)
) {
items(filterList) { filterName ->
FilterText(
filterName
)
}
}
}
#Composable
fun FilterText(
filter: String
) {
var isPressed by remember { mutableStateOf(false) }
val buttonColor: Color by animateColorAsState(
targetValue = when (isPressed) {
true -> Color.Blue
false -> Color.Green
},
animationSpec = tween()
)
Text(
text = filter,
modifier = Modifier
.background(shape = RoundedCornerShape(24.dp), color = buttonColor)
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable {
isPressed = !isPressed
}
)
}
I am trying to show a section within LazyColumn which has a list of Rows that are shown horizontally using LazyRow. I would like to have a button which displays show/hide so that I can show a minimal list in this section instead of full list. I would like to animate the expand/collapse part and currently expanding on button click is working as expected but when collapsing, the LazyCloumn scrolls up which seems to push this section out of screen (as shown in the video below). Is there any way we can collapse so that the button at least gets snapped to the top and the remaining section is removed? This way, user can still expand the list if required rather than scrolling up to find the button.
I have tried the following but none of them seem to work:
Using AnimatedVisibility
Using animate*AsState low level API's
Also tried to just remove the contents from list allowing LazyColumn to re-order based on the list content
val RandomColor
get() = Color(Random.nextInt(256), Random.nextInt(256), Random.nextInt(256))
typealias ClickHandler = (Boolean) -> Unit
#Composable
fun DemoLayout(demoDataList: List<DemoData>, isExpanded: Boolean, clickHandler: ClickHandler) {
LazyColumn {
demoDataList.forEachIndexed { index, it ->
when (it) {
is DemoData.Header -> item(key = "cell_$index") { HeaderComposable(header = it) }
is DemoData.BigCard -> item(key = "hero_$index") { BigCardComposable(bigCard = it) }
is DemoData.Card -> item(key = "banner_$index") { CardComposable(card = it) }
is DemoData.ExpandableSection -> {
items(count = 2, key = { indexInner: Int -> "categories_first_half_$index$indexInner" }) { index ->
Section(
sectionInfo = it.sectionInfo[index]
)
}
//Comment below and try another approach
item(key = "first_approach_$index") {
FirstApproach(
expandableSection = DemoData.ExpandableSection(
it.sectionInfo.subList(
3,
5
)
)
)
}
//Second approach
/*if (isExpanded)
items(count = 3, key = { indexInner -> "categories_second_half_$index$indexInner" }) { index ->
Section(
sectionInfo = it.sectionInfo[index + 2]
)
}
item(key = "button_$index") {
ShowHideButton(isExpanded, clickHandler)
}*/
}
}
}
}
}
#Composable
fun FirstApproach(expandableSection: DemoData.ExpandableSection) {
var expanded by remember { mutableStateOf(false) }
val density = LocalDensity.current
Column {
AnimatedVisibility(
visible = expanded,
enter = slideInVertically() +
expandVertically(
// Expand from the top.
expandFrom = Alignment.Top,
animationSpec = tween(durationMillis = 350, easing = FastOutLinearInEasing)
) + fadeIn(
// Fade in with the initial alpha of 0.3f.
initialAlpha = 0.3f
),
exit = slideOutVertically(
animationSpec = tween(durationMillis = 350, easing = FastOutLinearInEasing)
) + shrinkVertically(
shrinkTowards = Alignment.Bottom,
animationSpec = tween(durationMillis = 350, easing = FastOutLinearInEasing)
) + fadeOut(
animationSpec = tween(durationMillis = 350, easing = FastOutLinearInEasing),
targetAlpha = 0f
)
) {
Column {
for (i in 0 until expandableSection.sectionInfo.size) {
HeaderComposable(header = expandableSection.sectionInfo[i].header)
InfoCardsComposable(expandableSection.sectionInfo[i].infoCard)
DetailsCardComposable(expandableSection.sectionInfo[i].detailCard)
}
}
}
Button(
modifier = Modifier
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
.fillMaxWidth(),
onClick = {
expanded = !expanded
}) {
Text(text = if (expanded) "Hide" else "Show")
}
}
}
#Composable
fun HeaderComposable(header: DemoData.Header) {
Row(
modifier = Modifier
.padding(top = 16.dp)
.fillMaxWidth()
.height(64.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text = header.title, modifier = Modifier.padding(horizontal = 16.dp))
}
}
#Composable
fun CardComposable(card: DemoData.Card) {
Card(
modifier = Modifier
.padding(top = 16.dp)
.size(164.dp),
backgroundColor = RandomColor
) {
Text(text = card.cardText, modifier = Modifier.padding(horizontal = 16.dp))
}
}
#Composable
fun BigCardComposable(bigCard: DemoData.BigCard) {
Card(
modifier = Modifier
.padding(top = 16.dp)
.size(172.dp),
backgroundColor = RandomColor
) {
Text(text = bigCard.bigCardText, modifier = Modifier.padding(horizontal = 16.dp))
}
}
#Composable
fun Section(sectionInfo: SectionInfo) {
Column(
modifier = Modifier.animateContentSize()
) {
HeaderComposable(header = sectionInfo.header)
InfoCardsComposable(sectionInfo.infoCard)
DetailsCardComposable(sectionInfo.detailCard)
}
}
#Composable
private fun ShowHideButton(isExpanded: Boolean, clickHandler: ClickHandler) {
Button(
modifier = Modifier
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
.fillMaxWidth(),
onClick = {
clickHandler.invoke(
!isExpanded
)
}) {
Text(text = if (isExpanded) "Hide" else "Show")
}
}
#Composable
fun DetailsCardComposable(detailCardsList: List<DetailCard>) {
LazyRow(
modifier = Modifier.padding(top = 16.dp)
) {
items(detailCardsList) {
DetailCardComposable(detailCard = it)
}
}
}
#Composable
fun InfoCardsComposable(infoCardsList: List<InfoCard>) {
LazyRow(
modifier = Modifier.padding(top = 16.dp)
) {
items(infoCardsList) {
InfoCardComposable(infoCard = it)
}
}
}
#Composable
fun InfoCardComposable(infoCard: InfoCard) {
Card(
modifier = Modifier
.size(136.dp),
backgroundColor = RandomColor
) {
Text(text = infoCard.infoText, modifier = Modifier.padding(horizontal = 16.dp))
}
}
#Composable
fun DetailCardComposable(detailCard: DetailCard) {
Card(
modifier = Modifier
.size(156.dp),
backgroundColor = RandomColor
) {
Text(text = detailCard.detailText, modifier = Modifier.padding(horizontal = 16.dp))
}
}
Complete code to try out is available here: https://github.com/DirajHS/ComposeAnimation/tree/master
I would like to know if this is the expected behavior or am I doing something wrong?
Any suggestions on snapping the button to the top during collapse would be much appreciated.
I get lists from API and display them in LazyColumn with the expendable scroll. All list items are initially in a not expended state and while the user clicks them, I want to show the detail of that item in the expendable item view.
When my list items are in expended state, scrolling is smooth but when the initial state ( not expended ) scrolling is not smooth. I saved expendable state with rememberSavable and update when user clicks the item.
These are my code:
My viewModel
#HiltViewModel
class MainViewModel #Inject constructor(
private val appRepository: AppRepository
) : ViewModel() {
val breeds: Flow<PagingData<BreedItem>> = Pager(
config = PagingConfig(pageSize = 3)
) {
BreedsPagingDataSource(appRepository)
}.flow.cachedIn(viewModelScope)
}
My ListScreen
#Composable
fun DoggoListScreen() {
val vm: MainViewModel = hiltViewModel()
DoggoListView(
breedItems = vm.breeds,
)
}
My ListView
#Composable
fun DoggoListView(
breedItems: Flow<PagingData<BreedItem>>,
) {
val breed: LazyPagingItems<BreedItem> = breedItems.collectAsLazyPagingItems()
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(4.dp)
) {
items(breed) { item ->
FoldAbleItem(
item!!.toVo(),
onClick = {
//todo : do some click action
})
}
breed.apply {
when {
loadState.refresh is LoadState.Loading -> {
item {
LoadingView(modifier = Modifier.fillParentMaxSize())
}
}
loadState.append is LoadState.Loading -> {
item {
LoadingItem()
}
}
loadState.refresh is LoadState.Error -> {
val e = breed.loadState.refresh as LoadState.Error
item {
ErrorItem(
message = e.error.localizedMessage!!,
modifier = Modifier.fillParentMaxSize(),
onClickRetry = { retry() }
)
}
}
loadState.append is LoadState.Error -> {
val e = breed.loadState.append as LoadState.Error
item {
ErrorItem(
message = e.error.localizedMessage!!,
onClickRetry = { retry() }
)
}
}
}
}
}
}
My ItemView
#Composable
fun FoldAbleItem(
breed: Breed,
onClick: () -> Unit
) {
//you can save expandedState by remember if you don't want to save it across scrolling
var expandedState by rememberSaveable {
mutableStateOf(breed.isExpended)
}
val rotationState by animateFloatAsState(
targetValue = if (expandedState) 180f else 0f
)
val image = rememberCoilPainter(
request = breed.url,
fadeIn = true,
fadeInDurationMs = 500
)
Card(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp)
.animateContentSize(
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing
)
)
.clickable(
onClick = onClick
),
shape = MaterialTheme.shapes.medium,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.weight(1f),
text = breed.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h5
)
IconButton(
modifier = Modifier.rotate(rotationState),
onClick = { expandedState = !expandedState }
) {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = "Drop Arrow"
)
}
}
if (expandedState) {
Text(
text = breed.temperament ?: "movie.bred_for",
style = MaterialTheme.typography.body2
)
Spacer(modifier = Modifier.height(8.dp))
Image(
painter = image,
contentDescription = null,
//16:9 = 1.7f
modifier = Modifier
.aspectRatio(1.7f, false)
.clip(MaterialTheme.shapes.medium)
,
contentScale = ContentScale.FillWidth
)
breed.description?.let {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = it,
style = MaterialTheme.typography.body2
)
}
}
}
}
}
I think I am doing well with implementation LazyColumn and DataSet. But I am not sure with the expendable state in rememberSavable. Please emphersize the part.
//you can save expandedState by remember if you don't want to save it across scrolling
var expandedState by rememberSaveable {
mutableStateOf(breed.isExpended)
}
val rotationState by animateFloatAsState(
targetValue = if (expandedState) 180f else 0f
)
val image = rememberCoilPainter(
request = breed.url,
fadeIn = true,
fadeInDurationMs = 500
)
I have an existing app where I have implemented FlipCard animation like below using Objectanimator in XML. If I click on a card it flips horizontally. But now I want to migrate it to jetpack compose. So is it possible to make flip card animation in jetpack compose?
Update
Finally, I have ended up with this. Though I don't know if it is the right way or not but I got exactly what I wanted. If there is any better alternative you can suggest. Thank you.
Method 1: Using animate*AsState
#Composable
fun FlipCard() {
var rotated by remember { mutableStateOf(false) }
val rotation by animateFloatAsState(
targetValue = if (rotated) 180f else 0f,
animationSpec = tween(500)
)
val animateFront by animateFloatAsState(
targetValue = if (!rotated) 1f else 0f,
animationSpec = tween(500)
)
val animateBack by animateFloatAsState(
targetValue = if (rotated) 1f else 0f,
animationSpec = tween(500)
)
val animateColor by animateColorAsState(
targetValue = if (rotated) Color.Red else Color.Blue,
animationSpec = tween(500)
)
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Card(
Modifier
.fillMaxSize(.5f)
.graphicsLayer {
rotationY = rotation
cameraDistance = 8 * density
}
.clickable {
rotated = !rotated
},
backgroundColor = animateColor
)
{
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = if (rotated) "Back" else "Front",
modifier = Modifier
.graphicsLayer {
alpha = if (rotated) animateBack else animateFront
rotationY = rotation
})
}
}
}
}
Method 2: Encapsulate a Transition and make it reusable.
You will get the same output as method 1. But it is reusable and for the complex case.
enum class BoxState { Front, Back }
#Composable
fun AnimatingBox(
rotated: Boolean,
onRotate: (Boolean) -> Unit
) {
val transitionData = updateTransitionData(
if (rotated) BoxState.Back else BoxState.Front
)
Card(
Modifier
.fillMaxSize(.5f)
.graphicsLayer {
rotationY = transitionData.rotation
cameraDistance = 8 * density
}
.clickable { onRotate(!rotated) },
backgroundColor = transitionData.color
)
{
Column(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = if (rotated) "Back" else "Front",
modifier = Modifier
.graphicsLayer {
alpha =
if (rotated) transitionData.animateBack else transitionData.animateFront
rotationY = transitionData.rotation
})
}
}
}
private class TransitionData(
color: State<Color>,
rotation: State<Float>,
animateFront: State<Float>,
animateBack: State<Float>
) {
val color by color
val rotation by rotation
val animateFront by animateFront
val animateBack by animateBack
}
#Composable
private fun updateTransitionData(boxState: BoxState): TransitionData {
val transition = updateTransition(boxState, label = "")
val color = transition.animateColor(
transitionSpec = {
tween(500)
},
label = ""
) { state ->
when (state) {
BoxState.Front -> Color.Blue
BoxState.Back -> Color.Red
}
}
val rotation = transition.animateFloat(
transitionSpec = {
tween(500)
},
label = ""
) { state ->
when (state) {
BoxState.Front -> 0f
BoxState.Back -> 180f
}
}
val animateFront = transition.animateFloat(
transitionSpec = {
tween(500)
},
label = ""
) { state ->
when (state) {
BoxState.Front -> 1f
BoxState.Back -> 0f
}
}
val animateBack = transition.animateFloat(
transitionSpec = {
tween(500)
},
label = ""
) { state ->
when (state) {
BoxState.Front -> 0f
BoxState.Back -> 1f
}
}
return remember(transition) { TransitionData(color, rotation, animateFront, animateBack) }
}
Output
setContent {
ComposeAnimationTheme {
Surface(color = MaterialTheme.colors.background) {
var state by remember {
mutableStateOf(CardFace.Front)
}
FlipCard(
cardFace = state,
onClick = {
state = it.next
},
axis = RotationAxis.AxisY,
back = {
Text(text = "Front", Modifier
.fillMaxSize()
.background(Color.Red))
},
front = {
Text(text = "Back", Modifier
.fillMaxSize()
.background(Color.Green))
}
)
}
}
}
enum class CardFace(val angle: Float) {
Front(0f) {
override val next: CardFace
get() = Back
},
Back(180f) {
override val next: CardFace
get() = Front
};
abstract val next: CardFace
}
enum class RotationAxis {
AxisX,
AxisY,
}
#ExperimentalMaterialApi
#Composable
fun FlipCard(
cardFace: CardFace,
onClick: (CardFace) -> Unit,
modifier: Modifier = Modifier,
axis: RotationAxis = RotationAxis.AxisY,
back: #Composable () -> Unit = {},
front: #Composable () -> Unit = {},
) {
val rotation = animateFloatAsState(
targetValue = cardFace.angle,
animationSpec = tween(
durationMillis = 400,
easing = FastOutSlowInEasing,
)
)
Card(
onClick = { onClick(cardFace) },
modifier = modifier
.graphicsLayer {
if (axis == RotationAxis.AxisX) {
rotationX = rotation.value
} else {
rotationY = rotation.value
}
cameraDistance = 12f * density
},
) {
if (rotation.value <= 90f) {
Box(
Modifier.fillMaxSize()
) {
front()
}
} else {
Box(
Modifier
.fillMaxSize()
.graphicsLayer {
if (axis == RotationAxis.AxisX) {
rotationX = 180f
} else {
rotationY = 180f
}
},
) {
back()
}
}
}
}
Check this article. https://fvilarino.medium.com/creating-a-rotating-card-in-jetpack-compose-ba94c7dd76fb.