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 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,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(
|
||||
|
|
|
|||
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 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue