feat(日视图): 全天日程与待办改为浮动固定,最多3条可展开 (#68)

## 用户诉求
「打开日程之后时间会自动滚到下面(比如下午16点),全天日程被滚上去看不见了。
想做成浮动的,直接靠在日期下面,不管下面怎么滚都不动。
但条数多会盖住视野,所以最多显示3条,下面加个向下箭头,点击展开。」

## 改造
### 结构变化
改前:
  Scroll { timelineBody() }        ← 待办区+全天区+时间轴 全在滚动里
    Column { 待办区 / 全天区 / Row(刻度+三层时间轴) }

改后:
  Column {
    topBar()                        ← 固定,不滚
    Scroll { timelineBody() }       ← 只剩时间轴
  }

### 为什么用 Column 而不是 Stack 浮动
用户原话是"浮动",但实现上用 Column 顺序布局(固定区占位、时间轴在其下)
比 Stack 叠浮更稳妥:不遮挡、不需算偏移。视觉上同样是"贴着日期下方不动"。
这样避免了用户担心的"盖住一大片视野"——固定区本身占位,不会被内容压。

### 折叠机制
- 待办 + 全天/跨天**合并**成一个列表(否则两块各自3条会变6条)
- 折叠时最多 3 条
- 超过时显示「还有 N 条 ⌄」,点击展开为「收起 ⌃」
- 待办在前、全天日程在后

### 顺带修正
`allDayH`(原用于滚动定位时把全天区高度计入偏移)现在恒为 0,
因为该区已移出 Scroll。若不改,滚动定位会多偏移整个固定区高度。

## 真机验证(5NC0225B27004718)
创建 4 条今天截止的测试待办后:

| 验证项 | 实测 |
|---|---|
| 固定区位置 | 日期行 y=707 下方,依次 783/871/958 |
| 折叠生效 | 只显示 折叠测试1/2/3,第4条隐藏 ✅ |
| 合并计数 | 「还有 3 条」(6条-3条,含待办本的其他条目)✅ |
| 展开箭头 | `[595,1030] ⌄` 向下箭头 ✅ |
| 点击展开 | 显示全部 6 条,文字变「收起」、箭头变 `⌃` ✅ |
| 时间轴在其下 | 刻度 11:00 从 y=1104 开始,未被遮挡 ✅ |

## 编译
BUILD SUCCESSFUL
This commit is contained in:
2026-11-05 16:21:49 +08:00
parent f08a2a0dea
commit 2c565747ba
+133 -52
View File
@@ -4299,6 +4299,15 @@ class AgendaTimelineGroup {
timeline: DayTimeline = new DayTimeline(); timeline: DayTimeline = new DayTimeline();
} }
/**
* 顶部固定区(全天日程 + 待办)的一个条目(#68)。
* ArkTS 不允许内联对象字面量类型,必须用命名 class。
*/
class TopBarItem {
isTodo: boolean = false; // true=待办(带勾选圈) false=全天/跨天日程(色块)
ev: DisplayEvent = new DisplayEvent();
}
/** 日程层纵向序列中的一行:空隙 / 冲突组。整点线与红线已移到另外两层,本层不含任何"线"。 /** 日程层纵向序列中的一行:空隙 / 冲突组。整点线与红线已移到另外两层,本层不含任何"线"。
* topRatio~botRatio = 该行覆盖的当日比例区间(0~1,乘整日高得 vp)。 */ * topRatio~botRatio = 该行覆盖的当日比例区间(0~1,乘整日高得 vp)。 */
class TimeRow { class TimeRow {
@@ -4328,7 +4337,12 @@ struct DayTimelineView {
onToggleTodo: (t: DisplayEvent) => void = (t: DisplayEvent): void => {}; // 勾选待办回调 onToggleTodo: (t: DisplayEvent) => void = (t: DisplayEvent): void => {}; // 勾选待办回调
private scroller: Scroller = new Scroller(); private scroller: Scroller = new Scroller();
@State private viewportH: number = 0; // 滚动视口高度(由 Scroll 实测,用于把"当前时刻"居中) @State private viewportH: number = 0; // 滚动视口高度(由 Scroll 实测,用于把"当前时刻"居中)
@State private allDayH: number = 0; // 顶部"全天/跨天"区高度(它现在随内容滚动,居中定位要加上) /** ⭐ 顶部固定区(全天日程 + 待办)是否展开(#68)
* 默认折叠:最多显示 3 条,超出显示向下箭头。折叠是为了避免浮动区盖住大片时间轴视野。 */
@State private topExpanded: boolean = false;
/** 顶部"全天/跨天"区高度 —— #68 起该区移出 Scroll(浮动固定),
* 不再参与滚动偏移计算,恒为 0。保留字段仅为兼容旧引用。 */
@State private allDayH: number = 0;
private pendingScroll: boolean = false; private pendingScroll: boolean = false;
aboutToAppear(): void { aboutToAppear(): void {
@@ -4340,6 +4354,49 @@ struct DayTimelineView {
this.scrollToNow(); this.scrollToNow();
} }
/**
* 顶部固定区要显示的条目(#68):**待办在前、全天/跨天在后**,合并成一个列表。
* 合并而不是分两块,是为了统一"最多显示 3 条"的限制 —— 否则两块各自 3 条会变 6 条。
*/
private topItems(): TopBarItem[] {
const out: TopBarItem[] = [];
for (const td of this.timeline.todos) {
const it = new TopBarItem();
it.isTodo = true;
it.ev = td;
out.push(it);
}
for (const ev of this.timeline.allDay) {
const it = new TopBarItem();
it.isTodo = false;
it.ev = ev;
out.push(it);
}
return out;
}
/** 顶部固定区总条数 */
private topCount(): number {
return this.timeline.todos.length + this.timeline.allDay.length;
}
/** 折叠时最多显示 3 条(#68);展开后显示全部 */
private topLimit(): number {
return this.topExpanded ? this.topCount() : 3;
}
/** 折叠时是否还有被藏起来的条目(决定要不要画展开箭头) */
private topHasMore(): boolean {
return this.topCount() > 3;
}
/** 取前 N 条(展开状态由 topLimit 决定) */
private topVisible(): TopBarItem[] {
const all: TopBarItem[] = this.topItems();
const lim: number = this.topLimit();
return all.length > lim ? all.slice(0, lim) : all;
}
/** 自动滚动:把"当前时刻红线"定位到可视区**垂直居中** /** 自动滚动:把"当前时刻红线"定位到可视区**垂直居中**
* (目标 y = nowRatio * 整日高 − 视口高/2,夹取 ≥0)。仅今天且自带滚动时生效。 * (目标 y = nowRatio * 整日高 − 视口高/2,夹取 ≥0)。仅今天且自带滚动时生效。
* 首帧视口高度未知 → 先用估算值滚一次,等 onAreaChange 实测到高度后再校正一次。 */ * 首帧视口高度未知 → 先用估算值滚一次,等 onAreaChange 实测到高度后再校正一次。 */
@@ -4356,8 +4413,9 @@ struct DayTimelineView {
return; return;
} }
const vp: number = this.viewportH > 0 ? this.viewportH : this.hourUnit * 8; const vp: number = this.viewportH > 0 ? this.viewportH : this.hourUnit * 8;
// 内容顶部还有"全天/跨天"区(allDayH),且时间轴只从 viewTop 开始 → 都要计入偏移 // #68:全天/待办区已移出 Scroll(浮动固定),偏移里不再加 allDayH。
const y: number = this.allDayH + (TimelineUtil.nowRatio(this.nowMs) - this.viewTop()) * this.totalH() - vp / 2; // 时间轴只从 viewTop 开始 → 直接用比例换算即可。
const y: number = (TimelineUtil.nowRatio(this.nowMs) - this.viewTop()) * this.totalH() - vp / 2;
setTimeout((): void => { setTimeout((): void => {
this.scroller.scrollTo({ xOffset: 0, yOffset: y < 0 ? 0 : y }); this.scroller.scrollTo({ xOffset: 0, yOffset: y < 0 ? 0 : y });
}, 60); }, 60);
@@ -4571,6 +4629,10 @@ struct DayTimelineView {
build() { build() {
Column() { Column() {
// 时间轴主体(含顶部"全天/跨天"条 —— 它们随内容一起滚动,向下滑时跟 0 点一起上移)。 // 时间轴主体(含顶部"全天/跨天"条 —— 它们随内容一起滚动,向下滑时跟 0 点一起上移)。
// ⭐ 顶部固定区(待办 + 全天/跨天)#68:放在 Scroll **之外** → 不随时间轴滚动。
// 原来它在 timelineBody 里,会被"自动滚到当前时刻"带走,
// 导致下午 16 点打开时全天日程看不见。
this.topBar()
// scrollable=true 时包一层 Scroll 并自动定位到当前时刻;否则整页平铺。 // scrollable=true 时包一层 Scroll 并自动定位到当前时刻;否则整页平铺。
if (this.scrollable) { if (this.scrollable) {
Scroll(this.scroller) { Scroll(this.scroller) {
@@ -4588,6 +4650,7 @@ struct DayTimelineView {
} }
}) })
} else { } else {
this.topBar()
this.timelineBody() this.timelineBody()
} }
} }
@@ -4598,19 +4661,27 @@ struct DayTimelineView {
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
} }
/** 时间轴主体:顶部"全天/跨天"条(随滚动一起移动)+ 左侧小时刻度 + 右侧按冲突组分行、组内按列并排 */ /**
* 顶部固定区(#68):待办 + 全天/跨天日程,**不随时间轴滚动**。
*
* 为什么加这个区:原来它们排在时间轴最前面,但打开日视图会自动滚到当前时刻
* (比如下午 16 点),于是全天日程被滚上去看不见了。改成浮动固定后,
* 不管时间轴怎么滚,它们都贴在日期行下方。
*
* 折叠机制(用户提的折中方案):全天+待办合计**最多显示 3 条**,
* 超出时显示向下箭头,点一下展开全部;避免条数多时盖住大片视野。
*/
@Builder @Builder
private timelineBody() { private topBar() {
Column() { if (this.topCount() > 0) {
// ⭐ 当天截止待办区(#45):画在最顶,全天/跨天之上,随内容一起滚动。 Column({ space: 4 }) {
if (this.timeline.todos.length > 0) { ForEach(this.topVisible(), (item: TopBarItem) => {
Column({ space: 4 }) { if (item.isTodo) {
ForEach(this.timeline.todos, (td: DisplayEvent) => { // ---- 待办行:勾选圈 + 标题 ----
Row({ space: 8 }) { Row({ space: 8 }) {
// 勾选圈:点击切换完成(与待办列表同一回调) // 矢量勾(#50):字符 ✓ 依赖字体,粗细/居中不稳定
// 同列表:矢量勾(#50)
Stack() { Stack() {
if (td.completed) { if (item.ev.completed) {
Path() Path()
.width(18) .width(18)
.height(18) .height(18)
@@ -4625,15 +4696,17 @@ struct DayTimelineView {
.width(18) .width(18)
.height(18) .height(18)
.borderRadius(9) .borderRadius(9)
.backgroundColor(td.completed ? td.color : 'rgba(0,0,0,0)') .backgroundColor(item.ev.completed ? item.ev.color : 'rgba(0,0,0,0)')
.border({ width: 1.5, color: td.color }) .border({ width: 1.5, color: item.ev.color })
.onClick(() => { .onClick(() => {
this.onToggleTodo(td); this.onToggleTodo(item.ev);
}) })
Text(td.title === '' ? '(无标题待办)' : td.title) Text(item.ev.title === '' ? '(无标题待办)' : item.ev.title)
.fontSize(12) .fontSize(12)
.fontColor($r('app.color.text_primary')) .fontColor($r('app.color.text_primary'))
.decoration({ type: td.completed ? TextDecorationType.LineThrough : TextDecorationType.None }) .decoration({
type: item.ev.completed ? TextDecorationType.LineThrough : TextDecorationType.None
})
.maxLines(1) .maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1) .layoutWeight(1)
@@ -4645,26 +4718,16 @@ struct DayTimelineView {
.borderRadius(6) .borderRadius(6)
.backgroundColor($r('app.color.card_bg')) .backgroundColor($r('app.color.card_bg'))
.border({ width: 0.5, color: $r('app.color.shadow_color') }) .border({ width: 0.5, color: $r('app.color.shadow_color') })
}, (td: DisplayEvent) => `todo_${this.timeline.dateKey}_${td.id}_${td.completed}`) } else {
} // ---- 全天/跨天日程:色块 ----
.width('100%')
.padding({ bottom: 6 })
}
// 全天 / 跨天日程:同样用**色块**展示(与普通日程保持一致的视觉),
// 画在 0 点之前并**随内容一起滚动**(向下滑时跟 0 点一起上移,不再常驻顶部)
if (this.timeline.allDay.length > 0) {
Column({ space: 4 }) {
ForEach(this.timeline.allDay, (e: DisplayEvent) => {
Row({ space: 6 }) { Row({ space: 6 }) {
Text(e.isAllDay ? '全天' : '跨天') Text(item.ev.isAllDay ? '全天' : '跨天')
.fontSize(9) .fontSize(9)
.fontColor('#FFFFFF') .fontColor('#FFFFFF')
.backgroundColor('#26000000') .backgroundColor('#26000000')
.borderRadius(6) .borderRadius(6)
.padding({ left: 5, right: 5, top: 1, bottom: 1 }) .padding({ left: 5, right: 5, top: 1, bottom: 1 })
if (!e.writable) { if (!item.ev.writable) {
// 只读标记:圆角矩形白框 + 白字(与色块上的「只读」小标同一套样式)
Text('只读') Text('只读')
.fontSize(9) .fontSize(9)
.fontColor('#FFFFFF') .fontColor('#FFFFFF')
@@ -4672,43 +4735,61 @@ struct DayTimelineView {
.borderRadius(4) .borderRadius(4)
.padding({ left: 4, right: 4, top: 0, bottom: 0 }) .padding({ left: 4, right: 4, top: 0, bottom: 0 })
} }
Text(e.title === '' ? '(无标题)' : e.title) Text(item.ev.title === '' ? '(无标题)' : item.ev.title)
.fontSize(12) .fontSize(12)
.fontWeight(FontWeight.Medium) .fontWeight(FontWeight.Medium)
.fontColor('#FFFFFF') .fontColor('#FFFFFF')
.maxLines(1) .maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
.layoutWeight(1) .layoutWeight(1)
if (e.location !== '') { if (item.ev.location !== '') {
Text(e.location) Text(item.ev.location)
.fontSize(10) .fontSize(10)
.fontColor('#D9FFFFFF') .fontColor('#D9FFFFFF')
.maxLines(1) .maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
.constraintSize({ maxWidth: 120 }) .constraintSize({ maxWidth: 100 })
} }
} }
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.width('100%') .width('100%')
.height(26) .height(24)
.padding({ left: 8, right: 8 }) .padding({ left: 8, right: 8 })
.borderRadius(8) .borderRadius(6)
// 只读不再用灰蒙版(已撤),只靠标题后的「只读」白框小标区分 .backgroundColor(item.ev.color)
.backgroundColor(e.color) .onClick(() => this.onPick(item.ev))
.onClick(() => this.onPick(e))
}, (e: DisplayEvent) => `al_${this.timeline.dateKey}_${TimelineUtil.keyOf(e)}_${e.writable ? 1 : 0}_${e.location.length}`)
}
.width('100%')
.padding({ bottom: 6 })
.onAreaChange((oldVal: Area, newVal: Area): void => {
const h: number = Number(newVal.height);
if (h > 0 && (h - this.allDayH > 1 || h - this.allDayH < -1)) {
this.allDayH = h;
this.tryScroll();
} }
}) }, (item: TopBarItem) => `tb_${this.timeline.dateKey}_${item.isTodo ? 't' : 'a'}_${item.ev.id}_${item.ev.completed ? 1 : 0}`)
}
// 展开/收起箭头(合计超过 3 条时才出现)
if (this.topHasMore()) {
Row({ space: 4 }) {
Text(this.topExpanded ? '收起' : `还有 ${this.topCount() - 3} 条`)
.fontSize(10)
.fontColor($r('app.color.text_hint'))
Text(this.topExpanded ? '⌃' : '⌄')
.fontSize(11)
.fontColor($r('app.color.text_hint'))
}
.width('100%')
.height(18)
.justifyContent(FlexAlign.Center)
.onClick(() => {
this.topExpanded = !this.topExpanded;
})
}
}
.width('100%')
.padding({ bottom: 6 })
}
}
/** 时间轴主体:左侧小时刻度 + 右侧按冲突组分行、组内按列并排(顶部固定区已移出,#68) */
@Builder
private timelineBody() {
Column() {
// 顶部固定区(待办 + 全天/跨天)已移到 Scroll 之外(#68),
// 这里只剩可滚动的时间轴本体。
Row() { Row() {
// 左侧小时刻度(每格一条底线,作为时间尺;当前小时高亮)。只渲染视窗内的小时 // 左侧小时刻度(每格一条底线,作为时间尺;当前小时高亮)。只渲染视窗内的小时
Column() { Column() {