Browse Source

feat: 神秘界面优化和依赖更新

HapeLee 1 month ago
parent
commit
5c63a771a5

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/readRecord/ReadRecordScreen.kt

@@ -94,6 +94,7 @@ import io.legado.app.ui.widget.components.heatmap.WeekdayLabelsColumn
 import io.legado.app.ui.widget.components.heatmap.heatmapCalendarTitle
 import io.legado.app.ui.widget.components.heatmap.rememberDateRange
 import io.legado.app.ui.widget.components.heatmap.rememberDaysInRange
+import io.legado.app.ui.widget.components.heatmap.rememberHeatmapScale
 import io.legado.app.ui.widget.components.heatmap.rememberWeeks
 import io.legado.app.ui.widget.components.image.cover.CoilBookCover
 import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
@@ -847,6 +848,7 @@ fun HeatmapCalendarSection(
     val (startDate, endDate) = rememberDateRange(dailyReadCounts, dailyReadTimes)
     val days = rememberDaysInRange(startDate, endDate)
     val weeks = rememberWeeks(days, startDate)
+    val scale = rememberHeatmapScale(dailyReadCounts, dailyReadTimes)
 
     val listState = rememberLazyListState()
 
@@ -893,6 +895,7 @@ fun HeatmapCalendarSection(
                     HeatmapWeekColumn(
                         week = weeks[weekIndex],
                         mode = currentMode,
+                        scale = scale,
                         dailyReadCounts = dailyReadCounts,
                         dailyReadTimes = dailyReadTimes,
                         selectedDate = selectedDate,

+ 19 - 9
app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumToggleButton.kt

@@ -20,6 +20,10 @@ fun MediumToggleButton(
     text: String? = null,
     contentDescription: String? = null
 ) {
+    // 几何尺寸跟随对应的非 toggle 按钮:Tonal 对齐 MediumTonalButton,
+    // Outlined 对齐 MediumOutlinedButton(保留 48dp 最小交互尺寸与 12dp 纵向内边距)
+    val isTonal = style == ToggleStyle.Tonal
+
     SeriesButton(
         onClick = { onCheckedChange(!checked) },
         modifier = modifier,
@@ -27,21 +31,23 @@ fun MediumToggleButton(
         selected = checked,
         onLongClick = onLongClick,
         size = if (text == null) MediumSeriesIconButtonSize else null,
+        enforceMinimumInteractiveSize = isTonal,
         style = when (style) {
             ToggleStyle.Outlined -> SeriesIconButtonStyle.Outlined
             ToggleStyle.Tonal -> SeriesIconButtonStyle.Tonal
         },
-        // Outlined 未选中态留空,交给样式解析成透明容器
-        containerColor = if (style == ToggleStyle.Tonal) {
-            LegadoTheme.colorScheme.surfaceContainer
-        } else {
-            null
-        },
+        // 未选中容器色不在这里覆写,统一由 SeriesIconButtonStyle 解析
+        // (Tonal -> surfaceContainerLow,Outlined -> 透明),与对应的非 toggle 按钮一致
         contentColor = LegadoTheme.colorScheme.onSurfaceVariant,
-        // M3E 实心 toggle 选中态:反色容器,背景与描边用 inverseSurface,内容用 inverseOnSurface
+        // M3E 实心 toggle 选中态:反色容器,内容用 inverseOnSurface
         selectedContainerColor = LegadoTheme.colorScheme.inverseSurface,
         selectedContentColor = LegadoTheme.colorScheme.inverseOnSurface,
-        selectedBorderColor = LegadoTheme.colorScheme.inverseSurface
+        // 选中态描边只能覆盖已有描边:Outlined 有 1dp 描边,Tonal 无描边,传值也不会生效
+        selectedBorderColor = if (style == ToggleStyle.Outlined) {
+            LegadoTheme.colorScheme.inverseSurface
+        } else {
+            null
+        }
     ) { resolvedContentColor ->
         SeriesButtonContent(
             icon = if (checked) (iconChecked ?: icon)!! else icon!!,
@@ -50,7 +56,11 @@ fun MediumToggleButton(
             iconSize = MediumSeriesIconSize,
             textStyle = LegadoTheme.typography.labelMedium,
             contentColor = resolvedContentColor,
-            padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
+            padding = if (isTonal) {
+                PaddingValues(horizontal = 16.dp, vertical = 10.dp)
+            } else {
+                PaddingValues(horizontal = 16.dp, vertical = 12.dp)
+            },
             spacing = 8.dp
         )
     }

+ 20 - 11
app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallToggleButton.kt

@@ -22,6 +22,10 @@ fun SmallToggleButton(
     text: String? = null,
     contentDescription: String? = null
 ) {
+    // 几何尺寸跟随对应的非 toggle 按钮:Tonal 对齐 SmallTonalButton,
+    // Outlined 对齐 SmallOutlinedButton
+    val isTonal = style == ToggleStyle.Tonal
+
     SeriesButton(
         onClick = { onCheckedChange(!checked) },
         modifier = modifier,
@@ -35,27 +39,32 @@ fun SmallToggleButton(
             ToggleStyle.Outlined -> SeriesIconButtonStyle.Outlined
             ToggleStyle.Tonal -> SeriesIconButtonStyle.Tonal
         },
-        // Outlined 未选中态留空,交给样式解析成透明容器
-        containerColor = if (style == ToggleStyle.Tonal) {
-            LegadoTheme.colorScheme.surfaceContainer
-        } else {
-            null
-        },
+        // 未选中容器色不在这里覆写,统一由 SeriesIconButtonStyle 解析
+        // (Tonal -> surfaceContainerLow,Outlined -> 透明),与对应的非 toggle 按钮一致
         contentColor = LegadoTheme.colorScheme.onSurfaceVariant,
-        // M3E 实心 toggle 选中态:反色容器,背景与描边用 inverseSurface,内容用 inverseOnSurface
+        // M3E 实心 toggle 选中态:反色容器,内容用 inverseOnSurface
         selectedContainerColor = LegadoTheme.colorScheme.inverseSurface,
         selectedContentColor = LegadoTheme.colorScheme.inverseOnSurface,
-        selectedBorderColor = LegadoTheme.colorScheme.inverseSurface
+        // 选中态描边只能覆盖已有描边:Outlined 有 1dp 描边,Tonal 无描边,传值也不会生效
+        selectedBorderColor = if (style == ToggleStyle.Outlined) {
+            LegadoTheme.colorScheme.inverseSurface
+        } else {
+            null
+        }
     ) { contentColor ->
         SeriesButtonContent(
             icon = if (checked) (iconChecked ?: icon)!! else icon!!,
             text = text,
             contentDescription = contentDescription,
             iconSize = smallIconSize,
-            textStyle = LegadoTheme.typography.labelSmall,
+            textStyle = LegadoTheme.typography.labelMedium,
             contentColor = contentColor,
-            padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
-            spacing = 6.dp
+            padding = if (isTonal) {
+                PaddingValues(horizontal = 8.dp, vertical = 4.dp)
+            } else {
+                PaddingValues(horizontal = 8.dp, vertical = 6.dp)
+            },
+            spacing = 4.dp
         )
     }
 }

+ 24 - 14
app/src/main/java/io/legado/app/ui/widget/components/heatmap/HeatmapCalendarComponents.kt

@@ -31,19 +31,19 @@ import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.PathEffect
 import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.semantics.contentDescription
 import androidx.compose.ui.semantics.selected
 import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.semantics.stateDescription
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
-import androidx.compose.ui.res.stringResource
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.MediumToggleButton
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.button.series.ToggleStyle
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.formatReadDuration
 import java.time.LocalDate
@@ -67,9 +67,10 @@ fun HeatmapCalendarStartAction(
         onCheckedChange = {
             onModeChanged(if (it) HeatmapMode.TIME else HeatmapMode.COUNT)
         },
+        style = ToggleStyle.Tonal,
+        contentDescription = stringResource(R.string.by_duration),
         icon = Icons.Default.FormatListNumbered,
-        iconChecked = Icons.Default.AccessTime,
-        text = stringResource(R.string.by_duration)
+        iconChecked = Icons.Default.AccessTime
     )
 }
 
@@ -166,7 +167,10 @@ fun NoEarlierDataIndicator(
                                     color = outlineColor,
                                     style = stroke,
                                     topLeft = Offset(inset, inset),
-                                    size = Size(size.width - strokeWidth, size.height - strokeWidth),
+                                    size = Size(
+                                        size.width - strokeWidth,
+                                        size.height - strokeWidth
+                                    ),
                                     cornerRadius = CornerRadius(4.dp.toPx())
                                 )
                             }
@@ -200,6 +204,7 @@ fun NoEarlierDataIndicator(
 fun HeatmapCalendarCell(
     day: LocalDate,
     mode: HeatmapMode,
+    scale: HeatmapScale,
     dailyReadCounts: Map<LocalDate, Int>,
     dailyReadTimes: Map<LocalDate, Long>,
     isSelected: Boolean,
@@ -207,14 +212,17 @@ fun HeatmapCalendarCell(
     onDateSelected: ((LocalDate) -> Unit)?,
     modifier: Modifier = Modifier
 ) {
-    val level = rememberHeatmapLevel(day, mode, dailyReadCounts, dailyReadTimes)
+    val level = scale.levelOf(day, mode, dailyReadCounts, dailyReadTimes)
     val cellColor = heatmapColorForLevel(level)
-    val readCount = dailyReadCounts[day] ?: 0
-    val readDuration = formatReadDuration(dailyReadTimes[day] ?: 0L)
+    // 只取当前模式需要的文案,次数模式下不再为每个格子格式化时长
     val cellDescription = if (mode == HeatmapMode.COUNT) {
-        stringResource(R.string.a11y_heatmap_day_count, day.toString(), readCount)
+        stringResource(R.string.a11y_heatmap_day_count, day.toString(), dailyReadCounts[day] ?: 0)
     } else {
-        stringResource(R.string.a11y_heatmap_day_duration, day.toString(), readDuration)
+        stringResource(
+            R.string.a11y_heatmap_day_duration,
+            day.toString(),
+            formatReadDuration(dailyReadTimes[day] ?: 0L)
+        )
     }
     Box(
         modifier = modifier
@@ -253,6 +261,7 @@ fun HeatmapCalendarCell(
 fun HeatmapWeekColumn(
     week: List<LocalDate?>,
     mode: HeatmapMode,
+    scale: HeatmapScale,
     dailyReadCounts: Map<LocalDate, Int>,
     dailyReadTimes: Map<LocalDate, Long>,
     selectedDate: LocalDate?,
@@ -274,6 +283,7 @@ fun HeatmapWeekColumn(
                     HeatmapCalendarCell(
                         day = day,
                         mode = mode,
+                        scale = scale,
                         dailyReadCounts = dailyReadCounts,
                         dailyReadTimes = dailyReadTimes,
                         isSelected = day == selectedDate,
@@ -317,12 +327,12 @@ fun HeatmapLegend(
         AppText(
             stringResource(R.string.less_count) + legendUnit + ")",
             style = LegadoTheme.typography.bodySmall,
-            color = Color.Gray
+            color = LegadoTheme.colorScheme.onSurfaceVariant
         )
 
         Spacer(modifier = Modifier.width(4.dp))
 
-        for (level in 0..4) {
+        for (level in 0..HEATMAP_MAX_LEVEL) {
             Box(
                 modifier = Modifier
                     .size(config.legendSize)
@@ -335,7 +345,7 @@ fun HeatmapLegend(
         AppText(
             stringResource(R.string.more_count) + legendUnit + ")",
             style = LegadoTheme.typography.bodySmall,
-            color = Color.Gray
+            color = LegadoTheme.colorScheme.onSurfaceVariant
         )
     }
 }

+ 47 - 31
app/src/main/java/io/legado/app/ui/widget/components/heatmap/RememberDateUtil.kt

@@ -1,14 +1,22 @@
 package io.legado.app.ui.widget.components.heatmap
 
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.derivedStateOf
-import androidx.compose.runtime.getValue
+import androidx.compose.runtime.Immutable
 import androidx.compose.runtime.remember
 import androidx.compose.ui.graphics.Color
 import io.legado.app.ui.theme.LegadoTheme
 import java.time.LocalDate
 
+/** 热力图等级上限:0 表示无记录,[HEATMAP_MAX_LEVEL] 表示当日数据最多。 */
+const val HEATMAP_MAX_LEVEL = 4
+
+/**
+ * 各等级叠加在容器背景上的 primary 透明度。
+ * 用透明度叠加而不是固定色值,热力图才会跟随主题 primary,
+ * 并在浅色/深色、玻璃卡片等不同背景下保持单一色调递进。
+ */
+private val HEATMAP_LEVEL_ALPHAS = floatArrayOf(0.35f, 0.55f, 0.75f, 1f)
+
 /**
  * 计算日期范围:从最早有数据的日期前1个月到最晚有数据的日期后1个月
  */
@@ -64,23 +72,20 @@ fun rememberWeeks(days: List<LocalDate>, startDate: LocalDate): List<List<LocalD
 }
 
 /**
- * 计算日期对应的热力图等级(0-4)
+ * 热力图刻度:把每日数据映射到 0..[HEATMAP_MAX_LEVEL]。
+ * 上限在数据变化时只算一次,单元格共享同一刻度,避免每格各自遍历整张表。
  */
-@Composable
-fun rememberHeatmapLevel(
-    day: LocalDate,
-    mode: HeatmapMode,
-    dailyReadCounts: Map<LocalDate, Int>,
-    dailyReadTimes: Map<LocalDate, Long>
-): Int {
-    val maxCount by remember(dailyReadCounts) {
-        derivedStateOf { dailyReadCounts.values.maxOrNull()?.coerceAtLeast(1) ?: 1 }
-    }
-    val maxTime by remember(dailyReadTimes) {
-        derivedStateOf { dailyReadTimes.values.maxOrNull()?.coerceAtLeast(1L) ?: 1L }
-    }
-
-    return remember(day, mode, maxCount, maxTime) {
+@Immutable
+data class HeatmapScale(
+    val maxCount: Int,
+    val maxTime: Long,
+) {
+    fun levelOf(
+        day: LocalDate,
+        mode: HeatmapMode,
+        dailyReadCounts: Map<LocalDate, Int>,
+        dailyReadTimes: Map<LocalDate, Long>
+    ): Int {
         val fraction = if (mode == HeatmapMode.COUNT) {
             val value = dailyReadCounts[day] ?: 0
             value.toFloat() / maxCount
@@ -89,30 +94,41 @@ fun rememberHeatmapLevel(
             value.toFloat() / maxTime
         }
 
-        when {
-            fraction == 0f -> 0
+        return when {
+            fraction <= 0f -> 0
             fraction < 0.25f -> 1
             fraction < 0.5f -> 2
             fraction < 0.75f -> 3
-            else -> 4
+            else -> HEATMAP_MAX_LEVEL
         }
     }
 }
 
+@Composable
+fun rememberHeatmapScale(
+    dailyReadCounts: Map<LocalDate, Int>,
+    dailyReadTimes: Map<LocalDate, Long>
+): HeatmapScale {
+    return remember(dailyReadCounts, dailyReadTimes) {
+        HeatmapScale(
+            maxCount = dailyReadCounts.values.maxOrNull()?.coerceAtLeast(1) ?: 1,
+            maxTime = dailyReadTimes.values.maxOrNull()?.coerceAtLeast(1L) ?: 1L
+        )
+    }
+}
+
 /**
- * 根据等级获取对应的颜色
+ * 根据等级获取对应的颜色。
+ * 0 级使用主题 onSurface 的低透明度作为空态底色,保证在弹窗、玻璃卡片等
+ * 不同容器上都能看清网格;1..[HEATMAP_MAX_LEVEL] 全部由主题 primary 派生。
  */
 @Composable
 fun heatmapColorForLevel(
     level: Int,
     primary: Color = LegadoTheme.colorScheme.primary,
-    emptyColor: Color = LegadoTheme.colorScheme.surface
+    emptyColor: Color = LegadoTheme.colorScheme.onSurface.copy(alpha = 0.08f)
 ): Color {
-    return when (level) {
-        0 -> emptyColor
-        1 -> primary.copy(alpha = 0.35f)
-        2 -> primary.copy(alpha = 0.55f)
-        3 -> primary.copy(alpha = 0.75f)
-        else -> primary
-    }
+    if (level <= 0) return emptyColor
+    val alpha = HEATMAP_LEVEL_ALPHAS[(level - 1).coerceAtMost(HEATMAP_LEVEL_ALPHAS.lastIndex)]
+    return if (alpha >= 1f) primary else primary.copy(alpha = alpha)
 }

+ 87 - 41
app/src/main/java/io/legado/app/ui/widget/components/swipe/SwipeActionContainer.kt

@@ -2,14 +2,9 @@ package io.legado.app.ui.widget.components.swipe
 
 import androidx.compose.animation.animateColorAsState
 import androidx.compose.animation.core.animateFloatAsState
-import androidx.compose.foundation.background
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
 import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Surface
 import androidx.compose.material3.SwipeToDismissBox
 import androidx.compose.material3.SwipeToDismissBoxState
@@ -18,12 +13,19 @@ import androidx.compose.material3.contentColorFor
 import androidx.compose.material3.rememberSwipeToDismissBoxState
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
 import androidx.compose.ui.graphics.RectangleShape
-import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.graphics.vector.rememberVectorPainter
 import androidx.compose.ui.hapticfeedback.HapticFeedbackType
 import androidx.compose.ui.platform.LocalHapticFeedback
 import androidx.compose.ui.semantics.CustomAccessibilityAction
@@ -31,6 +33,19 @@ import androidx.compose.ui.semantics.customActions
 import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
+import kotlin.math.absoluteValue
+
+/** 滑动露出底块上下内缩量,让操作提示呈现为圆角矩形而不是整行色块。 */
+private val SwipeRevealVerticalInset = 6.dp
+
+/** 滑动露出底块在屏幕边缘一侧的内缩量,让圆角矩形与屏幕边缘之间保留边距。 */
+private val SwipeRevealEdgeInset = 16.dp
+
+/** 滑动露出底块的圆角半径。 */
+private val SwipeRevealCornerRadius = 16.dp
+
+/** 滑动操作图标尺寸。 */
+private val SwipeActionIconSize = 24.dp
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
@@ -79,8 +94,10 @@ fun SwipeActionContainer(
         enableDismissFromStartToEnd = startAction != null,
         enableDismissFromEndToStart = endAction != null,
         backgroundContent = {
-            val direction = dismissState.dismissDirection
-            val progress = dismissState.progress
+            // offset 在拖动过程中每帧变化,用派生状态收敛方向,避免拖动时逐帧重组背景层
+            val direction by remember(dismissState) {
+                derivedStateOf { dismissState.dismissDirection }
+            }
 
             when (direction) {
 
@@ -153,48 +170,77 @@ private fun SwipeBackground(
     dismissState: SwipeToDismissBoxState,
     alignStart: Boolean
 ) {
-    val isThresholdReached = dismissState.targetValue == if (alignStart)
+    val thresholdDirection = if (alignStart) {
         SwipeToDismissBoxValue.StartToEnd
-    else
+    } else {
         SwipeToDismissBoxValue.EndToStart
+    }
+    // 只在越过阈值时切换,避免拖动过程中逐帧重组
+    val isThresholdReached by remember(dismissState) {
+        derivedStateOf { dismissState.targetValue == thresholdDirection }
+    }
 
-    val backgroundColor by animateColorAsState(
-        targetValue = if (isThresholdReached)
+    val backgroundColor = animateColorAsState(
+        targetValue = if (isThresholdReached) {
             action.background
-        else
-            LegadoTheme.colorScheme.surfaceVariant,
+        } else {
+            LegadoTheme.colorScheme.surfaceVariant
+        },
         label = "bgColor"
     )
-
-    val iconScale by animateFloatAsState(
-        targetValue = if (isThresholdReached) 1.2f
-        else 1f,
+    val iconScale = animateFloatAsState(
+        targetValue = if (isThresholdReached) 1.2f else 1f,
         label = "iconScale"
     )
+    val iconTint = if (isThresholdReached) {
+        contentColorFor(action.background)
+    } else {
+        LegadoTheme.colorScheme.onSurfaceVariant
+    }
+    val iconPainter = rememberVectorPainter(action.icon)
 
     Box(
         modifier = Modifier
             .fillMaxSize()
-            .background(backgroundColor)
-            .padding(horizontal = 24.dp),
-        contentAlignment = if (alignStart)
-            Alignment.CenterStart
-        else
-            Alignment.CenterEnd
-    ) {
-        Icon(
-            imageVector = action.icon,
-            contentDescription = null,
-            modifier = Modifier
-                .size(24.dp)
-                .graphicsLayer {
-                    scaleX = iconScale
-                    scaleY = iconScale
-                },
-            tint = if (isThresholdReached)
-                contentColorFor(action.background)
-            else
-                LegadoTheme.colorScheme.onSurfaceVariant
-        )
-    }
+            .drawBehind {
+                // offset 在首次布局完成后才可读取,因此只能放到绘制阶段;
+                // 本组件仅在方向不为 Settled 时才会组合,此时 offset 必然已初始化。
+                val revealed = dismissState.requireOffset().absoluteValue
+                    .coerceIn(0f, size.width)
+                if (revealed <= 0f) return@drawBehind
+
+                val verticalInset = SwipeRevealVerticalInset.toPx()
+                val edgeInset = SwipeRevealEdgeInset.toPx()
+                val revealedHeight = (size.height - verticalInset * 2f).coerceAtLeast(0f)
+                // 外边缘与屏幕保持边距,内边缘仍与内容滑出的位置对齐
+                val revealedLeft = if (alignStart) edgeInset else size.width - revealed
+                val revealedWidth = (revealed - edgeInset).coerceAtLeast(0f)
+                if (revealedWidth <= 0f) return@drawBehind
+
+                // 随滑动距离增长的圆角底块
+                drawRoundRect(
+                    color = backgroundColor.value,
+                    topLeft = Offset(revealedLeft, verticalInset),
+                    size = Size(revealedWidth, revealedHeight),
+                    cornerRadius = CornerRadius(SwipeRevealCornerRadius.toPx())
+                )
+
+                val iconSize = SwipeActionIconSize.toPx() * iconScale.value
+                // 底块被拉开到能容纳图标之后,图标才居中出现并渐显
+                val iconAlpha = ((revealedWidth - iconSize) / iconSize).coerceIn(0f, 1f)
+                if (iconAlpha <= 0f) return@drawBehind
+
+                val iconLeft = revealedLeft + revealedWidth / 2f - iconSize / 2f
+                val iconTop = size.height / 2f - iconSize / 2f
+                translate(left = iconLeft, top = iconTop) {
+                    with(iconPainter) {
+                        draw(
+                            size = Size(iconSize, iconSize),
+                            alpha = iconAlpha,
+                            colorFilter = ColorFilter.tint(iconTint)
+                        )
+                    }
+                }
+            }
+    )
 }

+ 14 - 14
gradle/libs.versions.toml

@@ -4,24 +4,24 @@ accompanistWebview = "0.36.0"
 adaptive = "1.3.0"
 adaptiveLayout = "1.3.0"
 adaptiveNavigation = "1.3.0"
-animation = "1.12.0"
+animation = "1.12.1"
 biometric = "1.4.0-alpha07"
-coilCompose = "3.6.2"
-composeBom = "2026.08.00"
+coilCompose = "3.6.3"
+composeBom = "2026.09.00"
 constraintlayoutCompose = "1.1.2"
 coreSplashscreen = "1.2.0"
 datastorePreferences = "1.2.1"
-foundation = "1.12.0"
-kotlin = "2.4.10"
+foundation = "1.12.1"
+kotlin = "2.4.20"
 kotlinxSerialization = "1.11.0"
 kotlinxCoroutinesAndroid = "1.11.0"
 kotlinxSerializationJson = "1.11.0"
 kotlinxCollectionsImmutable = "0.5.2"
-ksp = "2.3.6"
+ksp = "2.3.12"
 agp = "9.2.1"
 appcompat = "1.8.0"
 colorpicker = "1.1.0"
-colorpicker-compose = "1.2.0"
+colorpicker-compose = "1.3.0"
 commonsText = "1.15.0"
 constraintlayout = "2.2.2"
 core = "1.19.0"
@@ -34,7 +34,7 @@ lifecycle = "2.11.0"
 glide = "5.0.9"
 gson = "2.14.0"
 jsonPath = "3.0.0"
-ktor = "3.5.2"
+ktor = "3.6.0"
 # issue #3811,不要更新版本,新版引入了一个破坏性变更(详见https://github.com/jhy/jsoup/pull/2017)
 # 若要升级请确保相关代码不会受此变更影响(如AnalyzeByJSoup.kt、JsoupXpath库等)
 intellijMarkdown = "0.7.12"
@@ -44,14 +44,14 @@ coroutines = "1.11.0"
 kotlinxDatetime = "0.8.0"
 lifecycleViewmodelCompose = "2.11.0"
 liveeventbus = "1.8.14"
-navigation3 = "1.2.0-beta01"
+navigation3 = "1.2.0-rc01"
 # 1.2.0-alpha02 修复了预测式返回手势中 NavigationEventInput.dispatchOnBackProgressed
 # 对已分离输入抛 IllegalStateException 的崩溃(1.1.2 仍会 checkNotNull 抛异常)。
 # 覆盖 navigation3 1.1.4 传递依赖的 1.1.2,等上游发布含该修复的稳定版后可移除。
 navigationevent = "1.2.0-alpha04"
 markwon = "4.6.2"
 material = "1.14.0"
-material3 = "1.5.0-alpha27"
+material3 = "1.5.0-alpha28"
 material3IconsExtended = "1.7.8"
 materialKolor = "5.0.1"
 media = "1.8.0"
@@ -65,8 +65,8 @@ quickChineseTransfer = "0.2.17"
 # 书源 JS 通过 Packages.cn.hutool.* 直接调用,Hutool 作为书源运行时加密库保留在 classpath。
 # 应用代码不直接依赖它;加密走 JCA / help/crypto/CryptoUtils.kt。版本固定,勿升级(参考上游基线)。
 hutool = "5.8.22"
-room = "2.8.4"
-robolectric = "4.16.1"
+room = "2.8.5"
+robolectric = "4.17"
 splitties = "3.0.0"
 rhino = "1.8.1"
 desugar = "2.1.5"
@@ -92,13 +92,13 @@ reorderable = "3.1.0"
 haze = "1.7.3"
 timber = "5.0.1"
 telephoto = "0.19.0"
-miuix = "0.9.3"
+miuix = "0.9.4"
 backdrop = "2.0.1"
 capsule = "2.1.3"
 lyricViewx = "1.3.2"
 uiautomator = "2.4.0"
 benchmarkMacroJunit4 = "1.4.1"
-baselineprofile = "1.4.1"
+baselineprofile = "1.5.0"
 profileinstaller = "1.4.1"
 [libraries]