mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
feat: add staggered card entrance animation in feed
This commit is contained in:
parent
677846a748
commit
64662f6bd4
1 changed files with 101 additions and 38 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue