Files
FamilyBook_harmonyos/entry/src/main/ets/components/FloatDock.ets
T
laoyang aafd83b130 悬浮栏选中底圆角:999 -> 12,不再那么圆鼓 (#58)
用户 2026-11-05:「颜色我感觉透明度什么的还是可以的,但是这个圆有点太圆了,
应该弄成更椭圆一点,效果可能是不是更好啊?」

borderRadius(999) 会把 40 高的块钳成两端完整半圆,看着很圆鼓。
改成 12:圆角半径只占高度的 30%,是"柔和的圆角矩形",
视觉更平更舒展,也与 dock 外框的大圆角(28)形成内外层次。

ArkUI 的 borderRadius 是等比圆角(四角半径相同、正圆圆弧),
没有"椭圆角"(x/y 半径不同)的写法 —— 所以"更椭圆"只能靠减小半径近似,
半径越小圆弧越平。

实机偏圆就降到 10,偏方就加到 14,改动只有这一个数字。
2026-11-05 13:25:06 +08:00

380 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 家书 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';
/**
* 选中项那个"椭圆灰底"的颜色(#58)。
*
* 🔴 用户(2026-11-05):「当选中其中一个按钮之后,它除了那个图标和文字颜色
* 发生变化之外,它还给它加了一个那种椭圆的那种框那种背景,灰色的。」
*
* ⚠️ 用**半透明黑**而不是实色灰:dock 底色是 `EBFFFFFF`(92% 白叠模糊层),
* 实色灰在深色系统主题下会突兀;半透明黑与背景混合,
* 浅色下是浅灰、深色下是深灰,两种主题都协调。
* (iOS / 微信那类悬浮栏都是这个做法。)
* ⚠️ 8% 太淡、12% 太深,8% 在这里刚好能看出轮廓又不抢图标颜色。
*/
const DOCK_PILL_BG: string = '#14000000';
/** 快捷栏高度(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() {
/* 内层:椭圆胶囊(自适应内容宽高)—— 这才是"选中框" */
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)
}
/* #58:**选中时加椭圆灰底** —— 用户点名要的效果
「它除了图标和文字颜色变化,还加了一个椭圆的那种框那种背景」。
⚠️ 内边距让椭圆包住图标+文字,但不撑满整格:
左右 10 保证「日程」这种两字标签也留出呼吸;
上下 3 不把胶囊撑得太胖(dock 总高只有 56)。
⚠️ 圆角用 **12** 而不是 999(全圆角)——
用户 2026-11-05 反馈:「这个圆有点太圆了,应该弄成更椭圆一点」。
999 会把 40 高的块钳成两端**完整半圆**,看着很圆鼓;
12 让圆角半径只占高度的 30%,是"柔和的圆角矩形",
视觉更平、更舒展,也与 dock 外框的大圆角(28)形成内外层次。
⚠️ ArkUI 的 borderRadius 是**等比圆角**(四角半径相同、正圆圆弧),
没有"椭圆角"(x/y 半径不同)的写法。所以"更椭圆"只能靠
**减小半径**来近似 —— 半径越小圆弧越平,越接近扁椭圆观感。
⚠️ 12 是按文档数值估的。实机偏圆就降到 10,偏方就加到 14,
改动只有这一个数字。 */
.padding({ left: 10, right: 10, top: 3, bottom: 3 })
.backgroundColor(this.active ? DOCK_PILL_BG : Color.Transparent)
.borderRadius(12)
}
.layoutWeight(1) // 等分
.height('100%')
.justifyContent(FlexAlign.Center)
.onClick((): void => {
this.onTap();
})
}
}