From a84e1b9528dace0d6a28e6e1c3832b317fc63ae1 Mon Sep 17 00:00:00 2001 From: meyou <2636699780@qq.com> Date: Mon, 25 May 2026 22:01:06 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=E5=86=9C=E5=8E=86?= =?UTF-8?q?=E5=88=9D=E4=B8=80=E6=9C=88=E4=BB=BD=E5=90=8D=E7=A7=B0=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E7=9A=84=E5=90=8E=E7=BC=80"=E6=9C=88"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lunarMonth.getName() 返回的月份名称已包含"月"字, 如"三月"、"四月",无需再手动拼接。 --- core/src/main/kotlin/plus/rua/project/LunarCache.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/kotlin/plus/rua/project/LunarCache.kt b/core/src/main/kotlin/plus/rua/project/LunarCache.kt index 3fa733e..5c1a6c9 100644 --- a/core/src/main/kotlin/plus/rua/project/LunarCache.kt +++ b/core/src/main/kotlin/plus/rua/project/LunarCache.kt @@ -100,7 +100,7 @@ class LunarCache( // 默认:农历日期 val name = lunarDay.getName() val text = if (name == "初一") { - "${lunarMonthName}月" + lunarMonthName } else { name } From 80ab32890603296d1dcd2b356c2e47a07be599d3 Mon Sep 17 00:00:00 2001 From: meyou <2636699780@qq.com> Date: Mon, 25 May 2026 22:43:45 +0800 Subject: [PATCH 2/8] =?UTF-8?q?perf:=20=E9=99=90=E5=88=B6=20WebP=20?= =?UTF-8?q?=E5=8A=A8=E7=94=BB=E9=87=8D=E5=A4=8D=E6=AC=A1=E6=95=B0=E5=B9=B6?= =?UTF-8?q?=E7=AE=80=E5=8C=96=E5=85=A5=E5=9C=BA=E5=8A=A8=E7=94=BB=EF=BC=8C?= =?UTF-8?q?=E5=87=8F=E5=B0=91=E5=B8=A7=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perfetto trace 分析显示 75.6% 帧延迟,根因为 250x250 WebP 持续以 11-14 FPS 无限循环解码。每次解码周期导致 4-26 帧延迟。 - AnimatedGif: repeatCount(2) 限制动画播放 3 次后停止 - AnimatedGif: 移除两阶段弹跳动画,改为 150ms 淡入 - README: 添加构建命令和产物路径 --- README.md | 26 +++++++++++++ .../kotlin/plus/rua/project/ui/AnimatedGif.kt | 39 ++++++++++--------- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 35149a4..56dfaea 100644 --- a/README.md +++ b/README.md @@ -19,4 +19,30 @@ - 双模块:`:shared`(UI + 逻辑) · `:androidApp`(薄壳) - iOS 入口为 `MainViewController.kt`,Xcode 工程位于 `iosApp/` +## 构建 + +```bash +# Debug +./gradlew :app:assembleDebug # 构建 debug APK +./gradlew :app:installDebug # 安装 debug APK 到设备 + +# Release +./gradlew :app:assembleRelease # 构建 release APK +./gradlew :app:installBenchmark # 安装 benchmark(release + 可调试)APK + +# 测试 +./gradlew :core:testDebugUnitTest # 运行全部测试 +./gradlew :core:testDebugUnitTest --tests "plus.rua.project.ui.CalendarUtilsTest" # 运行单个测试 + +# Baseline Profile(需要连接设备) +./gradlew :macrobenchmark:updateBaselineProfile # 一键生成 + 自动复制到 :core +./gradlew :macrobenchmark:connectedBenchmarkAndroidTest # 仅运行基准测试 + +# 性能 Profiling(需要连接设备) +./scripts/profile.sh # 默认 8 秒 +./scripts/profile.sh 15 # 自定义时长 +``` + +构建产物位于 `app/build/outputs/apk//` 目录。 + 线条小狗表情包来自 https://www.douban.com/group/topic/264788645/?_i=9181692phrDzjR,9241256phrDzjR diff --git a/core/src/main/kotlin/plus/rua/project/ui/AnimatedGif.kt b/core/src/main/kotlin/plus/rua/project/ui/AnimatedGif.kt index f24f9c0..7f4fbb1 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/AnimatedGif.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/AnimatedGif.kt @@ -2,8 +2,6 @@ package plus.rua.project.ui import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -11,6 +9,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import com.github.panpf.sketch.AsyncImage +import com.github.panpf.sketch.rememberAsyncImageState +import com.github.panpf.sketch.request.ImageOptions +import com.github.panpf.sketch.request.repeatCount import plus.rua.project.getWebpUri /** @@ -18,12 +19,16 @@ import plus.rua.project.getWebpUri */ private val WEBP_FILES = (1..152).map { "${it.toString().padStart(3, '0')}.webp" } +private const val REPEAT_COUNT = 2 + /** - * 显示动画 GIF 图片,切换日期时随机选择一个。 + * 显示动画 WebP 图片,切换日期时随机选择一个。 + * + * 动画播放 3 次(1 + [REPEAT_COUNT])后停止,避免持续解码导致的帧丢失。 * * @param modifier 应用于图片的 Modifier * @param contentDescription 无障碍描述 - * @param seed 用于控制重新随机时机的 key,变化时重新选择 GIF + * @param seed 用于控制重新随机时机的 key,变化时重新选择 WebP */ @Composable fun AnimatedGif( @@ -33,28 +38,26 @@ fun AnimatedGif( ) { val webpFile = remember(seed) { WEBP_FILES.random() } val uri = remember(webpFile) { getWebpUri(webpFile) } - val scale = remember { Animatable(0f) } + val alpha = remember { Animatable(0f) } LaunchedEffect(seed) { - scale.snapTo(0f) - scale.animateTo( - targetValue = 1.1f, - animationSpec = tween(250, easing = FastOutSlowInEasing), - ) - scale.animateTo( + alpha.snapTo(0f) + alpha.animateTo( targetValue = 1f, - animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), + animationSpec = tween(150, easing = FastOutSlowInEasing), ) } + val state = rememberAsyncImageState( + options = remember { ImageOptions { repeatCount(REPEAT_COUNT) } } + ) + AsyncImage( uri = uri, contentDescription = contentDescription, - modifier = modifier - .graphicsLayer { - scaleX = scale.value - scaleY = scale.value - alpha = scale.value.coerceIn(0f, 1f) - }, + state = state, + modifier = modifier.graphicsLayer { + this.alpha = alpha.value + }, ) } From 5158b998003254fadfeefe631874d76eb84df5ab Mon Sep 17 00:00:00 2001 From: meyou <2636699780@qq.com> Date: Mon, 25 May 2026 22:57:18 +0800 Subject: [PATCH 3/8] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E5=B9=B4?= =?UTF-8?q?=E6=9C=88=E5=88=87=E6=8D=A2=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Perfetto trace 分析: - YearGridView 首帧 168ms: 12 个 MiniMonth 各创建 sharedElement 节点,但仅 1 个 key 匹配 CalendarPager - compose:lazy:prefetch 最长 703ms: CalendarPager 预加载相邻页 修复: - YearGridView: 仅选中的月份使用 sharedElement(11→1 个), 减少首次组合的 modifier 节点创建开销 - CalendarPager: beyondViewportPageCount 1→0,消除预加载卡顿 --- .../plus/rua/project/ui/CalendarPager.kt | 2 +- .../plus/rua/project/ui/YearGridView.kt | 24 ++++++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ui/CalendarPager.kt b/core/src/main/kotlin/plus/rua/project/ui/CalendarPager.kt index 73b8a1b..fffa161 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/CalendarPager.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/CalendarPager.kt @@ -77,7 +77,7 @@ fun CalendarPager( HorizontalPager( state = pagerState, - beyondViewportPageCount = 1, + beyondViewportPageCount = 0, flingBehavior = PagerDefaults.flingBehavior(state = pagerState), modifier = modifier ) { page -> diff --git a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt index 6042dae..6abbcd8 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt @@ -167,12 +167,12 @@ fun YearGridView( ) { (0 until 3).forEach { col -> val month = row * 3 + col + 1 - val sharedKey = "month_grid_${year}_$month" + val isSelectedMonth = month == selectedMonth with(sharedTransitionScope) { MiniMonth( year = year, month = month, - isSelected = month == selectedMonth, + isSelected = isSelectedMonth, today = today, days = monthDays[month - 1], colors = colors, @@ -182,13 +182,19 @@ fun YearGridView( onClick = { onMonthClick(month) }, modifier = Modifier .weight(1f) - .sharedElement( - sharedContentState = rememberSharedContentState( - key = sharedKey - ), - animatedVisibilityScope = animatedVisibilityScope, - boundsTransform = { _, _ -> - tween(400, easing = FastOutSlowInEasing) + .then( + if (isSelectedMonth) { + Modifier.sharedElement( + sharedContentState = rememberSharedContentState( + key = "month_grid_${year}_$month" + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = { _, _ -> + tween(400, easing = FastOutSlowInEasing) + } + ) + } else { + Modifier } ) ) From ce84c614de5492bb53c490e89965eb8cfb541986 Mon Sep 17 00:00:00 2001 From: meyou <2636699780@qq.com> Date: Mon, 25 May 2026 23:06:15 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=20AnimatedGif=20?= =?UTF-8?q?=E5=BC=B9=E8=B7=B3=E5=8A=A8=E7=94=BB=E5=92=8C=20YearGridView=20?= =?UTF-8?q?=E5=85=A8=E9=87=8F=20sharedElement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AnimatedGif: 恢复原始 scale 弹跳入场动画,仅保留 repeatCount 限制 - YearGridView: 恢复所有 12 个月的 sharedElement,保证点击任意 月份都有正确的共享元素转场动画 - CalendarPager: beyondViewportPageCount 1→0 保留(无视觉影响) --- .../kotlin/plus/rua/project/ui/AnimatedGif.kt | 18 +++++++++++---- .../plus/rua/project/ui/YearGridView.kt | 23 +++++++------------ 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ui/AnimatedGif.kt b/core/src/main/kotlin/plus/rua/project/ui/AnimatedGif.kt index 7f4fbb1..e102446 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/AnimatedGif.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/AnimatedGif.kt @@ -2,6 +2,8 @@ package plus.rua.project.ui import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -38,13 +40,17 @@ fun AnimatedGif( ) { val webpFile = remember(seed) { WEBP_FILES.random() } val uri = remember(webpFile) { getWebpUri(webpFile) } - val alpha = remember { Animatable(0f) } + val scale = remember { Animatable(0f) } LaunchedEffect(seed) { - alpha.snapTo(0f) - alpha.animateTo( + scale.snapTo(0f) + scale.animateTo( + targetValue = 1.1f, + animationSpec = tween(250, easing = FastOutSlowInEasing), + ) + scale.animateTo( targetValue = 1f, - animationSpec = tween(150, easing = FastOutSlowInEasing), + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), ) } @@ -57,7 +63,9 @@ fun AnimatedGif( contentDescription = contentDescription, state = state, modifier = modifier.graphicsLayer { - this.alpha = alpha.value + scaleX = scale.value + scaleY = scale.value + alpha = scale.value.coerceIn(0f, 1f) }, ) } diff --git a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt index 6abbcd8..a20a451 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt @@ -167,12 +167,11 @@ fun YearGridView( ) { (0 until 3).forEach { col -> val month = row * 3 + col + 1 - val isSelectedMonth = month == selectedMonth with(sharedTransitionScope) { MiniMonth( year = year, month = month, - isSelected = isSelectedMonth, + isSelected = month == selectedMonth, today = today, days = monthDays[month - 1], colors = colors, @@ -182,19 +181,13 @@ fun YearGridView( onClick = { onMonthClick(month) }, modifier = Modifier .weight(1f) - .then( - if (isSelectedMonth) { - Modifier.sharedElement( - sharedContentState = rememberSharedContentState( - key = "month_grid_${year}_$month" - ), - animatedVisibilityScope = animatedVisibilityScope, - boundsTransform = { _, _ -> - tween(400, easing = FastOutSlowInEasing) - } - ) - } else { - Modifier + .sharedElement( + sharedContentState = rememberSharedContentState( + key = "month_grid_${year}_$month" + ), + animatedVisibilityScope = animatedVisibilityScope, + boundsTransform = { _, _ -> + tween(400, easing = FastOutSlowInEasing) } ) ) From 6fac313fdf683221ffc4f049931cd750fe7f8d9f Mon Sep 17 00:00:00 2001 From: meyou <2636699780@qq.com> Date: Mon, 25 May 2026 23:19:58 +0800 Subject: [PATCH 5/8] =?UTF-8?q?perf:=20=E5=BB=B6=E8=BF=9F=20YearGridView?= =?UTF-8?q?=20=E6=96=87=E6=9C=AC=E6=B5=8B=E9=87=8F=E5=88=B0=E7=AC=AC?= =?UTF-8?q?=E4=BA=8C=E5=B8=A7=EF=BC=8C=E8=A1=A5=E5=85=85=E6=80=A7=E8=83=BD?= =?UTF-8?q?=E5=88=86=E6=9E=90=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YearGridView 首帧 168ms 中约 24ms 来自 remember 同步文本测量。 将 dayLayouts/titleLayouts/weekdayLayouts 改为 produceState, 首帧 Canvas 渲染为空(sharedElement 结构不变),第二帧填充内容。 DEVELOPMENT.md 补充已知性能瓶颈分析和 Baseline Profile 覆盖建议。 --- DEVELOPMENT.md | 30 +++++++++++++- .../plus/rua/project/ui/YearGridView.kt | 40 +++++++++---------- 2 files changed, 49 insertions(+), 21 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index daf7c8b..4272a49 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -27,8 +27,36 @@ trace 中包含自定义标记: - `MonthView:Compose` — 月视图重组 - `YearView:Compose` — 年视图重组 +- `YearGridView:*` — 年视图网格组合(首帧耗时关键指标) +- `generateMiniMonthDays:*` — 月份网格计算 - `VM:collapseProgress` — 折叠动画 -- `getMonthDays:*` — 月份网格计算 + +### 已知性能瓶颈 + +#### AnimatedGif 持续解码 + +`AnimatedGif` 中的 250×250 WebP 动画在 `repeatCount` 未限制时会以 11-14 FPS 无限循环解码, +持续消耗 CPU/GPU。已通过 `ImageOptions { repeatCount(2) }` 限制播放 3 次后停止。 + +#### YearGridView 首帧 168ms + +切换到年视图时,`YearGridView` 首次组合需要: +- 创建 12 个 MiniMonth composable(含 `sharedElement` + `clickable` + `Canvas` 等 modifier 节点) +- 124 次文本测量(93 日期 + 24 标题 + 7 星期) +- `SharedTransitionLayout` 注册 12 个共享元素 + +文本测量已通过 `produceState` 延迟到第二帧执行,首帧 Canvas 渲染为空。 +剩余 ~140ms 是 12 个 composable 节点创建 + layout 的 Compose 运行时开销, +需要通过 **Baseline Profile** 预编译相关类来优化。 + +确保 `macrobenchmark` 的 `BaselineProfileGenerator` 覆盖以下路径: +- 冷启动 → FAB → 年视图(触发 YearGridView 首次组合) +- 年视图 → 点击任意月份返回月视图(触发 sharedElement 转场) + +#### CalendarPager 预加载 + +`beyondViewportPageCount` 已设为 0,避免翻页时预加载相邻月页导致的帧丢失。 +如需恢复预加载,注意 `compose:lazy:prefetch` 可能产生 400-700ms 卡顿。 ## Baseline Profile diff --git a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt index a20a451..cc2fb8a 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt @@ -25,7 +25,10 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -109,29 +112,24 @@ fun YearGridView( val textMeasurer = rememberTextMeasurer() val dayTextStyle = remember { TextStyle(fontSize = 8.sp, lineHeight = 12.sp) } - // P0-D: 预测量 1..31 × 3 种颜色 = 93 个 TextLayoutResult - val dayLayouts = remember(textMeasurer, dayTextStyle, colors) { + // 延迟文本测量到下一帧,避免首帧阻塞 + val dayLayouts by produceState, androidx.compose.ui.text.TextLayoutResult>?>(null, textMeasurer, dayTextStyle, colors) { val days = 1..31 val colorList = listOf(colors.day, colors.todayText, colors.otherMonth) - days.flatMap { d -> + value = days.flatMap { d -> colorList.map { c -> (d to c) to textMeasurer.measure(d.toString(), dayTextStyle.copy(color = c)) } }.toMap() } - // P0-H: 预测量月份标题(选中/非选中两种颜色) - val titleLayouts = remember(textMeasurer, colors) { - (1..12).flatMap { month -> + val titleLayouts by produceState, androidx.compose.ui.text.TextLayoutResult>?>(null, textMeasurer, colors) { + value = (1..12).flatMap { month -> val text = "${month}月" listOf( (month to true) to textMeasurer.measure( text, - TextStyle( - fontSize = 10.sp, - color = colors.titleSelected, - fontWeight = FontWeight.Bold - ) + TextStyle(fontSize = 10.sp, color = colors.titleSelected, fontWeight = FontWeight.Bold) ), (month to false) to textMeasurer.measure( text, @@ -141,9 +139,8 @@ fun YearGridView( }.toMap() } - // P0-H: 预测量星期标签 - val weekdayLayouts = remember(textMeasurer, colors) { - WEEKDAY_LABELS.associateWith { label -> + val weekdayLayouts by produceState?>(null, textMeasurer, colors) { + value = WEEKDAY_LABELS.associateWith { label -> textMeasurer.measure(label, TextStyle(fontSize = 8.sp, color = colors.weekday)) } } @@ -213,9 +210,9 @@ private fun MiniMonth( today: LocalDate, days: List, colors: MiniMonthColors, - dayLayouts: Map, androidx.compose.ui.text.TextLayoutResult>, - titleLayouts: Map, androidx.compose.ui.text.TextLayoutResult>, - weekdayLayouts: Map, + dayLayouts: Map, androidx.compose.ui.text.TextLayoutResult>?, + titleLayouts: Map, androidx.compose.ui.text.TextLayoutResult>?, + weekdayLayouts: Map?, onClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -240,10 +237,13 @@ private fun MiniMonth( horizontalAlignment = Alignment.CenterHorizontally ) { Canvas(modifier = Modifier.fillMaxWidth().height(totalHeight)) { + val dl = dayLayouts ?: return@Canvas + val tl = titleLayouts ?: return@Canvas + val wl = weekdayLayouts ?: return@Canvas val cellWidth = size.width / 7f // 1. 绘制标题 - val titleLayout = titleLayouts[month to isSelected]!! + val titleLayout = tl[month to isSelected]!! drawText( textLayoutResult = titleLayout, topLeft = Offset( @@ -255,7 +255,7 @@ private fun MiniMonth( // 2. 绘制星期行 val weekdayY = titleHeightPx + titleToWeekdayGapPx WEEKDAY_LABELS.forEachIndexed { i, label -> - val layout = weekdayLayouts[label]!! + val layout = wl[label]!! drawText( textLayoutResult = layout, topLeft = Offset( @@ -292,7 +292,7 @@ private fun MiniMonth( } if (dayNum > 0) { - dayLayouts[dayNum to textColor]?.let { layoutResult -> + dl[dayNum to textColor]?.let { layoutResult -> drawText( textLayoutResult = layoutResult, topLeft = Offset( From bbe51051ae83d56fda8ad965f7b73a014ddea7b8 Mon Sep 17 00:00:00 2001 From: meyou <2636699780@qq.com> Date: Mon, 25 May 2026 23:34:25 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B9=B4?= =?UTF-8?q?=E6=9C=88=E6=BB=9A=E8=BD=AE=E9=80=89=E6=8B=A9=E5=99=A8=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=A7=A6=E8=A7=89=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 WheelPicker composable:惯性吸附 + 触觉反馈滚轮 - 新增 MonthYearPickerDialog:年月双滚轮选择弹窗 - 新增 YearPickerDialog:年份滚轮选择弹窗 - MonthHeader:点击年月文字弹出年月选择器 - YearHeader:点击年份文字弹出年份选择器 - 滚动时触发 HapticFeedbackConstants.CLOCK_TICK 触觉反馈 --- .../plus/rua/project/ui/CalendarMonthView.kt | 11 +- .../kotlin/plus/rua/project/ui/MonthHeader.kt | 29 +++- .../rua/project/ui/MonthYearPickerDialog.kt | 122 ++++++++++++++ .../kotlin/plus/rua/project/ui/WheelPicker.kt | 151 ++++++++++++++++++ .../plus/rua/project/ui/YearGridView.kt | 19 ++- 5 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 core/src/main/kotlin/plus/rua/project/ui/MonthYearPickerDialog.kt create mode 100644 core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt diff --git a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt index b259462..0287ae9 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt @@ -207,12 +207,21 @@ fun CalendarMonthView( val onToday = remember(viewModel, today) { { viewModel.selectDate(today) } } + val onMonthYearSelect = remember(viewModel, today) { + { year: Int, month: Int -> + @Suppress("DEPRECATION") + val date = if (year == today.year && today.month.number == month) today + else LocalDate(year, month, 1) + viewModel.selectDate(date) + } + } MonthHeader( year = currentYear, month = currentMonth, weekNumber = weekNumber, showToday = selectedDate != today, - onToday = onToday + onToday = onToday, + onMonthYearSelect = onMonthYearSelect ) WeekdayHeader( modifier = Modifier.fillMaxWidth().padding(bottom = ROW_PADDING_DP.dp) diff --git a/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt b/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt index 7db15ff..316eb53 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt @@ -19,6 +19,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -29,11 +32,14 @@ import androidx.compose.ui.unit.sp /** * 月份标题栏,显示"年月"文字和 ISO 周号。 * + * 点击年月文字弹出滚轮选择器,可快速跳转到任意年月。 + * * @param year 年份 * @param month 月份(1-12) * @param weekNumber 当前 ISO 周号 * @param showToday 是否显示「今天」按钮(当 selectedDate ≠ today 时) * @param onToday 点击「今天」按钮跳转今天 + * @param onMonthYearSelect 年月选择回调 * @param modifier 外部布局修饰符 */ @Composable @@ -43,8 +49,23 @@ fun MonthHeader( weekNumber: Int, showToday: Boolean, onToday: (() -> Unit)? = null, + onMonthYearSelect: ((year: Int, month: Int) -> Unit)? = null, modifier: Modifier = Modifier ) { + var showPicker by remember { mutableStateOf(false) } + + if (showPicker && onMonthYearSelect != null) { + MonthYearPickerDialog( + currentYear = year, + currentMonth = month, + onConfirm = { y, m -> + onMonthYearSelect(y, m) + showPicker = false + }, + onDismiss = { showPicker = false } + ) + } + Row( modifier = modifier .fillMaxWidth() @@ -66,7 +87,13 @@ fun MonthHeader( Text( text = "${y}年${m}月", color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.titleLarge + style = MaterialTheme.typography.titleLarge, + modifier = if (onMonthYearSelect != null) { + Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { showPicker = true } + .padding(horizontal = 4.dp, vertical = 2.dp) + } else Modifier ) } Spacer(modifier = Modifier.width(6.dp)) diff --git a/core/src/main/kotlin/plus/rua/project/ui/MonthYearPickerDialog.kt b/core/src/main/kotlin/plus/rua/project/ui/MonthYearPickerDialog.kt new file mode 100644 index 0000000..ab5e3ad --- /dev/null +++ b/core/src/main/kotlin/plus/rua/project/ui/MonthYearPickerDialog.kt @@ -0,0 +1,122 @@ +package plus.rua.project.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +private val Years = (1970..2100).map { "${it}年" } +private val Months = (1..12).map { "${it}月" } + +/** + * 年月滚轮选择器弹窗。 + * + * 左侧年份滚轮 + 右侧月份滚轮,每次滚动触发触觉反馈。 + * + * @param currentYear 当前年份 + * @param currentMonth 当前月份(1-12) + * @param onConfirm 确认回调,参数为 (year, month) + * @param onDismiss 关闭回调 + */ +@Composable +fun MonthYearPickerDialog( + currentYear: Int, + currentMonth: Int, + onConfirm: (year: Int, month: Int) -> Unit, + onDismiss: () -> Unit +) { + var selectedYear by remember { mutableIntStateOf(currentYear) } + var selectedMonth by remember { mutableIntStateOf(currentMonth) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text("选择年月", style = MaterialTheme.typography.titleMedium) + }, + text = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + WheelPicker( + items = Years, + selectedIndex = selectedYear - 1970, + onSelectedChange = { selectedYear = it + 1970 }, + modifier = Modifier.weight(1f) + ) + Spacer(modifier = Modifier.width(16.dp)) + WheelPicker( + items = Months, + selectedIndex = selectedMonth - 1, + onSelectedChange = { selectedMonth = it + 1 }, + modifier = Modifier.weight(1f) + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(selectedYear, selectedMonth) }) { + Text("确定") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("取消") + } + } + ) +} + +/** + * 年份滚轮选择器弹窗(用于年视图)。 + * + * @param currentYear 当前年份 + * @param onConfirm 确认回调,参数为 year + * @param onDismiss 关闭回调 + */ +@Composable +fun YearPickerDialog( + currentYear: Int, + onConfirm: (year: Int) -> Unit, + onDismiss: () -> Unit +) { + var selectedYear by remember { mutableIntStateOf(currentYear) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text("选择年份", style = MaterialTheme.typography.titleMedium) + }, + text = { + WheelPicker( + items = Years, + selectedIndex = selectedYear - 1970, + onSelectedChange = { selectedYear = it + 1970 }, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton(onClick = { onConfirm(selectedYear) }) { + Text("确定") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("取消") + } + } + ) +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt b/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt new file mode 100644 index 0000000..6e4dae8 --- /dev/null +++ b/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt @@ -0,0 +1,151 @@ +package plus.rua.project.ui + +import android.os.Build +import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.TextStyle +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 kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.roundToInt + +private val ItemHeight = 48.dp +private const val VisibleItemCount = 5 +private val WheelHeight = ItemHeight * VisibleItemCount + +/** + * 通用滚轮选择器,支持惯性吸附和触觉反馈。 + * + * @param items 显示的项目列表 + * @param selectedIndex 当前选中项索引 + * @param onSelectedChange 选中项变化回调 + * @param modifier 外部布局修饰符 + * @param itemContent 单个项目渲染,[isSelected] 为 true 表示中心选中项 + */ +@Composable +fun WheelPicker( + items: List, + selectedIndex: Int, + onSelectedChange: (Int) -> Unit, + modifier: Modifier = Modifier, + itemContent: @Composable (index: Int, item: String, isSelected: Boolean) -> Unit = { _, item, isSelected -> + Text( + text = item, + color = if (isSelected) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + fontSize = if (isSelected) 20.sp else 16.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + style = LocalTextStyle.current + ) + } +) { + val paddingItems = VisibleItemCount / 2 + val totalItems = items.size + paddingItems * 2 + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = (selectedIndex - paddingItems).coerceAtLeast(0) + ) + val coroutineScope = rememberCoroutineScope() + val view = LocalView.current + + fun centerForLayoutIndex(layoutIndex: Int): Int = layoutIndex - paddingItems + + fun layoutIndexForCenter(center: Int): Int = center + paddingItems + + // 检测中心选中项变化 → 触觉反馈 + val currentCenter by remember { + derivedStateOf { + val viewportCenter = listState.layoutInfo.viewportSize.height / 2f + listState.layoutInfo.visibleItemsInfo.minByOrNull { + abs(it.offset + it.size / 2f - viewportCenter) + }?.index?.let { centerForLayoutIndex(it) } ?: -1 + } + } + + LaunchedEffect(currentCenter) { + if (currentCenter in items.indices && currentCenter != selectedIndex) { + onSelectedChange(currentCenter) + performHapticFeedback(view) + } + } + + // 初始滚动到选中项 + LaunchedEffect(selectedIndex) { + val target = layoutIndexForCenter(selectedIndex) + if (centerForLayoutIndex(listState.firstVisibleItemIndex) != selectedIndex) { + listState.scrollToItem((target - paddingItems).coerceAtLeast(0)) + } + } + + // 滚动停止后吸附到最近项 + LaunchedEffect(listState) { + snapshotFlow { listState.isScrollInProgress } + .collect { scrolling -> + if (!scrolling) { + val target = layoutIndexForCenter(currentCenter.coerceIn(0, items.lastIndex)) + val current = listState.firstVisibleItemIndex + paddingItems + if (target != current) { + coroutineScope.launch { + listState.animateScrollToItem((target - paddingItems).coerceAtLeast(0)) + } + } + } + } + } + + val snapLayoutInfoProvider = remember(listState) { + SnapLayoutInfoProvider(listState) + } + + LazyColumn( + state = listState, + modifier = modifier.height(WheelHeight), + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + horizontalAlignment = Alignment.CenterHorizontally, + userScrollEnabled = true + ) { + items(totalItems) { layoutIndex -> + val centerIndex = centerForLayoutIndex(layoutIndex) + val isValid = centerIndex in items.indices + Box( + modifier = Modifier + .height(ItemHeight) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + if (isValid) { + itemContent(centerIndex, items[centerIndex], centerIndex == currentCenter) + } + } + } + } +} + +private fun performHapticFeedback(view: android.view.View) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK) + } else { + @Suppress("DEPRECATION") + view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK) + } +} diff --git a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt index cc2fb8a..fb3b8a7 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt @@ -351,6 +351,19 @@ fun YearHeader( onYearChange: (Int) -> Unit, modifier: Modifier = Modifier ) { + var showPicker by remember { mutableStateOf(false) } + + if (showPicker) { + YearPickerDialog( + currentYear = year, + onConfirm = { y -> + onYearChange(y) + showPicker = false + }, + onDismiss = { showPicker = false } + ) + } + Column( modifier = modifier .fillMaxWidth() @@ -373,7 +386,11 @@ fun YearHeader( text = "${y}年", color = MaterialTheme.colorScheme.onBackground, style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold + fontWeight = FontWeight.Bold, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { showPicker = true } + .padding(horizontal = 4.dp, vertical = 2.dp) ) } Spacer(modifier = Modifier.weight(1f)) From 0b6d9ea87a892cfe98515406f338b4b39adc194e Mon Sep 17 00:00:00 2001 From: meyou <2636699780@qq.com> Date: Mon, 25 May 2026 23:38:59 +0800 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20=E6=BB=9A=E8=BD=AE=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E5=99=A8=E4=BB=85=E5=9C=A8=E6=BB=9A=E5=8A=A8=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E5=90=8E=E8=A7=A6=E5=8F=91=E9=80=89=E4=B8=AD=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E5=92=8C=E8=A7=A6=E8=A7=89=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 快速滑动时不再每帧更新 selectedIndex,改为等滚动停止后 计算最终中心项再触发回调,消除来回抽搐问题。 --- .../kotlin/plus/rua/project/ui/WheelPicker.kt | 95 +++++++------------ 1 file changed, 35 insertions(+), 60 deletions(-) diff --git a/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt b/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt index 6e4dae8..e46312f 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt @@ -1,6 +1,7 @@ package plus.rua.project.ui -import android.os.Build +import android.view.HapticFeedbackConstants +import android.view.View import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.Box @@ -16,32 +17,31 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.text.TextStyle 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 kotlinx.coroutines.launch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlin.math.abs -import kotlin.math.roundToInt private val ItemHeight = 48.dp private const val VisibleItemCount = 5 private val WheelHeight = ItemHeight * VisibleItemCount +private const val PaddingItems = VisibleItemCount / 2 /** * 通用滚轮选择器,支持惯性吸附和触觉反馈。 * + * 滚动停止后才触发选中变更和触觉反馈,避免快速滑动时抖动。 + * * @param items 显示的项目列表 * @param selectedIndex 当前选中项索引 - * @param onSelectedChange 选中项变化回调 + * @param onSelectedChange 选中项变化回调(仅在滚动停止后触发) * @param modifier 外部布局修饰符 - * @param itemContent 单个项目渲染,[isSelected] 为 true 表示中心选中项 */ @Composable fun WheelPicker( @@ -49,65 +49,41 @@ fun WheelPicker( selectedIndex: Int, onSelectedChange: (Int) -> Unit, modifier: Modifier = Modifier, - itemContent: @Composable (index: Int, item: String, isSelected: Boolean) -> Unit = { _, item, isSelected -> - Text( - text = item, - color = if (isSelected) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), - fontSize = if (isSelected) 20.sp else 16.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - style = LocalTextStyle.current - ) - } ) { - val paddingItems = VisibleItemCount / 2 - val totalItems = items.size + paddingItems * 2 val listState = rememberLazyListState( - initialFirstVisibleItemIndex = (selectedIndex - paddingItems).coerceAtLeast(0) + initialFirstVisibleItemIndex = (selectedIndex - PaddingItems).coerceAtLeast(0) ) - val coroutineScope = rememberCoroutineScope() val view = LocalView.current - fun centerForLayoutIndex(layoutIndex: Int): Int = layoutIndex - paddingItems - - fun layoutIndexForCenter(center: Int): Int = center + paddingItems - - // 检测中心选中项变化 → 触觉反馈 - val currentCenter by remember { + // 视觉中心项(实时,仅用于渲染高亮) + val visualCenter by remember { derivedStateOf { val viewportCenter = listState.layoutInfo.viewportSize.height / 2f listState.layoutInfo.visibleItemsInfo.minByOrNull { abs(it.offset + it.size / 2f - viewportCenter) - }?.index?.let { centerForLayoutIndex(it) } ?: -1 - } - } - - LaunchedEffect(currentCenter) { - if (currentCenter in items.indices && currentCenter != selectedIndex) { - onSelectedChange(currentCenter) - performHapticFeedback(view) + }?.index?.let { it - PaddingItems } ?: selectedIndex } } // 初始滚动到选中项 LaunchedEffect(selectedIndex) { - val target = layoutIndexForCenter(selectedIndex) - if (centerForLayoutIndex(listState.firstVisibleItemIndex) != selectedIndex) { - listState.scrollToItem((target - paddingItems).coerceAtLeast(0)) + val target = (selectedIndex - PaddingItems).coerceAtLeast(0) + if (listState.firstVisibleItemIndex != target) { + listState.scrollToItem(target) } } - // 滚动停止后吸附到最近项 + // 滚动停止后:计算最终中心项 → 触发选中变更 + 触觉反馈 LaunchedEffect(listState) { - snapshotFlow { listState.isScrollInProgress } - .collect { scrolling -> + var lastSettled = selectedIndex + snapshotFlow { listState.isScrollInProgress to listState.layoutInfo} + .collect { (scrolling, _) -> if (!scrolling) { - val target = layoutIndexForCenter(currentCenter.coerceIn(0, items.lastIndex)) - val current = listState.firstVisibleItemIndex + paddingItems - if (target != current) { - coroutineScope.launch { - listState.animateScrollToItem((target - paddingItems).coerceAtLeast(0)) - } + val center = visualCenter.coerceIn(0, items.lastIndex) + if (center != lastSettled) { + lastSettled = center + onSelectedChange(center) + view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) } } } @@ -124,8 +100,8 @@ fun WheelPicker( horizontalAlignment = Alignment.CenterHorizontally, userScrollEnabled = true ) { - items(totalItems) { layoutIndex -> - val centerIndex = centerForLayoutIndex(layoutIndex) + items(items.size + PaddingItems * 2) { layoutIndex -> + val centerIndex = layoutIndex - PaddingItems val isValid = centerIndex in items.indices Box( modifier = Modifier @@ -134,18 +110,17 @@ fun WheelPicker( contentAlignment = Alignment.Center ) { if (isValid) { - itemContent(centerIndex, items[centerIndex], centerIndex == currentCenter) + val isSelected = centerIndex == visualCenter + Text( + text = items[centerIndex], + color = if (isSelected) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), + fontSize = if (isSelected) 20.sp else 16.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + style = LocalTextStyle.current + ) } } } } } - -private fun performHapticFeedback(view: android.view.View) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { - view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK) - } else { - @Suppress("DEPRECATION") - view.performHapticFeedback(android.view.HapticFeedbackConstants.CLOCK_TICK) - } -} From dfb820d6105f7b5184d2266dba7ed9fa313369e7 Mon Sep 17 00:00:00 2001 From: meyou <2636699780@qq.com> Date: Tue, 26 May 2026 07:33:15 +0800 Subject: [PATCH 8/8] =?UTF-8?q?Revert=20"feat:=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=B9=B4=E6=9C=88=E6=BB=9A=E8=BD=AE=E9=80=89=E6=8B=A9=E5=99=A8?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E8=A7=A6=E8=A7=89=E5=8F=8D=E9=A6=88?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bbe51051ae83d56fda8ad965f7b73a014ddea7b8. --- .../plus/rua/project/ui/CalendarMonthView.kt | 11 +- .../kotlin/plus/rua/project/ui/MonthHeader.kt | 29 +--- .../rua/project/ui/MonthYearPickerDialog.kt | 122 ----------------- .../kotlin/plus/rua/project/ui/WheelPicker.kt | 126 ------------------ .../plus/rua/project/ui/YearGridView.kt | 19 +-- 5 files changed, 3 insertions(+), 304 deletions(-) delete mode 100644 core/src/main/kotlin/plus/rua/project/ui/MonthYearPickerDialog.kt delete mode 100644 core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt diff --git a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt index 0287ae9..b259462 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/CalendarMonthView.kt @@ -207,21 +207,12 @@ fun CalendarMonthView( val onToday = remember(viewModel, today) { { viewModel.selectDate(today) } } - val onMonthYearSelect = remember(viewModel, today) { - { year: Int, month: Int -> - @Suppress("DEPRECATION") - val date = if (year == today.year && today.month.number == month) today - else LocalDate(year, month, 1) - viewModel.selectDate(date) - } - } MonthHeader( year = currentYear, month = currentMonth, weekNumber = weekNumber, showToday = selectedDate != today, - onToday = onToday, - onMonthYearSelect = onMonthYearSelect + onToday = onToday ) WeekdayHeader( modifier = Modifier.fillMaxWidth().padding(bottom = ROW_PADDING_DP.dp) diff --git a/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt b/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt index 316eb53..7db15ff 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/MonthHeader.kt @@ -19,9 +19,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -32,14 +29,11 @@ import androidx.compose.ui.unit.sp /** * 月份标题栏,显示"年月"文字和 ISO 周号。 * - * 点击年月文字弹出滚轮选择器,可快速跳转到任意年月。 - * * @param year 年份 * @param month 月份(1-12) * @param weekNumber 当前 ISO 周号 * @param showToday 是否显示「今天」按钮(当 selectedDate ≠ today 时) * @param onToday 点击「今天」按钮跳转今天 - * @param onMonthYearSelect 年月选择回调 * @param modifier 外部布局修饰符 */ @Composable @@ -49,23 +43,8 @@ fun MonthHeader( weekNumber: Int, showToday: Boolean, onToday: (() -> Unit)? = null, - onMonthYearSelect: ((year: Int, month: Int) -> Unit)? = null, modifier: Modifier = Modifier ) { - var showPicker by remember { mutableStateOf(false) } - - if (showPicker && onMonthYearSelect != null) { - MonthYearPickerDialog( - currentYear = year, - currentMonth = month, - onConfirm = { y, m -> - onMonthYearSelect(y, m) - showPicker = false - }, - onDismiss = { showPicker = false } - ) - } - Row( modifier = modifier .fillMaxWidth() @@ -87,13 +66,7 @@ fun MonthHeader( Text( text = "${y}年${m}月", color = MaterialTheme.colorScheme.onBackground, - style = MaterialTheme.typography.titleLarge, - modifier = if (onMonthYearSelect != null) { - Modifier - .clip(RoundedCornerShape(8.dp)) - .clickable { showPicker = true } - .padding(horizontal = 4.dp, vertical = 2.dp) - } else Modifier + style = MaterialTheme.typography.titleLarge ) } Spacer(modifier = Modifier.width(6.dp)) diff --git a/core/src/main/kotlin/plus/rua/project/ui/MonthYearPickerDialog.kt b/core/src/main/kotlin/plus/rua/project/ui/MonthYearPickerDialog.kt deleted file mode 100644 index ab5e3ad..0000000 --- a/core/src/main/kotlin/plus/rua/project/ui/MonthYearPickerDialog.kt +++ /dev/null @@ -1,122 +0,0 @@ -package plus.rua.project.ui - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -private val Years = (1970..2100).map { "${it}年" } -private val Months = (1..12).map { "${it}月" } - -/** - * 年月滚轮选择器弹窗。 - * - * 左侧年份滚轮 + 右侧月份滚轮,每次滚动触发触觉反馈。 - * - * @param currentYear 当前年份 - * @param currentMonth 当前月份(1-12) - * @param onConfirm 确认回调,参数为 (year, month) - * @param onDismiss 关闭回调 - */ -@Composable -fun MonthYearPickerDialog( - currentYear: Int, - currentMonth: Int, - onConfirm: (year: Int, month: Int) -> Unit, - onDismiss: () -> Unit -) { - var selectedYear by remember { mutableIntStateOf(currentYear) } - var selectedMonth by remember { mutableIntStateOf(currentMonth) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text("选择年月", style = MaterialTheme.typography.titleMedium) - }, - text = { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - WheelPicker( - items = Years, - selectedIndex = selectedYear - 1970, - onSelectedChange = { selectedYear = it + 1970 }, - modifier = Modifier.weight(1f) - ) - Spacer(modifier = Modifier.width(16.dp)) - WheelPicker( - items = Months, - selectedIndex = selectedMonth - 1, - onSelectedChange = { selectedMonth = it + 1 }, - modifier = Modifier.weight(1f) - ) - } - }, - confirmButton = { - TextButton(onClick = { onConfirm(selectedYear, selectedMonth) }) { - Text("确定") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("取消") - } - } - ) -} - -/** - * 年份滚轮选择器弹窗(用于年视图)。 - * - * @param currentYear 当前年份 - * @param onConfirm 确认回调,参数为 year - * @param onDismiss 关闭回调 - */ -@Composable -fun YearPickerDialog( - currentYear: Int, - onConfirm: (year: Int) -> Unit, - onDismiss: () -> Unit -) { - var selectedYear by remember { mutableIntStateOf(currentYear) } - - AlertDialog( - onDismissRequest = onDismiss, - title = { - Text("选择年份", style = MaterialTheme.typography.titleMedium) - }, - text = { - WheelPicker( - items = Years, - selectedIndex = selectedYear - 1970, - onSelectedChange = { selectedYear = it + 1970 }, - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - TextButton(onClick = { onConfirm(selectedYear) }) { - Text("确定") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("取消") - } - } - ) -} diff --git a/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt b/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt deleted file mode 100644 index e46312f..0000000 --- a/core/src/main/kotlin/plus/rua/project/ui/WheelPicker.kt +++ /dev/null @@ -1,126 +0,0 @@ -package plus.rua.project.ui - -import android.view.HapticFeedbackConstants -import android.view.View -import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider -import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlin.math.abs - -private val ItemHeight = 48.dp -private const val VisibleItemCount = 5 -private val WheelHeight = ItemHeight * VisibleItemCount -private const val PaddingItems = VisibleItemCount / 2 - -/** - * 通用滚轮选择器,支持惯性吸附和触觉反馈。 - * - * 滚动停止后才触发选中变更和触觉反馈,避免快速滑动时抖动。 - * - * @param items 显示的项目列表 - * @param selectedIndex 当前选中项索引 - * @param onSelectedChange 选中项变化回调(仅在滚动停止后触发) - * @param modifier 外部布局修饰符 - */ -@Composable -fun WheelPicker( - items: List, - selectedIndex: Int, - onSelectedChange: (Int) -> Unit, - modifier: Modifier = Modifier, -) { - val listState = rememberLazyListState( - initialFirstVisibleItemIndex = (selectedIndex - PaddingItems).coerceAtLeast(0) - ) - val view = LocalView.current - - // 视觉中心项(实时,仅用于渲染高亮) - val visualCenter by remember { - derivedStateOf { - val viewportCenter = listState.layoutInfo.viewportSize.height / 2f - listState.layoutInfo.visibleItemsInfo.minByOrNull { - abs(it.offset + it.size / 2f - viewportCenter) - }?.index?.let { it - PaddingItems } ?: selectedIndex - } - } - - // 初始滚动到选中项 - LaunchedEffect(selectedIndex) { - val target = (selectedIndex - PaddingItems).coerceAtLeast(0) - if (listState.firstVisibleItemIndex != target) { - listState.scrollToItem(target) - } - } - - // 滚动停止后:计算最终中心项 → 触发选中变更 + 触觉反馈 - LaunchedEffect(listState) { - var lastSettled = selectedIndex - snapshotFlow { listState.isScrollInProgress to listState.layoutInfo} - .collect { (scrolling, _) -> - if (!scrolling) { - val center = visualCenter.coerceIn(0, items.lastIndex) - if (center != lastSettled) { - lastSettled = center - onSelectedChange(center) - view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) - } - } - } - } - - val snapLayoutInfoProvider = remember(listState) { - SnapLayoutInfoProvider(listState) - } - - LazyColumn( - state = listState, - modifier = modifier.height(WheelHeight), - flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), - horizontalAlignment = Alignment.CenterHorizontally, - userScrollEnabled = true - ) { - items(items.size + PaddingItems * 2) { layoutIndex -> - val centerIndex = layoutIndex - PaddingItems - val isValid = centerIndex in items.indices - Box( - modifier = Modifier - .height(ItemHeight) - .fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - if (isValid) { - val isSelected = centerIndex == visualCenter - Text( - text = items[centerIndex], - color = if (isSelected) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f), - fontSize = if (isSelected) 20.sp else 16.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - style = LocalTextStyle.current - ) - } - } - } - } -} diff --git a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt index fb3b8a7..cc2fb8a 100644 --- a/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt +++ b/core/src/main/kotlin/plus/rua/project/ui/YearGridView.kt @@ -351,19 +351,6 @@ fun YearHeader( onYearChange: (Int) -> Unit, modifier: Modifier = Modifier ) { - var showPicker by remember { mutableStateOf(false) } - - if (showPicker) { - YearPickerDialog( - currentYear = year, - onConfirm = { y -> - onYearChange(y) - showPicker = false - }, - onDismiss = { showPicker = false } - ) - } - Column( modifier = modifier .fillMaxWidth() @@ -386,11 +373,7 @@ fun YearHeader( text = "${y}年", color = MaterialTheme.colorScheme.onBackground, style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .clickable { showPicker = true } - .padding(horizontal = 4.dp, vertical = 2.dp) + fontWeight = FontWeight.Bold ) } Spacer(modifier = Modifier.weight(1f))