From 0687d1a7d756c94445a70546d022d6c5976f514f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Sat, 31 Jan 2026 02:27:16 +0100 Subject: [PATCH] Add shimmer loading effect for email list Introduced ShimmerEffect composables to display a skeleton loading UI while emails are loading. Refactored MaskedEmailCard to disable shared element transitions during scrolling for smoother performance. Updated MaskedEmailListScreen to use the shimmer effect instead of a generic loading indicator. --- app/.DS_Store | Bin 0 -> 6148 bytes .../com/fastmask/domain/model/MaskedEmail.kt | 17 +-- .../fastmask/ui/components/MaskedEmailCard.kt | 43 +++--- .../fastmask/ui/components/ShimmerEffect.kt | 131 ++++++++++++++++++ .../fastmask/ui/list/MaskedEmailListScreen.kt | 17 ++- 5 files changed, 177 insertions(+), 31 deletions(-) create mode 100644 app/.DS_Store create mode 100644 app/src/main/java/com/fastmask/ui/components/ShimmerEffect.kt diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..3d733c75d885641434f24d8b22aa3b0f637411d4 GIT binary patch literal 6148 zcmeHK%}T>S5Z>*NO(;SR3Oz1(Em&(W#Y>3w1&ruHr6#0kFlI}WnnNk%tS{t~_&m<+ zZlJ~BQN+%`?l(I>yO|HNKa4T%Eu$009LAUh4UwZ#BWSL5HB2xfS93&RkD7gq0qI z*YT{FItS-6&4W0dEmT4r%^>9NCQc(+cygIWnacIG!?rDJ>hxBt(P+>Yqw(pwFIMBB z(ueD{WgQ+JpIuI$lb2M!X(Bl=u4La}1@EA2RP^dE(nO|@V68H$NJ3(O7$63Sf!$=l zoCj8SH_NB0i2-8Z2L^C|5YP}^gQZ5bbwG#LXY{uaQ9#GH1fnqL8Z0$}2ZZZXK%L6X z6NBq?unQCC8Z0&HbjH=nFppWedc1J8I@pB@XWZ3DJuyHGY%|c*LkG|Q3;1PfANkuU zG$ICwfq%vTZ%zD(2Su5)^;>y()(U73&`>b0L Unit, sharedTransitionScope: SharedTransitionScope, animatedContentScope: AnimatedContentScope, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + isScrolling: Boolean = false ) { val haptic = LocalHapticFeedback.current @@ -61,9 +62,13 @@ fun MaskedEmailCard( Card( modifier = modifier .fillMaxWidth() - .sharedBounds( - sharedContentState = rememberSharedContentState(key = "card-${maskedEmail.id}"), - animatedVisibilityScope = animatedContentScope + .then( + if (!isScrolling) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState(key = "card-${maskedEmail.id}"), + animatedVisibilityScope = animatedContentScope + ) + } else Modifier ) .clickable { haptic.performHapticFeedback(HapticFeedbackType.LongPress) @@ -85,10 +90,12 @@ fun MaskedEmailCard( ) { StatusIcon( state = maskedEmail.state, - modifier = Modifier.sharedElement( - state = rememberSharedContentState(key = "icon-${maskedEmail.id}"), - animatedVisibilityScope = animatedContentScope - ) + modifier = if (!isScrolling) { + Modifier.sharedElement( + state = rememberSharedContentState(key = "icon-${maskedEmail.id}"), + animatedVisibilityScope = animatedContentScope + ) + } else Modifier ) Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { @@ -97,10 +104,12 @@ fun MaskedEmailCard( style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.sharedElement( - state = rememberSharedContentState(key = "title-${maskedEmail.id}"), - animatedVisibilityScope = animatedContentScope - ) + modifier = if (!isScrolling) { + Modifier.sharedElement( + state = rememberSharedContentState(key = "title-${maskedEmail.id}"), + animatedVisibilityScope = animatedContentScope + ) + } else Modifier ) Text( text = maskedEmail.email, @@ -108,10 +117,12 @@ fun MaskedEmailCard( color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.sharedElement( - state = rememberSharedContentState(key = "email-${maskedEmail.id}"), - animatedVisibilityScope = animatedContentScope - ) + modifier = if (!isScrolling) { + Modifier.sharedElement( + state = rememberSharedContentState(key = "email-${maskedEmail.id}"), + animatedVisibilityScope = animatedContentScope + ) + } else Modifier ) maskedEmail.forDomain?.let { domain -> Text( diff --git a/app/src/main/java/com/fastmask/ui/components/ShimmerEffect.kt b/app/src/main/java/com/fastmask/ui/components/ShimmerEffect.kt new file mode 100644 index 0000000..3060e6e --- /dev/null +++ b/app/src/main/java/com/fastmask/ui/components/ShimmerEffect.kt @@ -0,0 +1,131 @@ +package com.fastmask.ui.components + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.unit.dp + +@Composable +fun ShimmerEmailCard( + modifier: Modifier = Modifier +) { + val shimmerColors = listOf( + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.6f), + MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.2f), + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.6f) + ) + + val transition = rememberInfiniteTransition(label = "shimmer") + val translateAnim by transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart + ), + label = "shimmer_translate" + ) + + val brush = Brush.linearGradient( + colors = shimmerColors, + start = Offset(translateAnim - 500f, translateAnim - 500f), + end = Offset(translateAnim, translateAnim) + ) + + Card( + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Status icon placeholder + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(brush) + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + // Title placeholder + Box( + modifier = Modifier + .fillMaxWidth(0.6f) + .height(20.dp) + .clip(RoundedCornerShape(4.dp)) + .background(brush) + ) + Spacer(modifier = Modifier.height(8.dp)) + // Email placeholder + Box( + modifier = Modifier + .fillMaxWidth(0.8f) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(brush) + ) + Spacer(modifier = Modifier.height(6.dp)) + // Domain placeholder + Box( + modifier = Modifier + .fillMaxWidth(0.4f) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(brush) + ) + } + } + } +} + +@Composable +fun ShimmerEmailList( + modifier: Modifier = Modifier, + itemCount: Int = 8 +) { + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(itemCount) { + ShimmerEmailCard() + } + } +} diff --git a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt index b7043d5..f684a3b 100644 --- a/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt +++ b/app/src/main/java/com/fastmask/ui/list/MaskedEmailListScreen.kt @@ -57,8 +57,8 @@ import androidx.compose.ui.platform.LocalHapticFeedback import androidx.hilt.navigation.compose.hiltViewModel import com.fastmask.domain.model.MaskedEmail import com.fastmask.ui.components.ErrorMessage -import com.fastmask.ui.components.LoadingIndicator import com.fastmask.ui.components.MaskedEmailCard +import com.fastmask.ui.components.ShimmerEmailList import kotlinx.coroutines.flow.collectLatest @OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) @@ -77,6 +77,10 @@ fun MaskedEmailListScreen( val listState = rememberLazyListState() val haptic = LocalHapticFeedback.current + val isScrolling by remember { + derivedStateOf { listState.isScrollInProgress } + } + val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 @@ -196,7 +200,7 @@ fun MaskedEmailListScreen( when { uiState.isLoading && uiState.emails.isEmpty() -> { - LoadingIndicator() + ShimmerEmailList() } uiState.error != null && uiState.emails.isEmpty() -> { @@ -214,7 +218,8 @@ fun MaskedEmailListScreen( onEmailClick = { email -> onNavigateToDetail(email.id) }, listState = listState, sharedTransitionScope = sharedTransitionScope, - animatedContentScope = animatedContentScope + animatedContentScope = animatedContentScope, + isScrolling = isScrolling ) } } @@ -310,7 +315,8 @@ private fun EmailList( onEmailClick: (MaskedEmail) -> Unit, listState: LazyListState, sharedTransitionScope: SharedTransitionScope, - animatedContentScope: AnimatedContentScope + animatedContentScope: AnimatedContentScope, + isScrolling: Boolean ) { PullToRefreshBox( isRefreshing = isRefreshing, @@ -343,7 +349,8 @@ private fun EmailList( onClick = { onEmailClick(email) }, sharedTransitionScope = sharedTransitionScope, animatedContentScope = animatedContentScope, - modifier = Modifier.animateItem() + modifier = Modifier.animateItem(), + isScrolling = isScrolling ) } }