From 64662f6bd49318a9231245fddc65e67d33431527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Thu, 19 Mar 2026 14:13:03 +0100 Subject: [PATCH] feat: add staggered card entrance animation in feed --- .../swoosh/microblog/ui/feed/FeedScreen.kt | 139 +++++++++++++----- 1 file changed, 101 insertions(+), 38 deletions(-) diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index e2142f0..72af59d 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -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(null) } var showRenameDialog by remember { mutableStateOf(null) } + // Staggered entrance tracking + val animatedKeys = remember { mutableStateMapOf() } + 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, + 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(