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.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,18 +477,71 @@ 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 ->
|
||||||
PostCard(
|
val itemKey = post.ghostId ?: "local_${post.localId}"
|
||||||
post = post,
|
StaggeredItem(
|
||||||
onClick = { onPostClick(post) },
|
key = itemKey,
|
||||||
onCancelQueue = { viewModel.cancelQueuedPost(post) },
|
animatedKeys = animatedKeys,
|
||||||
highlightQuery = searchQuery
|
initialLoadComplete = initialLoadComplete
|
||||||
)
|
) {
|
||||||
|
PostCard(
|
||||||
|
post = post,
|
||||||
|
onClick = { onPostClick(post) },
|
||||||
|
onCancelQueue = { viewModel.cancelQueuedPost(post) },
|
||||||
|
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(
|
||||||
|
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(
|
SwipeablePostCard(
|
||||||
post = post,
|
post = post,
|
||||||
onClick = { onPostClick(post) },
|
onClick = { onPostClick(post) },
|
||||||
|
|
@ -512,38 +571,6 @@ fun FeedScreen(
|
||||||
snackbarHostState = snackbarHostState
|
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) {
|
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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue