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:
parent
c66d30af19
commit
0687d1a7d7
5 changed files with 177 additions and 31 deletions
BIN
app/.DS_Store
vendored
Normal file
BIN
app/.DS_Store
vendored
Normal file
Binary file not shown.
|
|
@ -15,26 +15,23 @@ data class MaskedEmail(
|
||||||
val url: String?,
|
val url: String?,
|
||||||
val emailPrefix: String?,
|
val emailPrefix: String?,
|
||||||
val createdAt: Instant?,
|
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
|
val displayName: String
|
||||||
get() = description?.takeIf { it.isNotBlank() }
|
get() = description?.takeIf { it.isNotBlank() }
|
||||||
?: forDomain?.takeIf { it.isNotBlank() }
|
?: forDomain?.takeIf { it.isNotBlank() }
|
||||||
?: email.substringBefore("@")
|
?: email.substringBefore("@")
|
||||||
|
|
||||||
val formattedCreatedAt: String?
|
|
||||||
get() = createdAt?.let { formatInstant(it) }
|
|
||||||
|
|
||||||
val formattedLastMessageAt: String?
|
|
||||||
get() = lastMessageAt?.let { formatInstant(it) }
|
|
||||||
|
|
||||||
val isActive: Boolean
|
val isActive: Boolean
|
||||||
get() = state == EmailState.ENABLED || state == EmailState.PENDING
|
get() = state == EmailState.ENABLED || state == EmailState.PENDING
|
||||||
|
|
||||||
private fun formatInstant(instant: Instant): String {
|
companion object {
|
||||||
val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
|
private val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
|
||||||
.withZone(ZoneId.systemDefault())
|
.withZone(ZoneId.systemDefault())
|
||||||
return formatter.format(instant)
|
|
||||||
|
fun formatInstant(instant: Instant): String = formatter.format(instant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ fun MaskedEmailCard(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
sharedTransitionScope: SharedTransitionScope,
|
sharedTransitionScope: SharedTransitionScope,
|
||||||
animatedContentScope: AnimatedContentScope,
|
animatedContentScope: AnimatedContentScope,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
isScrolling: Boolean = false
|
||||||
) {
|
) {
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
|
|
||||||
|
|
@ -61,9 +62,13 @@ fun MaskedEmailCard(
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.sharedBounds(
|
.then(
|
||||||
sharedContentState = rememberSharedContentState(key = "card-${maskedEmail.id}"),
|
if (!isScrolling) {
|
||||||
animatedVisibilityScope = animatedContentScope
|
Modifier.sharedBounds(
|
||||||
|
sharedContentState = rememberSharedContentState(key = "card-${maskedEmail.id}"),
|
||||||
|
animatedVisibilityScope = animatedContentScope
|
||||||
|
)
|
||||||
|
} else Modifier
|
||||||
)
|
)
|
||||||
.clickable {
|
.clickable {
|
||||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
|
@ -85,10 +90,12 @@ fun MaskedEmailCard(
|
||||||
) {
|
) {
|
||||||
StatusIcon(
|
StatusIcon(
|
||||||
state = maskedEmail.state,
|
state = maskedEmail.state,
|
||||||
modifier = Modifier.sharedElement(
|
modifier = if (!isScrolling) {
|
||||||
state = rememberSharedContentState(key = "icon-${maskedEmail.id}"),
|
Modifier.sharedElement(
|
||||||
animatedVisibilityScope = animatedContentScope
|
state = rememberSharedContentState(key = "icon-${maskedEmail.id}"),
|
||||||
)
|
animatedVisibilityScope = animatedContentScope
|
||||||
|
)
|
||||||
|
} else Modifier
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(16.dp))
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
|
@ -97,10 +104,12 @@ fun MaskedEmailCard(
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.sharedElement(
|
modifier = if (!isScrolling) {
|
||||||
state = rememberSharedContentState(key = "title-${maskedEmail.id}"),
|
Modifier.sharedElement(
|
||||||
animatedVisibilityScope = animatedContentScope
|
state = rememberSharedContentState(key = "title-${maskedEmail.id}"),
|
||||||
)
|
animatedVisibilityScope = animatedContentScope
|
||||||
|
)
|
||||||
|
} else Modifier
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = maskedEmail.email,
|
text = maskedEmail.email,
|
||||||
|
|
@ -108,10 +117,12 @@ fun MaskedEmailCard(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.sharedElement(
|
modifier = if (!isScrolling) {
|
||||||
state = rememberSharedContentState(key = "email-${maskedEmail.id}"),
|
Modifier.sharedElement(
|
||||||
animatedVisibilityScope = animatedContentScope
|
state = rememberSharedContentState(key = "email-${maskedEmail.id}"),
|
||||||
)
|
animatedVisibilityScope = animatedContentScope
|
||||||
|
)
|
||||||
|
} else Modifier
|
||||||
)
|
)
|
||||||
maskedEmail.forDomain?.let { domain ->
|
maskedEmail.forDomain?.let { domain ->
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
131
app/src/main/java/com/fastmask/ui/components/ShimmerEffect.kt
Normal file
131
app/src/main/java/com/fastmask/ui/components/ShimmerEffect.kt
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,8 +57,8 @@ import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.fastmask.domain.model.MaskedEmail
|
import com.fastmask.domain.model.MaskedEmail
|
||||||
import com.fastmask.ui.components.ErrorMessage
|
import com.fastmask.ui.components.ErrorMessage
|
||||||
import com.fastmask.ui.components.LoadingIndicator
|
|
||||||
import com.fastmask.ui.components.MaskedEmailCard
|
import com.fastmask.ui.components.MaskedEmailCard
|
||||||
|
import com.fastmask.ui.components.ShimmerEmailList
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
|
||||||
|
|
@ -77,6 +77,10 @@ fun MaskedEmailListScreen(
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val haptic = LocalHapticFeedback.current
|
val haptic = LocalHapticFeedback.current
|
||||||
|
|
||||||
|
val isScrolling by remember {
|
||||||
|
derivedStateOf { listState.isScrollInProgress }
|
||||||
|
}
|
||||||
|
|
||||||
val expandedFab by remember {
|
val expandedFab by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
||||||
|
|
@ -196,7 +200,7 @@ fun MaskedEmailListScreen(
|
||||||
|
|
||||||
when {
|
when {
|
||||||
uiState.isLoading && uiState.emails.isEmpty() -> {
|
uiState.isLoading && uiState.emails.isEmpty() -> {
|
||||||
LoadingIndicator()
|
ShimmerEmailList()
|
||||||
}
|
}
|
||||||
|
|
||||||
uiState.error != null && uiState.emails.isEmpty() -> {
|
uiState.error != null && uiState.emails.isEmpty() -> {
|
||||||
|
|
@ -214,7 +218,8 @@ fun MaskedEmailListScreen(
|
||||||
onEmailClick = { email -> onNavigateToDetail(email.id) },
|
onEmailClick = { email -> onNavigateToDetail(email.id) },
|
||||||
listState = listState,
|
listState = listState,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedContentScope = animatedContentScope
|
animatedContentScope = animatedContentScope,
|
||||||
|
isScrolling = isScrolling
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -310,7 +315,8 @@ private fun EmailList(
|
||||||
onEmailClick: (MaskedEmail) -> Unit,
|
onEmailClick: (MaskedEmail) -> Unit,
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
sharedTransitionScope: SharedTransitionScope,
|
sharedTransitionScope: SharedTransitionScope,
|
||||||
animatedContentScope: AnimatedContentScope
|
animatedContentScope: AnimatedContentScope,
|
||||||
|
isScrolling: Boolean
|
||||||
) {
|
) {
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = isRefreshing,
|
isRefreshing = isRefreshing,
|
||||||
|
|
@ -343,7 +349,8 @@ private fun EmailList(
|
||||||
onClick = { onEmailClick(email) },
|
onClick = { onEmailClick(email) },
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedContentScope = animatedContentScope,
|
animatedContentScope = animatedContentScope,
|
||||||
modifier = Modifier.animateItem()
|
modifier = Modifier.animateItem(),
|
||||||
|
isScrolling = isScrolling
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue