feat(ui): 为日期记录器多选模式添加过渡动画

This commit is contained in:
xfy 2026-07-23 14:21:39 +08:00
parent b6146a29ae
commit cfac607862

View File

@ -1,5 +1,21 @@
package plus.rua.project.ui package plus.rua.project.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -26,6 +42,7 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PhotoCamera import androidx.compose.material.icons.filled.PhotoCamera
import androidx.compose.material.icons.filled.Sort import androidx.compose.material.icons.filled.Sort
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Card import androidx.compose.material3.Card
@ -110,29 +127,55 @@ fun DateRecorderScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Text( AnimatedContent(
text = if (uiState.selectionMode) { targetState = if (uiState.selectionMode) {
"已选 ${uiState.selectedIds.size}" "已选 ${uiState.selectedIds.size}"
} else { } else {
"日期记录器" "日期记录器"
}, },
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) transitionSpec = {
) (fadeIn(tween(200)) + slideInVertically { height -> height / 2 }) togetherWith
(fadeOut(tween(200)) + slideOutVertically { height -> -height / 2 })
}, },
navigationIcon = { label = "top_bar_title"
IconButton(onClick = { ) { titleText ->
if (uiState.selectionMode) viewModel.toggleSelectionMode() Text(
else onBack() text = titleText,
}) { style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
Icon(
imageVector = if (uiState.selectionMode) Icons.Filled.Close
else Icons.Filled.ChevronLeft,
contentDescription = if (uiState.selectionMode) "退出多选" else "返回"
) )
} }
}, },
navigationIcon = {
AnimatedContent(
targetState = uiState.selectionMode,
transitionSpec = {
(fadeIn(tween(200)) + scaleIn(initialScale = 0.8f)) togetherWith
(fadeOut(tween(200)) + scaleOut(targetScale = 0.8f))
},
label = "top_bar_nav_icon"
) { isSelectionMode ->
IconButton(onClick = {
if (isSelectionMode) viewModel.toggleSelectionMode()
else onBack()
}) {
Icon(
imageVector = if (isSelectionMode) Icons.Filled.Close
else Icons.Filled.ChevronLeft,
contentDescription = if (isSelectionMode) "退出多选" else "返回"
)
}
}
},
actions = { actions = {
if (uiState.selectionMode) { AnimatedContent(
targetState = uiState.selectionMode,
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
},
label = "top_bar_actions"
) { isSelectionMode ->
Row(verticalAlignment = Alignment.CenterVertically) {
if (isSelectionMode) {
IconButton(onClick = viewModel::toggleSelectAll) { IconButton(onClick = viewModel::toggleSelectAll) {
Icon(Icons.Filled.Checklist, contentDescription = "全选") Icon(Icons.Filled.Checklist, contentDescription = "全选")
} }
@ -187,12 +230,18 @@ fun DateRecorderScreen(
Icon(Icons.Filled.Checklist, contentDescription = "多选") Icon(Icons.Filled.Checklist, contentDescription = "多选")
} }
} }
}
}
}, },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
) )
}, },
bottomBar = { bottomBar = {
if (uiState.selectionMode) { AnimatedVisibility(
visible = uiState.selectionMode,
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut()
) {
BottomAppBar { BottomAppBar {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -222,7 +271,11 @@ fun DateRecorderScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
if (!uiState.selectionMode) { AnimatedVisibility(
visible = !uiState.selectionMode,
enter = scaleIn(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(),
exit = scaleOut() + fadeOut()
) {
FloatingActionButton( FloatingActionButton(
onClick = onOpenCamera, onClick = onOpenCamera,
modifier = Modifier.testTag("date_recorder_fab"), modifier = Modifier.testTag("date_recorder_fab"),
@ -233,7 +286,6 @@ fun DateRecorderScreen(
} }
} }
}, },
containerColor = MaterialTheme.colorScheme.surface
) { innerPadding -> ) { innerPadding ->
Box( Box(
modifier = Modifier modifier = Modifier
@ -322,11 +374,32 @@ private fun RecordCard(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val cardScale by animateFloatAsState(
targetValue = if (selectionMode && isSelected) 0.95f else 1f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "record_card_scale"
)
val borderWidth by animateDpAsState(
targetValue = if (selectionMode && isSelected) 2.dp else 0.dp,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "record_card_border_width"
)
val borderColor by animateColorAsState(
targetValue = if (selectionMode && isSelected) MaterialTheme.colorScheme.primary else Color.Transparent,
animationSpec = tween(200),
label = "record_card_border_color"
)
Card( Card(
onClick = onClick, onClick = onClick,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
border = BorderStroke(borderWidth, borderColor),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
modifier = modifier.fillMaxWidth() modifier = modifier
.fillMaxWidth()
.graphicsLayer {
scaleX = cardScale
scaleY = cardScale
}
) { ) {
Box { Box {
AsyncImage( AsyncImage(
@ -366,14 +439,16 @@ private fun RecordCard(
) )
} }
// 多选态下的复选角标 // 多选态下的复选角标(淡入淡出及缩放动画)
if (selectionMode) { this@Card.AnimatedVisibility(
SelectionBadge( visible = selectionMode,
isSelected = isSelected, enter = scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(),
exit = scaleOut() + fadeOut(),
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .align(Alignment.TopEnd)
.padding(8.dp) .padding(8.dp)
) ) {
SelectionBadge(isSelected = isSelected)
} }
} }
} }
@ -381,15 +456,33 @@ private fun RecordCard(
@Composable @Composable
private fun SelectionBadge(isSelected: Boolean, modifier: Modifier = Modifier) { private fun SelectionBadge(isSelected: Boolean, modifier: Modifier = Modifier) {
val bg = if (isSelected) MaterialTheme.colorScheme.primary val bg by animateColorAsState(
else Color.Black.copy(alpha = 0.3f) targetValue = if (isSelected) MaterialTheme.colorScheme.primary else Color.Black.copy(alpha = 0.3f),
animationSpec = tween(200),
label = "selection_badge_bg"
)
val badgeScale by animateFloatAsState(
targetValue = if (isSelected) 1f else 0.85f,
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy),
label = "selection_badge_scale"
)
Box( Box(
modifier = modifier modifier = modifier
.graphicsLayer {
scaleX = badgeScale
scaleY = badgeScale
}
.clip(CircleShape) .clip(CircleShape)
.background(bg) .background(bg)
.padding(4.dp) .padding(4.dp),
contentAlignment = Alignment.Center
) {
Box(modifier = Modifier.size(16.dp), contentAlignment = Alignment.Center) {
androidx.compose.animation.AnimatedVisibility(
visible = isSelected,
enter = scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(),
exit = scaleOut() + fadeOut()
) { ) {
if (isSelected) {
Icon( Icon(
Icons.Filled.Check, Icons.Filled.Check,
contentDescription = "已选中", contentDescription = "已选中",
@ -399,6 +492,7 @@ private fun SelectionBadge(isSelected: Boolean, modifier: Modifier = Modifier) {
} }
} }
} }
}
@Composable @Composable
private fun EmptyState() { private fun EmptyState() {