/** * 家书 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; } }) ) } }