diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt index 7988037..331118d 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt @@ -435,7 +435,13 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) { * ## 两层滚动怎么分工 * 收起态:这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块 * 合起来的高度上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来 - * 就走嵌套滚动,不用额外接线。展开态下这一窗不设上限,手势整个交给外层。 + * 就走嵌套滚动,不用额外接线。 + * + * ⚠️ **展开态必须把这一层整个去掉(上限 + 滚动一起),只留外层那一层滚。** 只去掉上限是不够的 —— + * 外层 [ShareConfirmPreviews] 自己就是一个 `verticalScroll`,它给内容的是**无界高度**;这一层若 + * 留着 `verticalScroll`,那个 scroll 就会在无界高度下被测量 ⇒ 抛同一个 + * `IllegalStateException`(W58 真机在「点展开」的那一下崩了第二次,就是这么来的)。 + * 展开后读不读得到尾,只取决于外层那一层。 */ @Composable private fun ShareConfirmBody( @@ -446,15 +452,19 @@ private fun ShareConfirmBody( val density = LocalDensity.current // `null` = 还没量到。**不要**拿 [Dp.Infinity] 当这个哨兵值(见 KDoc 里那次崩溃)。 var cap by remember(text) { mutableStateOf(null) } + // 无条件 `remember`,避免在分支里建状态;滚动位置由这一窗自己的生命周期管。 + val scroll = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .then( - shareConfirmWindowCap(cap, expanded)?.let { Modifier.heightIn(max = it) } ?: Modifier, - ) - .verticalScroll(rememberScrollState()), - ) { + val window = if (expanded) { + // 展开态:上限与滚动**一起**去掉(见 KDoc)。内容自然撑开,交给外层预览区那一层滚。 + Modifier + } else { + Modifier + .heightIn(max = shareConfirmWindowCap(cap)) + .verticalScroll(scroll) + } + + Column(modifier = Modifier.fillMaxWidth().then(window)) { Text( text = text, style = MiuixTheme.textStyles.footnote1, @@ -464,7 +474,7 @@ private fun ShareConfirmBody( val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1 cap = with(density) { layout.getLineBottom(lastVisible).toDp() } } - // 行数与上限无关(这一窗没有 `maxLines`、测量高度不受限,排一次行数就定了),所以 + // 行数与上限无关(这一窗没有 `maxLines`、收起态的高度不受限,排一次行数就定了),所以 // 这一句同时管着「收不起时要不要给出展开控件」与「展开态下要不要留着它」。 onOverflowChange(sharePreviewTextOverflowsWindow(layout.lineCount)) }, @@ -473,18 +483,19 @@ private fun ShareConfirmBody( } /** - * 正文那一窗该套多大的高度上限(**纯函数**,可单测):`null` = 这一层**不设**上限。 + * **收起态**那一窗的高度上限(**纯函数**,可单测)。 * - * 三档:展开态不设(外层已有有限上限);量到过就用量的;还没量到就用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底。 + * 量到过就用量的,还没量到就用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底 —— 兜底必须是**有限值**,因为 + * `heightIn` 与 `verticalScroll` 就在同一串 modifier 上。 * - * ⚠️ **返回值要么是 `null`、要么是有限值,永远不是 [Dp.Infinity]** —— 后者会让 `verticalScroll` - * 在无界高度下被测量并抛 `IllegalStateException`(W58 真机崩过)。这条不变量有单测钉着。 + * 展开态**不走这里**:那一态连 `verticalScroll` 一起去掉(只留外层预览区那一层滚),没有「上限」 + * 这个维度可给 —— 见 [ShareConfirmBody] 的 KDoc。 + * + * ⚠️ 返回值**永远不是 [Dp.Infinity]** —— `heightIn(max = Dp.Infinity)` 等于不施加约束,而 + * `verticalScroll` 在无界高度下被测量时 Compose 会直接抛 `IllegalStateException` 把 App 打崩 + * (W58 真机崩过两次:一次是初值用了 `Dp.Infinity`,一次是展开态只去掉了上限)。这条不变量有单测钉着。 */ -internal fun shareConfirmWindowCap(measured: Dp?, expanded: Boolean): Dp? = when { - expanded -> null - measured != null -> measured - else -> SHARE_PREVIEW_MAX_HEIGHT -} +internal fun shareConfirmWindowCap(measured: Dp?): Dp = measured ?: SHARE_PREVIEW_MAX_HEIGHT /** 预览区在**收起态**的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */ internal val SHARE_PREVIEW_MAX_HEIGHT = 150.dp diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt index 746b560..8464b03 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt @@ -321,49 +321,47 @@ class ShareConfirmTest { /** * W58:正文那一窗的高度上限**永远不许是 [androidx.compose.ui.unit.Dp.Infinity]**。 * - * 背景是真机上的一次崩溃:`heightIn(max = Dp.Infinity)` 等于**不施加约束**,而 `verticalScroll` - * 一旦在无界高度下被测量,Compose 的 `checkScrollableContainerConstraints` 会直接抛 - * `IllegalStateException` 把 App 打崩(分享面板一打开就闪退,栈是 `ScrollNode.measure` ← - * `SizeNode.measure` ← `DialogLayout.internalOnMeasure`)。 + * 背景是真机上**两次**崩溃、同一个异常:`heightIn(max = Dp.Infinity)` 等于**不施加约束**,而 + * `verticalScroll` 一旦在无界高度下被测量,Compose 的 `checkScrollableContainerConstraints` 会 + * 直接抛 `IllegalStateException` 把 App 打崩。 * - * 这条不变量只能靠纯函数钉住:本模块的 `testImplementation` 只有 junit,没有 Compose UI 测试依赖, - * 渲染层的崩溃单测抓不到。 + * 两次踩在不同的地方,都值得钉住: + * 1. **初值**用了 `Dp.Infinity` 当「还没量到」的哨兵值(分享面板一打开就闪退); + * 2. **展开态**只去掉了上限、把 `verticalScroll` 留着 —— 外层预览区自己就是一个 `verticalScroll`, + * 它给内容的是**无界高度**,于是这一层的 scroll 在无界高度下被测量(点「展开」的那一下闪退)。 + * + * 结论:这一层的高度上限只经 [shareConfirmWindowCap] 一个出口(永远有限),且展开态**连滚动一起去掉**。 + * + * 这两条都只能靠纯函数钉住:本模块的 `testImplementation` 只有 junit,没有 Compose UI 测试依赖, + * 渲染层的崩溃单测抓不到 —— 这也是它两次都只在真机上暴露的原因。 */ class ShareConfirmWindowCapTest { @Test fun `the fallback cap is finite and never infinity`() { - val fallback = shareConfirmWindowCap(measured = null, expanded = false) - org.junit.Assert.assertNotNull("首帧必须给出一个上限,不能是 null", fallback) - assertTrue("首帧的兜底上限必须是有限值", fallback!!.value.isFinite()) + val fallback = shareConfirmWindowCap(measured = null) + assertTrue("首帧的兜底上限必须是有限值", fallback.value.isFinite()) assertEquals(SHARE_PREVIEW_MAX_HEIGHT, fallback) } - @Test - fun `the expanded state leaves this layer unbounded instead of passing infinity`() { - // 展开态是「这一层不设上限」(null),**不是**「给一个无穷大的上限」——后者正是崩溃的成因。 - org.junit.Assert.assertNull(shareConfirmWindowCap(measured = null, expanded = true)) - org.junit.Assert.assertNull(shareConfirmWindowCap(measured = 42.dp, expanded = true)) - // 而且外层仍然是一层**有限**约束在管事。 - assertTrue(sharePreviewMaxHeight(expanded = true).value.isFinite()) - } - @Test fun `a measured cap is passed through`() { - assertEquals(42.dp, shareConfirmWindowCap(measured = 42.dp, expanded = false)) + assertEquals(42.dp, shareConfirmWindowCap(measured = 42.dp)) } @Test - fun `no input combination can produce a non finite cap`() { - val measured = listOf(null, 0.dp, 1.dp, 150.dp, 10_000.dp) - for (m in measured) { - for (expanded in listOf(false, true)) { - val cap = shareConfirmWindowCap(m, expanded) - assertTrue( - "m=$m expanded=$expanded 给出了非有限的上限:$cap", - cap == null || cap.value.isFinite(), - ) - } + fun `no input can produce a non finite cap`() { + for (m in listOf(null, 0.dp, 1.dp, 150.dp, 10_000.dp)) { + val cap = shareConfirmWindowCap(m) + assertTrue("measured=$m 给出了非有限的上限:$cap", cap.value.isFinite()) } } + + @Test + fun `both preview states keep a finite height bound somewhere in the chain`() { + // 展开态这一层「连滚动一起去掉」了,但**外层预览区**仍是一层有限约束在管事 —— + // 这正是展开后不会掉进同一个「无界高度」崩溃的原因。 + assertTrue(sharePreviewMaxHeight(expanded = true).value.isFinite()) + assertTrue(sharePreviewMaxHeight(expanded = false).value.isFinite()) + } }