From bddc9af2f7d15fc197cbcf4ddede438c6fa04368 Mon Sep 17 00:00:00 2001 From: Super12138 <70494801+Super12138@users.noreply.github.com> Date: Fri, 6 Feb 2026 09:39:05 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E5=8A=A8=E7=94=BB=E5=92=8C=E9=A1=B5=E9=9D=A2=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 2 +- .../cn/super12138/todo/ui/TodoDefaults.kt | 29 ++- .../todo/ui/pages/settings/SettingsAbout.kt | 6 +- .../ui/pages/settings/SettingsAboutLicence.kt | 20 +- .../ui/pages/settings/SettingsAppearance.kt | 6 +- .../todo/ui/pages/settings/SettingsData.kt | 10 +- .../ui/pages/settings/SettingsDataCategory.kt | 7 - .../settings/SettingsDeveloperOptions.kt | 4 +- .../settings/SettingsInterfaceInteraction.kt | 14 +- .../todo/ui/pages/settings/SettingsMain.kt | 6 +- .../settings/components/RowSettingsItem.kt | 16 +- .../settings/components/SettingsDialogs.kt | 20 +- .../pages/settings/components/SettingsItem.kt | 54 +++-- .../settings/components/SwitchSettingsItem.kt | 7 +- .../appearance/contrast/ContrastPicker.kt | 4 - .../appearance/darkmode/DarkModePicker.kt | 4 - .../appearance/palette/PalettePicker.kt | 4 - .../todo/ui/pages/tasks/TasksPage.kt | 12 +- .../ui/pages/tasks/components/TodoCard.kt | 189 +++++++++--------- .../cn/super12138/todo/ui/theme/Shape.kt | 175 ++++++++++++++++ 20 files changed, 366 insertions(+), 223 deletions(-) create mode 100644 app/src/main/kotlin/cn/super12138/todo/ui/theme/Shape.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c22098..1497ff9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/TodoDefaults.kt b/app/src/main/kotlin/cn/super12138/todo/ui/TodoDefaults.kt index 7a3de92..ad31b96 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/TodoDefaults.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/TodoDefaults.kt @@ -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 + @Composable + get() = MaterialTheme.motionScheme.defaultEffectsSpec() } \ No newline at end of file diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAbout.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAbout.kt index b090140..7b63d23 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAbout.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAbout.kt @@ -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 ) } } diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAboutLicence.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAboutLicence.kt index 4797528..5fa612f 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAboutLicence.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAboutLicence.kt @@ -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( diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAppearance.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAppearance.kt index 7eac076..b451fef 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAppearance.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsAppearance.kt @@ -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) } } ) } } diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsData.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsData.kt index c42671d..f7eb820 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsData.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsData.kt @@ -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 ) } } diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsDataCategory.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsDataCategory.kt index fc6e788..197b649 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsDataCategory.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsDataCategory.kt @@ -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(), diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsDeveloperOptions.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsDeveloperOptions.kt index f57ff34..e7cedce 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsDeveloperOptions.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsDeveloperOptions.kt @@ -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 ) } } diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsInterfaceInteraction.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsInterfaceInteraction.kt index afbd75b..f7ab29c 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsInterfaceInteraction.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsInterfaceInteraction.kt @@ -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)) } diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsMain.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsMain.kt index 1ee7715..c513d26 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsMain.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/SettingsMain.kt @@ -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 ) } } diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/RowSettingsItem.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/RowSettingsItem.kt index ff1c36e..8121e8c 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/RowSettingsItem.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/RowSettingsItem.kt @@ -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, diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsDialogs.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsDialogs.kt index 0e1b4a1..82f3363 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsDialogs.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsDialogs.kt @@ -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) diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsItem.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsItem.kt index 9be0799..7087695 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsItem.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SettingsItem.kt @@ -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) diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SwitchSettingsItem.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SwitchSettingsItem.kt index 3ab8e77..f81ca80 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SwitchSettingsItem.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/SwitchSettingsItem.kt @@ -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, diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/appearance/contrast/ContrastPicker.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/appearance/contrast/ContrastPicker.kt index 2edea55..95a1496 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/appearance/contrast/ContrastPicker.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/appearance/contrast/ContrastPicker.kt @@ -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) } diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/appearance/darkmode/DarkModePicker.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/appearance/darkmode/DarkModePicker.kt index 1d5f177..9f6ceac 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/appearance/darkmode/DarkModePicker.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/appearance/darkmode/DarkModePicker.kt @@ -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 }) { diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/appearance/palette/PalettePicker.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/appearance/palette/PalettePicker.kt index d0dc5ec..ac4997e 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/appearance/palette/PalettePicker.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/settings/components/appearance/palette/PalettePicker.kt @@ -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 }) { diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/TasksPage.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/TasksPage.kt index 83a5b10..7443bff 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/TasksPage.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/TasksPage.kt @@ -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}"), diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/components/TodoCard.kt b/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/components/TodoCard.kt index 12d7a15..5aaa0d4 100644 --- a/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/components/TodoCard.kt +++ b/app/src/main/kotlin/cn/super12138/todo/ui/pages/tasks/components/TodoCard.kt @@ -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( ) }*/ } - + } } /* diff --git a/app/src/main/kotlin/cn/super12138/todo/ui/theme/Shape.kt b/app/src/main/kotlin/cn/super12138/todo/ui/theme/Shape.kt new file mode 100644 index 0000000..a55cc50 --- /dev/null +++ b/app/src/main/kotlin/cn/super12138/todo/ui/theme/Shape.kt @@ -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, +): 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, +) { + var size: Size = Size.Zero + var density: Density = Density(0f, 0f) + + private var topStart: Animatable? = null + + private var topEnd: Animatable? = null + + private var bottomStart: Animatable? = null + + private var bottomEnd: Animatable? = 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, +): Shape { + val state = + remember(animationSpec) { AnimatedShapeState(shape = currentShape, spec = animationSpec) } + + val channel = remember { Channel(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