/** * 家书 FamilyBook · 底部悬浮快捷栏(鸿蒙端) * * 用户 2026-10-12 要求: * 「页面正下方设置几个浮动按钮,放三、四个常见功能。比如日程、名册、任务和笔记, * 这四个应该比较常用,放在下面。这样用户不用拉抽屉,就可以直接点击使用。 * 但一定要注意,使用浮动的方式,**不要固定在下面**,而是**浮动着的**。」 * * 🔴 形态照**鸿蒙 6 官方设计规范**的「悬浮导航(Float Navigation)」: * · 脱离底部边界,以「**四周留白 + 圆角卡片**」形态**悬浮在内容之上**; * · 参考实现参数:`height(56) + 四周留白 + borderRadius(28)`; * · 与系统手势条保持间距,减少误触(所以 `bottom:16` 而不是贴底)。 * * 🔴 两处按用户实机反馈调过(2026-10-12): * · **宽度收窄**:「太宽了,不要顶到两边」⇒ 左右留白 40vp(比内容区块的 16vp 更收)。 * ⚠️ 收窄靠 `margin`,**千万别再写 `.width('100%')`** —— 它会让 margin 失效、变满宽。 * · **降低透明度**:「透明度太高,下面的内容糊在一起看不清」⇒ * 底色改 92% 白(`EBFFFFFF`)+ `backgroundBlurStyle(COMPONENT_THIN)`。 * * 🔴 为什么用 `backgroundBlurStyle` 而不用 `systemMaterial`(鸿蒙 6 的沉浸材质): * `systemMaterial` / `uiMaterial` 是 **SDK 26.0.0** 起的新 API, * 而本工程 `compatibleSdkVersion = 6.1.1(24)`(编译器会警告版本不匹配) * ⇒ 在 API 24 设备上有失效风险。`backgroundBlurStyle` 是 **API 9** 就有的, * 视觉几乎等价、且没有版本风险。**将来把兼容版本提到 26 再换回沉浸材质。** * * ⚠️ 为什么用 `Stack(alignContent: Bottom)` 覆盖在内容上,而不是在 Column 里占一行: * 用户明确说"不要固定在下边,要浮着"。占一行就是"固定的底部栏"(老样式); * 用 Stack 覆盖 ⇒ 内容从它下面穿过、它是浮在上面的。这是这个组件唯一正确的摆法。 * * ⚠️ 内容底部要留出 `FB_DOCK_RESERVE` 的空白, * 否则最后一块内容会被悬浮栏压住(悬浮 ≠ 可以挡内容)。 */ import { FbColors, FbIcon, FbMetric, fbTint } from '../common/Theme'; import { FbAppState, fbAppState } from '../common/Store'; import { FbModuleDef, fbModuleOf } from '../model/Modules'; /** * 未选中时的图标 / 文字色。 * * 🔴 从 muted(#7E6B5A) 加深到 #4A3F35: * 原先在白底上的对比度只有约 2.3:1(远低于 WCAG 的 4.5:1), * 加上悬浮栏材质半透,用户就觉得"和下面的内容糊在一起、看不清"。 * ⚠️ 图标与文字**必须用同一个常量**:用户 2026-10-18 要的就是"两处颜色一致", * 一处改、另一处忘了改,正是这次报的那个问题。 */ const INACTIVE_TINT: string = '#4A3F35'; /** 快捷栏高度(vp)—— 取设计规范的 56 */ const DOCK_H: number = 56; /** * 下边距(vp)。 * * 🔴 从 16 加到 **24**(2026-10-13 改"全沉浸"时)。 * 原因:系统**手势条已被隐藏**(用户要求"下面没有任何的条")—— * 手势区没有任何视觉提示了,悬浮栏贴得太近时, * "从下往上滑唤回内容"这个手势很容易先按到它上面。 * 多加 8vp 拉开距离,是给**手势区**留的缓冲,不是视觉上的调整。 */ const DOCK_PAD: number = 24; /** * 左右留白(vp)。 * * 🔴 用户 2026-10-12:「宽度有点太宽了,不要顶到两边…… * 我们那个白色的那种背景框,它不都是两边都有空隙的吗?所以这个宽度也往里面收一收。」 * ⇒ 收得比内容区块的留边(16vp)更窄:40vp。 * 这样悬浮栏明显比内容窄一圈,「浮着的一块」的感觉更强, * 也符合"页面里的卡片都不贴边"这套语言。 */ const DOCK_SIDE: number = 40; /** 内容区为悬浮栏预留的高度(vp):栏高 + 下边距 + 一点余量 */ export const FB_DOCK_RESERVE: number = DOCK_H + DOCK_PAD + 16; /** * 收起时向下平移的距离(vp)——**必须让整个胶囊完全移出可视区**。 * * 🔴 供 `Index.ets` 做"随滚动滑走"的位移动画用(用户 2026-10-13 要的 IT 之家那套)。 * ⚠️ 导出来复用而不是在 `Index` 里写死一个数字: * 它与栏高、下边距是**同一个几何量**,分散在两处必然漂移 —— * 一旦下移量小于"高 + 边距",收起后底下会**露出半截胶囊边**。 * +12 是余量(阴影半径 18、偏移 6,留一点避免阴影边缘仍可见)。 */ export const FB_DOCK_HIDE_Y: number = DOCK_H + DOCK_PAD + 12; /** * 悬浮快捷栏。 * * @param app 全局状态(切模块用) * @param keys 要放的模块 key(默认四个常用:日程 / 名册 / 任务 / 笔记) */ @ComponentV2 export struct FbFloatDock { @Param app: FbAppState = fbAppState(); /** * 快捷模块 key 列表。 * 🔴 用户点名要这四个("应该比较常用"):日程、名册、任务、笔记。 * 顺序按用户说的来。 * ⚠️ v54b:这四项现在是**两两分居 logo 两侧**(日程 名册 [家书] 任务 笔记), * 见下面 build 的左右两段。 */ @Param keys: string[] = ['calendar', 'people', 'tasks', 'notes']; build() { // 🔴 用**两层**解决"收窄":外层 Row 负责左右留白,内层 Row 是胶囊本体。 // // 为什么不用更直接的两个写法(都实测失败过): // ① `.width('100%')` + `.margin({left,right})` 并存 ⇒ width 先占满、 // margin 失效 ⇒ 栏体顶到屏幕两侧(实测 x=0..1079 满宽); // ② 去掉 width、只留 margin ⇒ **仍不够**:子项用了 `.layoutWeight(1)` // 四等分,含 layoutWeight 子项的容器会撑满父级可用宽度,把 margin 顶开 // (实测留白只有 17vp,而不是设的 40vp)。 // ③ `calc(100% - 80vp)` 也不行 —— SDK 明确写着 // "**width** and **height** do not support **calc()** dynamic calculations"。 // ⇒ 外层 padding 是唯一稳稳生效的办法:外层通栏、只做左右内缩, // 内层胶囊再用 layoutWeight(1) 吃满剩余宽度。语义清晰、不受子项布局影响。 Row() { Row() { // ---- 左半:日程 / 名册 ---- ForEach(this.leftKeys(), (k: string) => { FbDockItem({ icon: this.iconNameOf(k), label: this.labelOf(k), active: k === this.app.activeModule, accent: this.accentOf(k), onTap: (): void => { this.app.switchModule(k); } }) }, (k: string): string => 'dock_l_' + k) // ---- 正中间:**应用图标**(点它回首页)---- // 🔴 用户 2026-10-17:「下面悬浮的 4 个图标**中间**,放一个**我们应用的图标**, // 就是那个 **logo**,点击之后**是首页**。」 // ⚠️ 用真实 logo 图(`fb_logo`,与顶栏同一张),**不要**换成 home 图标 —— // 用户说的是"我们应用的图标/logo",那是品牌标识,不是"首页"这个功能图标。 // ⚠️ 也不加"首页"两个字:中间那格比两侧窄,加字会挤(它的语义由 logo 本身承担)。 this.homeButton() // ---- 右半:任务 / 笔记 ---- ForEach(this.rightKeys(), (k: string) => { FbDockItem({ icon: this.iconNameOf(k), label: this.labelOf(k), active: k === this.app.activeModule, accent: this.accentOf(k), onTap: (): void => { this.app.switchModule(k); } }) }, (k: string): string => 'dock_r_' + k) } // 内层胶囊:吃满外层扣掉内缩后的宽度 .layoutWeight(1) .height(DOCK_H) .padding({ left: 6, right: 6 }) .borderRadius(DOCK_H / 2) // 全圆角胶囊形(28 = 56/2) .alignItems(VerticalAlign.Center) // ---- 悬浮底:白色 + 毛玻璃 ---- // // 🔴 底色:**92% 白**。 // 用户反馈「透明度太高,下面的内容和标签糊在一起,看不清了」 // ⇒ 先把「读得清」这条底线守住:底色几乎实心,只留 8% 通透。 .backgroundColor('EBFFFFFF') // // 🔴 毛玻璃用 `backgroundBlurStyle(COMPONENT_THIN)`,**不用 `systemMaterial`**: // · `systemMaterial` / `uiMaterial` 是 **SDK 26.0.0** 起的新 API, // 而本工程 `compatibleSdkVersion = 6.1.1(24)` ⇒ 编译器会警告 // "current compatible SDK version is 6.1.1(24)",在 API 24 设备上有失效风险; // · `backgroundBlurStyle` 是 **API 9** 就有的老 API,兼容性完全没问题, // 且同样能做出"半透 + 模糊"的悬浮观感。 // ⇒ 在"视觉几乎等价"的前提下,选**没有版本风险**的那个。 // (将来工程把 compatibleSdkVersion 提到 26 再换回沉浸材质。) .backgroundBlurStyle(BlurStyle.COMPONENT_THIN) .border({ width: 1, color: FbColors.line2 }) // 浮在内容之上:给一点投影增强"悬浮"层次 .shadow({ radius: 18, color: '#1A2C231D', offsetY: 6 }) } .width('100%') // 🔴 左右内缩 40vp(比内容区块的 16vp 更收): // 用户要求「宽度有点太宽了,不要顶到两边……也往里面收一收」。 .padding({ left: DOCK_SIDE, right: DOCK_SIDE, bottom: DOCK_PAD }) } /** * 中间那颗 logo(点它回首页)。 * * ⚠️ 宽度给 `layoutWeight` 之外还要**略窄一点**:两侧各有 2 个带文字的项, * 中间这格只有图、没有字,等宽会让它显得很空。 * 用固定宽 + `justifyContent(Center)`,视觉上就是个"居中的品牌钉"。 */ @Builder homeButton() { Column({ space: 2 }) { Image($r('app.media.fb_logo')) .width(26) .height(26) .borderRadius(7) // 描边:logo 是「米色底 + 砖红图案」,放在白底胶囊上底板会"化掉" //(与顶栏那颗 logo 同一个理由,见 TopBar 的注释) .border({ width: 1, color: FbColors.line }) // 当前就在首页 ⇒ 给一行小字标出来(与两侧项的"图标+文字"节奏也对齐) Text('首页') .fontSize(FbMetric.fontTiny) .fontColor(this.app.activeModule === 'home' ? '#A84A28' : '#4A3F35') .maxLines(1) } .width(52) .height('100%') .justifyContent(FlexAlign.Center) .onClick((): void => { this.app.switchModule('home'); }) } /** 左半段的 key(前一半;四项时 = 前 2 个) */ private leftKeys(): string[] { const n: number = Math.floor(this.keys.length / 2); const out: string[] = []; for (let i = 0; i < n; i++) { out.push(this.keys[i]); } return out; } /** 右半段的 key(后一半) */ private rightKeys(): string[] { const n: number = Math.floor(this.keys.length / 2); const out: string[] = []; for (let i = n; i < this.keys.length; i++) { out.push(this.keys[i]); } return out; } /** 模块名(取注册中心,避免各处写死) */ private labelOf(key: string): string { const m: FbModuleDef | undefined = fbModuleOf(key); return m !== undefined ? m.name : key; } /** 模块主题色(返回 **string 色值**,供 fillColor/fontColor 用) */ private accentOf(key: string): string { const m: FbModuleDef | undefined = fbModuleOf(key); return m !== undefined ? m.color : '#A84A28'; } /** * 模块 → 图标名。 * * ⚠️ 这里必须显式映射,不能用 `fb_ic_ + key`:模块 key 与图标文件名并不一一对应 * (people → fb_ic_people 对得上,但 notes → fb_ic_notes 也对得上…… * 而将来若有 key 与文件名不一致的,拼串就会拿到 undefined 资源而崩)。 * 显式写死更稳。 */ private iconNameOf(key: string): string { switch (key) { case 'calendar': return 'calendar'; case 'people': return 'people'; case 'tasks': return 'tasks'; case 'notes': return 'notes'; case 'home': return 'home'; case 'movies': return 'movies'; case 'trips': return 'trips'; case 'resources': return 'resources'; case 'days': return 'days'; case 'ledger': return 'ledger'; case 'books': return 'books'; default: return 'home'; } } } /** * 悬浮栏里的一个快捷项(图标 + 文字,竖排,紧凑)。 * * 🔴🔴 v54b(用户 2026-10-17)—— 为什么必须是**组件**: * 用户原话:「日程、名册、任务、笔记这几个图标,应该点击后,在它们自己的页面中, * **是变颜色的**,颜色就是它们自己的设定颜色,**图标和文字,都变颜色**。」 * —— 也就是说:点了「日程」进日程页后,那颗图标与文字应当变成**日程的琥珀色**。 * * ⚠️ 原来这里是 `@Builder dockItem(key, active)` —— **两个参数**, * SDK 规则 `r/quick-rules/05-state.md`: * > **两个及以上参数不触发刷新** * ⇒ 切模块后 `this.app.activeModule` 变了、`build()` 重跑了, * 但**这个多值 @Builder 的子树不重建** ⇒ 高亮**永远停在初始那一项**上。 * 这与本轮"概述卡数字永远是 0"是**同一个根因**(本项目第 6、7 次)。 * * ⇒ 抽成 `@ComponentV2` + `@Param`(@Param 参与观察)。 * ⚠️ 别改回 `@Builder`,哪怕"只有一个参数"也别用(见 FbStatCell 的说明)。 */ @ComponentV2 export struct FbDockItem { /** 图标名(`FbIcon.ui()` 的 key) */ @Param icon: string = 'home'; /** 下面那行文字(模块名) */ @Param label: string = ''; /** * 是不是当前所在模块。 * 🔴 由父组件**算好传进来**(父组件读 `this.app.activeModule`)—— * 组件自己拿不到父的状态,这正是"@Param 会被观察"的价值。 */ @Param active: boolean = false; /** 该模块的**设定色**(当前模块时,图标与文字都用它) */ @Param accent: string = '#A84A28'; /** 点击回调(切模块) */ @Event onTap: () => void = (): void => { }; build() { Column({ space: 2 }) { Image(FbIcon.ui(this.icon)) .width(20) .height(20) /* 🔴🔴 v59(用户 2026-10-18):「现在下面的悬浮框只是那个汉字的颜色变了, 上面的图标颜色没有变。我的要求是:除了下面的汉字,上面图标的颜色 也变成跟汉字一样的颜色,变成彩色的。」 ⇒ 真根因:`fillColor()` **只对 SVG 生效**,而本工程图标**全是 PNG** (SDK `image.d.ts` 原话:"This attribute applies only to SVG images. To set the fill color for a PNG image, use `colorFilter`")。 所以原来那行 `fillColor(...)` 一直是**空操作** —— 文字走 fontColor 是真的生效,图标就一直是原图的黑色。 ⇒ 改用 `colorFilter(fbTint(...))`:用颜色矩阵把单色 PNG 染成目标色, 并保留原图 alpha(描边抗锯齿不能丢)。详见 `Theme.fbTint` 的说明。 ⚠️ 不用 `renderMode(ImageRenderMode.Template)`: 它在本工程里**与 PNG 搭配同样不产生着色效果**(那套是给 SVG 用的 语义),留着只会让人以为"已经处理了颜色"。 */ .colorFilter(fbTint(this.active ? this.accent : INACTIVE_TINT)) Text(this.label) .fontSize(FbMetric.fontTiny) .fontColor(this.active ? this.accent : INACTIVE_TINT) .fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal) .maxLines(1) } .layoutWeight(1) // 等分 .height('100%') .justifyContent(FlexAlign.Center) .onClick((): void => { this.onTap(); }) } }