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,18 +477,71 @@ fun FeedScreen(
if (isSearchActive && searchQuery.isNotBlank()) {
// Search results: flat list with highlighting, no swipe actions
items(displayPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
PostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
highlightQuery = searchQuery
)
val itemKey = post.ghostId ?: "local_${post.localId}"
StaggeredItem(
key = itemKey,
animatedKeys = animatedKeys,
initialLoadComplete = initialLoadComplete
) {
PostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
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) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
onShare = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val shareText = ShareUtils.formatShareContent(post, postUrl)
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
}
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
}
},
onCopyLink = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
}
},
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
onTagClick = { tag -> viewModel.filterByTag(tag) },
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) },
@ -512,38 +571,6 @@ fun FeedScreen(
snackbarHostState = snackbarHostState
)
}
// No extra separator — thick dividers built into each post
}
items(regularPosts, key = { it.ghostId ?: "local_${it.localId}" }) { post ->
SwipeablePostCard(
post = post,
onClick = { onPostClick(post) },
onCancelQueue = { viewModel.cancelQueuedPost(post) },
onShare = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val shareText = ShareUtils.formatShareContent(post, postUrl)
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
}
context.startActivity(Intent.createChooser(sendIntent, "Share post"))
}
},
onCopyLink = {
val postUrl = ShareUtils.resolvePostUrl(post, baseUrl)
if (postUrl != null) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("Post URL", postUrl))
}
},
onEdit = { onEditPost(post) },
onDelete = { postPendingDelete = post },
onTogglePin = { viewModel.toggleFeatured(post) },
onTagClick = { tag -> viewModel.filterByTag(tag) },
snackbarHostState = snackbarHostState
)
}
}
@ -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(