fix(待办): 修正行高异常(我误读了测量单位,绕了三轮)

## 现象
待办列表每一条都非常高,几乎占满半屏。

## 根因
做 #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,层级清晰。
This commit is contained in:
2026-11-04 08:44:27 +08:00
parent 413900bbce
commit b623a876dd
+108 -49
View File
@@ -44,6 +44,35 @@ class MonthCell {
* 用 class 而不是 Map/匿名对象:ArkUI 的 ForEach 需要稳定结构, * 用 class 而不是 Map/匿名对象:ArkUI 的 ForEach 需要稳定结构,
* 用带类型的类更不容易在 ArkTS 严格模式下出错。 * 用带类型的类更不容易在 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 { class TodoGroup {
calKey: string = ''; calKey: string = '';
name: string = ''; name: string = '';
@@ -2672,64 +2701,90 @@ struct Index {
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`; return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
} }
/** 待办页:与日历视图完全分开,只读展示服务器端 VTODO */ /**
@Builder * 待办列表的扁平行(摊平后渲染)。
// ⭐ TODO_GROUPED 待办视图:未完成 / 已完成 两大段,段内再按**日历本**分组。 *
// 用户要求"按日历本分类显示,未完成里也能按家庭、生活分开,要有区分度"。 * ⚠️ 这是**普通方法**,不能挂 @Builder —— 里面是普通语句(new/赋值/循环),
// 分组标题前带该本的颜色圆点 —— 这才是"区分度"的来源。 * @Builder 内只允许 UI 组件语法。
todoBody() { */
// ⭐ 外层由 Scroll 改为 List:`swipeAction` 只能挂在 ListItem 上, private todoFlatRows(): TodoFlatRow[] {
// 而 ListItem 必须是 List 的直接子节点(#39 左滑删除)。 const rows: TodoFlatRow[] = [];
// 分组用 ListItemGroup 实现 —— 它天生支持分组头,正好放"色点 + 本名 + 条数"。 const undone: TodoGroup[] = this.todoGroups(false);
List({ space: 6 }) { const r0 = new TodoFlatRow();
ListItem() { r0.role = 'caption';
Text(`未完成(${this.todos.filter((t: DisplayEvent): boolean => !t.completed).length})`) r0.text = `未完成(${this.todos.filter((t: DisplayEvent): boolean => !t.completed).length})`;
.fontSize(13) rows.push(r0);
.fontWeight(FontWeight.Bold) for (const g of undone) {
.fontColor($r('app.color.text_secondary')) const rh = new TodoFlatRow();
.width('100%') rh.role = 'header';
.textAlign(TextAlign.Start) 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}`)
}
.width('100%')
}, (g: TodoGroup) => `g0_${g.calKey}`)
if (this.todos.some((t: DisplayEvent): boolean => 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);
}
}
}
return rows;
}
@Builder
todoBody() {
// ⭐ 用扁平 List 而非 ListItemGroup:
// ListItemGroup 会把剩余空间按比例再分给组内 ListItem,
// 实测「不设高度=176px、设 height(96)=300px」(越限越高),
// 无法得到"由内容决定"的行高。扁平化后行高自然贴合内容。
// 分组视觉不受影响 —— 分组头就是一行普通 ListItem。
List({ space: 4 }) {
ForEach(this.todoFlatRows(), (row: TodoFlatRow) => {
if (row.role === 'caption') {
ListItem() { ListItem() {
Text(`已完成(${this.todos.filter((t: DisplayEvent): boolean => t.completed).length})`) Text(row.text)
.fontSize(13) .fontSize(13)
.fontWeight(FontWeight.Bold) .fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_secondary')) .fontColor($r('app.color.text_secondary'))
.width('100%') .width('100%')
.textAlign(TextAlign.Start) .textAlign(TextAlign.Start)
.margin({ top: 10 }) .margin({ top: row.extraTop })
} }
.width('100%') .width('100%')
} else if (row.role === 'header') {
ForEach(this.todoGroups(true), (g: TodoGroup) => {
ListItemGroup({ header: this.todoGroupHeader(g) }) {
ForEach(g.items, (t: DisplayEvent) => {
ListItem() { ListItem() {
this.todoRow(t) if (row.group !== undefined) {
this.todoGroupHeader(row.group)
}
} }
.width('100%') .width('100%')
.swipeAction({ end: this.todoSwipeDelete(t) }) } else if (row.event !== undefined) {
}, (t: DisplayEvent) => `c${t.id}_${t.completed}`) ListItem() {
this.todoRow(row.event as DisplayEvent)
} }
.width('100%') .width('100%')
}, (g: TodoGroup) => `g1_${g.calKey}`) // 高度由内容自然决定(实测两行文字 ≈ 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) { if (this.todos.length === 0 && !this.loading) {
ListItem() { ListItem() {
@@ -2763,7 +2818,7 @@ struct Index {
.edgeEffect(EdgeEffect.Spring) .edgeEffect(EdgeEffect.Spring)
} }
/** 分组头:色点 + 本名(条数) —— 与原 Column 版一致 */ /** 分组头:色点 + 本名(条数) */
@Builder @Builder
todoGroupHeader(g: TodoGroup) { todoGroupHeader(g: TodoGroup) {
Row({ space: 6 }) { Row({ space: 6 }) {
@@ -2781,9 +2836,8 @@ struct Index {
/** /**
* 左滑露出的「删除」按钮。 * 左滑露出的「删除」按钮。
* *
* 复用现有 `askDeleteTodo`(二次确认)→ `doDeleteTodo`(本地软删 + 服务器 DELETE), * ⚠️ 不要用 height('100%'):swipeAction 的 end 组件参与行高测量,
* 不新写删除逻辑,保证与编辑弹层里的删除走同一条路径。 * 百分比会造成放大循环。用固定高度。
* 删除前先把目标存进 todoEditId,因为 askDeleteTodo 用的是这个字段。
*/ */
@Builder @Builder
todoSwipeDelete(t: DisplayEvent) { todoSwipeDelete(t: DisplayEvent) {
@@ -2794,13 +2848,15 @@ struct Index {
.textAlign(TextAlign.Center) .textAlign(TextAlign.Center)
} }
.width(72) .width(72)
// 用 100% 与 ListItem 同高:ListItem 高度由内容决定,
// 按钮跟随即可(官方文档:swipeAction builder 高度默认不匹配内容,
// 需要显式撑满才不会只占一半)。
.height('100%') .height('100%')
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.backgroundColor($r('app.color.error')) .backgroundColor($r('app.color.error'))
.borderRadius(12) .borderRadius(12)
.margin({ left: 6 }) .margin({ left: 6 })
.onClick(() => { .onClick(() => {
// askDeleteTodo 依赖 todoEditId 定位要删哪一条
this.todoEditId = t.id; this.todoEditId = t.id;
this.todoEditTitle = t.title; this.todoEditTitle = t.title;
this.todoEditWritable = t.writable; this.todoEditWritable = t.writable;
@@ -2808,6 +2864,8 @@ struct Index {
}) })
} }
/** /**
* 待办编辑覆盖层。 * 待办编辑覆盖层。
* *
@@ -3254,9 +3312,10 @@ struct Index {
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.layoutWeight(1) .layoutWeight(1)
// ⭐ 撑满行高:原来高度只到文字高度,行内上下留白点不到, // ⚠️ 不要用 .height('100%'):ListItem 里百分比会参照被拉伸的可用高度,
// 用户觉得"只有点标题才有反应"(#42)。撑满后整块内容区都可点。 // 实测让单条变成 1790px(几乎占满整屏)。
.height('100%') // 热区扩大改由外层 Row 的 padding 承担(见下方 .padding(10)),
// 内容区自身不再加 padding,避免内外叠加把行撑高。
// ⭐ 内容区点击 → 打开编辑(不改变完成状态) // ⭐ 内容区点击 → 打开编辑(不改变完成状态)
.onClick(() => { .onClick(() => {
this.openTodoEditor(t); this.openTodoEditor(t);