feat(ui): 为日期记录器多选模式添加过渡动画
This commit is contained in:
parent
b6146a29ae
commit
cfac607862
@ -1,5 +1,21 @@
|
||||
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.layout.Arrangement
|
||||
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.PhotoCamera
|
||||
import androidx.compose.material.icons.filled.Sort
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.BottomAppBar
|
||||
import androidx.compose.material3.Card
|
||||
@ -110,89 +127,121 @@ fun DateRecorderScreen(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = if (uiState.selectionMode) {
|
||||
AnimatedContent(
|
||||
targetState = if (uiState.selectionMode) {
|
||||
"已选 ${uiState.selectedIds.size}"
|
||||
} else {
|
||||
"日期记录器"
|
||||
},
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = {
|
||||
if (uiState.selectionMode) viewModel.toggleSelectionMode()
|
||||
else onBack()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = if (uiState.selectionMode) Icons.Filled.Close
|
||||
else Icons.Filled.ChevronLeft,
|
||||
contentDescription = if (uiState.selectionMode) "退出多选" else "返回"
|
||||
transitionSpec = {
|
||||
(fadeIn(tween(200)) + slideInVertically { height -> height / 2 }) togetherWith
|
||||
(fadeOut(tween(200)) + slideOutVertically { height -> -height / 2 })
|
||||
},
|
||||
label = "top_bar_title"
|
||||
) { titleText ->
|
||||
Text(
|
||||
text = titleText,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (uiState.selectionMode) {
|
||||
IconButton(onClick = viewModel::toggleSelectAll) {
|
||||
Icon(Icons.Filled.Checklist, contentDescription = "全选")
|
||||
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 "返回"
|
||||
)
|
||||
}
|
||||
} else if (!uiState.isLoading && uiState.records.isNotEmpty()) {
|
||||
// 排序菜单
|
||||
Box {
|
||||
IconButton(onClick = { showSortMenu = true }) {
|
||||
Icon(Icons.Filled.Sort, contentDescription = "排序")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSortMenu,
|
||||
onDismissRequest = { showSortMenu = false }
|
||||
) {
|
||||
sortFields.forEach { (field, label) ->
|
||||
val selected = uiState.sortOrder.field == field
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = {
|
||||
viewModel.setSortOrder(
|
||||
RecordSortOrder.nextAfter(uiState.sortOrder, field)
|
||||
)
|
||||
showSortMenu = false
|
||||
},
|
||||
leadingIcon = if (selected) {
|
||||
{ Icon(Icons.Filled.Check, contentDescription = null) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
trailingIcon = if (selected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = if (uiState.sortOrder.ascending) {
|
||||
Icons.Filled.ArrowUpward
|
||||
} else {
|
||||
Icons.Filled.ArrowDownward
|
||||
},
|
||||
contentDescription = if (uiState.sortOrder.ascending) {
|
||||
"旧→新"
|
||||
} else {
|
||||
"新→旧"
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
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) {
|
||||
Icon(Icons.Filled.Checklist, contentDescription = "全选")
|
||||
}
|
||||
} else if (!uiState.isLoading && uiState.records.isNotEmpty()) {
|
||||
// 排序菜单
|
||||
Box {
|
||||
IconButton(onClick = { showSortMenu = true }) {
|
||||
Icon(Icons.Filled.Sort, contentDescription = "排序")
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSortMenu,
|
||||
onDismissRequest = { showSortMenu = false }
|
||||
) {
|
||||
sortFields.forEach { (field, label) ->
|
||||
val selected = uiState.sortOrder.field == field
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = {
|
||||
viewModel.setSortOrder(
|
||||
RecordSortOrder.nextAfter(uiState.sortOrder, field)
|
||||
)
|
||||
showSortMenu = false
|
||||
},
|
||||
leadingIcon = if (selected) {
|
||||
{ Icon(Icons.Filled.Check, contentDescription = null) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
trailingIcon = if (selected) {
|
||||
{
|
||||
Icon(
|
||||
imageVector = if (uiState.sortOrder.ascending) {
|
||||
Icons.Filled.ArrowUpward
|
||||
} else {
|
||||
Icons.Filled.ArrowDownward
|
||||
},
|
||||
contentDescription = if (uiState.sortOrder.ascending) {
|
||||
"旧→新"
|
||||
} else {
|
||||
"新→旧"
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = viewModel::toggleSelectionMode) {
|
||||
Icon(Icons.Filled.Checklist, contentDescription = "多选")
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = viewModel::toggleSelectionMode) {
|
||||
Icon(Icons.Filled.Checklist, contentDescription = "多选")
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (uiState.selectionMode) {
|
||||
AnimatedVisibility(
|
||||
visible = uiState.selectionMode,
|
||||
enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { it }) + fadeOut()
|
||||
) {
|
||||
BottomAppBar {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@ -222,7 +271,11 @@ fun DateRecorderScreen(
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
if (!uiState.selectionMode) {
|
||||
AnimatedVisibility(
|
||||
visible = !uiState.selectionMode,
|
||||
enter = scaleIn(animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(),
|
||||
exit = scaleOut() + fadeOut()
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = onOpenCamera,
|
||||
modifier = Modifier.testTag("date_recorder_fab"),
|
||||
@ -233,7 +286,6 @@ fun DateRecorderScreen(
|
||||
}
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@ -322,11 +374,32 @@ private fun RecordCard(
|
||||
onClick: () -> Unit,
|
||||
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(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
border = BorderStroke(borderWidth, borderColor),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp),
|
||||
modifier = modifier.fillMaxWidth()
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.graphicsLayer {
|
||||
scaleX = cardScale
|
||||
scaleY = cardScale
|
||||
}
|
||||
) {
|
||||
Box {
|
||||
AsyncImage(
|
||||
@ -366,14 +439,16 @@ private fun RecordCard(
|
||||
)
|
||||
}
|
||||
|
||||
// 多选态下的复选角标
|
||||
if (selectionMode) {
|
||||
SelectionBadge(
|
||||
isSelected = isSelected,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
)
|
||||
// 多选态下的复选角标(淡入淡出及缩放动画)
|
||||
this@Card.AnimatedVisibility(
|
||||
visible = selectionMode,
|
||||
enter = scaleIn(spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + fadeIn(),
|
||||
exit = scaleOut() + fadeOut(),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
SelectionBadge(isSelected = isSelected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -381,21 +456,40 @@ private fun RecordCard(
|
||||
|
||||
@Composable
|
||||
private fun SelectionBadge(isSelected: Boolean, modifier: Modifier = Modifier) {
|
||||
val bg = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else Color.Black.copy(alpha = 0.3f)
|
||||
val bg by animateColorAsState(
|
||||
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(
|
||||
modifier = modifier
|
||||
.graphicsLayer {
|
||||
scaleX = badgeScale
|
||||
scaleY = badgeScale
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(bg)
|
||||
.padding(4.dp)
|
||||
.padding(4.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = "已选中",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
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()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
contentDescription = "已选中",
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user