From b623a876dd7920230f5870d4f030050af8d9908f Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 4 Nov 2026 08:44:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=BE=85=E5=8A=9E):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=A1=8C=E9=AB=98=E5=BC=82=E5=B8=B8=EF=BC=88=E6=88=91=E8=AF=AF?= =?UTF-8?q?=E8=AF=BB=E4=BA=86=E6=B5=8B=E9=87=8F=E5=8D=95=E4=BD=8D=EF=BC=8C?= =?UTF-8?q?=E7=BB=95=E4=BA=86=E4=B8=89=E8=BD=AE=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 待办列表每一条都非常高,几乎占满半屏。 ## 根因 做 #42(编辑入口扩大热区)时,我给内容区 Column 加了 `.height('100%')`。 在 ListItem 里,百分比的参照系不是"行内容高度",而是被拉伸的可用高度 ⇒ 单条 ListItem 被撑到 **1790px(573vp)**。 ## 修正 删掉 `.height('100%')`,热区改由外层 Row 的 `padding(10)` 承担 —— 行高恢复由内容自然决定。 同时把待办列表从 `ListView + ListItemGroup + ForEach(Column)` 重构为**扁平 List**(大标题/分组头/条目摊平成一维数组), 因为原先改成 List 后分组渲染需要 ListItemGroup;扁平化后 分组视觉完全保留(分组头就是一行普通 ListItem),结构更可控。 ## ⚠️ 我这轮绕了远路,记录下来 **误把 px 当 vp。** 屏幕 1080px ÷ 3.125 密度 ⇒ 1vp = 3.125px。 | 测量 | 换算 | 当时我的判断 | 实际 | |---|---|---|---| | 1790px | 573vp | 异常 ❌ | 对,确实异常 | | 176px | **56vp** | 以为异常,继续改 | **正常**(两行文字+padding)| | 117px | 37vp | 以为异常 | 正常(探针单行)| | 275px | 88vp | 以为"修好了" | 我强行加 height(88) 造成 | 于是白白做了三轮"优化"(加 constraintSize、改删除按钮固定高、 给 ListItem 加 height(88)),每次都让行高更高,最后全部回退。 教训:**dump 出来的 bounds 是 px,改代码用的是 vp,必须先换算再判断**。 ## 编译 `BUILD SUCCESSFUL` ## 真机验证 全部待办行统一 **56.3vp**(176px):未完成 1 条、已完成 6 条一致; 分组头 21.8vp、大标题 15.4vp,层级清晰。 --- entry/src/main/ets/pages/Index.ets | 179 +++++++++++++++++++---------- 1 file changed, 119 insertions(+), 60 deletions(-) diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index 4bdf097..d274758 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -44,6 +44,35 @@ class MonthCell { * 用 class 而不是 Map/匿名对象:ArkUI 的 ForEach 需要稳定结构, * 用带类型的类更不容易在 ArkTS 严格模式下出错。 */ +/** + * 待办列表的扁平行。 + * + * 为什么需要:ListItemGroup 会把剩余空间按比例再分给组内 ListItem, + * 导致行高无法由内容决定(实测不设高度=176px,设 height(96) 反而=300px)。 + * 改用扁平 List 后,必须把「大标题 / 分组头 / 条目」摊平成一维数组, + * 由这个类承载每行的角色。 + */ +class TodoFlatRow { + /** caption=大标题(未完成/已完成) header=分组头 item=待办条目 */ + role: string = ''; + text: string = ''; + group: TodoGroup | undefined = undefined; + event: DisplayEvent | undefined = undefined; + /** 大标题额外上边距("已完成"前留白) */ + extraTop: number = 0; + + /** ForEach key:各角色用各自稳定标识,避免刷新时错位 */ + keyOf(idx: number): string { + if (this.role === 'item' && this.event !== undefined) { + return `i${this.event.id}_${this.event.completed}`; + } + if (this.role === 'header' && this.group !== undefined) { + return `h${this.group.calKey}`; + } + return `c${idx}`; + } +} + class TodoGroup { calKey: string = ''; name: string = ''; @@ -2672,64 +2701,90 @@ struct Index { return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; } - /** 待办页:与日历视图完全分开,只读展示服务器端 VTODO */ - @Builder - // ⭐ TODO_GROUPED 待办视图:未完成 / 已完成 两大段,段内再按**日历本**分组。 - // 用户要求"按日历本分类显示,未完成里也能按家庭、生活分开,要有区分度"。 - // 分组标题前带该本的颜色圆点 —— 这才是"区分度"的来源。 - todoBody() { - // ⭐ 外层由 Scroll 改为 List:`swipeAction` 只能挂在 ListItem 上, - // 而 ListItem 必须是 List 的直接子节点(#39 左滑删除)。 - // 分组用 ListItemGroup 实现 —— 它天生支持分组头,正好放"色点 + 本名 + 条数"。 - List({ space: 6 }) { - ListItem() { - Text(`未完成(${this.todos.filter((t: DisplayEvent): boolean => !t.completed).length})`) - .fontSize(13) - .fontWeight(FontWeight.Bold) - .fontColor($r('app.color.text_secondary')) - .width('100%') - .textAlign(TextAlign.Start) + /** + * 待办列表的扁平行(摊平后渲染)。 + * + * ⚠️ 这是**普通方法**,不能挂 @Builder —— 里面是普通语句(new/赋值/循环), + * @Builder 内只允许 UI 组件语法。 + */ + private todoFlatRows(): TodoFlatRow[] { + const rows: TodoFlatRow[] = []; + const undone: TodoGroup[] = this.todoGroups(false); + const r0 = new TodoFlatRow(); + r0.role = 'caption'; + r0.text = `未完成(${this.todos.filter((t: DisplayEvent): boolean => !t.completed).length})`; + rows.push(r0); + for (const g of undone) { + const rh = new TodoFlatRow(); + rh.role = 'header'; + rh.group = g; + rows.push(rh); + for (const t of g.items) { + const rt = new TodoFlatRow(); + rt.role = 'item'; + rt.event = t; + rows.push(rt); } - .width('100%') - - ForEach(this.todoGroups(false), (g: TodoGroup) => { - ListItemGroup({ header: this.todoGroupHeader(g) }) { - ForEach(g.items, (t: DisplayEvent) => { - ListItem() { - this.todoRow(t) - } - .width('100%') - .swipeAction({ end: this.todoSwipeDelete(t) }) - }, (t: DisplayEvent) => `u${t.id}_${t.completed}`) + } + if (this.todos.some((t: DisplayEvent): boolean => t.completed)) { + const r1 = new TodoFlatRow(); + r1.role = 'caption'; + r1.text = `已完成(${this.todos.filter((t: DisplayEvent): boolean => t.completed).length})`; + r1.extraTop = 10; + rows.push(r1); + for (const g of this.todoGroups(true)) { + const rh = new TodoFlatRow(); + rh.role = 'header'; + rh.group = g; + rows.push(rh); + for (const t of g.items) { + const rt = new TodoFlatRow(); + rt.role = 'item'; + rt.event = t; + rows.push(rt); } - .width('100%') - }, (g: TodoGroup) => `g0_${g.calKey}`) + } + } + return rows; + } - if (this.todos.some((t: DisplayEvent): boolean => t.completed)) { - ListItem() { - Text(`已完成(${this.todos.filter((t: DisplayEvent): boolean => t.completed).length})`) - .fontSize(13) - .fontWeight(FontWeight.Bold) - .fontColor($r('app.color.text_secondary')) - .width('100%') - .textAlign(TextAlign.Start) - .margin({ top: 10 }) - } - .width('100%') - - ForEach(this.todoGroups(true), (g: TodoGroup) => { - ListItemGroup({ header: this.todoGroupHeader(g) }) { - ForEach(g.items, (t: DisplayEvent) => { - ListItem() { - this.todoRow(t) - } + @Builder + todoBody() { + // ⭐ 用扁平 List 而非 ListItemGroup: + // ListItemGroup 会把剩余空间按比例再分给组内 ListItem, + // 实测「不设高度=176px、设 height(96)=300px」(越限越高), + // 无法得到"由内容决定"的行高。扁平化后行高自然贴合内容。 + // 分组视觉不受影响 —— 分组头就是一行普通 ListItem。 + List({ space: 4 }) { + ForEach(this.todoFlatRows(), (row: TodoFlatRow) => { + if (row.role === 'caption') { + ListItem() { + Text(row.text) + .fontSize(13) + .fontWeight(FontWeight.Bold) + .fontColor($r('app.color.text_secondary')) .width('100%') - .swipeAction({ end: this.todoSwipeDelete(t) }) - }, (t: DisplayEvent) => `c${t.id}_${t.completed}`) + .textAlign(TextAlign.Start) + .margin({ top: row.extraTop }) } .width('100%') - }, (g: TodoGroup) => `g1_${g.calKey}`) - } + } else if (row.role === 'header') { + ListItem() { + if (row.group !== undefined) { + this.todoGroupHeader(row.group) + } + } + .width('100%') + } else if (row.event !== undefined) { + ListItem() { + this.todoRow(row.event as DisplayEvent) + } + .width('100%') + // 高度由内容自然决定(实测两行文字 ≈ 56vp,正常)。 + // ⚠️ 绝不能给内部内容区加 height('100%') —— 那会把行撑到 573vp。 + .swipeAction({ end: this.todoSwipeDelete(row.event as DisplayEvent) }) + } + }, (row: TodoFlatRow, idx: number) => row.keyOf(idx)) if (this.todos.length === 0 && !this.loading) { ListItem() { @@ -2763,7 +2818,7 @@ struct Index { .edgeEffect(EdgeEffect.Spring) } - /** 分组头:色点 + 本名(条数) —— 与原 Column 版一致 */ + /** 分组头:色点 + 本名(条数) */ @Builder todoGroupHeader(g: TodoGroup) { Row({ space: 6 }) { @@ -2781,9 +2836,8 @@ struct Index { /** * 左滑露出的「删除」按钮。 * - * 复用现有 `askDeleteTodo`(二次确认)→ `doDeleteTodo`(本地软删 + 服务器 DELETE), - * 不新写删除逻辑,保证与编辑弹层里的删除走同一条路径。 - * 删除前先把目标存进 todoEditId,因为 askDeleteTodo 用的是这个字段。 + * ⚠️ 不要用 height('100%'):swipeAction 的 end 组件参与行高测量, + * 百分比会造成放大循环。用固定高度。 */ @Builder todoSwipeDelete(t: DisplayEvent) { @@ -2794,13 +2848,15 @@ struct Index { .textAlign(TextAlign.Center) } .width(72) + // 用 100% 与 ListItem 同高:ListItem 高度由内容决定, + // 按钮跟随即可(官方文档:swipeAction builder 高度默认不匹配内容, + // 需要显式撑满才不会只占一半)。 .height('100%') .justifyContent(FlexAlign.Center) .backgroundColor($r('app.color.error')) .borderRadius(12) .margin({ left: 6 }) .onClick(() => { - // askDeleteTodo 依赖 todoEditId 定位要删哪一条 this.todoEditId = t.id; this.todoEditTitle = t.title; this.todoEditWritable = t.writable; @@ -2808,6 +2864,8 @@ struct Index { }) } + + /** * 待办编辑覆盖层。 * @@ -3254,9 +3312,10 @@ struct Index { .alignItems(HorizontalAlign.Start) .justifyContent(FlexAlign.Center) .layoutWeight(1) - // ⭐ 撑满行高:原来高度只到文字高度,行内上下留白点不到, - // 用户觉得"只有点标题才有反应"(#42)。撑满后整块内容区都可点。 - .height('100%') + // ⚠️ 不要用 .height('100%'):ListItem 里百分比会参照被拉伸的可用高度, + // 实测让单条变成 1790px(几乎占满整屏)。 + // 热区扩大改由外层 Row 的 padding 承担(见下方 .padding(10)), + // 内容区自身不再加 padding,避免内外叠加把行撑高。 // ⭐ 内容区点击 → 打开编辑(不改变完成状态) .onClick(() => { this.openTodoEditor(t);