diff --git a/cmd/ohos/HomeAgent/AppScope/app.json5 b/cmd/ohos/HomeAgent/AppScope/app.json5 index 5f89745..5291775 100644 --- a/cmd/ohos/HomeAgent/AppScope/app.json5 +++ b/cmd/ohos/HomeAgent/AppScope/app.json5 @@ -4,7 +4,9 @@ "vendor": "HomeAgent", "versionCode": 1000000, "versionName": "1.0.0", - "icon": "$media:app_icon", + // 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。 + // 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。 + "icon": "$media:layered_image", "label": "$string:app_name" } } diff --git a/cmd/ohos/HomeAgent/AppScope/resources/base/media/background.png b/cmd/ohos/HomeAgent/AppScope/resources/base/media/background.png new file mode 100644 index 0000000..c4873ea Binary files /dev/null and b/cmd/ohos/HomeAgent/AppScope/resources/base/media/background.png differ diff --git a/cmd/ohos/HomeAgent/AppScope/resources/base/media/foreground.png b/cmd/ohos/HomeAgent/AppScope/resources/base/media/foreground.png new file mode 100644 index 0000000..73614e1 Binary files /dev/null and b/cmd/ohos/HomeAgent/AppScope/resources/base/media/foreground.png differ diff --git a/cmd/ohos/HomeAgent/AppScope/resources/base/media/layered_image.json b/cmd/ohos/HomeAgent/AppScope/resources/base/media/layered_image.json new file mode 100644 index 0000000..d2e870e --- /dev/null +++ b/cmd/ohos/HomeAgent/AppScope/resources/base/media/layered_image.json @@ -0,0 +1,6 @@ +{ + "layered-image": { + "background": "$media:background", + "foreground": "$media:foreground" + } +} diff --git a/cmd/ohos/HomeAgent/AppScope/resources/dark/media/background.png b/cmd/ohos/HomeAgent/AppScope/resources/dark/media/background.png new file mode 100644 index 0000000..c29bd99 Binary files /dev/null and b/cmd/ohos/HomeAgent/AppScope/resources/dark/media/background.png differ diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/common/Constants.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/common/Constants.ets index 9049d71..46b5fa2 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/common/Constants.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/common/Constants.ets @@ -52,6 +52,25 @@ export const WIDE_MIN_CONTENT: number = 380; /** 悬浮导航胶囊的最大宽度(vp):宽屏下避免被拉成长条 */ export const NAV_PILL_MAX_WIDTH: number = 620; +// ===== 动效时长(ms)===== +/** + * 统一动效节奏,避免每处各写一个魔数导致快慢不一。 + * + * 取值依据:状态切换类交互(按下、展开、选中)控制在 150-250ms, + * 超过 300ms 会显得"拖";进出场大位移(导航栏收起、悬浮层)用 300-400ms + * 才不突兀。曲线统一 EaseOut:起步快、收尾缓,手感上更"跟手"。 + */ +/** 按压反馈、图标旋转等即时反馈 */ +export const ANIM_FAST: number = 150; +/** 展开/折叠、选中态迁移等常规状态切换 */ +export const ANIM_NORMAL: number = 220; +/** 卡片入场、列表项出现 */ +export const ANIM_ENTER: number = 280; +/** 导航栏收起、悬浮层进出等大位移 */ +export const ANIM_SLOW: number = 400; +/** 按压态缩放:轻微下沉即可,过度缩放显廉价 */ +export const PRESS_SCALE: number = 0.97; + /** Every color token the app renders with. */ export interface ThemePalette { bgPrimary: string; diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets index cc8c4df..9ff7f6a 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/Attachment.ets @@ -4,8 +4,9 @@ import { common } from '@kit.AbilityKit'; import { apiClient } from '../common/ApiClient'; import { userMessage } from '../common/UserError'; import { ChatAttachment } from '../model/Model'; -import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM } from '../common/Constants'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_MD, RADIUS_SM, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; import { COLOR_ERROR } from '../common/Constants'; +import { MotionBase } from './MotionBase'; import { PlainCard } from './SubPage'; /** @@ -131,6 +132,9 @@ export struct AttachmentCard { } build() { + // 整卡按压反馈统一收进 MotionBase(父组件):scale 回弹 + 透明度压暗。 + // fillWidth: false —— 附件卡在聊天气泡内按内容自适应宽度,不能撑满整行。 + MotionBase({ pressEnabled: true, pressOpacity: 0.88, fillWidth: false }) { Column({ space: 6 }) { Text(this.mine ? '你发送的' : '小宅发送的') .fontSize(11) @@ -144,6 +148,9 @@ export struct AttachmentCard { .objectFit(ImageFit.Cover) .borderRadius(RADIUS_MD) .draggable(false) + .transition(TransitionEffect.OPACITY + .combine(TransitionEffect.scale({ x: 0.98, y: 0.98 })) + .animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) } else if (this.failed) { Row({ space: 6 }) { Image($r('app.media.ic_error')) @@ -158,6 +165,7 @@ export struct AttachmentCard { .padding({ left: 10, right: 10, top: 8, bottom: 8 }) .borderRadius(RADIUS_SM) .backgroundColor(this.palette().bgHover) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } else { Row() { LoadingProgress() @@ -170,6 +178,7 @@ export struct AttachmentCard { .justifyContent(FlexAlign.Center) .borderRadius(RADIUS_SM) .backgroundColor(this.palette().bgHover) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } } else { Row({ space: 8 }) { @@ -212,6 +221,7 @@ export struct AttachmentCard { cb(); } }) + } } private palette(): ThemePalette { @@ -259,7 +269,9 @@ export struct AttachmentDetailContent { } catch (e) { this.err = userMessage('attachment.load', e); } - this.loading = false; + this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { + this.loading = false; + }); } /** 保存到应用沙箱 files 目录(不申请媒体库权限,避免为了看一张图要授权相册)。 */ @@ -300,6 +312,9 @@ export struct AttachmentDetailContent { .border({ width: 1, color: this.palette().glassBorder }) .alignItems(VerticalAlign.Top) .margin({ bottom: 14 }) + .transition(TransitionEffect.OPACITY + .combine(TransitionEffect.translate({ y: -12 })) + .animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) } // 预览:图片铺满宽度可缩放查看;文件给一个大图标占位 @@ -315,6 +330,7 @@ export struct AttachmentDetailContent { .width('100%') .height(200) .justifyContent(FlexAlign.Center) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } else if (this.pixel !== undefined) { Image(this.pixel) .width('100%') @@ -322,6 +338,7 @@ export struct AttachmentDetailContent { .objectFit(ImageFit.Contain) .borderRadius(RADIUS_MD) .draggable(false) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) } } else { Column({ space: 10 }) { @@ -375,6 +392,9 @@ export struct AttachmentDetailContent { .fontColor(this.palette().textMuted) .width('100%') .margin({ top: 8 }) + .transition(TransitionEffect.OPACITY + .combine(TransitionEffect.translate({ y: 6 })) + .animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) } } } @@ -401,6 +421,7 @@ export struct AttachmentDetailContent { .width('100%') .padding({ top: 8, bottom: 8 }) .alignItems(VerticalAlign.Top) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) } private palette(): ThemePalette { diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/MotionBase.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/MotionBase.ets new file mode 100644 index 0000000..ca6a537 --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/MotionBase.ets @@ -0,0 +1,87 @@ +/** + * MotionBase —— 全局通用动效的"父组件"。 + * + * 目标:把各个组件重复实现的按压反馈收拢到这唯一一个父组件里, + * 全局统一手感(时长 token + EaseOut 曲线 + 形变幅度)。 + * 公共组件(NavRow / StatusSummaryCard / AttachmentCard / 插件卡片 …) + * 在自己的 build 根节点接入 MotionBase,通用按压动效即随父组件生效, + * 子组件无需再各自维护 @State pressed + scale + animation + onTouch 四件套。 + * + * 特有动画(多态扩展):留在各组件内部。例如聊天输入框向上弹出 + * (ChatPage 的 inputMultiLine 上移 + Curve.Friction 展开)、加号菜单浮起、 + * 折叠面板展开、toast 进出场——这些是"某个组件专属"的动效, + * 不应塞进父组件,由各组件按需声明。 + * + * ArkUI V1 的 @Component struct 不能继承另一位 struct 的 build, + * 这里用「组合」表达继承:调用组件把内容经唯一内容插槽传给 MotionBase, + * MotionBase 在包装节点上统一挂通用动效修饰器。 + * + * 通用动效契约: + * - 按压反馈:pressEnabled 时整容器 scale → pressScale,快速回弹。 + * V1 自带按压态在玻璃/透明底上几乎不可见,这里统一补足; + * scale 只作用于容器自身,不侵入子节点外观。 + * - onPress 只是按压瞬时的轻量钩子(如提前高亮), + * 真正的导航/提交语义仍由调用组件的 onClick(点击释放)负责, + * 二者不耦合,避免"按下即触发"的手感偏差。 + * + * 用法: + * MotionBase({ pressEnabled: true }) { + * // 实际内容;外层 onClick 由调用组件自己绑在内容上 + * } + * + * 注意:包装节点默认 width('100%')。像聊天气泡里的附件卡那样需要 + * 「内容自适应宽度」的组件传 fillWidth: false,否则会被撑满整行。 + */ +import { ANIM_FAST, PRESS_SCALE } from '../common/Constants'; + +@Component +export struct MotionBase { + /** 内容插槽:调用组件把实际内容传进来 */ + @BuilderParam content: () => void; + /** 是否启用按压反馈(默认关,纯展示容器传 false 零开销) */ + @Prop pressEnabled: boolean = false; + /** 按下的瞬间回调(可选):只做轻量即时反馈,不要在这里做导航/提交 */ + onPress?: () => void; + /** 按压缩放幅度,默认全局 PRESS_SCALE */ + @Prop pressScale: number = PRESS_SCALE; + /** 按压时整体透明度(1 = 不启用透明度反馈) */ + @Prop pressOpacity: number = 1; + /** 包装节点是否撑满父宽;false = 由内容决定宽度(气泡内的卡片必须传 false) */ + @Prop fillWidth: boolean = true; + /** 在 Row/Column 中参与剩余空间分配的权重;0 = 不参与(等分排列的按钮传 1) */ + @Prop flexWeight: number = 0; + + /** 按压态:只对容器自身 scale 生效 */ + @State private pressed: boolean = false; + + build() { + Column() { + this.content() + } + .width(this.fillWidth && this.flexWeight <= 0 ? '100%' : undefined) + .layoutWeight(this.flexWeight) + .alignItems(HorizontalAlign.Start) + // ===== 通用动效(父组件统一声明,子组件继承) ===== + // 按压回弹:pressed 驱动 scale 增量,.animation 在下方覆盖该增量属性 + .scale({ + x: this.pressed ? this.pressScale : 1, + y: this.pressed ? this.pressScale : 1, + }) + .opacity(this.pressed ? this.pressOpacity : 1) + .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) + .onTouch((e: TouchEvent) => { + if (!this.pressEnabled) { + return; + } + if (e.type === TouchType.Down) { + this.pressed = true; + const cb = this.onPress; + if (cb !== undefined) { + cb(); + } + } else if (e.type === TouchType.Up || e.type === TouchType.Cancel) { + this.pressed = false; + } + }) + } +} \ No newline at end of file diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/PageTopBar.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/PageTopBar.ets index 9f48c65..35d645e 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/PageTopBar.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/PageTopBar.ets @@ -1,4 +1,6 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants'; +import { ANIM_FAST, ANIM_SLOW } from '../common/Constants'; +import { MotionBase } from './MotionBase'; /** * 顶栏高度(vp)。页面 padding-top 应略大于此值,避免内容被标题压住, @@ -123,31 +125,36 @@ export struct FloatIconButton { onTap?: () => void; build() { - Button() { - Image(this.icon) - .width(18) - .height(18) - .fillColor(this.accent ? this.palette().accent : this.palette().textSecondary) - } - .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(); + // 按压缩放交给 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 { @@ -203,6 +210,6 @@ export struct NavFloatOverlay { .margin({ bottom: 94 }) .translate({ y: (!this.navVisible || this.currentTab !== this.tab) ? 140 : 0 }) .opacity((!this.navVisible || this.currentTab !== this.tab) ? 0 : 1) - .animation({ duration: 400, curve: Curve.EaseOut }) + .animation({ duration: ANIM_SLOW, curve: Curve.EaseOut }) } } \ No newline at end of file diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsEditor.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsEditor.ets index f21976c..023a95a 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsEditor.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SettingsEditor.ets @@ -3,6 +3,7 @@ import { userMessage, noConnectionMessage } from '../common/UserError'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants'; import { RADIUS_SM } from '../common/Constants'; import { COLOR_ERROR } from '../common/Constants'; +import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; /** * 可复用的后端配置编辑器(按 key 前缀取一段配置并就地编辑)。 @@ -243,6 +244,7 @@ export struct SettingsEditor { .width('100%') .justifyContent(FlexAlign.Center) .padding({ top: 14, bottom: 14 }) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } if (this.err.length > 0) { @@ -250,6 +252,8 @@ export struct SettingsEditor { .fontSize(12) .fontColor(COLOR_ERROR) .width('100%') + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -12 })) + .animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) } if (!this.loading && this.err.length === 0 && this.entries.length === 0) { @@ -257,6 +261,7 @@ export struct SettingsEditor { .fontSize(12) .fontColor(this.palette().textMuted) .width('100%') + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } ForEach(this.entries, (entry: EditorEntry) => { @@ -268,6 +273,8 @@ export struct SettingsEditor { .fontSize(11) .fontColor(this.toastErr ? COLOR_ERROR : this.palette().accent) .margin({ top: 4 }) + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -12 })) + .animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) } } .width('100%') @@ -302,6 +309,7 @@ export struct SettingsEditor { Text(entry.value === 'true' ? '已开启' : '已关闭') .fontSize(12) .fontColor(entry.value === 'true' ? this.palette().accent : this.palette().textMuted) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) Blank() Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' }) .selectedColor(this.palette().accent) @@ -325,6 +333,7 @@ export struct SettingsEditor { .margin({ right: 6, bottom: 6 }) .backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover) .fontColor(entry.value === opt ? Color.White : this.palette().textSecondary) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) .onClick(() => { this.save(entry, opt); }) @@ -352,6 +361,7 @@ export struct SettingsEditor { .fontSize(12) .backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent) .fontColor(Color.White) + .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) .margin({ left: 8 }) .onClick(() => { this.save(entry, this.latestValue(entry.key)); diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets index 3bf9b70..29b06f3 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/StatusCards.ets @@ -1,5 +1,7 @@ -import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM } from '../common/Constants'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, RADIUS_SM, + ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; import { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants'; +import { MotionBase } from './MotionBase'; import { statusStore, StatGroup, StatField, compactDuration, K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, @@ -43,6 +45,10 @@ export struct StatusSummaryCard { } build() { + // 整卡按压反馈统一收进 MotionBase(父组件):这里是进明细页的唯一入口, + // 父组件负责 scale 回弹;导航语义仍由本卡自己的 onClick(点击释放)负责, + // 避免"按下即跳转"的误触手感。 + MotionBase({ pressEnabled: true }) { Column() { if (this.err.length > 0) { // 连接失败:只显示一句人话,技术细节在 hilog 里 @@ -60,6 +66,7 @@ export struct StatusSummaryCard { } .width('100%') .alignItems(VerticalAlign.Top) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) } else if (this.loading && !this.up) { Row() { LoadingProgress() @@ -70,6 +77,7 @@ export struct StatusSummaryCard { .width('100%') .justifyContent(FlexAlign.Center) .padding({ top: 20, bottom: 20 }) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } else { // 状态主行:一枚状态图标(运行=脉搏,离线=断开)+ 状态词 + 运行时长。 // 之前那个环形进度条表达的是"100% / 0%",而在线与否是布尔量, @@ -81,18 +89,21 @@ export struct StatusSummaryCard { .width(24) .height(24) .fillColor(this.up ? COLOR_SUCCESS : this.palette().textMuted) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) .draggable(false) } .width(48) .height(48) .borderRadius(RADIUS_SM) .backgroundColor(this.up ? 'rgba(23, 169, 100, 0.16)' : this.palette().bgHover) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) Column({ space: 3 }) { Text(this.up ? '运行中' : '离线') .fontSize(17) .fontWeight(FontWeight.Bold) .fontColor(this.up ? COLOR_SUCCESS : this.palette().textMuted) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) Text(this.up ? '已运行 ' + this.uptime : '未连接到后端服务') .fontSize(12) .fontColor(this.palette().textSecondary) @@ -104,6 +115,7 @@ export struct StatusSummaryCard { } .width('100%') .alignItems(VerticalAlign.Center) + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) // 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具) Row({ space: 10 }) { @@ -155,6 +167,7 @@ export struct StatusSummaryCard { cb(); } }) + } } @Builder @@ -237,6 +250,7 @@ export struct StatusDetailContent { .border({ width: 1, color: this.palette().glassBorder }) .alignItems(VerticalAlign.Top) .margin({ bottom: 14 }) + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) } if (this.loading && this.groups.length === 0) { @@ -249,6 +263,7 @@ export struct StatusDetailContent { .width('100%') .justifyContent(FlexAlign.Center) .padding({ top: 40, bottom: 40 }) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } // 这里原本有一张"服务负载"卡,用两条容量条画插件/工具数量。 @@ -295,6 +310,7 @@ export struct StatusDetailContent { .border({ width: 1, color: this.palette().glassBorder }) .alignItems(HorizontalAlign.Start) .margin({ bottom: 14 }) + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) }, (g: StatGroup) => g.title) } .width('100%') diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets index fd1e140..5a4069e 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/components/SubPage.ets @@ -1,4 +1,6 @@ -import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG } from '../common/Constants'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, RADIUS_LG, + ANIM_NORMAL } from '../common/Constants'; +import { MotionBase } from './MotionBase'; import { handleNavOnScroll } from '../common/NavBarController'; import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from './PageTopBar'; import { GradientBackground } from './GradientBackground'; @@ -69,6 +71,7 @@ export struct SubPageLayer { .width('100%') // 宽屏右栏底部没有主导航胶囊,只保留悬浮键的空间 .padding({ left: 16, right: 16, top: 76, bottom: this.isWide ? 108 : 174 }) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) .alignItems(HorizontalAlign.Start) } .width('100%') @@ -88,25 +91,34 @@ export struct SubPageLayer { NavFloatOverlay({ tab: this.tab }) { NavFloatRow() { if (!this.isWide) { - SubPageBackButton({ - onTap: () => { - const cb = this.onBack; - if (cb !== undefined) { - cb(); - } - }, - }) - if (this.showRefresh) { - FloatIconButton({ - icon: $r('app.media.ic_refresh'), + // 自定义组件不能直接挂 .transition()(会生成 __Common__ 包装节点), + // 所以各自套一层无 padding 的 Column,由它承载出入场动画, + // 让悬浮键与 400ms 平滑滑出的悬浮层不再"一帧硬切"。 + Column() { + SubPageBackButton({ onTap: () => { - const cb = this.onRefresh; + const cb = this.onBack; if (cb !== undefined) { cb(); } }, }) } + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) + if (this.showRefresh) { + Column() { + FloatIconButton({ + icon: $r('app.media.ic_refresh'), + onTap: () => { + const cb = this.onRefresh; + if (cb !== undefined) { + cb(); + } + }, + }) + } + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) + } } } } @@ -138,71 +150,82 @@ export struct NavRow { onTap?: () => void; build() { - Row() { - if (this.icon !== undefined) { - Row() { - Image(this.icon) - .width(17) - .height(17) - .fillColor(this.iconColor()) - .draggable(false) + // 通用按压动画收进 MotionBase(父组件):scale 回弹统一。 + // 选中态底色仍在这里管:它是 NavRow 特有外观,不归父组件。 + MotionBase({ pressEnabled: true }) { + Row() { + if (this.icon !== undefined) { + Row() { + Image(this.icon) + .width(17) + .height(17) + .fillColor(this.iconColor()) + .draggable(false) + } + .width(32) + .height(32) + .borderRadius(10) + .backgroundColor(this.iconBg()) + .justifyContent(FlexAlign.Center) + .margin({ right: 12 }) } - .width(32) - .height(32) - .borderRadius(10) - .backgroundColor(this.iconBg()) - .justifyContent(FlexAlign.Center) - .margin({ right: 12 }) - } - Column({ space: 2 }) { - Text(this.title) - .fontSize(15) - .fontColor(this.selected ? this.palette().accent : this.palette().textPrimary) - .fontWeight(this.selected ? FontWeight.Medium : FontWeight.Normal) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - if (this.subtitle.length > 0) { - Text(this.subtitle) - .fontSize(11) - .fontColor(this.palette().textMuted) + Column({ space: 2 }) { + Text(this.title) + .fontSize(15) + .fontColor(this.selected ? this.palette().accent : this.palette().textPrimary) + // 父行 MotionBase 的 .animation() 到不了子节点,选中态字色自己缓动 + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) + .fontWeight(this.selected ? FontWeight.Medium : FontWeight.Normal) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) + if (this.subtitle.length > 0) { + Text(this.subtitle) + .fontSize(11) + .fontColor(this.palette().textMuted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } } - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) - if (this.value.length > 0) { - Text(this.value) - .fontSize(12) - .fontColor(this.palette().textSecondary) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .constraintSize({ maxWidth: 130 }) - .margin({ right: 8 }) - } + if (this.value.length > 0) { + Text(this.value) + .fontSize(12) + .fontColor(this.palette().textSecondary) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .constraintSize({ maxWidth: 130 }) + .margin({ right: 8 }) + } - Image($r('app.media.ic_chevron_right')) - .width(15) - .height(15) - .fillColor(this.selected ? this.palette().accent : this.palette().textMuted) - .draggable(false) + Image($r('app.media.ic_chevron_right')) + .width(15) + .height(15) + .fillColor(this.selected ? this.palette().accent : this.palette().textMuted) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) + .draggable(false) + } + .width('100%') + .padding({ left: 14, right: 14, top: 12, bottom: 12 }) + .alignItems(VerticalAlign.Center) + // 选中态底色:写在 .border 之前,避免分割线宽度被纳入动画。 + // MotionBase 的 .animation() 只作用在包装 Column 上,到不了这里, + // 选中高亮的底色迁移要本行自己声明。 + .backgroundColor(this.selected ? this.palette().accentBg : Color.Transparent) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) + .border({ + width: { bottom: this.showDivider ? 1 : 0 }, + color: this.palette().kvBorder, + }) + .onClick(() => { + const cb = this.onTap; + if (cb !== undefined) { + cb(); + } + }) } - .width('100%') - .padding({ left: 14, right: 14, top: 12, bottom: 12 }) - .alignItems(VerticalAlign.Center) - .backgroundColor(this.selected ? this.palette().accentBg : Color.Transparent) - .border({ - width: { bottom: this.showDivider ? 1 : 0 }, - color: this.palette().kvBorder, - }) - .onClick(() => { - const cb = this.onTap; - if (cb !== undefined) { - cb(); - } - }) } private iconColor(): string { @@ -301,30 +324,34 @@ export struct SubPageBackButton { onTap?: () => void; build() { - Button() { - Row({ space: 4 }) { - Image($r('app.media.ic_arrow_back')) - .width(17) - .height(17) - .fillColor(this.palette().textSecondary) - .draggable(false) + // 玻璃底自带的按压变暗在 45% 透明度上几乎看不见,缩放反馈由 MotionBase 统一提供。 + // fillWidth: false —— 悬浮区按内容宽度排列,不能撑满整行。 + MotionBase({ pressEnabled: true, fillWidth: false }) { + Button() { + Row({ space: 4 }) { + Image($r('app.media.ic_arrow_back')) + .width(17) + .height(17) + .fillColor(this.palette().textSecondary) + .draggable(false) + } } + .width(42) + .height(42) + .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(); + } + }) } - .width(42) - .height(42) - .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 { diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets index 312ee12..9a8547a 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/ChatPage.ets @@ -9,6 +9,8 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants'; import { RADIUS_MD, RADIUS_PILL, RADIUS_SM } from '../common/Constants'; import { WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants'; import { CHAT_PAGE_SIZE } from '../common/Constants'; +import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; +import { MotionBase } from '../components/MotionBase'; import { MarkdownView } from '../components/MarkdownView'; import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar'; import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage'; @@ -746,7 +748,9 @@ export struct ChatPage { /** 从图库挑一张图 */ private async pickImage(): Promise { - this.attachMenuOpen = false; + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.attachMenuOpen = false; + }); try { const options = new picker.PhotoSelectOptions(); options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; @@ -765,7 +769,9 @@ export struct ChatPage { /** 从文件管理器挑一个文件 */ private async pickFile(): Promise { - this.attachMenuOpen = false; + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.attachMenuOpen = false; + }); try { const options = new picker.DocumentSelectOptions(); options.maxSelectNumber = 1; @@ -993,8 +999,12 @@ export struct ChatPage { } private toggleReasoning(msg: ChatMessage): void { - msg.reasoningOpen = !(msg.reasoningOpen === true); - this.forceRefresh(); + // 在 animateTo 里翻转:展开/收起时 chevron 走已有 .animation, + // 面板节点在 animateTo 帧内获得默认过渡,不会再硬切。 + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + msg.reasoningOpen = !(msg.reasoningOpen === true); + this.forceRefresh(); + }); } /** @@ -1168,7 +1178,9 @@ export struct ChatPage { .width('100%') .justifyContent(FlexAlign.Start) .padding({ left: 52, top: 6, bottom: 6 }) - .opacity(1) + // if 控制的节点无法用 .animation() 做进出场(那只驱动自身属性的增量更新); + // 用 transition 才能让"处理中"这条在出现和消失时都淡入淡出。 + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } } .width('100%') @@ -1229,7 +1241,11 @@ export struct ChatPage { FloatIconButton({ icon: $r('app.media.ic_plus'), onTap: () => { - this.attachMenuOpen = !this.attachMenuOpen; + // 菜单展开会同时改变 listBottomPad,用 animateTo 把 + // 列表内边距和菜单进出场拉到同一个时钟上。 + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.attachMenuOpen = !this.attachMenuOpen; + }); }, }) Blank() @@ -1244,6 +1260,8 @@ export struct ChatPage { .height(44) .type(ButtonType.Circle) .backgroundColor('#77809A') + // 发送/中断切换是 if 分支整体替换,用 transition 淡入淡出 + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) .onClick(() => { this.interruptChat(); }) @@ -1259,6 +1277,7 @@ export struct ChatPage { .type(ButtonType.Circle) .backgroundColor(this.palette().accent) .enabled(this.inputText.trim().length > 0 || this.pendingPath.length > 0) + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) .onClick(() => { this.sendChat(); }) @@ -1353,73 +1372,83 @@ export struct ChatPage { .justifyContent(FlexAlign.Start) .margin({ bottom: 8 }) .hitTestBehavior(HitTestMode.Transparent) + // 加号菜单由 if 控制,进出场只能靠 transition;配合下面 toggle 处的 + // animateTo,展开时两枚胶囊从加号上方浮起而不是硬闪出来。 + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) } @Builder AttachOption(icon: Resource, label: string, tap: () => void) { - Row({ space: 6 }) { - Image(icon) - .width(15) - .height(15) - .fillColor(this.palette().textSecondary) - .draggable(false) - Text(label) - .fontSize(12) - .fontColor(this.palette().textPrimary) + // 按压反馈交给 MotionBase:每个实例自带独立按压态, + // 修掉了原先两枚胶囊共用一个 @State、按一枚两枚同时缩放的问题。 + MotionBase({ pressEnabled: true, fillWidth: false }) { + Row({ space: 6 }) { + Image(icon) + .width(15) + .height(15) + .fillColor(this.palette().textSecondary) + .draggable(false) + Text(label) + .fontSize(12) + .fontColor(this.palette().textPrimary) + } + .padding({ left: 12, right: 14, top: 8, bottom: 8 }) + .backgroundColor(this.palette().navBarBg) + .borderRadius(RADIUS_PILL) + .border({ width: 1, color: this.palette().navBarBorder }) + .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 }) + .onClick(tap) } - .padding({ left: 12, right: 14, top: 8, bottom: 8 }) - .backgroundColor(this.palette().navBarBg) - .borderRadius(RADIUS_PILL) - .border({ width: 1, color: this.palette().navBarBorder }) - .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 }) - .onClick(tap) } /** 待发送附件预览条:缩略信息 + 一个移除按钮 */ @Builder PendingAttachmentChip() { - Row({ space: 8 }) { - Image(this.pendingIsImage ? $r('app.media.ic_image') : $r('app.media.ic_file')) - .width(15) - .height(15) - .fillColor(this.palette().accent) - .draggable(false) - Column({ space: 1 }) { - Text(this.pendingName) - .fontSize(12) - .fontColor(this.palette().textPrimary) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - Text(this.uploading ? '上传中...' : formatBytes(this.pendingSize)) - .fontSize(10) - .fontColor(this.palette().textMuted) - } - .alignItems(HorizontalAlign.Start) - .layoutWeight(1) - if (this.uploading) { - LoadingProgress() - .width(14) - .height(14) - .color(this.palette().accent) - } else { - Image($r('app.media.ic_close')) - .width(13) - .height(13) - .fillColor(this.palette().textMuted) + // 按压反馈交给 MotionBase(全宽预览条) + MotionBase({ pressEnabled: true }) { + Row({ space: 8 }) { + Image(this.pendingIsImage ? $r('app.media.ic_image') : $r('app.media.ic_file')) + .width(15) + .height(15) + .fillColor(this.palette().accent) .draggable(false) - .onClick(() => { - this.clearPendingFile(); - this.forceRefresh(); - }) + Column({ space: 1 }) { + Text(this.pendingName) + .fontSize(12) + .fontColor(this.palette().textPrimary) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + Text(this.uploading ? '上传中...' : formatBytes(this.pendingSize)) + .fontSize(10) + .fontColor(this.palette().textMuted) + } + .alignItems(HorizontalAlign.Start) + .layoutWeight(1) + if (this.uploading) { + LoadingProgress() + .width(14) + .height(14) + .color(this.palette().accent) + } else { + Image($r('app.media.ic_close')) + .width(13) + .height(13) + .fillColor(this.palette().textMuted) + .draggable(false) + .onClick(() => { + this.clearPendingFile(); + this.forceRefresh(); + }) + } } + .width('100%') + .padding({ left: 12, right: 12, top: 8, bottom: 8 }) + .margin({ bottom: 8 }) + .backgroundColor(this.palette().navBarBg) + .borderRadius(RADIUS_MD) + .border({ width: 1, color: this.palette().navBarBorder }) + .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 }) } - .width('100%') - .padding({ left: 12, right: 12, top: 8, bottom: 8 }) - .margin({ bottom: 8 }) - .backgroundColor(this.palette().navBarBg) - .borderRadius(RADIUS_MD) - .border({ width: 1, color: this.palette().navBarBorder }) - .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 }) } @Builder @@ -1487,6 +1516,8 @@ export struct ChatPage { .maxLines(24) .textOverflow({ overflow: TextOverflow.Ellipsis }) .wordBreak(WordBreak.BREAK_ALL) + // 面板内容靠 if 挂载:用 transition 在展开/收起时淡入淡出 + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) } // 流式思考中:扫光动画条(对齐 WebGUI reasoningSweep) @@ -1614,38 +1645,44 @@ export struct ChatPage { }) if (tc.open === true) { - if (tc.args.length > 0 && tc.args !== '{}') { - Text('参数') - .fontSize(9.5).fontWeight(FontWeight.Medium) - .fontColor(this.palette().textMuted) - .margin({ top: 6, bottom: 2 }) - Text(tc.args) - .fontSize(11) - .fontColor(this.palette().preText) - .backgroundColor(this.palette().preBg) - .borderRadius(4) - .padding({ left: 7, right: 7, top: 5, bottom: 5 }) - .width('100%') - .textAlign(TextAlign.Start) - .wordBreak(WordBreak.BREAK_ALL) - } - if (tc.result !== undefined && tc.result.length > 0) { - Text('结果') - .fontSize(9.5).fontWeight(FontWeight.Medium) - .fontColor(this.palette().textMuted) - .margin({ top: 6, bottom: 2 }) - Text(tc.result) - .fontSize(11) - .fontColor(this.palette().preText) - .backgroundColor(this.palette().preBg) - .borderRadius(4) - .padding({ left: 7, right: 7, top: 5, bottom: 5 }) - .width('100%') - .textAlign(TextAlign.Start) - .wordBreak(WordBreak.BREAK_ALL) - .maxLines(8) - .textOverflow({ overflow: TextOverflow.Ellipsis }) + Column() { + if (tc.args.length > 0 && tc.args !== '{}') { + Text('参数') + .fontSize(9.5).fontWeight(FontWeight.Medium) + .fontColor(this.palette().textMuted) + .margin({ top: 6, bottom: 2 }) + Text(tc.args) + .fontSize(11) + .fontColor(this.palette().preText) + .backgroundColor(this.palette().preBg) + .borderRadius(4) + .padding({ left: 7, right: 7, top: 5, bottom: 5 }) + .width('100%') + .textAlign(TextAlign.Start) + .wordBreak(WordBreak.BREAK_ALL) + } + if (tc.result !== undefined && tc.result.length > 0) { + Text('结果') + .fontSize(9.5).fontWeight(FontWeight.Medium) + .fontColor(this.palette().textMuted) + .margin({ top: 6, bottom: 2 }) + Text(tc.result) + .fontSize(11) + .fontColor(this.palette().preText) + .backgroundColor(this.palette().preBg) + .borderRadius(4) + .padding({ left: 7, right: 7, top: 5, bottom: 5 }) + .width('100%') + .textAlign(TextAlign.Start) + .wordBreak(WordBreak.BREAK_ALL) + .maxLines(8) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + } } + .width('100%') + .alignItems(HorizontalAlign.Start) + // 展开内容整体用 if 挂载:transition 让参数/结果随 chevron 一起淡入 + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) } } .layoutWeight(1) @@ -1710,8 +1747,10 @@ export struct ChatPage { /** 工具卡/思考卡在气泡内是独占一行的块,气泡 78% 宽度对它们太窄 —— 见 BubbleBody */ private toggleToolCard(tc: ToolCallInfo): void { - tc.open = !(tc.open === true); - this.forceRefresh(); + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + tc.open = !(tc.open === true); + this.forceRefresh(); + }); } /** diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets index defc85d..617dfdf 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/DevicePage.ets @@ -6,7 +6,8 @@ import { noConnectionMessage } from '../common/UserError'; import { handleNavOnScroll } from '../common/NavBarController'; import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry'; import { DeviceInfo } from '../model/Model'; -import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants'; +import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT, ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; +import { MotionBase } from '../components/MotionBase'; import { common } from '@kit.AbilityKit'; import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar'; import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage'; @@ -79,7 +80,9 @@ export struct DevicePage { // ignore context errors } deviceBridge.setStateListener((open: boolean) => { - this.bridgeConnected = open; + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.bridgeConnected = open; + }); if (open) { this.lastError = ''; this.showToast('设备网关已连接', false); @@ -140,7 +143,9 @@ export struct DevicePage { * 所以用 replace 换页而不是叠栈 —— 否则返回手势要一层层退回去。 */ private openSub(id: string): void { - this.activeSub = id; + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.activeSub = id; + }); if (this.isWide && this.navStack.size() > 0) { this.navStack.replacePathByName(id, subPageParam(id), false); } else { @@ -151,7 +156,9 @@ export struct DevicePage { /** 二级页面返回键:宽屏下左栏常驻可见,SubPageLayer 已隐藏返回键 */ private closeSub(): void { this.navStack.pop(); - this.activeSub = SUB_NONE; + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.activeSub = SUB_NONE; + }); } /** Toggle local authorization flag; hello 同步到网关。 */ @@ -203,10 +210,15 @@ export struct DevicePage { } private showToast(msg: string, isError: boolean): void { - this.toastMsg = msg; + // 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色 this.toastIsError = isError; + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.toastMsg = msg; + }); setTimeout(() => { - this.toastMsg = ''; + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.toastMsg = ''; + }); }, 2500); } @@ -244,7 +256,9 @@ export struct DevicePage { list.push(info); } } - this.devices = list; + this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { + this.devices = list; + }); } else { this.devices = []; } @@ -327,7 +341,9 @@ export struct DevicePage { } } else { this.navStack.clear(false); - this.activeSub = SUB_NONE; + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.activeSub = SUB_NONE; + }); markSubPageOpen(false); } }) @@ -403,6 +419,9 @@ export struct DevicePage { .justifyContent(FlexAlign.End) .padding({ right: 20 }) .margin({ bottom: 166 }) + .transition(TransitionEffect.OPACITY + .combine(TransitionEffect.translate({ y: 12 })) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) } } @@ -499,10 +518,12 @@ export struct DevicePage { Row() { Circle({ width: 8, height: 8 }) .fill(this.authorized ? '#17A964' : '#E84026') + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) .margin({ right: 8 }) Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令') .fontSize(12) .fontColor(this.authorized ? '#17A964' : this.palette().textMuted) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) } .width('100%') .margin({ top: 12 }) @@ -551,36 +572,46 @@ export struct DevicePage { .fontSize(12) .fontColor('#E84026') .padding({ left: 4 }) + .transition(TransitionEffect.OPACITY + .combine(TransitionEffect.translate({ y: -8 })) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) } PlainCard({ caption: '操作' }) { Row() { - Button(this.bridgeConnected ? '断开' : '连接网关') - .height(34) - .fontSize(12) - .backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent) - .fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White) - .border({ - width: this.bridgeConnected ? 1 : 0, - color: this.palette().btnGhostBorder, - }) - .onClick(() => { - if (this.bridgeConnected) { - deviceBridge.disconnect(); - } else { - this.connectBridge(); - } - }) + // 缩放反馈由 MotionBase 统一;连接态的底色/字色切换仍需按钮自己缓动, + // 因为父容器的 .animation() 到不了子节点。 + MotionBase({ pressEnabled: true, fillWidth: false }) { + Button(this.bridgeConnected ? '断开' : '连接网关') + .height(34) + .fontSize(12) + .backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent) + .fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White) + .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) + .border({ + width: this.bridgeConnected ? 1 : 0, + color: this.palette().btnGhostBorder, + }) + .onClick(() => { + if (this.bridgeConnected) { + deviceBridge.disconnect(); + } else { + this.connectBridge(); + } + }) + } Blank() - Button('刷新设备') - .height(34) - .fontSize(12) - .backgroundColor(Color.Transparent) - .border({ width: 1, color: this.palette().btnGhostBorder }) - .fontColor(this.palette().textSecondary) - .onClick(() => { - this.refreshDevices(); - }) + MotionBase({ pressEnabled: true, fillWidth: false }) { + Button('刷新设备') + .height(34) + .fontSize(12) + .backgroundColor(Color.Transparent) + .border({ width: 1, color: this.palette().btnGhostBorder }) + .fontColor(this.palette().textSecondary) + .onClick(() => { + this.refreshDevices(); + }) + } } .width('100%') @@ -600,31 +631,40 @@ export struct DevicePage { .fontSize(12) .fontColor(this.palette().textMuted) .padding({ left: 4 }) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } ForEach(this.devices, (dev: DeviceInfo) => { - PlainCard({ caption: '' }) { - Row() { - Circle({ width: 8, height: 8 }) - .fill(dev.online ? '#17A964' : '#77809A') - .margin({ right: 10 }) - Column() { - Text(dev.name.length > 0 ? dev.name : dev.deviceId) - .fontSize(14) - .fontColor(this.palette().textPrimary) - Text(dev.kind + (dev.authorized ? ' · 已授权' : ' · 未授权')) - .fontSize(11) - .fontColor(dev.authorized ? '#17A964' : this.palette().textMuted) - } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) + // PlainCard 是自定义组件,transition 不能直接挂在它上面(会生成 __Common__ 包装), + // 所以用一个无 padding、满宽的 Column 承载入场动画,布局不受影响。 + Column() { + PlainCard({ caption: '' }) { + Row() { + Circle({ width: 8, height: 8 }) + .fill(dev.online ? '#17A964' : '#77809A') + .margin({ right: 10 }) + Column() { + Text(dev.name.length > 0 ? dev.name : dev.deviceId) + .fontSize(14) + .fontColor(this.palette().textPrimary) + Text(dev.kind + (dev.authorized ? ' · 已授权' : ' · 未授权')) + .fontSize(11) + .fontColor(dev.authorized ? '#17A964' : this.palette().textMuted) + } + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) - Text(dev.caps.length.toString() + ' 能力') - .fontSize(10) - .fontColor(this.palette().textMuted) + Text(dev.caps.length.toString() + ' 能力') + .fontSize(10) + .fontColor(this.palette().textMuted) + } + .width('100%') } - .width('100%') } + .width('100%') + .transition(TransitionEffect.OPACITY + .combine(TransitionEffect.translate({ y: 12 })) + .animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) }, (dev: DeviceInfo) => dev.deviceId + dev.online.toString()) } diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets index ac9f814..e0ee45c 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/Index.ets @@ -8,6 +8,8 @@ import { navBar } from '../common/NavBarController'; import { handleBackPress } from '../common/NavStackRegistry'; import { ConnectionConfig } from '../model/Model'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH } from '../common/Constants'; +import { ANIM_NORMAL, ANIM_SLOW } from '../common/Constants'; +import { MotionBase } from '../components/MotionBase'; import { GradientBackground } from '../components/GradientBackground'; import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter'; import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps'; @@ -33,24 +35,32 @@ export struct RailTab { @Prop muted: string = ''; build() { - Column({ space: 3 }) { - Image(this.icon) - .width(22) - .height(22) - .fillColor(this.active ? this.accent : this.muted) - .draggable(false) + // 底部导航按压反馈:交给 MotionBase 统一(flexWeight 由外层调用点的 + // .layoutWeight(1) 负责,这里内部只需撑满自己那一格)。 + MotionBase({ pressEnabled: true }) { + Column({ space: 3 }) { + Image(this.icon) + .width(22) + .height(22) + .fillColor(this.active ? this.accent : this.muted) + // 选中态的颜色迁移必须写在子节点上:父容器的 .animation() + // 只驱动它自己的属性,不会下传给 Image / Text。 + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) + .draggable(false) - Text(this.label) - .fontSize(10) - .fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal) - .fontColor(this.active ? this.accent : this.muted) - .maxLines(1) + Text(this.label) + .fontSize(10) + // fontWeight 不可插值,切换时仍是跳变,只对颜色做过渡 + .fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal) + .fontColor(this.active ? this.accent : this.muted) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) + .maxLines(1) + } + .justifyContent(FlexAlign.Center) + .alignItems(HorizontalAlign.Center) + .width('100%') + .height(48) } - .justifyContent(FlexAlign.Center) - .alignItems(HorizontalAlign.Center) - .width('100%') - .height(48) - .animation({ duration: 200, curve: Curve.EaseOut }) } } @@ -325,7 +335,7 @@ struct Index { // 所有页面滑动时隐藏导航栏 .translate({ y: !this.navVisible ? 130 : 0 }) .opacity(!this.navVisible ? 0 : 1) - .animation({ duration: 400, curve: Curve.EaseOut }) + .animation({ duration: ANIM_SLOW, curve: Curve.EaseOut }) // 导航栏本身不吃触摸空白区,避免遮住下层内容点击 .hitTestBehavior(HitTestMode.Transparent) diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/PluginsPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/PluginsPage.ets index 8b0543c..854331c 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/PluginsPage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/PluginsPage.ets @@ -5,6 +5,8 @@ import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants'; import { RADIUS_LG, RADIUS_MD, RADIUS_SM, RADIUS_PILL, COLOR_ERROR } from '../common/Constants'; +import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; +import { MotionBase } from '../components/MotionBase'; import { PageTopBar, NavFloatOverlay, NavFloatRow, GlassShell, FloatIconButton } from '../components/PageTopBar'; import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage'; import { SettingsEditor } from '../components/SettingsEditor'; @@ -184,11 +186,17 @@ export struct PluginsPage { }; rows.push(row); } - this.plugins = rows; + // 列表整体重建(ForEach key 含 loaded/disabled,启停会整行重挂载): + // 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。 + this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { + this.plugins = rows; + }); } catch (e) { this.showToast(userMessage('plugins.load', e), true); } - this.loading = false; + this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { + this.loading = false; + }); } /** 徽标状态:与 GUI 一致 —— 已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。 */ @@ -239,7 +247,9 @@ export struct PluginsPage { await apiClient.post('/plugins', bodyObj); this.showToast('安装请求已发送', false); this.installUrl = ''; - this.showInstallForm = false; + this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { + this.showInstallForm = false; + }); this.loadPlugins(); } catch (e) { this.showToast(userMessage('plugins.install', e), true); @@ -293,7 +303,10 @@ export struct PluginsPage { d.deprecated = o['deprecated'] as boolean ?? false; d.tags = this.strArray(o['tags']); d.files = this.strArray(o['files']); - this.detail = d; + // 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡 + this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { + this.detail = d; + }); } catch (e) { // 内置插件没有清单,属于预期情况,不当成错误刷红 const row: PluginRow | null = this.findRow(name); @@ -303,12 +316,15 @@ export struct PluginsPage { d.version = row.version ?? ''; d.description = row.description ?? ''; } - this.detail = d; - if (row !== null && row.external) { - this.detailError = userMessage('plugins.detail', e); - } + const errText: string = (row !== null && row.external) ? userMessage('plugins.detail', e) : ''; + this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { + this.detail = d; + this.detailError = errText; + }); } - this.detailLoading = false; + this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { + this.detailLoading = false; + }); } private strArray(raw: Object | undefined): string[] { @@ -351,10 +367,15 @@ export struct PluginsPage { } private showToast(msg: string, isError: boolean): void { - this.toastMsg = msg; + // 颜色标志必须在 animateTo 闭包之外先落地,否则第一帧配色是上一次的 this.toastIsError = isError; + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.toastMsg = msg; + }); setTimeout(() => { - this.toastMsg = ''; + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.toastMsg = ''; + }); }, 2500); } @@ -404,6 +425,8 @@ export struct PluginsPage { .width(20) .height(20) .fillColor(Color.White) + .rotate({ angle: this.showInstallForm ? 45 : 0 }) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) } .width(50) .height(50) @@ -411,7 +434,9 @@ export struct PluginsPage { .backgroundColor(this.palette().accent) .shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 }) .onClick(() => { - this.showInstallForm = !this.showInstallForm; + this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => { + this.showInstallForm = !this.showInstallForm; + }); }) } } @@ -510,6 +535,9 @@ export struct PluginsPage { .border({ width: 1, color: this.palette().navBarBorder }) .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 }) .alignItems(VerticalAlign.Center) + .transition(TransitionEffect.OPACITY + .combine(TransitionEffect.translate({ y: 12 })) + .animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) } /** 加载中 / 未配置 / 空列表三种占位态 */ @@ -521,6 +549,7 @@ export struct PluginsPage { .height(32) .color(this.palette().accent) .margin({ top: 40 }) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } if (!apiClient.hasConnection()) { @@ -528,6 +557,7 @@ export struct PluginsPage { .fontSize(13) .fontColor(this.palette().textMuted) .padding(20) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } if (!this.loading && this.plugins.length === 0 && apiClient.hasConnection()) { @@ -535,6 +565,7 @@ export struct PluginsPage { .fontSize(13) .fontColor(this.palette().textMuted) .padding(20) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } } @@ -545,62 +576,78 @@ export struct PluginsPage { @Builder PluginList() { ForEach(this.plugins, (plugin: PluginRow) => { - Row() { - Column({ space: 3 }) { - Row({ space: 6 }) { - Text(plugin.name) - .fontSize(15) - .fontWeight(this.activeName === plugin.name ? FontWeight.Medium : FontWeight.Normal) - .fontColor(this.activeName === plugin.name - ? this.palette().accent : this.palette().textPrimary) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - if (plugin.version !== undefined && plugin.version.length > 0) { - Text('v' + plugin.version) - .fontSize(10) - .fontColor(this.palette().textMuted) + // 外层 Column 只为承载 transition:.transition() 不能直接挂在自定义组件 + // 调用点上(会生成 __Common__ 包装节点)。按压缩放由 MotionBase 统一提供, + // 每行自带独立按压态,不再需要 pressedName 这种"哪一行被按"的手工记账。 + Column() { + MotionBase({ pressEnabled: true }) { + Row() { + Column({ space: 3 }) { + Row({ space: 6 }) { + Text(plugin.name) + .fontSize(15) + .fontWeight(this.activeName === plugin.name ? FontWeight.Medium : FontWeight.Normal) + .fontColor(this.activeName === plugin.name + ? this.palette().accent : this.palette().textPrimary) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) + if (plugin.version !== undefined && plugin.version.length > 0) { + Text('v' + plugin.version) + .fontSize(10) + .fontColor(this.palette().textMuted) + } + } + // 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达, + // 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。 + Row({ space: 6 }) { + Circle({ width: 6, height: 6 }) + .fill(this.statusDotColor(plugin)) + Text(this.rowSubtitle(plugin)) + .fontSize(11) + .fontColor(this.palette().textMuted) + .maxLines(1) + .textOverflow({ overflow: TextOverflow.Ellipsis }) + .layoutWeight(1) + } + .width('100%') + .alignItems(VerticalAlign.Center) } - } - // 徽标全部去掉(用户要求):状态用一个 3vp 圆点表达, - // 其余信息退化为一行灰字副标题 —— 列表只负责"选谁",细节看详情页。 - Row({ space: 6 }) { - Circle({ width: 6, height: 6 }) - .fill(this.statusDotColor(plugin)) - Text(this.rowSubtitle(plugin)) - .fontSize(11) - .fontColor(this.palette().textMuted) - .maxLines(1) - .textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) + .layoutWeight(1) + .alignItems(HorizontalAlign.Start) + + Image($r('app.media.ic_chevron_right')) + .width(15) + .height(15) + .fillColor(this.activeName === plugin.name + ? this.palette().accent : this.palette().textMuted) + .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) + .draggable(false) } .width('100%') + .padding(14) + .borderRadius(RADIUS_LG) + .backgroundColor(this.activeName === plugin.name + ? this.palette().accentBg : this.palette().bgCard) + .border({ + width: 1, + color: this.activeName === plugin.name + ? this.palette().accent : this.palette().glassBorder, + }) + // 选中态的底色/描边渐变:MotionBase 的 .animation() 到不了这里 + .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) .alignItems(VerticalAlign.Center) + .onClick(() => { + this.openDetail(plugin.name); + }) } - .layoutWeight(1) - .alignItems(HorizontalAlign.Start) - - Image($r('app.media.ic_chevron_right')) - .width(15) - .height(15) - .fillColor(this.activeName === plugin.name - ? this.palette().accent : this.palette().textMuted) - .draggable(false) } .width('100%') - .padding(14) - .borderRadius(RADIUS_LG) - .backgroundColor(this.activeName === plugin.name - ? this.palette().accentBg : this.palette().bgCard) - .border({ - width: 1, - color: this.activeName === plugin.name - ? this.palette().accent : this.palette().glassBorder, - }) .margin({ bottom: 10 }) - .alignItems(VerticalAlign.Center) - .onClick(() => { - this.openDetail(plugin.name); - }) + // ForEach key 含 loaded/disabled:启停会整行重挂载,靠 transition 变成交叉淡入 + .transition(TransitionEffect.OPACITY + .combine(TransitionEffect.translate({ y: 12 })) + .animation({ duration: ANIM_ENTER, curve: Curve.EaseOut })) }, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : '')) } @@ -621,6 +668,7 @@ export struct PluginsPage { .width('100%') .justifyContent(FlexAlign.Center) .padding({ top: 30, bottom: 30 }) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } if (this.detailError.length > 0) { @@ -628,6 +676,7 @@ export struct PluginsPage { .fontSize(12) .fontColor(COLOR_ERROR) .padding({ left: 4, bottom: 12 }) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } // 概览卡:名称、版本、状态徽标、描述 @@ -786,6 +835,7 @@ export struct PluginsPage { }) .fontColor(this.activeIsDisabled() ? this.palette().textSecondary : '#D99A2B') + .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) .onClick(() => { const r: PluginRow | null = this.activeRow(); if (r !== null) { @@ -806,6 +856,7 @@ export struct PluginsPage { this.removePlugin(r); } }) + .transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })) } } .width('100%') @@ -892,6 +943,9 @@ export struct PluginsPage { .justifyContent(FlexAlign.End) .padding({ right: 20 }) .margin({ bottom: 166 }) + .transition(TransitionEffect.OPACITY + .combine(TransitionEffect.translate({ y: 12 })) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) } } diff --git a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets index d2222dc..2a5470f 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets +++ b/cmd/ohos/HomeAgent/entry/src/main/ets/pages/SettingsPage.ets @@ -5,6 +5,8 @@ import { handleNavOnScroll } from '../common/NavBarController'; import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry'; import { ConnectionConfig, AppSettings, emptySettings } from '../model/Model'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, applyThemeMode, WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants'; +import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants'; +import { MotionBase } from '../components/MotionBase'; import { picker, fileIo } from '@kit.CoreFileKit'; import { common } from '@kit.AbilityKit'; import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar'; @@ -572,10 +574,15 @@ export struct SettingsPage { } private showToast(msg: string, isError: boolean): void { - this.toastMsg = msg; + // 颜色标记必须在 animateTo 之外先定好,否则第一帧会用上一条 toast 的配色 this.toastIsError = isError; + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.toastMsg = msg; + }); setTimeout(() => { - this.toastMsg = ''; + this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => { + this.toastMsg = ''; + }); }, 2500); } @@ -748,6 +755,8 @@ export struct SettingsPage { .justifyContent(FlexAlign.End) .padding({ right: 20 }) .margin({ bottom: 166 }) + // if 控制的节点靠 transition 做进出场,配合 showToast 里的 animateTo + .transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })) } } @@ -1139,23 +1148,31 @@ export struct SettingsPage { @Builder ThemeOption(label: string, mode: string) { - Column() { - Text(label) - .fontSize(12) - .fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary) + // 按压缩放由 MotionBase 统一;flexWeight: 1 让三枚选项在 Row 里继续等分。 + MotionBase({ pressEnabled: true, flexWeight: 1 }) { + Column() { + Text(label) + .fontSize(12) + .fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary) + // 子节点的颜色迁移要自己声明:父容器的 .animation() 不下传 + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) + } + .justifyContent(FlexAlign.Center) + .width('100%') + .height(34) + .borderRadius(RADIUS_MD) + .backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover) + // 三选一的选中态迁移:底色与描边一起过渡。写在 .border 之后、 + // 覆盖它上面的所有状态驱动属性。 + .border({ + width: 1, + color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder, + }) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) + .onClick(() => { + this.applyTheme(mode); + }) } - .justifyContent(FlexAlign.Center) - .layoutWeight(1) - .height(34) - .borderRadius(RADIUS_MD) - .backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover) - .border({ - width: 1, - color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder, - }) - .onClick(() => { - this.applyTheme(mode); - }) } @Builder @@ -1200,6 +1217,7 @@ export struct SettingsPage { Text(entry.value === 'true' ? 'true' : 'false') .fontSize(12) .fontColor(entry.value === 'true' ? '#17A964' : this.palette().textMuted) + .animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }) Blank() Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' }) .selectedColor(this.palette().accent) @@ -1223,6 +1241,8 @@ export struct SettingsPage { .margin({ right: 6, bottom: 6 }) .backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover) .fontColor(entry.value === opt ? Color.White : this.palette().textSecondary) + // 选中项迁移:底色与字色一起过渡,避免整排选项同时硬切 + .animation({ duration: ANIM_FAST, curve: Curve.EaseOut }) .onClick(() => { this.saveSetting(entry, opt); }) diff --git a/cmd/ohos/HomeAgent/entry/src/main/module.json5 b/cmd/ohos/HomeAgent/entry/src/main/module.json5 index 56ce176..6dcbc37 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/module.json5 +++ b/cmd/ohos/HomeAgent/entry/src/main/module.json5 @@ -24,9 +24,11 @@ "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "description": "HomeAgent main entry", - "icon": "$media:app_icon", + "icon": "$media:layered_image", "label": "$string:app_name", - "startWindowIcon": "$media:app_icon", + // 启动页图标用透明底字形,配合随主题切换的 start_window_background, + // 浅色/深色模式下遮罩与图标都能对上,不再恒为深色样式。 + "startWindowIcon": "$media:start_icon", "startWindowBackground": "$color:start_window_background", "exported": true, "skills": [ diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/color.json b/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/color.json index abcff3b..5ad94d7 100644 --- a/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/color.json +++ b/cmd/ohos/HomeAgent/entry/src/main/resources/base/element/color.json @@ -2,7 +2,7 @@ "color": [ { "name": "start_window_background", - "value": "#0B1020" + "value": "#F1F3F5" } ] } diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/start_icon.png b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/start_icon.png new file mode 100644 index 0000000..5aa3e43 Binary files /dev/null and b/cmd/ohos/HomeAgent/entry/src/main/resources/base/media/start_icon.png differ diff --git a/cmd/ohos/HomeAgent/entry/src/main/resources/dark/element/color.json b/cmd/ohos/HomeAgent/entry/src/main/resources/dark/element/color.json new file mode 100644 index 0000000..2e711ab --- /dev/null +++ b/cmd/ohos/HomeAgent/entry/src/main/resources/dark/element/color.json @@ -0,0 +1,8 @@ +{ + "color": [ + { + "name": "start_window_background", + "value": "#000000" + } + ] +}