From a7a2d2f18017dde5ce8f67d8e3ba88a70d1d5e08 Mon Sep 17 00:00:00 2001 From: Aayush Gupta Date: Mon, 22 Sep 2025 15:49:18 +0800 Subject: [PATCH] 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 --- .../compose/composables/DownloadComposable.kt | 57 ++++++-- .../composables/FavouriteComposable.kt | 124 +++++++++++------- .../compose/ui/downloads/DownloadsScreen.kt | 1 + .../compose/ui/favourite/FavouriteScreen.kt | 26 ++-- 4 files changed, 136 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/com/aurora/store/compose/composables/DownloadComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/DownloadComposable.kt index d1c3282f2..2b33c6261 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/DownloadComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/DownloadComposable.kt @@ -36,7 +36,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue 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.ui.Alignment 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.preview.AppPreviewProvider 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.util.CommonUtil.getETAString +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import java.util.Date /** @@ -78,16 +82,20 @@ fun DownloadComposable( val speed = "${Formatter.formatShortFileSize(LocalContext.current, download.speed)}/s" 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( - modifier = Modifier - .clickable(onClick = onClick) - .padding( - horizontal = dimensionResource(R.dimen.padding_medium), - vertical = dimensionResource(R.dimen.padding_small) - ) - ) { + fun requestClear() { + coroutineScope.launch { + isVisible = false + delay(300) // Let the animation play + onClear() + } + } + + @Composable + fun SetupDownload() { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween @@ -100,7 +108,8 @@ fun DownloadComposable( progress = download.progress.toFloat() ) Column( - modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small)), + modifier = Modifier + .padding(horizontal = dimensionResource(R.dimen.margin_small)), ) { Text( text = download.displayName, @@ -112,10 +121,10 @@ fun DownloadComposable( text = stringResource(download.downloadStatus.localized), style = MaterialTheme.typography.bodySmall ) - AnimatedContent(targetState = download.isRunning) { isRunning -> + AnimatedContent(targetState = download.downloadStatus) { status -> Text( style = MaterialTheme.typography.bodySmall, - text = if (isRunning) { + text = if (status in DownloadStatus.running) { "$progress • $speed • $eta" } else { DateUtils.getRelativeTimeSpanString( @@ -142,7 +151,7 @@ fun DownloadComposable( } else -> { - SplitButtonDefaults.LeadingButton(onClick = onClear) { + SplitButtonDefaults.LeadingButton(onClick = { requestClear() }) { Icon( painter = painterResource(R.drawable.ic_delete_forever), contentDescription = stringResource(R.string.action_clear) @@ -168,7 +177,10 @@ fun DownloadComposable( } ) } + } + @Composable + fun SetupExpandedMenu() { AnimatedVisibility( visible = isExpanded, 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) diff --git a/app/src/main/java/com/aurora/store/compose/composables/FavouriteComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/FavouriteComposable.kt index 27c1bb220..413ccce31 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/FavouriteComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/FavouriteComposable.kt @@ -6,6 +6,9 @@ package com.aurora.store.compose.composables 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.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -20,6 +23,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable 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.Modifier 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.coilPreviewProvider 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 @@ -55,60 +65,76 @@ fun FavouriteComposable( onClick: () -> Unit = {}, onClear: () -> Unit = {} ) { - Row( - modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding( - horizontal = dimensionResource(R.dimen.padding_medium), - vertical = dimensionResource(R.dimen.padding_xsmall) - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + val coroutineScope = rememberCoroutineScope() + var isVisible by remember { mutableStateOf(true) } + + fun requestClear() { + coroutineScope.launch { + isVisible = false + delay(300) // Let the animation play + onClear() + } + } + + AnimatedVisibility( + visible = isVisible, + exit = shrinkVertically() + fadeOut() ) { - Row(modifier = Modifier.weight(1F)) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(favourite.iconURL) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .requiredSize(dimensionResource(R.dimen.icon_size_medium)) - .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) - ) - Column( - modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small)) - ) { - Text( - text = favourite.displayName, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding( + horizontal = dimensionResource(R.dimen.padding_medium), + vertical = dimensionResource(R.dimen.padding_xsmall) + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(modifier = Modifier.weight(1F)) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(favourite.iconURL) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_medium)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) ) - Text( - text = favourite.packageName, - 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 + Column( + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small)) + ) { + Text( + text = favourite.displayName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = favourite.packageName, + 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) - ) - } } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/downloads/DownloadsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/downloads/DownloadsScreen.kt index bde67cfc6..d63738e8f 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/downloads/DownloadsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/downloads/DownloadsScreen.kt @@ -151,6 +151,7 @@ private fun ScreenContent( ) { index -> downloads[index]?.let { download -> DownloadComposable( + modifier = Modifier.animateItem(), download = download, onClick = { onNavigateToAppDetails(download.packageName) }, onClear = { onClear(download) }, diff --git a/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt index 658781c12..2b2ce87b9 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/favourite/FavouriteScreen.kt @@ -13,6 +13,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Scaffold 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.platform.LocalContext import androidx.compose.ui.res.dimensionResource @@ -96,6 +100,13 @@ private fun ScreenContent( onImportFavourites: () -> 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 fun SetupMenu() { @@ -122,18 +133,14 @@ private fun ScreenContent( .fillMaxSize() .padding(vertical = dimensionResource(R.dimen.padding_medium)) ) { - when (favourites.loadState.refresh) { - is LoadState.Loading -> ProgressComposable() - - is LoadState.Error -> { - ErrorComposable( - modifier = Modifier.padding(paddingValues), - icon = painterResource(R.drawable.ic_disclaimer), - message = stringResource(R.string.error) - ) + when { + favourites.loadState.refresh is LoadState.Loading && initialLoad -> { + ProgressComposable() } else -> { + initialLoad = false + if (favourites.itemCount == 0) { ErrorComposable( modifier = Modifier.padding(paddingValues), @@ -148,6 +155,7 @@ private fun ScreenContent( ) { index -> favourites[index]?.let { favourite -> FavouriteComposable( + modifier = Modifier.animateItem(), favourite = favourite, onClick = { onNavigateToAppDetails(favourite.packageName) }, onClear = { onRemoveFavourite(favourite.packageName) }