refactor: 重构点击动画和页面样式

This commit is contained in:
Super12138 2026-02-06 09:39:05 +08:00
parent a487e4b890
commit bddc9af2f7
20 changed files with 366 additions and 223 deletions

View file

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

View file

@ -1,6 +1,9 @@
package cn.super12138.todo.ui
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.material3.ButtonShapes
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
@ -33,7 +36,7 @@ object TodoDefaults {
*/
val settingsItemVerticalPadding = 16.dp
val settingsItemPadding = 2.dp
val settingsItemPadding = 4.dp
/**
* 待办进度条粗度
@ -70,13 +73,27 @@ object TodoDefaults {
@Composable
get() = MaterialTheme.colorScheme.surfaceContainer
val SettingsItemDefaultShape: CornerBasedShape
@Composable
get() = MaterialTheme.shapes.small
val SettingsItemRoundedShape: CornerBasedShape
val fadedEdgeWidth = 8.dp
val defaultShape: CornerBasedShape
@Composable
get() = MaterialTheme.shapes.large
val fadedEdgeWidth = 8.dp
val pressedShape: CornerBasedShape
@Composable
get() = MaterialTheme.shapes.small
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun shapes() = ButtonShapes(
shape = defaultShape,
pressedShape = pressedShape
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
val shapesDefaultAnimationSpec: FiniteAnimationSpec<Float>
@Composable
get() = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
}

View file

@ -71,8 +71,7 @@ fun SettingsAbout(
}
clickCount = 0
}
},
topRounded = true
}
)
}
item {
@ -104,8 +103,7 @@ fun SettingsAbout(
leadingIconRes = R.drawable.ic_code_blocks,
title = stringResource(R.string.pref_developer_options),
description = stringResource(R.string.pref_developer_options_desc),
onClick = toDevPage,
bottomRounded = true
onClick = toDevPage
)
}
}

View file

@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
@ -16,10 +16,8 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@ -55,17 +53,11 @@ fun SettingsAboutLicence(
modifier = modifier
) {
SettingsContainer(Modifier.fillMaxSize()) {
itemsIndexed(
items(
items = libraries?.libraries ?: listOf(),
key = { _, library -> library.artifactId }
) { index, library ->
key = { it.artifactId }
) { library ->
var openDialog by rememberSaveable { mutableStateOf(false) }
val topRounded by remember { derivedStateOf { index == 0 } }
val bottomRounded by remember {
derivedStateOf {
index == (libraries?.libraries?.size ?: 1) - 1
}
}
SettingsItem(
headlineContent = {
Row(
@ -126,9 +118,7 @@ fun SettingsAboutLicence(
}
}
}
},
topRounded = topRounded,
bottomRounded = bottomRounded
}
)
BasicDialog(

View file

@ -46,8 +46,7 @@ fun SettingsAppearance(
leadingIconRes = R.drawable.ic_wand_stars,
title = stringResource(R.string.pref_appearance_dynamic_color),
description = stringResource(R.string.pref_appearance_dynamic_color_desc),
onCheckedChange = { scope.launch { DataStoreManager.setDynamicColor(it) } },
topRounded = true
onCheckedChange = { scope.launch { DataStoreManager.setDynamicColor(it) } }
)
}
@ -71,8 +70,7 @@ fun SettingsAppearance(
item(key = 4) {
ContrastPicker(
currentContrast = ContrastLevel.fromFloat(contrastLevel),
onContrastChange = { scope.launch { DataStoreManager.setContrastLevel(it.value) } },
bottomRounded = true
onContrastChange = { scope.launch { DataStoreManager.setContrastLevel(it.value) } }
)
}
}

View file

@ -113,8 +113,7 @@ fun SettingsData(
description = stringResource(R.string.pref_backup_desc),
onClick = {
backupLauncher.launch("Todo-backup-${SystemUtils.getTime()}.zip")
},
topRounded = true
}
)
}
item {
@ -124,8 +123,7 @@ fun SettingsData(
description = stringResource(R.string.pref_restore_desc),
onClick = {
restoreLauncher.launch(arrayOf("application/zip"))
},
bottomRounded = true
}
)
}
@ -135,9 +133,7 @@ fun SettingsData(
leadingIconRes = R.drawable.ic_category,
title = stringResource(R.string.pref_category_category_management),
description = stringResource(R.string.pref_category_management_desc),
onClick = toCategoryManager,
topRounded = true,
bottomRounded = true
onClick = toCategoryManager
)
}
}

View file

@ -92,11 +92,6 @@ fun SettingsDataCategory(
items = categories,
key = { it }
) { category ->
// compute rounding by content rather than by index so insertion/moves
// correctly update rounded corners while keeping content key for animations
val topRounded = category == categories.first()
val bottomRounded = category == categories.last()
SettingsItem(
headlineContent = {
Text(
@ -125,8 +120,6 @@ fun SettingsDataCategory(
initialCategory = category
showDialog = true
},
topRounded = topRounded,
bottomRounded = bottomRounded,
modifier = Modifier.animateItem(
fadeInSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
placementSpec = MaterialTheme.motionScheme.defaultSpatialSpec(),

View file

@ -27,9 +27,7 @@ fun SettingsDeveloperOptions(
SettingsItem(
leadingIconRes = R.drawable.ic_padding,
title = stringResource(R.string.pref_padding),
onClick = toPaddingPage,
topRounded = true,
bottomRounded = true
onClick = toPaddingPage
)
}
}

View file

@ -11,7 +11,6 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import cn.super12138.todo.R
import cn.super12138.todo.constants.Constants
@ -38,7 +37,6 @@ fun SettingsInterface(
val sortingMethod by DataStoreManager.sortingMethodFlow.collectAsState(initial = Constants.PREF_SORTING_METHOD_DEFAULT)
val hapticFeedback by DataStoreManager.hapticFeedbackFlow.collectAsState(initial = Constants.PREF_HAPTIC_FEEDBACK_DEFAULT)
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showSortingMethodDialog by rememberSaveable { mutableStateOf(false) }
TopAppBarScaffold(
@ -57,8 +55,7 @@ fun SettingsInterface(
title = stringResource(R.string.pref_show_completed),
description = stringResource(R.string.pref_show_completed_desc),
checked = showCompleted,
onCheckedChange = { scope.launch { DataStoreManager.setShowCompleted(it) } },
topRounded = true
onCheckedChange = { scope.launch { DataStoreManager.setShowCompleted(it) } }
)
}
item {
@ -66,8 +63,7 @@ fun SettingsInterface(
leadingIconRes = R.drawable.ic_sort,
title = stringResource(R.string.pref_sorting_method),
description = stringResource(SortingMethod.fromId(sortingMethod).nameRes),
onClick = { showSortingMethodDialog = true },
bottomRounded = true
onClick = { showSortingMethodDialog = true }
)
}
@ -78,8 +74,7 @@ fun SettingsInterface(
leadingIconRes = R.drawable.ic_shield,
title = stringResource(R.string.pref_secure_mode),
description = stringResource(R.string.pref_secure_mode_desc),
onCheckedChange = { scope.launch { DataStoreManager.setSecureMode(it) } },
topRounded = true
onCheckedChange = { scope.launch { DataStoreManager.setSecureMode(it) } }
)
}
@ -89,8 +84,7 @@ fun SettingsInterface(
leadingIconRes = R.drawable.ic_touch_long,
title = stringResource(R.string.pref_haptic_feedback),
description = stringResource(R.string.pref_haptic_feedback_desc),
onCheckedChange = { scope.launch { DataStoreManager.setHapticFeedback(it) } },
bottomRounded = true
onCheckedChange = { scope.launch { DataStoreManager.setHapticFeedback(it) } }
)
SettingsPlainBox(stringResource(R.string.pref_haptic_feedback_more_info))
}

View file

@ -30,8 +30,7 @@ fun SettingsMain(
leadingIconRes = R.drawable.ic_palette,
title = stringResource(R.string.pref_appearance),
description = stringResource(R.string.pref_appearance_desc),
onClick = toAppearancePage,
topRounded = true
onClick = toAppearancePage
)
}
@ -58,8 +57,7 @@ fun SettingsMain(
leadingIconRes = R.drawable.ic_info,
title = stringResource(R.string.pref_about),
description = stringResource(R.string.pref_about_desc),
onClick = toAboutPage,
bottomRounded = true
onClick = toAboutPage
)
}
}

View file

@ -29,7 +29,6 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.sp
import cn.super12138.todo.ui.TodoDefaults
import cn.super12138.todo.utils.drawFadedEdge
import cn.super12138.todo.utils.getPartialRoundedShape
@Composable
fun RowSettingsItem(
@ -44,8 +43,6 @@ fun RowSettingsItem(
scrollState: ScrollState = rememberScrollState(),
fadedEdgeWidth: Dp,
maskColor: Color = TodoDefaults.ContainerColor,
topRounded: Boolean = false,
bottomRounded: Boolean = false,
content: @Composable RowScope.() -> Unit
) {
MoreContentSettingsItem(
@ -54,8 +51,6 @@ fun RowSettingsItem(
description = description,
trailingContent = trailingContent,
background = background,
topRounded = topRounded,
bottomRounded = bottomRounded,
modifier = modifier
) {
Row(
@ -98,8 +93,6 @@ fun LazyRowSettingsItem(
verticalAlignment: Alignment.Vertical = Alignment.Top,
fadedEdgeWidth: Dp,
maskColor: Color = TodoDefaults.ContainerColor,
topRounded: Boolean = false,
bottomRounded: Boolean = false,
content: LazyListScope.() -> Unit
) {
MoreContentSettingsItem(
@ -107,8 +100,6 @@ fun LazyRowSettingsItem(
title = title,
description = description,
trailingContent = trailingContent,
topRounded = topRounded,
bottomRounded = bottomRounded,
background = background,
modifier = modifier
) {
@ -147,17 +138,14 @@ fun MoreContentSettingsItem(
description: String? = null,
trailingContent: (@Composable () -> Unit)? = null,
background: Color = TodoDefaults.ContainerColor,
shape: CornerBasedShape = TodoDefaults.SettingsItemDefaultShape,
roundedShape: CornerBasedShape = TodoDefaults.SettingsItemRoundedShape,
topRounded: Boolean = false,
bottomRounded: Boolean = false,
shape: CornerBasedShape = TodoDefaults.defaultShape,
content: @Composable () -> Unit
) {
Column(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.clip(shape.getPartialRoundedShape(topRounded, bottomRounded, roundedShape))
.clip(shape)
.background(background)
.padding(
horizontal = TodoDefaults.settingsItemHorizontalPadding,

View file

@ -1,5 +1,7 @@
package cn.super12138.todo.ui.pages.settings.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -10,10 +12,13 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -22,6 +27,7 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import cn.super12138.todo.ui.TodoDefaults
import cn.super12138.todo.ui.components.BasicDialog
import cn.super12138.todo.ui.theme.shapeByInteraction
import cn.super12138.todo.utils.VibrationUtils
@Composable
@ -42,7 +48,6 @@ fun SettingsRadioDialog(
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.clip(TodoDefaults.SettingsItemRoundedShape)
.selectableGroup()
.verticalScroll(rememberScrollState())
) {
@ -63,6 +68,7 @@ fun SettingsRadioDialog(
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun RadioItem(
selected: Boolean,
@ -71,13 +77,23 @@ fun RadioItem(
modifier: Modifier = Modifier
) {
val view = LocalView.current
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
Row(
modifier
.fillMaxWidth()
.height(56.dp)
.clip(TodoDefaults.SettingsItemDefaultShape)
.clip(
shapeByInteraction(
shapes = TodoDefaults.shapes(),
pressed = pressed,
animationSpec = TodoDefaults.shapesDefaultAnimationSpec
)
)
// .background(MaterialTheme.colorScheme.surfaceContainerHighest)
.selectable(
interactionSource = interactionSource,
selected = selected,
onClick = {
VibrationUtils.performHapticFeedback(view)

View file

@ -3,17 +3,21 @@ package cn.super12138.todo.ui.pages.settings.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.material3.ButtonShapes
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -25,18 +29,17 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import cn.super12138.todo.ui.TodoDefaults
import cn.super12138.todo.ui.theme.shapeByInteraction
import cn.super12138.todo.utils.VibrationUtils
import cn.super12138.todo.utils.getPartialRoundedShape
// Leading icon as drawable resource
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SettingsItem(
modifier: Modifier = Modifier,
@DrawableRes leadingIconRes: Int,
title: String,
description: String? = null,
topRounded: Boolean = false,
bottomRounded: Boolean = false,
enableClick: Boolean = true,
onClick: () -> Unit = {}
) = SettingsItem(
@ -44,8 +47,6 @@ fun SettingsItem(
title = title,
description = description,
trailingContent = null,
topRounded = topRounded,
bottomRounded = bottomRounded,
enableClick = enableClick,
onClick = onClick,
modifier = modifier
@ -61,8 +62,7 @@ fun SettingsItem(
description: String? = null,
trailingContent: (@Composable () -> Unit)? = null,
background: Color = TodoDefaults.ContainerColor,
topRounded: Boolean = false,
bottomRounded: Boolean = false,
shapes: ButtonShapes = TodoDefaults.shapes(),
enableClick: Boolean = true,
onClick: () -> Unit = {}
) = SettingsItem(
@ -91,22 +91,20 @@ fun SettingsItem(
description = description,
trailingContent = trailingContent,
background = background,
topRounded = topRounded,
bottomRounded = bottomRounded,
shapes = shapes,
enableClick = enableClick,
onClick = onClick,
modifier = modifier
)
// Leading icon as ImageVector
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SettingsItem(
modifier: Modifier = Modifier,
leadingIcon: ImageVector? = null,
title: String,
description: String? = null,
topRounded: Boolean = false,
bottomRounded: Boolean = false,
enableClick: Boolean = true,
onClick: () -> Unit = {}
) = SettingsItem(
@ -114,13 +112,12 @@ fun SettingsItem(
title = title,
description = description,
trailingContent = null,
topRounded = topRounded,
bottomRounded = bottomRounded,
enableClick = enableClick,
onClick = onClick,
modifier = modifier
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SettingsItem(
modifier: Modifier = Modifier,
@ -129,8 +126,7 @@ fun SettingsItem(
description: String? = null,
trailingContent: (@Composable () -> Unit)? = null,
background: Color = TodoDefaults.ContainerColor,
topRounded: Boolean = false,
bottomRounded: Boolean = false,
shapes: ButtonShapes = TodoDefaults.shapes(),
enableClick: Boolean = true,
onClick: () -> Unit = {}
) = SettingsItem(
@ -148,9 +144,8 @@ fun SettingsItem(
description = description,
trailingContent = trailingContent,
background = background,
topRounded = topRounded,
bottomRounded = bottomRounded,
enableClick = enableClick,
shapes = shapes,
onClick = onClick,
modifier = modifier
)
@ -165,10 +160,7 @@ fun SettingsItem(
description: String? = null,
trailingContent: (@Composable () -> Unit)? = null,
background: Color = TodoDefaults.ContainerColor,
shape: CornerBasedShape = TodoDefaults.SettingsItemDefaultShape,
roundedShape: CornerBasedShape = TodoDefaults.SettingsItemRoundedShape,
topRounded: Boolean = false,
bottomRounded: Boolean = false,
shapes: ButtonShapes = TodoDefaults.shapes(),
enableClick: Boolean = true,
onClick: () -> Unit = {},
) = SettingsItem(
@ -199,10 +191,7 @@ fun SettingsItem(
},
trailingContent = trailingContent,
background = background,
shape = shape,
roundedShape = roundedShape,
topRounded = topRounded,
bottomRounded = bottomRounded,
shapes = shapes,
enableClick = enableClick,
onClick = onClick
)
@ -216,20 +205,23 @@ fun SettingsItem(
supportingContent: (@Composable () -> Unit)? = null,
trailingContent: (@Composable () -> Unit)? = null,
background: Color = TodoDefaults.ContainerColor,
shape: CornerBasedShape = TodoDefaults.SettingsItemDefaultShape,
roundedShape: CornerBasedShape = TodoDefaults.SettingsItemRoundedShape,
topRounded: Boolean = false,
bottomRounded: Boolean = false,
shapes: ButtonShapes = TodoDefaults.shapes(),
interactionSource: MutableInteractionSource? = null,
enableClick: Boolean = true,
onClick: () -> Unit = {},
) {
val view = LocalView.current
val userInteractionSource = interactionSource ?: remember { MutableInteractionSource() }
val pressed by userInteractionSource.collectIsPressedAsState()
val animatedShape = shapeByInteraction(shapes, pressed, TodoDefaults.shapesDefaultAnimationSpec)
Row(
modifier = modifier
.fillMaxWidth()
.wrapContentHeight()
.clip(shape.getPartialRoundedShape(topRounded, bottomRounded, roundedShape))
.clip(animatedShape)
.clickable(
interactionSource = userInteractionSource,
enabled = enableClick,
onClick = {
VibrationUtils.performHapticFeedback(view)

View file

@ -2,6 +2,7 @@ package cn.super12138.todo.ui.pages.settings.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -11,14 +12,13 @@ import androidx.compose.ui.res.painterResource
import cn.super12138.todo.ui.TodoDefaults
import cn.super12138.todo.utils.VibrationUtils
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SwitchSettingsItem(
modifier: Modifier = Modifier,
@DrawableRes leadingIconRes: Int,
title: String,
description: String? = null,
topRounded: Boolean = false,
bottomRounded: Boolean = false,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit
) {
@ -34,12 +34,11 @@ fun SwitchSettingsItem(
)
},
onClick = { onCheckedChange(!checked) },
topRounded = topRounded,
bottomRounded = bottomRounded,
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun SwitchSettingsItem(
modifier: Modifier = Modifier,

View file

@ -38,16 +38,12 @@ fun ContrastPicker(
modifier: Modifier = Modifier,
currentContrast: ContrastLevel,
onContrastChange: (ContrastLevel) -> Unit,
topRounded: Boolean = false,
bottomRounded: Boolean = false
) {
val view = LocalView.current
val context = LocalContext.current
MoreContentSettingsItem(
title = stringResource(R.string.pref_contrast_level),
description = stringResource(R.string.pref_contrast_level_desc),
topRounded = topRounded,
bottomRounded = bottomRounded,
modifier = modifier
) {
val contrastLevelName = ContrastLevel.entries.map { stringResource(it.nameRes) }

View file

@ -21,8 +21,6 @@ fun DarkModePicker(
modifier: Modifier = Modifier,
currentDarkMode: () -> DarkMode,
onDarkModeChange: (darkMode: DarkMode) -> Unit,
topRounded: Boolean = false,
bottomRounded: Boolean = false
) {
val isInDarkTheme = isSystemInDarkTheme()
@ -33,8 +31,6 @@ fun DarkModePicker(
description = stringResource(R.string.pref_dark_mode_desc),
horizontalArrangement = Arrangement.spacedBy(5.dp),
fadedEdgeWidth = TodoDefaults.fadedEdgeWidth,
topRounded = topRounded,
bottomRounded = bottomRounded,
modifier = modifier
) {
items(items = darkModeList, key = { it.id }) {

View file

@ -25,8 +25,6 @@ fun PalettePicker(
isDynamicColor: Boolean,
isDarkMode: DarkMode,
contrastLevel: ContrastLevel,
topRounded: Boolean = false,
bottomRounded: Boolean = false
) {
val paletteOptions = remember { PaletteStyle.entries.toList() }
@ -35,8 +33,6 @@ fun PalettePicker(
description = stringResource(R.string.pref_palette_style_desc),
horizontalArrangement = Arrangement.spacedBy(5.dp),
fadedEdgeWidth = TodoDefaults.fadedEdgeWidth,
topRounded = topRounded,
bottomRounded = bottomRounded,
modifier = modifier
) {
items(items = paletteOptions, key = { it.id }) {

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.itemsIndexed
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme
@ -119,12 +119,10 @@ fun SharedTransitionScope.TasksPage(
)
}
} else {
itemsIndexed(
items(
items = filteredTodoList,
key = { _, task -> task.id }
) { index, task ->
val topRounded by remember { derivedStateOf { index == 0 } }
val bottomRounded by remember { derivedStateOf { index == filteredTodoList.size - 1 } }
key = { it.id }
) { task ->
TodoCard(
// id = item.id,
content = task.content,
@ -144,8 +142,6 @@ fun SharedTransitionScope.TasksPage(
viewModel.updateTodo(task.copy(isCompleted = true))
viewModel.playConfetti()
},
topRounded = topRounded,
bottomRounded = bottomRounded,
modifier = Modifier
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "${Constants.KEY_TODO_ITEM_TRANSITION}_${task.id}"),

View file

@ -9,6 +9,8 @@ import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -18,8 +20,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.ButtonShapes
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -28,6 +30,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -40,9 +43,9 @@ import androidx.compose.ui.unit.dp
import cn.super12138.todo.R
import cn.super12138.todo.logic.model.Priority
import cn.super12138.todo.ui.TodoDefaults
import cn.super12138.todo.ui.theme.shapeByInteraction
import cn.super12138.todo.utils.VibrationUtils
import cn.super12138.todo.utils.containerColor
import cn.super12138.todo.utils.getPartialRoundedShape
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@ -57,111 +60,115 @@ fun TodoCard(
onCardClick: () -> Unit = {},
onCardLongClick: () -> Unit = {},
onChecked: () -> Unit = {},
shape: CornerBasedShape = TodoDefaults.SettingsItemDefaultShape,
roundedShape: CornerBasedShape = TodoDefaults.SettingsItemRoundedShape,
topRounded: Boolean = false,
bottomRounded: Boolean = false
shapes: ButtonShapes = TodoDefaults.shapes(),
) {
val view = LocalView.current
// TODO: 滑动删除
val animatedContainerColor by animateColorAsState(if (selected) MaterialTheme.colorScheme.secondaryContainer else TodoDefaults.ContainerColor)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.height(TodoDefaults.toDoCardHeight)
.clip(shape.getPartialRoundedShape(topRounded, bottomRounded, roundedShape))
.combinedClickable(
onClick = {
VibrationUtils.performHapticFeedback(view)
onCardClick()
},
// 不再需要使用VibrationUtils.performHapticFeedback(view, HapticFeedbackConstants.LONG_PRESS)
// 因为 combinedClickable 在更新的 Compose 里已经处理好了触感反馈
onLongClick = onCardLongClick
)
.background(animatedContainerColor)
.padding(horizontal = TodoDefaults.screenHorizontalPadding)
val interactionSource = remember { MutableInteractionSource() }
val pressed by interactionSource.collectIsPressedAsState()
val animatedShape = shapeByInteraction(
shapes = shapes,
pressed = if (selected) true else pressed,
animationSpec = TodoDefaults.shapesDefaultAnimationSpec
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.height(TodoDefaults.toDoCardHeight)
.clip(animatedShape)
.combinedClickable(
interactionSource = interactionSource,
onClick = {
VibrationUtils.performHapticFeedback(view)
onCardClick()
},
// 不再需要使用VibrationUtils.performHapticFeedback(view, HapticFeedbackConstants.LONG_PRESS)
// 因为 combinedClickable 在更新的 Compose 里已经处理好了触感反馈
onLongClick = onCardLongClick
)
.background(animatedContainerColor)
.padding(horizontal = TodoDefaults.screenHorizontalPadding)
) {
AnimatedVisibility(
visible = selected,
enter = fadeIn(MaterialTheme.motionScheme.fastSpatialSpec()) + expandHorizontally(
MaterialTheme.motionScheme.fastSpatialSpec()
),
exit = fadeOut(MaterialTheme.motionScheme.fastSpatialSpec()) + shrinkHorizontally(
MaterialTheme.motionScheme.fastSpatialSpec()
)
) {
AnimatedVisibility(
visible = selected,
enter = fadeIn(MaterialTheme.motionScheme.fastSpatialSpec()) + expandHorizontally(
MaterialTheme.motionScheme.fastSpatialSpec()
),
exit = fadeOut(MaterialTheme.motionScheme.fastSpatialSpec()) + shrinkHorizontally(
MaterialTheme.motionScheme.fastSpatialSpec()
)
Box(
Modifier
.padding(end = 15.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondary)
.padding(5.dp)
) {
Box(
Modifier
.padding(end = 15.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.secondary)
.padding(5.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_check),
tint = contentColorFor(MaterialTheme.colorScheme.secondary),
contentDescription = stringResource(R.string.tip_selected)
)
}
Icon(
painter = painterResource(R.drawable.ic_check),
tint = contentColorFor(MaterialTheme.colorScheme.secondary),
contentDescription = stringResource(R.string.tip_selected)
)
}
}
Column(
verticalArrangement = Arrangement.spacedBy(
space = 5.dp,
alignment = Alignment.CenterVertically
),
modifier = Modifier
.weight(1f)
.fillMaxSize()
Column(
verticalArrangement = Arrangement.spacedBy(
space = 5.dp,
alignment = Alignment.CenterVertically
),
modifier = Modifier
.weight(1f)
.fillMaxSize()
) {
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)
) {
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
)
}
Badge(containerColor = MaterialTheme.colorScheme.primary) {
Text(
text = stringResource(priority.nameRes),
style = MaterialTheme.typography.labelMedium.copy(priority.containerColor()),
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) {
IconButton(
onClick = {
VibrationUtils.performHapticFeedback(view)
onChecked()
}
) {
Icon(
painter = painterResource(R.drawable.ic_check),
tint = MaterialTheme.colorScheme.primary,
contentDescription = stringResource(R.string.tip_mark_completed)
)
Text(
text = stringResource(priority.nameRes),
style = MaterialTheme.typography.labelMedium.copy(priority.containerColor()),
textDecoration = if (completed) TextDecoration.LineThrough else TextDecoration.None,
)
}
}
AnimatedVisibility(!selected && !completed) {
IconButton(
onClick = {
VibrationUtils.performHapticFeedback(view)
onChecked()
}
) {
Icon(
painter = painterResource(R.drawable.ic_check),
tint = MaterialTheme.colorScheme.primary,
contentDescription = stringResource(R.string.tip_mark_completed)
)
}
/*Box(
contentAlignment = Alignment.Center,
modifier = Modifier
@ -179,7 +186,7 @@ fun TodoCard(
)
}*/
}
}
}
/*

View file

@ -0,0 +1,175 @@
package cn.super12138.todo.ui.theme
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ButtonShapes
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
internal val ButtonShapes.hasRoundedCornerShapes: Boolean
get() = shape is RoundedCornerShape && pressedShape is RoundedCornerShape
/**
* 来自 Compose 内部类
*
* 根据交互状态返回对应的形状
*
* * 如果 *原形状* *按下形状* **均为圆角形状**则在形状变换时使用动画过渡
*
* @param shapes 按钮形状需包含原形状和按下形状
* @param pressed 是否处于按下状态
* @param animationSpec 动画曲线
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun shapeByInteraction(
shapes: ButtonShapes,
pressed: Boolean,
animationSpec: FiniteAnimationSpec<Float>,
): Shape {
val shape =
if (pressed) {
shapes.pressedShape
} else {
shapes.shape
}
ButtonDefaults.shapes()
if (shapes.hasRoundedCornerShapes)
return key(shapes) { rememberAnimatedShape(shape as RoundedCornerShape, animationSpec) }
return shape
}
@Stable
internal class AnimatedShapeState(
val shape: RoundedCornerShape,
val spec: FiniteAnimationSpec<Float>,
) {
var size: Size = Size.Zero
var density: Density = Density(0f, 0f)
private var topStart: Animatable<Float, AnimationVector1D>? = null
private var topEnd: Animatable<Float, AnimationVector1D>? = null
private var bottomStart: Animatable<Float, AnimationVector1D>? = null
private var bottomEnd: Animatable<Float, AnimationVector1D>? = null
fun topStart(size: Size = this.size, density: Density = this.density): Float {
return (topStart ?: Animatable(shape.topStart.toPx(size, density)).also { topStart = it })
.value
}
fun topEnd(size: Size = this.size, density: Density = this.density): Float {
return (topEnd ?: Animatable(shape.topEnd.toPx(size, density)).also { topEnd = it }).value
}
fun bottomStart(size: Size = this.size, density: Density = this.density): Float {
return (bottomStart
?: Animatable(shape.bottomStart.toPx(size, density)).also { bottomStart = it })
.value
}
fun bottomEnd(size: Size = this.size, density: Density = this.density): Float {
return (bottomEnd
?: Animatable(shape.bottomEnd.toPx(size, density)).also { bottomEnd = it })
.value
}
suspend fun animateToShape(shape: CornerBasedShape) = coroutineScope {
launch { topStart?.animateTo(shape.topStart.toPx(size, density), spec) }
launch { topEnd?.animateTo(shape.topEnd.toPx(size, density), spec) }
launch { bottomStart?.animateTo(shape.bottomStart.toPx(size, density), spec) }
launch { bottomEnd?.animateTo(shape.bottomEnd.toPx(size, density), spec) }
}
}
@Composable
private fun rememberAnimatedShape(state: AnimatedShapeState): Shape {
val density = LocalDensity.current
state.density = density
return remember(density, state) {
object : ShapeWithHorizontalCenterOptically {
var clampedRange by mutableStateOf(0f..1f)
override fun offset(): Float {
val topStart = state.topStart().coerceIn(clampedRange)
val topEnd = state.topEnd().coerceIn(clampedRange)
val bottomStart = state.bottomStart().coerceIn(clampedRange)
val bottomEnd = state.bottomEnd().coerceIn(clampedRange)
val avgStart = (topStart + bottomStart) / 2
val avgEnd = (topEnd + bottomEnd) / 2
return CenterOpticallyCoefficient * (avgStart - avgEnd)
}
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density,
): Outline {
state.size = size
clampedRange = 0f..size.height / 2
return RoundedCornerShape(
topStart = state.topStart().coerceIn(clampedRange),
topEnd = state.topEnd().coerceIn(clampedRange),
bottomStart = state.bottomStart().coerceIn(clampedRange),
bottomEnd = state.bottomEnd().coerceIn(clampedRange),
)
.createOutline(size, layoutDirection, density)
}
}
}
}
@Composable
internal fun rememberAnimatedShape(
currentShape: RoundedCornerShape,
animationSpec: FiniteAnimationSpec<Float>,
): Shape {
val state =
remember(animationSpec) { AnimatedShapeState(shape = currentShape, spec = animationSpec) }
val channel = remember { Channel<RoundedCornerShape>(Channel.CONFLATED) }
SideEffect { channel.trySend(currentShape) }
LaunchedEffect(state, channel) {
for (target in channel) {
val newTarget = channel.tryReceive().getOrNull() ?: target
launch { state.animateToShape(newTarget) }
}
}
return rememberAnimatedShape(state)
}
internal interface ShapeWithHorizontalCenterOptically : Shape {
fun offset(): Float
}
internal const val CenterOpticallyCoefficient = 0.11f