Files
FamilyBook_harmonyos/entry/src/main/ets/components/Drawer.ets
T
laoyang 705a65ae2e v0.5.0 抽屉改「应用内左右滑动」触发 + 去掉汉堡按钮 + 统一区块边距
用户 2026-10-12 三条反馈。

## ① 抽屉改「应用内水平滑动」触发(关键更正)

用户实测指出:「左滑右滑不是从边上滑,而是在应用的内部从左往右滑或者从右往左滑。
如果从边上往里边滑的话,它是系统手势,我们是没有办法去控制它的。」

⇒ **这是之前手势完全没生效的根因**:屏幕边缘是系统手势区(返回/侧边栏),
  从边缘往里滑的事件根本轮不到应用。判据改为「在应用内部水平滑动」:
  · 向右滑 ⇒ 抽屉从左边出;· 向左滑 ⇒ 抽屉从右边出。
  (左手持机往右划、右手持机往左划,单手都顺。)

🔴 同时修掉一个真 bug:**`offsetX` 的单位是 vp**(SDK 原文 "in vp"),
   我之前又除以 density,把位移缩到 1/3 ⇒ 阈值永远达不到。
⚠️ 用 `parallelGesture`(不是 gesture):内容区还有竖向滚动与
   纪念日/财务/任务的横向滚动条,用 gesture 会把它们抢掉。
   阈值 80vp,轻扫横向列表不会误开。

## ② 去掉顶栏汉堡按钮
用户:「那个三个点、三个横线的按钮就不要了,图标靠左,显得更好看一些」。
⇒ TopBar 删掉按钮,`padding.left` 由 14 改为 `pagePad`,图标与下方内容左边线对齐。

## ③ 🔴 统一区块边距(修掉 15 处真 bug)
用户:「页面里的方框大小不一样,有的左右充满,有的有缝隙 —— 最好都留一点距离」。

实测界面树 + 像素采样找到根因:**卡片同时写了 `.width('100%')` 和
`.margin({left, right})` ⇒ width 先把宽度占满、margin 失效,卡片顶到屏幕两侧**。
(首页个人信息卡实测 x=0..1080 满宽,而别的区块是留边 17vp 的。)

全项目扫出 **15 处**同款写法(calendar 3 / days 2 / ledger 2 / movies 1 /
people 2 / settings 5),**全是卡片**(backgroundColor+borderRadius+margin)。
统一删掉那条 `.width('100%')`,宽度交给 margin 撑出。

### 验证(真机)
· 手势:向右滑 ⇒ 抽屉「家书」x=187(贴左);向左滑 ⇒ x=442(贴右)—— 双向 ✓
· 边距:像素级逐行采样,17 行里 15 行左右均 50px(17vp)完全一致;
  另 2 行是"标题文字起点"(文字有额外缩进),不是边距问题 ✓
· 14 页遍历 **14/14**;深验(每页特征文本)**12/12**
· 编译 **0 error / 0 warning**

### 工具链修复(都很关键)
· 🔴 `hdc file recv` 在 **Git Bash** 下会被路径转换改写:`/data/local/tmp/x`
  被当成 `C:/.../tmp/x` ⇒ **静默失败、界面树永远抓不到**。
  三个验证脚本已统一注入 `MSYS_NO_PATHCONV=1`。
· 探针不再点汉堡按钮开抽屉(按钮已删),改用「应用内向右滑」。
· 修探针一处误判:顶栏与抽屉**都有**「家书」,原来没区分 ⇒
  两种滑动都读到顶栏那个固定 x,误报"手势没生效"(其实生效了)。
2026-10-12 11:37:45 +08:00

303 lines
10 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 · 抽屉(鸿蒙端)
*
* 形态对齐 Web 端顶栏 `.nav` 那一排模块入口,但**改成抽屉**(用户建议):
* Web 端导航条最多放 12 个模块,手机宽度下必然溢出;抽屉竖向排列,
* 还能顺带放下「模块说明 + 主题色」这层信息,一眼看清每个模块是干什么的。
*
* 三条形态,同一个组件:
* · **常驻侧栏**(宽屏:窗口短边 ≥ 600vp)—— 固定在左边,占 264vp;
* · **左滑出层**(手机,从左边缘向右滑)—— 覆盖内容,点遮罩或选中项即收起;
* · **右滑出层**(手机,从右边缘向左滑)—— 同上,只是贴右边(右手单手持机顺手)。
*
* ⚠️ 这里**不用 SideBarContainer**:它的控制按钮与「滑出」是内建的一套,
* 样式(图标、位置、宽度动画)很难与家书的视觉统一,且它只能有一层侧栏
* 内容 —— 而我们要在抽屉里放「品牌区 + 模块列表 + 底部账号区」三段。
* 自己用 Stack + 位移动画做,行为完全可控。
*/
import { FbColors, FbIcon, FbMetric } from '../common/Theme';
import { FbAppState, fbAppState } from '../common/Store';
import { FbModuleDef, FB_MODULES, fbDefaultNavModules, fbThemeVars } from '../model/Modules';
import { FbAvatar } from './Widgets';
@ComponentV2
export struct FbDrawer {
/** 全局状态(父组件传入,保证与页面同一份实例) */
@Param app: FbAppState = fbAppState();
/** 抽屉宽度 */
@Param drawerW: number = FbMetric.drawerW;
/** 是否以「覆盖层」形态出现(宽屏时为 false = 常驻) */
@Param overlayMode: boolean = true;
/** 覆盖层形态下:点遮罩关闭 */
@Event onDismiss: () => void = () => {
};
/** 抽屉里列出的模块(接口接上后由 user_settings('nav_modules') 下发) */
@Local items: FbModuleDef[] = fbDefaultNavModules();
/** 品牌区:书名(家书)+ 家庭名 */
@Builder
brandHeader() {
Row({ space: 10 }) {
Image($r('app.media.fb_logo'))
.width(32)
.height(32)
.borderRadius(9)
Column({ space: 1 }) {
Text('家书')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor(FbColors.ink)
.letterSpacing(1)
Text(this.app.session.familyTitle)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.muted)
.maxLines(1)
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
// 顶部内边距 = 状态栏高度 + 一点呼吸,保证不压状态栏
.padding({ left: 18, right: 12, top: 16, bottom: 10 })
}
/** 一个模块行 */
@Builder
moduleRow(m: FbModuleDef) {
Row({ space: 12 }) {
// 图标底:选中时用模块色实底(白图标),未选中时用极浅染色底(彩色图标)
Stack() {
Image(FbIcon.module(m.key))
.width(18)
.height(18)
.renderMode(ImageRenderMode.Template)
.fillColor(this.isActive(m.key) ? Color.White : fbThemeVars(m.color).brand)
}
.width(34)
.height(34)
.borderRadius(10)
.backgroundColor(this.isActive(m.key) ? fbThemeVars(m.color).brand : this.washOf(m.color))
Column({ space: 1 }) {
Text(m.name)
.fontSize(FbMetric.fontBody)
.fontWeight(this.isActive(m.key) ? FontWeight.Medium : FontWeight.Normal)
.fontColor(FbColors.ink)
Text(m.desc)
.fontSize(FbMetric.fontTiny)
.fontColor(FbColors.faint)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.padding({ left: 12, right: 12, top: 9, bottom: 9 })
.borderRadius(FbMetric.radiusS)
.backgroundColor(this.isActive(m.key) ? this.washOf(m.color) : Color.Transparent)
.onClick(() => {
this.app.switchModule(m.key);
this.onDismiss();
})
}
/** 底部账号区(对应 Web 端右上角头像菜单里的那几项) */
@Builder
accountFooter() {
Column({ space: 2 }) {
Divider()
.color(FbColors.line2)
.margin({ bottom: 6 })
this.footerRow('fb_ic_bell', '通知消息', this.app.unread > 0 ? this.app.unread.toString() : '')
this.footerRow('fb_ic_user', '个人设置', '')
this.footerRow('fb_ic_lock', '修改密码', '')
if (this.app.session.me.role === 'admin') {
this.footerRow('fb_ic_gear', '系统设置', '')
}
}
.width('100%')
.padding({ left: 12, right: 12, bottom: 10 })
}
/** 底部一行 */
@Builder
footerRow(iconName: string, label: string, badge: string) {
Row({ space: 12 }) {
Image(FbIcon.ui(iconName))
.width(17)
.height(17)
.renderMode(ImageRenderMode.Template)
.fillColor(FbColors.muted)
Text(label)
.fontSize(FbMetric.fontSmall)
.fontColor(FbColors.ink)
.layoutWeight(1)
if (badge.length > 0) {
Text(badge)
.fontSize(FbMetric.fontTiny)
.fontColor(Color.White)
.backgroundColor(FbColors.red)
.borderRadius(FbMetric.radiusPill)
.padding({ left: 6, right: 6, top: 1, bottom: 1 })
}
}
.width('100%')
.padding({ left: 10, right: 10, top: 9, bottom: 9 })
.borderRadius(FbMetric.radiusS)
}
/** 当前是否选中该模块 */
private isActive(key: string): boolean {
return this.app.activeModule === key;
}
/** 模块色的极浅染色底(选中行 / 图标底) */
private washOf(hex: string): string {
// 与 Theme.fbPalette 的 wash 同口径:压饱和、抬明度到 0.94
const t = fbThemeVars(hex);
return t.brand + '1A';
}
build() {
Column() {
this.brandHeader()
// 模块列表:可滚动,模块多时不会把底部账号区挤掉
List({ space: 1 }) {
ForEach(this.items, (m: FbModuleDef) => {
ListItem() {
this.moduleRow(m)
}
}, (m: FbModuleDef): string => 'nav_' + m.key)
}
.layoutWeight(1)
.width('100%')
.padding({ left: 6, right: 6 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
this.accountFooter()
}
.width(this.drawerW)
.height('100%')
.backgroundColor(FbColors.surface)
// ⚠️ 侧栏右侧的分隔线只在常驻形态下需要;覆盖层形态下它贴着屏幕边,加了反而怪
.border(this.overlayMode
? { width: 0 }
: { width: { right: 1 }, color: FbColors.line2 })
}
}
/**
* 抽屉 + 内容 的整体骨架。
*
* 两种形态在这里分派:
* · 宽屏 → Row(抽屉占位 + 内容区);
* · 窄屏 → Stack(内容铺满,抽屉带遮罩浮在上面)。
*/
@ComponentV2
export struct FbDrawerShell {
@Param app: FbAppState = fbAppState();
/** 内容区(由父组件通过尾随 lambda 传入) */
@BuilderParam content: () => void;
build() {
// 抽屉贴哪边由 drawerSide 决定 ⇒ Stack 的 alignment 也跟着换。
Stack({
alignContent: this.app.drawerSide === 'right' ? Alignment.TopEnd : Alignment.TopStart
}) {
// ---- 宽屏:并排 ----
if (this.app.wideLayout) {
Row() {
FbDrawer({
app: this.app,
overlayMode: false
})
Column() {
this.content()
}
.layoutWeight(1)
.height('100%')
}
.width('100%')
.height('100%')
} else {
// ---- 窄屏:内容铺满 + 抽屉浮层(可从左或右滑出)----
Column() {
this.content()
}
.width('100%')
.height('100%')
if (this.app.drawerOpen) {
// 遮罩
Column()
.width('100%')
.height('100%')
.backgroundColor('#66000000')
.onClick(() => {
this.app.drawerOpen = false;
})
.transition(TransitionEffect.OPACITY.animation({ duration: 180 }))
// 抽屉本体:由 drawerSide 决定贴左还是贴右
// ⚠️ 用 Stack 的 alignment 而不是 margin 来定位:
// 动画是 translateX,用 alignment 铺位更稳(不参与测量)。
FbDrawer({
app: this.app,
overlayMode: true,
onDismiss: (): void => {
this.app.drawerOpen = false;
}
})
.transition(TransitionEffect.translate({
x: this.app.drawerSide === 'right' ? FbMetric.drawerW : -FbMetric.drawerW
}).animation({ duration: 220, curve: Curve.FastOutSlowIn }))
}
}
}
.width('100%')
.height('100%')
.backgroundColor(FbColors.paper)
// 🔴 横向滑动开抽屉(用户 2026-10-12 定的规则)。
//
// ⚠️ **不要判断"起点是否贴屏幕边缘"** —— 用户实测反馈:屏幕边缘是
// **系统手势区**(返回/侧边栏),从边缘往里滑的事件根本轮不到应用处理,
// 我们无法控制。所以判据只能是"**在应用内部水平滑动**":
// 向右滑 → 抽屉从左边出;向左滑 → 抽屉从右边出。
// 这样左手持机时往右划、右手持机时往左划,单手都顺。
//
// ⚠️ `offsetX` 的单位是 **vp**(SDK 原文 "in vp"),不要再除以 density ——
// 除一次会把位移缩到 1/3,导致阈值永远达不到、手势"看起来没生效"。
//
// ⚠️ 用 `parallelGesture` 而不是 `gesture`:内容区自己还有竖向滚动
// (以及纪念日/财务/任务的横向滚动条),用 gesture 会把它们抢掉。
// 代价是横向滚动条扫得猛时也可能触发 —— 所以阈值给到 80vp,
// 轻扫(浏览横向列表)不会误开。
.parallelGesture(
PanGesture({ direction: PanDirection.Horizontal, distance: 30 })
.onActionEnd((e: GestureEvent) => {
// 宽屏下抽屉常驻,不需要手势
if (this.app.wideLayout) {
return;
}
const dx: number = e.offsetX; // vp
// 向右滑 ⇒ 抽屉在左
if (dx > 80) {
this.app.drawerSide = 'left';
this.app.drawerOpen = true;
return;
}
// 向左滑 ⇒ 抽屉在右
if (dx < -80) {
this.app.drawerSide = 'right';
this.app.drawerOpen = true;
}
})
)
}
}