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.core.animateFloatAsState
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import kotlinx.coroutines.delay
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import com.swoosh.microblog.ui.animation.SwooshMotion import com.swoosh.microblog.ui.animation.SwooshMotion
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
@ -115,6 +117,10 @@ fun FeedScreen(
var showDeleteConfirmation by remember { mutableStateOf<GhostAccount?>(null) } var showDeleteConfirmation by remember { mutableStateOf<GhostAccount?>(null) }
var showRenameDialog 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 // FAB entrance animation
var fabVisible by remember { mutableStateOf(false) } var fabVisible by remember { mutableStateOf(false) }
val fabScale by animateFloatAsState( val fabScale by animateFloatAsState(
@ -471,6 +477,12 @@ fun FeedScreen(
if (isSearchActive && searchQuery.isNotBlank()) { if (isSearchActive && searchQuery.isNotBlank()) {
// Search results: flat list with highlighting, no swipe actions // Search results: flat list with highlighting, no swipe actions
items(displayPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post -> items(displayPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
val itemKey = post.ghostId ?: "local_${post.localId}"
StaggeredItem(
key = itemKey,
animatedKeys = animatedKeys,
initialLoadComplete = initialLoadComplete
) {
PostCard( PostCard(
post = post, post = post,
onClick = { onPostClick(post) }, onClick = { onPostClick(post) },
@ -478,11 +490,18 @@ fun FeedScreen(
highlightQuery = searchQuery highlightQuery = searchQuery
) )
} }
}
} else { } else {
// Normal feed: pinned section + swipe actions // Normal feed: pinned section + swipe actions
// Pinned posts (no section header — pin icon on post) // Pinned posts (no section header — pin icon on post)
if (pinnedPosts.isNotEmpty()) { if (pinnedPosts.isNotEmpty()) {
items(pinnedPosts, key = { "pinned_${it.ghostId ?: "local_${it.localId}"}" }) { post -> 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( SwipeablePostCard(
post = post, post = post,
onClick = { onPostClick(post) }, onClick = { onPostClick(post) },
@ -512,10 +531,17 @@ fun FeedScreen(
snackbarHostState = snackbarHostState snackbarHostState = snackbarHostState
) )
} }
}
// No extra separator — thick dividers built into each post // No extra separator — thick dividers built into each post
} }
items(regularPosts, key = { it.ghostId ?: "local_${it.localId}" }) { 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( SwipeablePostCard(
post = post, post = post,
onClick = { onPostClick(post) }, onClick = { onPostClick(post) },
@ -546,6 +572,7 @@ fun FeedScreen(
) )
} }
} }
}
if (!isSearchActive && state.isLoadingMore) { if (!isSearchActive && state.isLoadingMore) {
item { 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) { if (!isSearchActive) {
PullRefreshIndicator( PullRefreshIndicator(
refreshing = state.isRefreshing, 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun FilterChipsBar( fun FilterChipsBar(