feat: add staggered card entrance animation in feed

This commit is contained in:
Paweł Orzech 2026-03-19 14:13:03 +01:00
parent 677846a748
commit 64662f6bd4
No known key found for this signature in database

View file

@ -9,6 +9,8 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import kotlinx.coroutines.delay
import androidx.compose.foundation.gestures.detectTapGestures
import com.swoosh.microblog.ui.animation.SwooshMotion
import androidx.compose.foundation.ExperimentalFoundationApi
@ -115,6 +117,10 @@ fun FeedScreen(
var showDeleteConfirmation by remember { mutableStateOf<GhostAccount?>(null) }
var showRenameDialog by remember { mutableStateOf<GhostAccount?>(null) }
// Staggered entrance tracking
val animatedKeys = remember { mutableStateMapOf<String, Boolean>() }
var initialLoadComplete by remember { mutableStateOf(false) }
// FAB entrance animation
var fabVisible by remember { mutableStateOf(false) }
val fabScale by animateFloatAsState(
@ -471,6 +477,12 @@ fun FeedScreen(
if (isSearchActive && searchQuery.isNotBlank()) {
// Search results: flat list with highlighting, no swipe actions
items(displayPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
val itemKey = post.ghostId ?: "local_${post.localId}"
StaggeredItem(
key = itemKey,
animatedKeys = animatedKeys,
initialLoadComplete = initialLoadComplete
) {
PostCard(
post = post,
onClick = { onPostClick(post) },
@ -478,11 +490,18 @@ fun FeedScreen(
highlightQuery = searchQuery
)
}
}
} else {
// Normal feed: pinned section + swipe actions
// Pinned posts (no section header — pin icon on post)
if (pinnedPosts.isNotEmpty()) {
items(pinnedPosts, key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" }) { post ->
val itemKey = post.ghostId ?: "local_${post.localId}"
StaggeredItem(
key = itemKey,
animatedKeys = animatedKeys,
initialLoadComplete = initialLoadComplete
) {
SwipeablePostCard(
post = post,
onClick = { onPostClick(post) },
@ -512,10 +531,17 @@ fun FeedScreen(
snackbarHostState = snackbarHostState
)
}
}
// No extra separator — thick dividers built into each post
}
items(regularPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
val itemKey = post.ghostId ?: "local_${post.localId}"
StaggeredItem(
key = itemKey,
animatedKeys = animatedKeys,
initialLoadComplete = initialLoadComplete
) {
SwipeablePostCard(
post = post,
onClick = { onPostClick(post) },
@ -546,6 +572,7 @@ fun FeedScreen(
)
}
}
}
if (!isSearchActive && state.isLoadingMore) {
item {
@ -559,6 +586,13 @@ fun FeedScreen(
}
}
LaunchedEffect(state.posts) {
if (state.posts.isNotEmpty() && !initialLoadComplete) {
delay(SwooshMotion.StaggerDelayMs * minOf(state.posts.size, 8) + 300)
initialLoadComplete = true
}
}
if (!isSearchActive) {
PullRefreshIndicator(
refreshing = state.isRefreshing,
@ -707,6 +741,35 @@ fun FeedScreen(
}
}
@Composable
private fun StaggeredItem(
key: String,
animatedKeys: MutableMap<String, Boolean>,
initialLoadComplete: Boolean,
content: @Composable () -> Unit
) {
val shouldAnimate = !initialLoadComplete && key !in animatedKeys
var visible by remember { mutableStateOf(!shouldAnimate) }
LaunchedEffect(key) {
if (shouldAnimate && animatedKeys.size < 8) {
delay(SwooshMotion.StaggerDelayMs * animatedKeys.size)
animatedKeys[key] = true
}
visible = true
}
AnimatedVisibility(
visible = visible,
enter = slideInVertically(
initialOffsetY = { it / 3 },
animationSpec = SwooshMotion.gentle()
) + fadeIn(animationSpec = SwooshMotion.quick())
) {
content()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterChipsBar(