compose: Play short animation on removing items

Compose makes it easy to animate items. Play a short animation when
items are removed from downloads and favourites.

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2025-09-22 15:49:18 +08:00
parent e8a1e18750
commit a7a2d2f180
4 changed files with 136 additions and 72 deletions

View File

@@ -36,7 +36,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -53,8 +54,11 @@ import com.aurora.store.R
import com.aurora.store.compose.composables.app.AnimatedAppIconComposable import com.aurora.store.compose.composables.app.AnimatedAppIconComposable
import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.compose.preview.coilPreviewProvider import com.aurora.store.compose.preview.coilPreviewProvider
import com.aurora.store.data.model.DownloadStatus
import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.download.Download
import com.aurora.store.util.CommonUtil.getETAString import com.aurora.store.util.CommonUtil.getETAString
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.Date import java.util.Date
/** /**
@@ -78,16 +82,20 @@ fun DownloadComposable(
val speed = "${Formatter.formatShortFileSize(LocalContext.current, download.speed)}/s" val speed = "${Formatter.formatShortFileSize(LocalContext.current, download.speed)}/s"
val eta = getETAString(LocalContext.current, download.timeRemaining) val eta = getETAString(LocalContext.current, download.timeRemaining)
var isExpanded by rememberSaveable { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope()
var isVisible by remember { mutableStateOf(true) }
var isExpanded by remember { mutableStateOf(false) }
Column( fun requestClear() {
modifier = Modifier coroutineScope.launch {
.clickable(onClick = onClick) isVisible = false
.padding( delay(300) // Let the animation play
horizontal = dimensionResource(R.dimen.padding_medium), onClear()
vertical = dimensionResource(R.dimen.padding_small) }
) }
) {
@Composable
fun SetupDownload() {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
@@ -100,7 +108,8 @@ fun DownloadComposable(
progress = download.progress.toFloat() progress = download.progress.toFloat()
) )
Column( Column(
modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small)), modifier = Modifier
.padding(horizontal = dimensionResource(R.dimen.margin_small)),
) { ) {
Text( Text(
text = download.displayName, text = download.displayName,
@@ -112,10 +121,10 @@ fun DownloadComposable(
text = stringResource(download.downloadStatus.localized), text = stringResource(download.downloadStatus.localized),
style = MaterialTheme.typography.bodySmall style = MaterialTheme.typography.bodySmall
) )
AnimatedContent(targetState = download.isRunning) { isRunning -> AnimatedContent(targetState = download.downloadStatus) { status ->
Text( Text(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
text = if (isRunning) { text = if (status in DownloadStatus.running) {
"$progress$speed$eta" "$progress$speed$eta"
} else { } else {
DateUtils.getRelativeTimeSpanString( DateUtils.getRelativeTimeSpanString(
@@ -142,7 +151,7 @@ fun DownloadComposable(
} }
else -> { else -> {
SplitButtonDefaults.LeadingButton(onClick = onClear) { SplitButtonDefaults.LeadingButton(onClick = { requestClear() }) {
Icon( Icon(
painter = painterResource(R.drawable.ic_delete_forever), painter = painterResource(R.drawable.ic_delete_forever),
contentDescription = stringResource(R.string.action_clear) contentDescription = stringResource(R.string.action_clear)
@@ -168,7 +177,10 @@ fun DownloadComposable(
} }
) )
} }
}
@Composable
fun SetupExpandedMenu() {
AnimatedVisibility( AnimatedVisibility(
visible = isExpanded, visible = isExpanded,
enter = slideInVertically() + expandVertically() + fadeIn(), enter = slideInVertically() + expandVertically() + fadeIn(),
@@ -209,6 +221,23 @@ fun DownloadComposable(
} }
} }
} }
AnimatedVisibility(
visible = isVisible,
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = modifier
.clickable(onClick = onClick)
.padding(
horizontal = dimensionResource(R.dimen.padding_medium),
vertical = dimensionResource(R.dimen.padding_small)
)
) {
SetupDownload()
SetupExpandedMenu()
}
}
} }
@Preview(showBackground = true) @Preview(showBackground = true)

View File

@@ -6,6 +6,9 @@
package com.aurora.store.compose.composables package com.aurora.store.compose.composables
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -20,6 +23,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -40,6 +48,8 @@ import com.aurora.store.R
import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.AppPreviewProvider
import com.aurora.store.compose.preview.coilPreviewProvider import com.aurora.store.compose.preview.coilPreviewProvider
import com.aurora.store.data.room.favourite.Favourite import com.aurora.store.data.room.favourite.Favourite
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/** /**
* Composable to display a favourite app in a list * Composable to display a favourite app in a list
@@ -55,60 +65,76 @@ fun FavouriteComposable(
onClick: () -> Unit = {}, onClick: () -> Unit = {},
onClear: () -> Unit = {} onClear: () -> Unit = {}
) { ) {
Row( val coroutineScope = rememberCoroutineScope()
modifier = modifier var isVisible by remember { mutableStateOf(true) }
.fillMaxWidth()
.clickable(onClick = onClick) fun requestClear() {
.padding( coroutineScope.launch {
horizontal = dimensionResource(R.dimen.padding_medium), isVisible = false
vertical = dimensionResource(R.dimen.padding_xsmall) delay(300) // Let the animation play
), onClear()
verticalAlignment = Alignment.CenterVertically, }
horizontalArrangement = Arrangement.SpaceBetween }
AnimatedVisibility(
visible = isVisible,
exit = shrinkVertically() + fadeOut()
) { ) {
Row(modifier = Modifier.weight(1F)) { Row(
AsyncImage( modifier = modifier
model = ImageRequest.Builder(LocalContext.current) .fillMaxWidth()
.data(favourite.iconURL) .clickable(onClick = onClick)
.crossfade(true) .padding(
.build(), horizontal = dimensionResource(R.dimen.padding_medium),
contentDescription = null, vertical = dimensionResource(R.dimen.padding_xsmall)
contentScale = ContentScale.Crop, ),
modifier = Modifier verticalAlignment = Alignment.CenterVertically,
.requiredSize(dimensionResource(R.dimen.icon_size_medium)) horizontalArrangement = Arrangement.SpaceBetween
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) ) {
) Row(modifier = Modifier.weight(1F)) {
Column( AsyncImage(
modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small)) model = ImageRequest.Builder(LocalContext.current)
) { .data(favourite.iconURL)
Text( .crossfade(true)
text = favourite.displayName, .build(),
style = MaterialTheme.typography.bodyMedium, contentDescription = null,
maxLines = 1, contentScale = ContentScale.Crop,
overflow = TextOverflow.Ellipsis modifier = Modifier
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
) )
Text( Column(
text = favourite.packageName, modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small))
style = MaterialTheme.typography.bodySmall, ) {
maxLines = 1, Text(
overflow = TextOverflow.Ellipsis text = favourite.displayName,
) style = MaterialTheme.typography.bodyMedium,
Text( maxLines = 1,
text = DateUtils.formatDateTime( overflow = TextOverflow.Ellipsis
LocalContext.current, )
favourite.added, Text(
DateUtils.FORMAT_SHOW_DATE text = favourite.packageName,
), style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodySmall maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = DateUtils.formatDateTime(
LocalContext.current,
favourite.added,
DateUtils.FORMAT_SHOW_DATE
),
style = MaterialTheme.typography.bodySmall
)
}
}
IconButton(onClick = { requestClear() }) {
Icon(
painter = painterResource(R.drawable.ic_favorite_checked),
contentDescription = stringResource(R.string.action_favourite)
) )
} }
} }
IconButton(onClick = onClear) {
Icon(
painter = painterResource(R.drawable.ic_favorite_checked),
contentDescription = stringResource(R.string.action_favourite)
)
}
} }
} }

View File

@@ -151,6 +151,7 @@ private fun ScreenContent(
) { index -> ) { index ->
downloads[index]?.let { download -> downloads[index]?.let { download ->
DownloadComposable( DownloadComposable(
modifier = Modifier.animateItem(),
download = download, download = download,
onClick = { onNavigateToAppDetails(download.packageName) }, onClick = { onNavigateToAppDetails(download.packageName) },
onClear = { onClear(download) }, onClear = { onClear(download) },

View File

@@ -13,6 +13,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
@@ -96,6 +100,13 @@ private fun ScreenContent(
onImportFavourites: () -> Unit = {}, onImportFavourites: () -> Unit = {},
onExportFavourites: () -> Unit = {} onExportFavourites: () -> Unit = {}
) { ) {
/*
* For some reason paging3 frequently out-of-nowhere invalidates the list which causes
* the loading animation to play again even if the keys are same causing a glitching effect.
*
* Save the initial loading state to make sure we don't replay the loading animation again.
*/
var initialLoad by rememberSaveable { mutableStateOf(true) }
@Composable @Composable
fun SetupMenu() { fun SetupMenu() {
@@ -122,18 +133,14 @@ private fun ScreenContent(
.fillMaxSize() .fillMaxSize()
.padding(vertical = dimensionResource(R.dimen.padding_medium)) .padding(vertical = dimensionResource(R.dimen.padding_medium))
) { ) {
when (favourites.loadState.refresh) { when {
is LoadState.Loading -> ProgressComposable() favourites.loadState.refresh is LoadState.Loading && initialLoad -> {
ProgressComposable()
is LoadState.Error -> {
ErrorComposable(
modifier = Modifier.padding(paddingValues),
icon = painterResource(R.drawable.ic_disclaimer),
message = stringResource(R.string.error)
)
} }
else -> { else -> {
initialLoad = false
if (favourites.itemCount == 0) { if (favourites.itemCount == 0) {
ErrorComposable( ErrorComposable(
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
@@ -148,6 +155,7 @@ private fun ScreenContent(
) { index -> ) { index ->
favourites[index]?.let { favourite -> favourites[index]?.let { favourite ->
FavouriteComposable( FavouriteComposable(
modifier = Modifier.animateItem(),
favourite = favourite, favourite = favourite,
onClick = { onNavigateToAppDetails(favourite.packageName) }, onClick = { onNavigateToAppDetails(favourite.packageName) },
onClear = { onRemoveFavourite(favourite.packageName) } onClear = { onRemoveFavourite(favourite.packageName) }