fix(chat): 分享确认面板一打开就崩 —— 正文那一层绝不能再拿到 Dp.Infinity
真机崩溃栈:heightIn(max = Dp.Infinity) 等于不施加约束,verticalScroll 在无界高度下被测量时 checkScrollableContainerConstraints 直接抛 IllegalStateException(ScrollNode.measure ← SizeNode.measure ← DialogLayout.internalOnMeasure),分享进来、面板刚打开就闪退。 改法:这一层的高度上限只经纯函数 shareConfirmWindowCap() 一个出口 —— 首帧用 SHARE_PREVIEW_MAX_HEIGHT(150dp) 兜底(有限值),展开态这一层不设上限(外层 240dp 仍是有限约束), 并且不再用 Dp.Infinity 当「还没量到」的哨兵值(改用 null)。新增 4 条单测钉住 「任何输入组合都不会产出非有限上限」。
This commit is contained in:
1 parent
af9c63d267
commit
6d4f2cdbae
2 files changed
+91
-17
No files matched your search
@@ -394,11 +394,22 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
|
||||
* 这个上限悄悄放宽。而三行有多高没法提前写出来——Miuix 的 `footnote1` 只给了 13sp 的字号,
|
||||
* `lineHeight` 是 `Unspecified`,行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。
|
||||
* 一个 dp 常量在默认字号档上勉强对得上,到了大字号档就会矮一大截(行数不足三行、末行被切)。
|
||||
* 所以这里改为**量**:先按没有上限排一次,从 `TextLayoutResult` 上取第三行的底边当上限。
|
||||
* 所以这里改为**量**:从 `TextLayoutResult` 上取第三行的底边当上限。
|
||||
*
|
||||
* 上限只写一次(`cap` 还是 [Dp.Infinity] 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度
|
||||
* ## ⚠️ 这一层绝不能拿到 [Dp.Infinity](W58 修的崩溃)
|
||||
* `heightIn(max = Dp.Infinity)` 等于**不施加约束**,而 `verticalScroll` 一旦在**无界高度**下被测量,
|
||||
* Compose 的 `checkScrollableContainerConstraints` 会**直接抛 `IllegalStateException` 把 App 打崩**
|
||||
* (真机复现:分享进来、面板刚打开就闪退,栈是 `ScrollNode.measure` ← `SizeNode.measure` ← `DialogLayout`)。
|
||||
*
|
||||
* 所以这一层的高度上限**只经 [shareConfirmWindowCap] 一个出口**,它保证返回值要么是 `null`
|
||||
* (这一层不设上限)、要么是**有限值**,永远不是 `Dp.Infinity`:
|
||||
* - **首帧**(还没量到)用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底 —— 有限,且正好是收起态外层预览区的上限;
|
||||
* - **展开态**整层不设上限,但**外层**预览区此时换成 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],那仍是一层
|
||||
* 有限的约束在管事(不是「没有约束」)。
|
||||
*
|
||||
* 上限只写一次(`cap` 还是 `null` 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度
|
||||
* 与样式都没变,行位置与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的
|
||||
* 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,高度不受限,两种状态下排出来的行位置一样)。
|
||||
* 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,两种状态下排出来的行位置一样)。
|
||||
*
|
||||
* ## 展开态为什么干脆不留这一层上限
|
||||
* 展开之后正文可能几屏长。若在这一窗上再设一个上限,就成了「窗外滚 + 窗内滚」两层都要滚的手势
|
||||
@@ -407,16 +418,15 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
|
||||
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],整段正文摊在里面,一层滚到底,读不读得到尾只取决于那一层。
|
||||
* 代价是展开后不能「只滚正文、图片与文件钉着不动」——那要把两层滚动接起来,这个面板不值这个复杂度。
|
||||
*
|
||||
* ## 首帧的那次回流:留着,且确认它看不见(W56 复核)
|
||||
* 上限是**首帧之后**才量到的,所以长正文会先按没有上限排一次,下一帧才收到三行。上一批留了个悬念:
|
||||
* 真机上会不会看见一次跳动。复核的结论是**看不见**,两条理由:
|
||||
* ## 首帧的那次回流:留着,且确认它看不见
|
||||
* 上限是**首帧之后**才量到的,所以长正文会先按 fallback([SHARE_PREVIEW_MAX_HEIGHT])排一次,
|
||||
* 下一帧才收到三行。真机上**看不见**跳动,两条理由:
|
||||
* 1. 首帧那一下撑不动任何别的东西——正文块是预览区里的**最后一个**元素(图片、文件都在它上面),
|
||||
* 而预览区自己还有一层独立的上限在管可视高度:下面没有东西被它顶下去,上面那两样的位置一分
|
||||
* 不动,可视的像素与量完之后**一样**(那些像素由 `heightIn` 的裁剪决定,裁剪高度不变)。
|
||||
* 2. 加展开态**不会把它变明显**:展开态本来就没有这一层上限(见上),也就没有「量完再回落」这一步;
|
||||
* 而展开 / 收起是用户主动的一次布局变化,那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的
|
||||
* 是展开控件本身——它要到第二帧才出现(行数是排完才知道的),但这一枚也顶不动东西:标签行里那
|
||||
* 两句话用的是同一个 `footnote2`,行高不变。
|
||||
* 2. 展开态本来就没有这一层上限,不存在「量完再回落」;而展开 / 收起是用户主动的一次布局变化,
|
||||
* 那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的是展开控件本身——它要到第二帧才出现
|
||||
* (行数是排完才知道的),但这一枚也顶不动东西:标签行里那两句话用的是同一个 `footnote2`,行高不变。
|
||||
*
|
||||
* 另一条路是在组合期用 `rememberTextMeasurer` 把上限量出来,好让控件从首帧就摆上:那要先知道宽度
|
||||
* (得外包一层 `BoxWithConstraints`),还要自己复现字号、字体缩放与字体回退,等于把排版做两遍——
|
||||
@@ -425,8 +435,7 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
|
||||
* ## 两层滚动怎么分工
|
||||
* 收起态:这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块
|
||||
* 合起来的高度上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来
|
||||
* 就走嵌套滚动,不用额外接线。展开态下这一窗没有可滚的余量(`heightIn` 给了 [Dp.Infinity]),
|
||||
* 手势整个交给外层。
|
||||
* 就走嵌套滚动,不用额外接线。展开态下这一窗不设上限,手势整个交给外层。
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareConfirmBody(
|
||||
@@ -435,21 +444,22 @@ private fun ShareConfirmBody(
|
||||
onOverflowChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
var cap by remember(text) { mutableStateOf(Dp.Infinity) }
|
||||
// `null` = 还没量到。**不要**拿 [Dp.Infinity] 当这个哨兵值(见 KDoc 里那次崩溃)。
|
||||
var cap by remember(text) { mutableStateOf<Dp?>(null) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// 展开态给 [Dp.Infinity] = 这一层没有上限;这正是**首帧那个状态**(`cap` 一开始就是它),
|
||||
// 所以展开态不多一条代码路径,也不会比首帧更糟。
|
||||
.heightIn(max = if (expanded) Dp.Infinity else cap)
|
||||
.then(
|
||||
shareConfirmWindowCap(cap, expanded)?.let { Modifier.heightIn(max = it) } ?: Modifier,
|
||||
)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MiuixTheme.textStyles.footnote1,
|
||||
onTextLayout = { layout ->
|
||||
if (cap == Dp.Infinity && layout.lineCount > 0) {
|
||||
if (cap == null && layout.lineCount > 0) {
|
||||
// 正文不到三行时上限就取全文的高度(`heightIn` 是个上限,短了本来也不生效)。
|
||||
val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1
|
||||
cap = with(density) { layout.getLineBottom(lastVisible).toDp() }
|
||||
@@ -462,6 +472,20 @@ private fun ShareConfirmBody(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文那一窗该套多大的高度上限(**纯函数**,可单测):`null` = 这一层**不设**上限。
|
||||
*
|
||||
* 三档:展开态不设(外层已有有限上限);量到过就用量的;还没量到就用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底。
|
||||
*
|
||||
* ⚠️ **返回值要么是 `null`、要么是有限值,永远不是 [Dp.Infinity]** —— 后者会让 `verticalScroll`
|
||||
* 在无界高度下被测量并抛 `IllegalStateException`(W58 真机崩过)。这条不变量有单测钉着。
|
||||
*/
|
||||
internal fun shareConfirmWindowCap(measured: Dp?, expanded: Boolean): Dp? = when {
|
||||
expanded -> null
|
||||
measured != null -> measured
|
||||
else -> SHARE_PREVIEW_MAX_HEIGHT
|
||||
}
|
||||
|
||||
/** 预览区在**收起态**的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
|
||||
internal val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
|
||||
|
||||
|
||||
@@ -317,3 +317,53 @@ class ShareConfirmTest {
|
||||
assertTrue(SHARE_PREVIEW_COLLAPSE_LABEL.isNotBlank())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* W58:正文那一窗的高度上限**永远不许是 [androidx.compose.ui.unit.Dp.Infinity]**。
|
||||
*
|
||||
* 背景是真机上的一次崩溃:`heightIn(max = Dp.Infinity)` 等于**不施加约束**,而 `verticalScroll`
|
||||
* 一旦在无界高度下被测量,Compose 的 `checkScrollableContainerConstraints` 会直接抛
|
||||
* `IllegalStateException` 把 App 打崩(分享面板一打开就闪退,栈是 `ScrollNode.measure` ←
|
||||
* `SizeNode.measure` ← `DialogLayout.internalOnMeasure`)。
|
||||
*
|
||||
* 这条不变量只能靠纯函数钉住:本模块的 `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())
|
||||
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))
|
||||
}
|
||||
|
||||
@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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user