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" applicationId = "cn.super12138.todo"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 952 versionCode = 955
versionName = "2.3.3" versionName = "2.3.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View file

@ -1,6 +1,9 @@
package cn.super12138.todo.ui package cn.super12138.todo.ui
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.foundation.shape.CornerBasedShape import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.material3.ButtonShapes
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -33,7 +36,7 @@ object TodoDefaults {
*/ */
val settingsItemVerticalPadding = 16.dp val settingsItemVerticalPadding = 16.dp
val settingsItemPadding = 2.dp val settingsItemPadding = 4.dp
/** /**
* 待办进度条粗度 * 待办进度条粗度
@ -70,13 +73,27 @@ object TodoDefaults {
@Composable @Composable
get() = MaterialTheme.colorScheme.surfaceContainer get() = MaterialTheme.colorScheme.surfaceContainer
val SettingsItemDefaultShape: CornerBasedShape
@Composable
get() = MaterialTheme.shapes.small
val SettingsItemRoundedShape: CornerBasedShape val fadedEdgeWidth = 8.dp
val defaultShape: CornerBasedShape
@Composable @Composable
get() = MaterialTheme.shapes.large 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 clickCount = 0
} }
}, }
topRounded = true
) )
} }
item { item {
@ -104,8 +103,7 @@ fun SettingsAbout(
leadingIconRes = R.drawable.ic_code_blocks, leadingIconRes = R.drawable.ic_code_blocks,
title = stringResource(R.string.pref_developer_options), title = stringResource(R.string.pref_developer_options),
description = stringResource(R.string.pref_developer_options_desc), description = stringResource(R.string.pref_developer_options_desc),
onClick = toDevPage, onClick = toDevPage
bottomRounded = true
) )
} }
} }

View file

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

View file

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

View file

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

View file

@ -92,11 +92,6 @@ fun SettingsDataCategory(
items = categories, items = categories,
key = { it } key = { it }
) { category -> ) { 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( SettingsItem(
headlineContent = { headlineContent = {
Text( Text(
@ -125,8 +120,6 @@ fun SettingsDataCategory(
initialCategory = category initialCategory = category
showDialog = true showDialog = true
}, },
topRounded = topRounded,
bottomRounded = bottomRounded,
modifier = Modifier.animateItem( modifier = Modifier.animateItem(
fadeInSpec = MaterialTheme.motionScheme.defaultEffectsSpec(), fadeInSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
placementSpec = MaterialTheme.motionScheme.defaultSpatialSpec(), placementSpec = MaterialTheme.motionScheme.defaultSpatialSpec(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,8 +25,6 @@ fun PalettePicker(
isDynamicColor: Boolean, isDynamicColor: Boolean,
isDarkMode: DarkMode, isDarkMode: DarkMode,
contrastLevel: ContrastLevel, contrastLevel: ContrastLevel,
topRounded: Boolean = false,
bottomRounded: Boolean = false
) { ) {
val paletteOptions = remember { PaletteStyle.entries.toList() } val paletteOptions = remember { PaletteStyle.entries.toList() }
@ -35,8 +33,6 @@ fun PalettePicker(
description = stringResource(R.string.pref_palette_style_desc), description = stringResource(R.string.pref_palette_style_desc),
horizontalArrangement = Arrangement.spacedBy(5.dp), horizontalArrangement = Arrangement.spacedBy(5.dp),
fadedEdgeWidth = TodoDefaults.fadedEdgeWidth, fadedEdgeWidth = TodoDefaults.fadedEdgeWidth,
topRounded = topRounded,
bottomRounded = bottomRounded,
modifier = modifier modifier = modifier
) { ) {
items(items = paletteOptions, key = { it.id }) { 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.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn 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.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -119,12 +119,10 @@ fun SharedTransitionScope.TasksPage(
) )
} }
} else { } else {
itemsIndexed( items(
items = filteredTodoList, items = filteredTodoList,
key = { _, task -> task.id } key = { it.id }
) { index, task -> ) { task ->
val topRounded by remember { derivedStateOf { index == 0 } }
val bottomRounded by remember { derivedStateOf { index == filteredTodoList.size - 1 } }
TodoCard( TodoCard(
// id = item.id, // id = item.id,
content = task.content, content = task.content,
@ -144,8 +142,6 @@ fun SharedTransitionScope.TasksPage(
viewModel.updateTodo(task.copy(isCompleted = true)) viewModel.updateTodo(task.copy(isCompleted = true))
viewModel.playConfetti() viewModel.playConfetti()
}, },
topRounded = topRounded,
bottomRounded = bottomRounded,
modifier = Modifier modifier = Modifier
.sharedBounds( .sharedBounds(
sharedContentState = rememberSharedContentState(key = "${Constants.KEY_TODO_ITEM_TRANSITION}_${task.id}"), 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.background
import androidx.compose.foundation.basicMarquee import androidx.compose.foundation.basicMarquee
import androidx.compose.foundation.combinedClickable 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.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerBasedShape
import androidx.compose.material3.Badge import androidx.compose.material3.Badge
import androidx.compose.material3.ButtonShapes
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@ -28,6 +30,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.R
import cn.super12138.todo.logic.model.Priority import cn.super12138.todo.logic.model.Priority
import cn.super12138.todo.ui.TodoDefaults import cn.super12138.todo.ui.TodoDefaults
import cn.super12138.todo.ui.theme.shapeByInteraction
import cn.super12138.todo.utils.VibrationUtils import cn.super12138.todo.utils.VibrationUtils
import cn.super12138.todo.utils.containerColor import cn.super12138.todo.utils.containerColor
import cn.super12138.todo.utils.getPartialRoundedShape
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@ -57,111 +60,115 @@ fun TodoCard(
onCardClick: () -> Unit = {}, onCardClick: () -> Unit = {},
onCardLongClick: () -> Unit = {}, onCardLongClick: () -> Unit = {},
onChecked: () -> Unit = {}, onChecked: () -> Unit = {},
shape: CornerBasedShape = TodoDefaults.SettingsItemDefaultShape, shapes: ButtonShapes = TodoDefaults.shapes(),
roundedShape: CornerBasedShape = TodoDefaults.SettingsItemRoundedShape,
topRounded: Boolean = false,
bottomRounded: Boolean = false
) { ) {
val view = LocalView.current val view = LocalView.current
// TODO: 滑动删除 // TODO: 滑动删除
val animatedContainerColor by animateColorAsState(if (selected) MaterialTheme.colorScheme.secondaryContainer else TodoDefaults.ContainerColor) val animatedContainerColor by animateColorAsState(if (selected) MaterialTheme.colorScheme.secondaryContainer else TodoDefaults.ContainerColor)
Row( val interactionSource = remember { MutableInteractionSource() }
verticalAlignment = Alignment.CenterVertically, val pressed by interactionSource.collectIsPressedAsState()
modifier = modifier val animatedShape = shapeByInteraction(
.fillMaxWidth() shapes = shapes,
.height(TodoDefaults.toDoCardHeight) pressed = if (selected) true else pressed,
.clip(shape.getPartialRoundedShape(topRounded, bottomRounded, roundedShape)) animationSpec = TodoDefaults.shapesDefaultAnimationSpec
.combinedClickable( )
onClick = {
VibrationUtils.performHapticFeedback(view) Row(
onCardClick() verticalAlignment = Alignment.CenterVertically,
}, modifier = modifier
// 不再需要使用VibrationUtils.performHapticFeedback(view, HapticFeedbackConstants.LONG_PRESS) .fillMaxWidth()
// 因为 combinedClickable 在更新的 Compose 里已经处理好了触感反馈 .height(TodoDefaults.toDoCardHeight)
onLongClick = onCardLongClick .clip(animatedShape)
) .combinedClickable(
.background(animatedContainerColor) interactionSource = interactionSource,
.padding(horizontal = TodoDefaults.screenHorizontalPadding) 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( Box(
visible = selected, Modifier
enter = fadeIn(MaterialTheme.motionScheme.fastSpatialSpec()) + expandHorizontally( .padding(end = 15.dp)
MaterialTheme.motionScheme.fastSpatialSpec() .clip(CircleShape)
), .background(MaterialTheme.colorScheme.secondary)
exit = fadeOut(MaterialTheme.motionScheme.fastSpatialSpec()) + shrinkHorizontally( .padding(5.dp)
MaterialTheme.motionScheme.fastSpatialSpec()
)
) { ) {
Box( Icon(
Modifier painter = painterResource(R.drawable.ic_check),
.padding(end = 15.dp) tint = contentColorFor(MaterialTheme.colorScheme.secondary),
.clip(CircleShape) contentDescription = stringResource(R.string.tip_selected)
.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)
)
}
} }
}
Column( Column(
verticalArrangement = Arrangement.spacedBy( verticalArrangement = Arrangement.spacedBy(
space = 5.dp, space = 5.dp,
alignment = Alignment.CenterVertically alignment = Alignment.CenterVertically
), ),
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxSize() .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( Badge(containerColor = MaterialTheme.colorScheme.primary) {
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(
text = stringResource(priority.nameRes), text = category.ifEmpty { stringResource(R.string.tip_default_category) },
style = MaterialTheme.typography.labelMedium.copy(priority.containerColor()), style = MaterialTheme.typography.labelMedium,
textDecoration = if (completed) TextDecoration.LineThrough else TextDecoration.None, textDecoration = if (completed) TextDecoration.LineThrough else TextDecoration.None,
maxLines = 1
) )
} }
}
AnimatedVisibility(!selected && !completed) { Text(
IconButton( text = stringResource(priority.nameRes),
onClick = { style = MaterialTheme.typography.labelMedium.copy(priority.containerColor()),
VibrationUtils.performHapticFeedback(view) textDecoration = if (completed) TextDecoration.LineThrough else TextDecoration.None,
onChecked() )
} }
) { }
Icon(
painter = painterResource(R.drawable.ic_check), AnimatedVisibility(!selected && !completed) {
tint = MaterialTheme.colorScheme.primary, IconButton(
contentDescription = stringResource(R.string.tip_mark_completed) onClick = {
) VibrationUtils.performHapticFeedback(view)
onChecked()
} }
) {
Icon(
painter = painterResource(R.drawable.ic_check),
tint = MaterialTheme.colorScheme.primary,
contentDescription = stringResource(R.string.tip_mark_completed)
)
} }
/*Box( /*Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier 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