feat(ui): 为日期记录器多选模式添加过渡动画
This commit is contained in:
parent
b6146a29ae
commit
cfac607862
@ -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() {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user