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.
This commit is contained in:
Paweł Orzech 2026-01-31 02:27:16 +01:00
parent c66d30af19
commit 0687d1a7d7
No known key found for this signature in database
5 changed files with 177 additions and 31 deletions

BIN
app/.DS_Store vendored Normal file

Binary file not shown.

View file

@ -15,26 +15,23 @@ data class MaskedEmail(
val url: String?,
val emailPrefix: String?,
val createdAt: Instant?,
val lastMessageAt: Instant?
val lastMessageAt: Instant?,
val formattedCreatedAt: String? = createdAt?.let { formatInstant(it) },
val formattedLastMessageAt: String? = lastMessageAt?.let { formatInstant(it) }
) {
val displayName: String
get() = description?.takeIf { it.isNotBlank() }
?: forDomain?.takeIf { it.isNotBlank() }
?: email.substringBefore("@")
val formattedCreatedAt: String?
get() = createdAt?.let { formatInstant(it) }
val formattedLastMessageAt: String?
get() = lastMessageAt?.let { formatInstant(it) }
val isActive: Boolean
get() = state == EmailState.ENABLED || state == EmailState.PENDING
private fun formatInstant(instant: Instant): String {
val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
companion object {
private val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
.withZone(ZoneId.systemDefault())
return formatter.format(instant)
fun formatInstant(instant: Instant): String = formatter.format(instant)
}
}

View file

@ -46,7 +46,8 @@ fun MaskedEmailCard(
onClick: () -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedContentScope: AnimatedContentScope,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
isScrolling: Boolean = false
) {
val haptic = LocalHapticFeedback.current
@ -61,10 +62,14 @@ fun MaskedEmailCard(
Card(
modifier = modifier
.fillMaxWidth()
.sharedBounds(
.then(
if (!isScrolling) {
Modifier.sharedBounds(
sharedContentState = rememberSharedContentState(key = "card-${maskedEmail.id}"),
animatedVisibilityScope = animatedContentScope
)
} else Modifier
)
.clickable {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onClick()
@ -85,10 +90,12 @@ fun MaskedEmailCard(
) {
StatusIcon(
state = maskedEmail.state,
modifier = Modifier.sharedElement(
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(
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(
modifier = if (!isScrolling) {
Modifier.sharedElement(
state = rememberSharedContentState(key = "email-${maskedEmail.id}"),
animatedVisibilityScope = animatedContentScope
)
} else Modifier
)
maskedEmail.forDomain?.let { domain ->
Text(

View file

@ -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()
}
}
}

View file

@ -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
)
}
}