Files
HomeAgent/cmd/ohos/HomeAgent/entry/src/main/ets/components/PageTopBar.ets
JianFeeeee 48b5c2401c feat(ohos): MotionBase 统一按压动效 + 分层图标随主题切换
## 动效统一

各组件重复实现按压反馈(@State pressed + scale + animation + onTouch 四件套),
时长各写魔数导致全局手感不一致。

- 新增 components/MotionBase.ets:通用动效的"父组件"。ArkUI V1 的 @Component
  struct 无法继承他人 build,改用组合表达继承——调用组件把内容经 @BuilderParam
  内容插槽传入,MotionBase 在包装节点统一挂动效修饰器。
  pressEnabled 默认关(纯展示容器零开销);fillWidth=false 供气泡内卡片按内容
  自适应宽度;flexWeight 供等分排列按钮参与剩余空间分配。
  onPress 只作按压瞬时轻量钩子,导航/提交语义仍由调用组件 onClick 负责,
  避免"按下即触发"的手感偏差。
- Constants.ets 新增动效 token:ANIM_FAST(150) / ANIM_NORMAL(220) /
  ANIM_ENTER(280) / ANIM_SLOW(400) / PRESS_SCALE(0.97)。
  取值依据:状态切换 150-250ms(>300ms 显拖沓),大位移进出场 300-400ms
  才不突兀;曲线统一 EaseOut 起步快收尾缓。
- NavRow / StatusSummaryCard / AttachmentCard / 插件卡片等公共组件接入
  MotionBase,移除各自的按压四件套。组件专属动效(聊天输入框上弹、加号菜单
  浮起、折叠面板展开、toast 进出场)保留在各组件内,不塞进父组件。

## 图标与启动页随主题切换

原先直接指向位图 app_icon.png,浅色底被烧进图标,深色模式下桌面与启动页跳脱。

- 改用分层图标 layered_image:foreground 为字形,background(沉淀色)在
  base/ 与 dark/ 各一份,随系统主题切换。app.json5 与 module.json5 的
  icon 均指向 :layered_image。
- startWindowIcon 改用透明底 start_icon.png,配合 start_window_background
  的 base(#F1F3F5) / dark(#000000) 两份取值,浅深模式遮罩与图标都能对上。

## 验证

清空 entry/build 后全量重编:hvigorw assembleHap BUILD SUCCESSFUL(9.8s),
零 ArkTS 错误。提交内容已确认不含 build/ oh_modules/ .hap 与签名材料。
2026-08-30 17:23:40 +08:00

215 lines
7.2 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.

import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { ANIM_FAST, ANIM_SLOW } from '../common/Constants';
import { MotionBase } from './MotionBase';
/**
* 顶栏高度(vp)。页面 padding-top 应略大于此值,避免内容被标题压住,
* 同时不要留过多空白。
*/
export const TOP_BAR_HEIGHT: number = 68;
/**
* 页面顶栏遮罩:仅标题文字,无按钮无状态。
* 浮在滚动区之上(页面用 PageTopBarLayer 置顶),背景为 不透明 -> 透明 的线性渐变,
* 滚动内容从其下方穿过时逐渐淡出,形成"逐渐加深"的柔化边界,而不是硬截断。
*
* 关键:自定义组件被外部施加 .position() 时,ArkUI 会生成一个 __Common__ 包裹节点,
* 该节点默认铺满父约束并使用默认命中测试 —— 会吞掉整页触摸事件。
* 因此必须在【调用点】同时显式给出尺寸与 hitTestBehavior(None),
* 统一封装在 PageTopBarLayer 里,页面不要再手写 .position()。
*/
@Component
export struct PageTopBar {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop title: string = '';
build() {
// 外层撑满整页并顶部对齐,替代调用点的 .position():
// 自定义组件一旦在调用点被施加 .position(),ArkUI 会生成铺满父约束的
// __Common__ 包裹节点,该节点使用默认命中测试,会吞掉整页的滚动与点击。
// 这里改为自身撑满 + 全链路 HitTestMode.None,触摸完全穿透到下层滚动区。
Column() {
Column() {
Text(this.title)
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor(this.palette().textPrimary)
.margin({ left: 16, top: 10 })
.hitTestBehavior(HitTestMode.None)
}
.width('100%')
.height(TOP_BAR_HEIGHT)
.alignItems(HorizontalAlign.Start)
.linearGradient({
direction: GradientDirection.Bottom,
colors: [
[this.opaqueBg(), 0.0],
[this.opaqueBg(), 0.45],
[this.transparentBg(), 1.0],
],
})
.hitTestBehavior(HitTestMode.None)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Start)
.hitTestBehavior(HitTestMode.None)
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
private opaqueBg(): string {
const bg: string = this.palette().bgPrimary;
return '#FF' + bg.substring(1);
}
private transparentBg(): string {
const bg: string = this.palette().bgPrimary;
return '#00' + bg.substring(1);
}
}
/**
* 独立气态玻璃节点:单个悬浮组件,与底部导航同款玻璃(半透明底 + 高光渐变)。
* 尺寸自适应内容(宽度随内容,高度/圆角参数化),视觉上独立、不与其他组件共框。
* 用于承载状态胶囊、图标按钮等单个悬浮元素。
*/
@Component
export struct GlassShell {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop nodeHeight: number = 32;
@Prop nodeRadius: number = 16;
@BuilderParam content: () => void;
build() {
Row() {
this.content()
}
.height(this.nodeHeight)
.padding({ left: 8, right: 8 })
.alignItems(VerticalAlign.Center)
.backgroundColor(this.palette().navBarBg)
.borderRadius(this.nodeRadius)
.border({
width: { left: 1, top: 1, right: 1, bottom: 1 },
color: this.palette().navBarBorder,
})
.shadow({
radius: 16,
color: this.palette().shadow,
offsetY: 5,
})
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 悬浮区圆形图标按钮的直径(vp)。徽标不使用它——徽标是竖向窄条。
*/
export const FLOAT_NODE_SIZE: number = 42;
/**
* 悬浮图标按钮:与底部导航同一套玻璃语言的圆形按钮。
* 统一 42vp 直径、同款玻璃底/描边/投影,图标 18vp。
*/
@Component
export struct FloatIconButton {
@StorageProp('themeIsDark') private isDark: boolean = true;
@Prop icon: Resource | undefined = undefined;
@Prop accent: boolean = false;
onTap?: () => void;
build() {
// 按压缩放交给 MotionBase(父组件统一手感);
// fillWidth: false —— 悬浮区靠右对齐,包装节点必须按内容 42vp 收窄。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button() {
Image(this.icon)
.width(18)
.height(18)
.fillColor(this.accent ? this.palette().accent : this.palette().textSecondary)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
}
.width(FLOAT_NODE_SIZE)
.height(FLOAT_NODE_SIZE)
.type(ButtonType.Circle)
.backgroundColor(this.palette().navBarBg)
.border({
width: { left: 1, top: 1, right: 1, bottom: 1 },
color: this.palette().navBarBorder,
})
.shadow({
radius: 24,
color: this.palette().shadow,
offsetY: 8,
})
.onClick(() => {
const cb = this.onTap;
if (cb !== undefined) {
cb();
}
})
}
}
private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
}
}
/**
* 悬浮区的"一行":宽度自适应内容,内部组件从右向左排列(右边缘与导航栏平齐)。
* 放在 NavFloatOverlay 内部使用;声明顺序越靠前越贴近导航栏,后声明的行叠在其上方。
*/
@Component
export struct NavFloatRow {
@BuilderParam content: () => void;
build() {
Row({ space: 8 }) {
this.content()
}
.alignItems(VerticalAlign.Center)
}
}
/**
* 底部悬浮容器:透明、无任何背景/边框(视觉上不包裹任何东西,组件彼此独立),
* 仅负责 右侧对齐(与导航栏同宽 88%)、自下而上分行堆叠 与 随滚动渐隐/滑出动画。
*
* 布局约定:直接子节点是"一行"(通常是自适应宽度的 Row)。
* 行按【从上到下】声明:先声明的行在上方,最后声明的行贴住导航栏,
* 即"一行放不下时向上面再开一行"(把新行写在前面)。
*
* 关键:这里必须用 Column 而不是 Flex —— Flex 在 ArkUI 中默认铺满父约束,
* 会形成一个覆盖整页的默认命中测试节点,吞掉页面滚动与点击;
* Column 高度自适应内容。再加 HitTestMode.Transparent,
* 让每行右侧之外的空白区域触摸穿透到下层滚动区。
*/
@Component
export struct NavFloatOverlay {
@StorageProp('themeIsDark') private isDark: boolean = true;
@StorageProp('navVisible') private navVisible: boolean = true;
@StorageProp('currentTab') private currentTab: number = 0;
@Prop tab: number = 0;
@Prop alignEnd: boolean = true;
@BuilderParam content: () => void;
build() {
Column({ space: 8 }) {
this.content()
}
.width('88%')
.alignItems(this.alignEnd ? HorizontalAlign.End : HorizontalAlign.Start)
.hitTestBehavior(HitTestMode.Transparent)
.margin({ bottom: 94 })
.translate({ y: (!this.navVisible || this.currentTab !== this.tab) ? 140 : 0 })
.opacity((!this.navVisible || this.currentTab !== this.tab) ? 0 : 1)
.animation({ duration: ANIM_SLOW, curve: Curve.EaseOut })
}
}