feat: 转场动画优化 & 任务界面 MD3E 适配 & 待办卡片样式更新

This commit is contained in:
Super12138 2026-02-05 20:43:03 +08:00
parent 2b28b446eb
commit 5b87a17a82
8 changed files with 182 additions and 93 deletions

View file

@ -38,7 +38,7 @@ android {
applicationId = "cn.super12138.todo"
minSdk = 24
targetSdk = 36
versionCode = 950
versionCode = 951
versionName = "2.3.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View file

@ -21,7 +21,7 @@ object TodoDefaults {
/**
* 待办卡片默认高度
*/
val toDoCardHeight = 80.dp
val toDoCardHeight = 86.dp
/**
* 设置项水平边距

View file

@ -2,11 +2,6 @@ package cn.super12138.todo.ui.navigation
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.animation.unveilIn
import androidx.compose.animation.veilOut
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -17,8 +12,13 @@ import cn.super12138.todo.ui.pages.editor.TodoAddPage
import cn.super12138.todo.ui.pages.editor.TodoEditPage
import cn.super12138.todo.ui.pages.overview.OverviewPage
import cn.super12138.todo.ui.pages.tasks.TasksPage
import cn.super12138.todo.ui.theme.fadeThrough
import cn.super12138.todo.ui.viewmodels.MainViewModel
/**
* 来自https://github.com/material-components/material-components-android/blob/master/lib/java/com/google/android/material/transition/MaterialFadeThrough.java#L33
*/
@OptIn(ExperimentalAnimationApi::class)
@Composable
fun TopNavigation(
@ -53,12 +53,12 @@ fun TopNavigation(
* }
*/
val veilColor = MaterialTheme.colorScheme.surfaceDim
// val veilColor = MaterialTheme.colorScheme.surfaceDim
SharedTransitionLayout {
NavDisplay(
backStack = backStack.backStack,
onBack = ::onBack,
transitionSpec = {
/*transitionSpec = {
fadeIn() togetherWith veilOut(targetColor = veilColor)
},
popTransitionSpec = {
@ -66,6 +66,15 @@ fun TopNavigation(
},
predictivePopTransitionSpec = {
unveilIn(initialColor = veilColor) togetherWith fadeOut()
},*/
transitionSpec = {
fadeThrough()
},
popTransitionSpec = {
fadeThrough()
},
predictivePopTransitionSpec = {
fadeThrough()
},
entryProvider = entryProvider {
entry<TodoScreen.Overview> {

View file

@ -13,23 +13,20 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation3.ui.LocalNavAnimatedContentScope
@ -45,9 +42,9 @@ import cn.super12138.todo.ui.components.TopAppBarScaffold
import cn.super12138.todo.ui.pages.editor.components.TodoCategoryChip
import cn.super12138.todo.ui.pages.editor.components.TodoCategoryTextField
import cn.super12138.todo.ui.pages.editor.components.TodoContentTextField
import cn.super12138.todo.ui.pages.editor.components.TodoMarkAsCompletedCheckbox
import cn.super12138.todo.ui.pages.editor.components.TodoPrioritySlider
import cn.super12138.todo.ui.pages.editor.state.rememberEditorState
import cn.super12138.todo.utils.VibrationUtils
@Composable
fun SharedTransitionScope.TodoAddPage(
@ -92,8 +89,6 @@ private fun TodoEditorPage(
onNavigateUp: () -> Unit
) {
// TODO: 本页及其相关组件重组性能检查优化
val view = LocalView.current
val uiState = rememberEditorState(initialTodo = toDo)
val originalCategories by DataStoreManager.categoriesFlow.collectAsState(initial = emptyList())
@ -180,11 +175,11 @@ private fun TodoEditorPage(
verticalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.fillMaxSize()
) {
item {
item(key = 0) {
Spacer(modifier = Modifier.size(TodoDefaults.screenVerticalPadding))
}
item {
item(key=1) {
TodoContentTextField(
value = uiState.toDoContent,
onValueChange = { uiState.toDoContent = it },
@ -193,7 +188,7 @@ private fun TodoEditorPage(
)
}
item {
item(key = 2) {
Text(
text = stringResource(R.string.label_category),
style = MaterialTheme.typography.titleMedium
@ -222,7 +217,7 @@ private fun TodoEditorPage(
}
}
item {
item (key = 3){
Text(
text = stringResource(R.string.label_priority),
style = MaterialTheme.typography.titleMedium
@ -234,34 +229,22 @@ private fun TodoEditorPage(
)
}
item {
item(key = 4) {
if (toDo != null) {
Text(
text = stringResource(R.string.label_more),
style = MaterialTheme.typography.titleMedium
)
Row(
verticalAlignment = Alignment.CenterVertically,
TodoMarkAsCompletedCheckbox(
checked = uiState.isCompleted,
onCheckedChange = { uiState.isCompleted = it },
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.tip_mark_completed),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.padding(end = 10.dp)
)
Switch(
checked = uiState.isCompleted,
onCheckedChange = {
VibrationUtils.performHapticFeedback(view)
uiState.isCompleted = it
}
)
}
)
}
}
item {
item (key = 5){
Spacer(modifier = Modifier.size(TodoDefaults.screenVerticalPadding))
}
}

View file

@ -0,0 +1,59 @@
package cn.super12138.todo.ui.pages.editor.components
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.selection.toggleable
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import cn.super12138.todo.R
import cn.super12138.todo.utils.VibrationUtils
@Composable
fun TodoMarkAsCompletedCheckbox(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val view = LocalView.current
val interactionSource = remember { MutableInteractionSource() }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier
.toggleable(
value = checked,
onValueChange = {
VibrationUtils.performHapticFeedback(view, HapticFeedbackConstants.LONG_PRESS)
onCheckedChange(it)
},
role = Role.Checkbox,
indication = null,
interactionSource = interactionSource,
)
) {
Text(
text = stringResource(R.string.tip_mark_completed),
style = MaterialTheme.typography.labelLarge
)
Checkbox(
checked = checked,
onCheckedChange = {
VibrationUtils.performHapticFeedback(view, HapticFeedbackConstants.LONG_PRESS)
onCheckedChange(it)
},
interactionSource = interactionSource
)
}
}

View file

@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
@ -27,7 +27,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation3.ui.LocalNavAnimatedContentScope
import cn.super12138.todo.R
import cn.super12138.todo.constants.Constants
@ -103,7 +102,7 @@ fun SharedTransitionScope.TasksPage(
) {
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(5.dp),
verticalArrangement = Arrangement.spacedBy(TodoDefaults.settingsItemPadding),
modifier = Modifier.fillMaxSize()
) {
item {
@ -120,32 +119,34 @@ fun SharedTransitionScope.TasksPage(
)
}
} else {
items(
itemsIndexed(
items = filteredTodoList,
key = { it.id }
) {
key = { _, task -> task.id }
) { index, task ->
TodoCard(
// id = item.id,
content = it.content,
category = it.category,
completed = it.isCompleted,
priority = Priority.fromFloat(it.priority),
selected = selectedTodoIds.contains(it.id),
content = task.content,
category = task.category,
completed = task.isCompleted,
priority = Priority.fromFloat(task.priority),
selected = selectedTodoIds.contains(task.id),
onCardClick = {
if (inSelectedMode) {
viewModel.toggleTodoSelection(it)
viewModel.toggleTodoSelection(task)
} else {
toTodoEditPage(it)
toTodoEditPage(task)
}
},
onCardLongClick = { viewModel.toggleTodoSelection(it) },
onCardLongClick = { viewModel.toggleTodoSelection(task) },
onChecked = {
viewModel.updateTodo(it.copy(isCompleted = true))
viewModel.updateTodo(task.copy(isCompleted = true))
viewModel.playConfetti()
},
topRounded = index == 0,
bottomRounded = index == filteredTodoList.size - 1,
modifier = Modifier
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "${Constants.KEY_TODO_ITEM_TRANSITION}_${it.id}"),
sharedContentState = rememberSharedContentState(key = "${Constants.KEY_TODO_ITEM_TRANSITION}_${task.id}"),
animatedVisibilityScope = LocalNavAnimatedContentScope.current
)
.animateItem(

View file

@ -1,6 +1,7 @@
package cn.super12138.todo.ui.pages.tasks.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@ -17,10 +18,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.material3.Badge
import androidx.compose.material3.BadgedBox
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -28,6 +27,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -42,6 +42,7 @@ import cn.super12138.todo.logic.model.Priority
import cn.super12138.todo.ui.TodoDefaults
import cn.super12138.todo.utils.VibrationUtils
import cn.super12138.todo.utils.containerColor
import cn.super12138.todo.utils.getPartialRoundedShape
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@ -55,19 +56,22 @@ fun TodoCard(
selected: Boolean,
onCardClick: () -> Unit = {},
onCardLongClick: () -> Unit = {},
onChecked: () -> Unit = {}
onChecked: () -> Unit = {},
shape: CornerBasedShape = TodoDefaults.SettingsItemDefaultShape,
roundedShape: CornerBasedShape = TodoDefaults.SettingsItemRoundedShape,
topRounded: Boolean = false,
bottomRounded: Boolean = false
) {
val view = LocalView.current
Card(
colors = CardDefaults.cardColors(containerColor = TodoDefaults.ContainerColor),
modifier = modifier
.fillMaxWidth()
.height(TodoDefaults.toDoCardHeight)
) {
// TODO: 滑动删除
val animatedContainerColor by animateColorAsState(if (selected) MaterialTheme.colorScheme.secondaryContainer else TodoDefaults.ContainerColor)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
modifier = modifier
.fillMaxWidth()
.height(TodoDefaults.toDoCardHeight)
.clip(shape.getPartialRoundedShape(topRounded, bottomRounded, roundedShape))
.combinedClickable(
onClick = {
VibrationUtils.performHapticFeedback(view)
@ -77,7 +81,8 @@ fun TodoCard(
// 因为 combinedClickable 在更新的 Compose 里已经处理好了触感反馈
onLongClick = onCardLongClick
)
.padding(horizontal = 15.dp)
.background(animatedContainerColor)
.padding(horizontal = TodoDefaults.screenHorizontalPadding)
) {
AnimatedVisibility(
visible = selected,
@ -104,40 +109,42 @@ fun TodoCard(
}
Column(
verticalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(
space = 5.dp,
alignment = Alignment.CenterVertically
),
modifier = Modifier
.weight(1f)
.fillMaxSize()
) {
BadgedBox(
badge = {
Badge(
containerColor = priority.containerColor(),
modifier = Modifier.padding(start = 5.dp)
) {
Text(
text = stringResource(priority.nameRes),
textDecoration = if (completed) TextDecoration.LineThrough else TextDecoration.None,
)
}
}
Text(
text = content,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textDecoration = if (completed) TextDecoration.LineThrough else TextDecoration.None,
modifier = Modifier.basicMarquee() // TODO: 后续评估性能影响
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
Badge(containerColor = MaterialTheme.colorScheme.primary) {
Text(
text = category.ifEmpty { stringResource(R.string.tip_default_category) },
style = MaterialTheme.typography.labelMedium,
textDecoration = if (completed) TextDecoration.LineThrough else TextDecoration.None,
maxLines = 1
)
}
Text(
text = content,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
text = stringResource(priority.nameRes),
style = MaterialTheme.typography.labelMedium.copy(priority.containerColor()),
textDecoration = if (completed) TextDecoration.LineThrough else TextDecoration.None,
modifier = Modifier.basicMarquee() // TODO: 后续评估性能影响
)
}
Text(
text = category.ifEmpty { stringResource(R.string.tip_default_category) },
style = MaterialTheme.typography.labelMedium,
textDecoration = if (completed) TextDecoration.LineThrough else TextDecoration.None,
maxLines = 1
)
}
AnimatedVisibility(!selected && !completed) {
@ -172,7 +179,7 @@ fun TodoCard(
)
}*/
}
}
}
/*

View file

@ -9,6 +9,7 @@ import androidx.compose.animation.core.LinearOutSlowInEasing
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.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
@ -17,6 +18,8 @@ import androidx.compose.animation.slideOutHorizontally
*/
private const val ProgressThreshold = 0.35f
private const val DEFAULT_START_SCALE = 0.95f //0.92f
private const val THRESHOLD_ALPHA = 0.5f
private val Int.ForOutgoing: Int
get() = (this * ProgressThreshold).toInt()
@ -84,4 +87,31 @@ fun materialSharedAxisXOut(
delayMillis = 0,
easing = FastOutLinearInEasing
)
)
fun fadeThrough(
durationMillis: Int = 200,
): ContentTransform = ContentTransform(
fadeThroughIn(durationMillis = durationMillis),
fadeThroughOut(durationMillis = durationMillis)
)
fun fadeThroughIn(
durationMillis: Int = DefaultMotionDuration,
): EnterTransition =
fadeIn(
animationSpec = tween(durationMillis),
initialAlpha = THRESHOLD_ALPHA
) + scaleIn(
animationSpec = tween(durationMillis),
initialScale = DEFAULT_START_SCALE
)
fun fadeThroughOut(
durationMillis: Int = DefaultMotionDuration,
): ExitTransition = fadeOut(
animationSpec = tween(
durationMillis = durationMillis,
easing = FastOutLinearInEasing
)
)