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 与签名材料。
This commit is contained in:
JianFeeeee
2026-08-30 17:23:40 +08:00
parent 001c656a8d
commit 59ff80ee33
21 changed files with 732 additions and 364 deletions

View File

@ -4,7 +4,9 @@
"vendor": "HomeAgent", "vendor": "HomeAgent",
"versionCode": 1000000, "versionCode": 1000000,
"versionName": "1.0.0", "versionName": "1.0.0",
"icon": "$media:app_icon", // 分层图标:前景是字形,背景(沉淀色)在 base/ 与 dark/ 各一份,随系统主题切换。
// 直接指向位图会把浅色底烧进图标,深色模式下桌面和启动页都会跳脱。
"icon": "$media:layered_image",
"label": "$string:app_name" "label": "$string:app_name"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,6 @@
{
"layered-image": {
"background": "$media:background",
"foreground": "$media:foreground"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 988 B

View File

@ -52,6 +52,25 @@ export const WIDE_MIN_CONTENT: number = 380;
/** 悬浮导航胶囊的最大宽度(vp):宽屏下避免被拉成长条 */ /** 悬浮导航胶囊的最大宽度(vp):宽屏下避免被拉成长条 */
export const NAV_PILL_MAX_WIDTH: number = 620; 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. */ /** Every color token the app renders with. */
export interface ThemePalette { export interface ThemePalette {
bgPrimary: string; bgPrimary: string;

View File

@ -4,8 +4,9 @@ import { common } from '@kit.AbilityKit';
import { apiClient } from '../common/ApiClient'; import { apiClient } from '../common/ApiClient';
import { userMessage } from '../common/UserError'; import { userMessage } from '../common/UserError';
import { ChatAttachment } from '../model/Model'; 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 { COLOR_ERROR } from '../common/Constants';
import { MotionBase } from './MotionBase';
import { PlainCard } from './SubPage'; import { PlainCard } from './SubPage';
/** /**
@ -131,6 +132,9 @@ export struct AttachmentCard {
} }
build() { build() {
// 整卡按压反馈统一收进 MotionBase(父组件):scale 回弹 + 透明度压暗。
// fillWidth: false —— 附件卡在聊天气泡内按内容自适应宽度,不能撑满整行。
MotionBase({ pressEnabled: true, pressOpacity: 0.88, fillWidth: false }) {
Column({ space: 6 }) { Column({ space: 6 }) {
Text(this.mine ? '你发送的' : '小宅发送的') Text(this.mine ? '你发送的' : '小宅发送的')
.fontSize(11) .fontSize(11)
@ -144,6 +148,9 @@ export struct AttachmentCard {
.objectFit(ImageFit.Cover) .objectFit(ImageFit.Cover)
.borderRadius(RADIUS_MD) .borderRadius(RADIUS_MD)
.draggable(false) .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) { } else if (this.failed) {
Row({ space: 6 }) { Row({ space: 6 }) {
Image($r('app.media.ic_error')) Image($r('app.media.ic_error'))
@ -158,6 +165,7 @@ export struct AttachmentCard {
.padding({ left: 10, right: 10, top: 8, bottom: 8 }) .padding({ left: 10, right: 10, top: 8, bottom: 8 })
.borderRadius(RADIUS_SM) .borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover) .backgroundColor(this.palette().bgHover)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} else { } else {
Row() { Row() {
LoadingProgress() LoadingProgress()
@ -170,6 +178,7 @@ export struct AttachmentCard {
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.borderRadius(RADIUS_SM) .borderRadius(RADIUS_SM)
.backgroundColor(this.palette().bgHover) .backgroundColor(this.palette().bgHover)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} }
} else { } else {
Row({ space: 8 }) { Row({ space: 8 }) {
@ -213,6 +222,7 @@ export struct AttachmentCard {
} }
}) })
} }
}
private palette(): ThemePalette { private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
@ -259,7 +269,9 @@ export struct AttachmentDetailContent {
} catch (e) { } catch (e) {
this.err = userMessage('attachment.load', e); this.err = userMessage('attachment.load', e);
} }
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.loading = false; this.loading = false;
});
} }
/** 保存到应用沙箱 files 目录(不申请媒体库权限,避免为了看一张图要授权相册)。 */ /** 保存到应用沙箱 files 目录(不申请媒体库权限,避免为了看一张图要授权相册)。 */
@ -300,6 +312,9 @@ export struct AttachmentDetailContent {
.border({ width: 1, color: this.palette().glassBorder }) .border({ width: 1, color: this.palette().glassBorder })
.alignItems(VerticalAlign.Top) .alignItems(VerticalAlign.Top)
.margin({ bottom: 14 }) .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%') .width('100%')
.height(200) .height(200)
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} else if (this.pixel !== undefined) { } else if (this.pixel !== undefined) {
Image(this.pixel) Image(this.pixel)
.width('100%') .width('100%')
@ -322,6 +338,7 @@ export struct AttachmentDetailContent {
.objectFit(ImageFit.Contain) .objectFit(ImageFit.Contain)
.borderRadius(RADIUS_MD) .borderRadius(RADIUS_MD)
.draggable(false) .draggable(false)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
} }
} else { } else {
Column({ space: 10 }) { Column({ space: 10 }) {
@ -375,6 +392,9 @@ export struct AttachmentDetailContent {
.fontColor(this.palette().textMuted) .fontColor(this.palette().textMuted)
.width('100%') .width('100%')
.margin({ top: 8 }) .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%') .width('100%')
.padding({ top: 8, bottom: 8 }) .padding({ top: 8, bottom: 8 })
.alignItems(VerticalAlign.Top) .alignItems(VerticalAlign.Top)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
} }
private palette(): ThemePalette { private palette(): ThemePalette {

View File

@ -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;
}
})
}
}

View File

@ -1,4 +1,6 @@
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { ANIM_FAST, ANIM_SLOW } from '../common/Constants';
import { MotionBase } from './MotionBase';
/** /**
* 顶栏高度(vp)。页面 padding-top 应略大于此值,避免内容被标题压住, * 顶栏高度(vp)。页面 padding-top 应略大于此值,避免内容被标题压住,
@ -123,11 +125,15 @@ export struct FloatIconButton {
onTap?: () => void; onTap?: () => void;
build() { build() {
// 按压缩放交给 MotionBase(父组件统一手感);
// fillWidth: false —— 悬浮区靠右对齐,包装节点必须按内容 42vp 收窄。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button() { Button() {
Image(this.icon) Image(this.icon)
.width(18) .width(18)
.height(18) .height(18)
.fillColor(this.accent ? this.palette().accent : this.palette().textSecondary) .fillColor(this.accent ? this.palette().accent : this.palette().textSecondary)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
} }
.width(FLOAT_NODE_SIZE) .width(FLOAT_NODE_SIZE)
.height(FLOAT_NODE_SIZE) .height(FLOAT_NODE_SIZE)
@ -149,6 +155,7 @@ export struct FloatIconButton {
} }
}) })
} }
}
private palette(): ThemePalette { private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;
@ -203,6 +210,6 @@ export struct NavFloatOverlay {
.margin({ bottom: 94 }) .margin({ bottom: 94 })
.translate({ y: (!this.navVisible || this.currentTab !== this.tab) ? 140 : 0 }) .translate({ y: (!this.navVisible || this.currentTab !== this.tab) ? 140 : 0 })
.opacity((!this.navVisible || this.currentTab !== this.tab) ? 0 : 1) .opacity((!this.navVisible || this.currentTab !== this.tab) ? 0 : 1)
.animation({ duration: 400, curve: Curve.EaseOut }) .animation({ duration: ANIM_SLOW, curve: Curve.EaseOut })
} }
} }

View File

@ -3,6 +3,7 @@ import { userMessage, noConnectionMessage } from '../common/UserError';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants'; import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { RADIUS_SM } from '../common/Constants'; import { RADIUS_SM } from '../common/Constants';
import { COLOR_ERROR } from '../common/Constants'; import { COLOR_ERROR } from '../common/Constants';
import { ANIM_FAST, ANIM_NORMAL, ANIM_ENTER } from '../common/Constants';
/** /**
* 可复用的后端配置编辑器(按 key 前缀取一段配置并就地编辑)。 * 可复用的后端配置编辑器(按 key 前缀取一段配置并就地编辑)。
@ -243,6 +244,7 @@ export struct SettingsEditor {
.width('100%') .width('100%')
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.padding({ top: 14, bottom: 14 }) .padding({ top: 14, bottom: 14 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} }
if (this.err.length > 0) { if (this.err.length > 0) {
@ -250,6 +252,8 @@ export struct SettingsEditor {
.fontSize(12) .fontSize(12)
.fontColor(COLOR_ERROR) .fontColor(COLOR_ERROR)
.width('100%') .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) { if (!this.loading && this.err.length === 0 && this.entries.length === 0) {
@ -257,6 +261,7 @@ export struct SettingsEditor {
.fontSize(12) .fontSize(12)
.fontColor(this.palette().textMuted) .fontColor(this.palette().textMuted)
.width('100%') .width('100%')
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} }
ForEach(this.entries, (entry: EditorEntry) => { ForEach(this.entries, (entry: EditorEntry) => {
@ -268,6 +273,8 @@ export struct SettingsEditor {
.fontSize(11) .fontSize(11)
.fontColor(this.toastErr ? COLOR_ERROR : this.palette().accent) .fontColor(this.toastErr ? COLOR_ERROR : this.palette().accent)
.margin({ top: 4 }) .margin({ top: 4 })
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -12 }))
.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
} }
} }
.width('100%') .width('100%')
@ -302,6 +309,7 @@ export struct SettingsEditor {
Text(entry.value === 'true' ? '已开启' : '已关闭') Text(entry.value === 'true' ? '已开启' : '已关闭')
.fontSize(12) .fontSize(12)
.fontColor(entry.value === 'true' ? this.palette().accent : this.palette().textMuted) .fontColor(entry.value === 'true' ? this.palette().accent : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
Blank() Blank()
Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' }) Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' })
.selectedColor(this.palette().accent) .selectedColor(this.palette().accent)
@ -325,6 +333,7 @@ export struct SettingsEditor {
.margin({ right: 6, bottom: 6 }) .margin({ right: 6, bottom: 6 })
.backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover) .backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover)
.fontColor(entry.value === opt ? Color.White : this.palette().textSecondary) .fontColor(entry.value === opt ? Color.White : this.palette().textSecondary)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.onClick(() => { .onClick(() => {
this.save(entry, opt); this.save(entry, opt);
}) })
@ -352,6 +361,7 @@ export struct SettingsEditor {
.fontSize(12) .fontSize(12)
.backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent) .backgroundColor(entry.dirty ? '#D99A2B' : this.palette().accent)
.fontColor(Color.White) .fontColor(Color.White)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.margin({ left: 8 }) .margin({ left: 8 })
.onClick(() => { .onClick(() => {
this.save(entry, this.latestValue(entry.key)); this.save(entry, this.latestValue(entry.key));

View File

@ -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 { COLOR_ACCENT, COLOR_SUCCESS, COLOR_CYAN, COLOR_ERROR } from '../common/Constants';
import { MotionBase } from './MotionBase';
import { import {
statusStore, StatGroup, StatField, compactDuration, statusStore, StatGroup, StatField, compactDuration,
K_UP, K_VERSION, K_STARTED, K_AGENTS, K_PLUGINS, K_TOOLS, K_ERR, K_LOADING, K_REV, 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() { build() {
// 整卡按压反馈统一收进 MotionBase(父组件):这里是进明细页的唯一入口,
// 父组件负责 scale 回弹;导航语义仍由本卡自己的 onClick(点击释放)负责,
// 避免"按下即跳转"的误触手感。
MotionBase({ pressEnabled: true }) {
Column() { Column() {
if (this.err.length > 0) { if (this.err.length > 0) {
// 连接失败:只显示一句人话,技术细节在 hilog 里 // 连接失败:只显示一句人话,技术细节在 hilog 里
@ -60,6 +66,7 @@ export struct StatusSummaryCard {
} }
.width('100%') .width('100%')
.alignItems(VerticalAlign.Top) .alignItems(VerticalAlign.Top)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
} else if (this.loading && !this.up) { } else if (this.loading && !this.up) {
Row() { Row() {
LoadingProgress() LoadingProgress()
@ -70,6 +77,7 @@ export struct StatusSummaryCard {
.width('100%') .width('100%')
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.padding({ top: 20, bottom: 20 }) .padding({ top: 20, bottom: 20 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} else { } else {
// 状态主行:一枚状态图标(运行=脉搏,离线=断开)+ 状态词 + 运行时长。 // 状态主行:一枚状态图标(运行=脉搏,离线=断开)+ 状态词 + 运行时长。
// 之前那个环形进度条表达的是"100% / 0%",而在线与否是布尔量, // 之前那个环形进度条表达的是"100% / 0%",而在线与否是布尔量,
@ -81,18 +89,21 @@ export struct StatusSummaryCard {
.width(24) .width(24)
.height(24) .height(24)
.fillColor(this.up ? COLOR_SUCCESS : this.palette().textMuted) .fillColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.draggable(false) .draggable(false)
} }
.width(48) .width(48)
.height(48) .height(48)
.borderRadius(RADIUS_SM) .borderRadius(RADIUS_SM)
.backgroundColor(this.up ? 'rgba(23, 169, 100, 0.16)' : this.palette().bgHover) .backgroundColor(this.up ? 'rgba(23, 169, 100, 0.16)' : this.palette().bgHover)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
Column({ space: 3 }) { Column({ space: 3 }) {
Text(this.up ? '运行中' : '离线') Text(this.up ? '运行中' : '离线')
.fontSize(17) .fontSize(17)
.fontWeight(FontWeight.Bold) .fontWeight(FontWeight.Bold)
.fontColor(this.up ? COLOR_SUCCESS : this.palette().textMuted) .fontColor(this.up ? COLOR_SUCCESS : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
Text(this.up ? '已运行 ' + this.uptime : '未连接到后端服务') Text(this.up ? '已运行 ' + this.uptime : '未连接到后端服务')
.fontSize(12) .fontSize(12)
.fontColor(this.palette().textSecondary) .fontColor(this.palette().textSecondary)
@ -104,6 +115,7 @@ export struct StatusSummaryCard {
} }
.width('100%') .width('100%')
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
// 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具) // 能力计数:图标 + 数字,只保留真正会变的两项(插件 / 工具)
Row({ space: 10 }) { Row({ space: 10 }) {
@ -156,6 +168,7 @@ export struct StatusSummaryCard {
} }
}) })
} }
}
@Builder @Builder
kpiTile(icon: Resource, label: string, value: number, color: string) { kpiTile(icon: Resource, label: string, value: number, color: string) {
@ -237,6 +250,7 @@ export struct StatusDetailContent {
.border({ width: 1, color: this.palette().glassBorder }) .border({ width: 1, color: this.palette().glassBorder })
.alignItems(VerticalAlign.Top) .alignItems(VerticalAlign.Top)
.margin({ bottom: 14 }) .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) { if (this.loading && this.groups.length === 0) {
@ -249,6 +263,7 @@ export struct StatusDetailContent {
.width('100%') .width('100%')
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.padding({ top: 40, bottom: 40 }) .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 }) .border({ width: 1, color: this.palette().glassBorder })
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
.margin({ bottom: 14 }) .margin({ bottom: 14 })
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_ENTER, curve: Curve.EaseOut }))
}, (g: StatGroup) => g.title) }, (g: StatGroup) => g.title)
} }
.width('100%') .width('100%')

View File

@ -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 { handleNavOnScroll } from '../common/NavBarController';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from './PageTopBar'; import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from './PageTopBar';
import { GradientBackground } from './GradientBackground'; import { GradientBackground } from './GradientBackground';
@ -69,6 +71,7 @@ export struct SubPageLayer {
.width('100%') .width('100%')
// 宽屏右栏底部没有主导航胶囊,只保留悬浮键的空间 // 宽屏右栏底部没有主导航胶囊,只保留悬浮键的空间
.padding({ left: 16, right: 16, top: 76, bottom: this.isWide ? 108 : 174 }) .padding({ left: 16, right: 16, top: 76, bottom: this.isWide ? 108 : 174 })
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
} }
.width('100%') .width('100%')
@ -88,6 +91,10 @@ export struct SubPageLayer {
NavFloatOverlay({ tab: this.tab }) { NavFloatOverlay({ tab: this.tab }) {
NavFloatRow() { NavFloatRow() {
if (!this.isWide) { if (!this.isWide) {
// 自定义组件不能直接挂 .transition()(会生成 __Common__ 包装节点),
// 所以各自套一层无 padding 的 Column,由它承载出入场动画,
// 让悬浮键与 400ms 平滑滑出的悬浮层不再"一帧硬切"。
Column() {
SubPageBackButton({ SubPageBackButton({
onTap: () => { onTap: () => {
const cb = this.onBack; const cb = this.onBack;
@ -96,7 +103,10 @@ export struct SubPageLayer {
} }
}, },
}) })
}
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
if (this.showRefresh) { if (this.showRefresh) {
Column() {
FloatIconButton({ FloatIconButton({
icon: $r('app.media.ic_refresh'), icon: $r('app.media.ic_refresh'),
onTap: () => { onTap: () => {
@ -107,6 +117,8 @@ export struct SubPageLayer {
}, },
}) })
} }
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.8, y: 0.8 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
}
} }
} }
} }
@ -138,6 +150,9 @@ export struct NavRow {
onTap?: () => void; onTap?: () => void;
build() { build() {
// 通用按压动画收进 MotionBase(父组件):scale 回弹统一。
// 选中态底色仍在这里管:它是 NavRow 特有外观,不归父组件。
MotionBase({ pressEnabled: true }) {
Row() { Row() {
if (this.icon !== undefined) { if (this.icon !== undefined) {
Row() { Row() {
@ -159,6 +174,8 @@ export struct NavRow {
Text(this.title) Text(this.title)
.fontSize(15) .fontSize(15)
.fontColor(this.selected ? this.palette().accent : this.palette().textPrimary) .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) .fontWeight(this.selected ? FontWeight.Medium : FontWeight.Normal)
.maxLines(1) .maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
@ -187,12 +204,17 @@ export struct NavRow {
.width(15) .width(15)
.height(15) .height(15)
.fillColor(this.selected ? this.palette().accent : this.palette().textMuted) .fillColor(this.selected ? this.palette().accent : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.draggable(false) .draggable(false)
} }
.width('100%') .width('100%')
.padding({ left: 14, right: 14, top: 12, bottom: 12 }) .padding({ left: 14, right: 14, top: 12, bottom: 12 })
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
// 选中态底色:写在 .border 之前,避免分割线宽度被纳入动画。
// MotionBase 的 .animation() 只作用在包装 Column 上,到不了这里,
// 选中高亮的底色迁移要本行自己声明。
.backgroundColor(this.selected ? this.palette().accentBg : Color.Transparent) .backgroundColor(this.selected ? this.palette().accentBg : Color.Transparent)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.border({ .border({
width: { bottom: this.showDivider ? 1 : 0 }, width: { bottom: this.showDivider ? 1 : 0 },
color: this.palette().kvBorder, color: this.palette().kvBorder,
@ -204,6 +226,7 @@ export struct NavRow {
} }
}) })
} }
}
private iconColor(): string { private iconColor(): string {
return this.tint.length > 0 ? this.tint : this.palette().accent; return this.tint.length > 0 ? this.tint : this.palette().accent;
@ -301,6 +324,9 @@ export struct SubPageBackButton {
onTap?: () => void; onTap?: () => void;
build() { build() {
// 玻璃底自带的按压变暗在 45% 透明度上几乎看不见,缩放反馈由 MotionBase 统一提供。
// fillWidth: false —— 悬浮区按内容宽度排列,不能撑满整行。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button() { Button() {
Row({ space: 4 }) { Row({ space: 4 }) {
Image($r('app.media.ic_arrow_back')) Image($r('app.media.ic_arrow_back'))
@ -326,6 +352,7 @@ export struct SubPageBackButton {
} }
}) })
} }
}
private palette(): ThemePalette { private palette(): ThemePalette {
return this.isDark ? DARK_PALETTE : LIGHT_PALETTE; return this.isDark ? DARK_PALETTE : LIGHT_PALETTE;

View File

@ -9,6 +9,8 @@ import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE } from '../common/Constants';
import { RADIUS_MD, RADIUS_PILL, RADIUS_SM } 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 { WIDE_NAV_BAR_WIDTH, WIDE_MIN_CONTENT } from '../common/Constants';
import { CHAT_PAGE_SIZE } 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 { MarkdownView } from '../components/MarkdownView';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar'; import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage'; import { SubPageLayer, markSubPageOpen, subPageParam } from '../components/SubPage';
@ -746,7 +748,9 @@ export struct ChatPage {
/** 从图库挑一张图 */ /** 从图库挑一张图 */
private async pickImage(): Promise<void> { private async pickImage(): Promise<void> {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.attachMenuOpen = false; this.attachMenuOpen = false;
});
try { try {
const options = new picker.PhotoSelectOptions(); const options = new picker.PhotoSelectOptions();
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE;
@ -765,7 +769,9 @@ export struct ChatPage {
/** 从文件管理器挑一个文件 */ /** 从文件管理器挑一个文件 */
private async pickFile(): Promise<void> { private async pickFile(): Promise<void> {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.attachMenuOpen = false; this.attachMenuOpen = false;
});
try { try {
const options = new picker.DocumentSelectOptions(); const options = new picker.DocumentSelectOptions();
options.maxSelectNumber = 1; options.maxSelectNumber = 1;
@ -993,8 +999,12 @@ export struct ChatPage {
} }
private toggleReasoning(msg: ChatMessage): void { private toggleReasoning(msg: ChatMessage): void {
// 在 animateTo 里翻转:展开/收起时 chevron 走已有 .animation,
// 面板节点在 animateTo 帧内获得默认过渡,不会再硬切。
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
msg.reasoningOpen = !(msg.reasoningOpen === true); msg.reasoningOpen = !(msg.reasoningOpen === true);
this.forceRefresh(); this.forceRefresh();
});
} }
/** /**
@ -1168,7 +1178,9 @@ export struct ChatPage {
.width('100%') .width('100%')
.justifyContent(FlexAlign.Start) .justifyContent(FlexAlign.Start)
.padding({ left: 52, top: 6, bottom: 6 }) .padding({ left: 52, top: 6, bottom: 6 })
.opacity(1) // if 控制的节点无法用 .animation() 做进出场(那只驱动自身属性的增量更新);
// 用 transition 才能让"处理中"这条在出现和消失时都淡入淡出。
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} }
} }
.width('100%') .width('100%')
@ -1229,7 +1241,11 @@ export struct ChatPage {
FloatIconButton({ FloatIconButton({
icon: $r('app.media.ic_plus'), icon: $r('app.media.ic_plus'),
onTap: () => { onTap: () => {
// 菜单展开会同时改变 listBottomPad,用 animateTo 把
// 列表内边距和菜单进出场拉到同一个时钟上。
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.attachMenuOpen = !this.attachMenuOpen; this.attachMenuOpen = !this.attachMenuOpen;
});
}, },
}) })
Blank() Blank()
@ -1244,6 +1260,8 @@ export struct ChatPage {
.height(44) .height(44)
.type(ButtonType.Circle) .type(ButtonType.Circle)
.backgroundColor('#77809A') .backgroundColor('#77809A')
// 发送/中断切换是 if 分支整体替换,用 transition 淡入淡出
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.scale({ x: 0.9, y: 0.9 })).animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
.onClick(() => { .onClick(() => {
this.interruptChat(); this.interruptChat();
}) })
@ -1259,6 +1277,7 @@ export struct ChatPage {
.type(ButtonType.Circle) .type(ButtonType.Circle)
.backgroundColor(this.palette().accent) .backgroundColor(this.palette().accent)
.enabled(this.inputText.trim().length > 0 || this.pendingPath.length > 0) .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(() => { .onClick(() => {
this.sendChat(); this.sendChat();
}) })
@ -1353,10 +1372,16 @@ export struct ChatPage {
.justifyContent(FlexAlign.Start) .justifyContent(FlexAlign.Start)
.margin({ bottom: 8 }) .margin({ bottom: 8 })
.hitTestBehavior(HitTestMode.Transparent) .hitTestBehavior(HitTestMode.Transparent)
// 加号菜单由 if 控制,进出场只能靠 transition;配合下面 toggle 处的
// animateTo,展开时两枚胶囊从加号上方浮起而不是硬闪出来。
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
} }
@Builder @Builder
AttachOption(icon: Resource, label: string, tap: () => void) { AttachOption(icon: Resource, label: string, tap: () => void) {
// 按压反馈交给 MotionBase:每个实例自带独立按压态,
// 修掉了原先两枚胶囊共用一个 @State、按一枚两枚同时缩放的问题。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Row({ space: 6 }) { Row({ space: 6 }) {
Image(icon) Image(icon)
.width(15) .width(15)
@ -1374,10 +1399,13 @@ export struct ChatPage {
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 }) .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
.onClick(tap) .onClick(tap)
} }
}
/** 待发送附件预览条:缩略信息 + 一个移除按钮 */ /** 待发送附件预览条:缩略信息 + 一个移除按钮 */
@Builder @Builder
PendingAttachmentChip() { PendingAttachmentChip() {
// 按压反馈交给 MotionBase(全宽预览条)
MotionBase({ pressEnabled: true }) {
Row({ space: 8 }) { Row({ space: 8 }) {
Image(this.pendingIsImage ? $r('app.media.ic_image') : $r('app.media.ic_file')) Image(this.pendingIsImage ? $r('app.media.ic_image') : $r('app.media.ic_file'))
.width(15) .width(15)
@ -1421,6 +1449,7 @@ export struct ChatPage {
.border({ width: 1, color: this.palette().navBarBorder }) .border({ width: 1, color: this.palette().navBarBorder })
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 }) .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
} }
}
@Builder @Builder
Avatar(role: string) { Text(role === 'user' ? '我' : 'AI') Avatar(role: string) { Text(role === 'user' ? '我' : 'AI')
@ -1487,6 +1516,8 @@ export struct ChatPage {
.maxLines(24) .maxLines(24)
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
.wordBreak(WordBreak.BREAK_ALL) .wordBreak(WordBreak.BREAK_ALL)
// 面板内容靠 if 挂载:用 transition 在展开/收起时淡入淡出
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: -6 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
} }
// 流式思考中:扫光动画条(对齐 WebGUI reasoningSweep) // 流式思考中:扫光动画条(对齐 WebGUI reasoningSweep)
@ -1614,6 +1645,7 @@ export struct ChatPage {
}) })
if (tc.open === true) { if (tc.open === true) {
Column() {
if (tc.args.length > 0 && tc.args !== '{}') { if (tc.args.length > 0 && tc.args !== '{}') {
Text('参数') Text('参数')
.fontSize(9.5).fontWeight(FontWeight.Medium) .fontSize(9.5).fontWeight(FontWeight.Medium)
@ -1647,6 +1679,11 @@ export struct ChatPage {
.textOverflow({ overflow: TextOverflow.Ellipsis }) .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) .layoutWeight(1)
.alignItems(HorizontalAlign.Start) .alignItems(HorizontalAlign.Start)
@ -1710,8 +1747,10 @@ export struct ChatPage {
/** 工具卡/思考卡在气泡内是独占一行的块,气泡 78% 宽度对它们太窄 —— 见 BubbleBody */ /** 工具卡/思考卡在气泡内是独占一行的块,气泡 78% 宽度对它们太窄 —— 见 BubbleBody */
private toggleToolCard(tc: ToolCallInfo): void { private toggleToolCard(tc: ToolCallInfo): void {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
tc.open = !(tc.open === true); tc.open = !(tc.open === true);
this.forceRefresh(); this.forceRefresh();
});
} }
/** /**

View File

@ -6,7 +6,8 @@ import { noConnectionMessage } from '../common/UserError';
import { handleNavOnScroll } from '../common/NavBarController'; import { handleNavOnScroll } from '../common/NavBarController';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry'; import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { DeviceInfo } from '../model/Model'; 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 { common } from '@kit.AbilityKit';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar'; import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage'; import { SubPageLayer, NavGroup, NavRow, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
@ -79,7 +80,9 @@ export struct DevicePage {
// ignore context errors // ignore context errors
} }
deviceBridge.setStateListener((open: boolean) => { deviceBridge.setStateListener((open: boolean) => {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.bridgeConnected = open; this.bridgeConnected = open;
});
if (open) { if (open) {
this.lastError = ''; this.lastError = '';
this.showToast('设备网关已连接', false); this.showToast('设备网关已连接', false);
@ -140,7 +143,9 @@ export struct DevicePage {
* 所以用 replace 换页而不是叠栈 —— 否则返回手势要一层层退回去。 * 所以用 replace 换页而不是叠栈 —— 否则返回手势要一层层退回去。
*/ */
private openSub(id: string): void { private openSub(id: string): void {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.activeSub = id; this.activeSub = id;
});
if (this.isWide && this.navStack.size() > 0) { if (this.isWide && this.navStack.size() > 0) {
this.navStack.replacePathByName(id, subPageParam(id), false); this.navStack.replacePathByName(id, subPageParam(id), false);
} else { } else {
@ -151,7 +156,9 @@ export struct DevicePage {
/** 二级页面返回键:宽屏下左栏常驻可见,SubPageLayer 已隐藏返回键 */ /** 二级页面返回键:宽屏下左栏常驻可见,SubPageLayer 已隐藏返回键 */
private closeSub(): void { private closeSub(): void {
this.navStack.pop(); this.navStack.pop();
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.activeSub = SUB_NONE; this.activeSub = SUB_NONE;
});
} }
/** Toggle local authorization flag; hello 同步到网关。 */ /** Toggle local authorization flag; hello 同步到网关。 */
@ -203,10 +210,15 @@ export struct DevicePage {
} }
private showToast(msg: string, isError: boolean): void { private showToast(msg: string, isError: boolean): void {
this.toastMsg = msg; // 颜色标记必须在动画闭包外先落定,否则第一帧用的还是上一条 toast 的配色
this.toastIsError = isError; this.toastIsError = isError;
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = msg;
});
setTimeout(() => { setTimeout(() => {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = ''; this.toastMsg = '';
});
}, 2500); }, 2500);
} }
@ -244,7 +256,9 @@ export struct DevicePage {
list.push(info); list.push(info);
} }
} }
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.devices = list; this.devices = list;
});
} else { } else {
this.devices = []; this.devices = [];
} }
@ -327,7 +341,9 @@ export struct DevicePage {
} }
} else { } else {
this.navStack.clear(false); this.navStack.clear(false);
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.activeSub = SUB_NONE; this.activeSub = SUB_NONE;
});
markSubPageOpen(false); markSubPageOpen(false);
} }
}) })
@ -403,6 +419,9 @@ export struct DevicePage {
.justifyContent(FlexAlign.End) .justifyContent(FlexAlign.End)
.padding({ right: 20 }) .padding({ right: 20 })
.margin({ bottom: 166 }) .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() { Row() {
Circle({ width: 8, height: 8 }) Circle({ width: 8, height: 8 })
.fill(this.authorized ? '#17A964' : '#E84026') .fill(this.authorized ? '#17A964' : '#E84026')
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.margin({ right: 8 }) .margin({ right: 8 })
Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令') Text(this.authorized ? '已授权 — agent 可远程调用能力' : '未授权 — agent 将拒绝远程命令')
.fontSize(12) .fontSize(12)
.fontColor(this.authorized ? '#17A964' : this.palette().textMuted) .fontColor(this.authorized ? '#17A964' : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
} }
.width('100%') .width('100%')
.margin({ top: 12 }) .margin({ top: 12 })
@ -551,15 +572,22 @@ export struct DevicePage {
.fontSize(12) .fontSize(12)
.fontColor('#E84026') .fontColor('#E84026')
.padding({ left: 4 }) .padding({ left: 4 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: -8 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
} }
PlainCard({ caption: '操作' }) { PlainCard({ caption: '操作' }) {
Row() { Row() {
// 缩放反馈由 MotionBase 统一;连接态的底色/字色切换仍需按钮自己缓动,
// 因为父容器的 .animation() 到不了子节点。
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button(this.bridgeConnected ? '断开' : '连接网关') Button(this.bridgeConnected ? '断开' : '连接网关')
.height(34) .height(34)
.fontSize(12) .fontSize(12)
.backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent) .backgroundColor(this.bridgeConnected ? Color.Transparent : this.palette().accent)
.fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White) .fontColor(this.bridgeConnected ? this.palette().textSecondary : Color.White)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.border({ .border({
width: this.bridgeConnected ? 1 : 0, width: this.bridgeConnected ? 1 : 0,
color: this.palette().btnGhostBorder, color: this.palette().btnGhostBorder,
@ -571,7 +599,9 @@ export struct DevicePage {
this.connectBridge(); this.connectBridge();
} }
}) })
}
Blank() Blank()
MotionBase({ pressEnabled: true, fillWidth: false }) {
Button('刷新设备') Button('刷新设备')
.height(34) .height(34)
.fontSize(12) .fontSize(12)
@ -582,6 +612,7 @@ export struct DevicePage {
this.refreshDevices(); this.refreshDevices();
}) })
} }
}
.width('100%') .width('100%')
Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态,bind 携带 Token 完成身份绑定。') Text('进入本页自动连接;断开后每 5 秒自动重连。hello 登记能力与授权状态,bind 携带 Token 完成身份绑定。')
@ -600,9 +631,13 @@ export struct DevicePage {
.fontSize(12) .fontSize(12)
.fontColor(this.palette().textMuted) .fontColor(this.palette().textMuted)
.padding({ left: 4 }) .padding({ left: 4 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} }
ForEach(this.devices, (dev: DeviceInfo) => { ForEach(this.devices, (dev: DeviceInfo) => {
// PlainCard 是自定义组件,transition 不能直接挂在它上面(会生成 __Common__ 包装),
// 所以用一个无 padding、满宽的 Column 承载入场动画,布局不受影响。
Column() {
PlainCard({ caption: '' }) { PlainCard({ caption: '' }) {
Row() { Row() {
Circle({ width: 8, height: 8 }) Circle({ width: 8, height: 8 })
@ -625,6 +660,11 @@ export struct DevicePage {
} }
.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()) }, (dev: DeviceInfo) => dev.deviceId + dev.online.toString())
} }

View File

@ -8,6 +8,8 @@ import { navBar } from '../common/NavBarController';
import { handleBackPress } from '../common/NavStackRegistry'; import { handleBackPress } from '../common/NavStackRegistry';
import { ConnectionConfig } from '../model/Model'; import { ConnectionConfig } from '../model/Model';
import { ThemePalette, DARK_PALETTE, LIGHT_PALETTE, WIDE_MIN_WIDTH, WIDE_NAV_BAR_WIDTH } from '../common/Constants'; 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 { GradientBackground } from '../components/GradientBackground';
import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter'; import { registerScreensueHandler, installCmdRouter } from '../common/BridgeRouter';
import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps'; import { ScreensuePayload, snapshotComponentId } from '../common/BridgeCaps';
@ -33,24 +35,32 @@ export struct RailTab {
@Prop muted: string = ''; @Prop muted: string = '';
build() { build() {
// 底部导航按压反馈:交给 MotionBase 统一(flexWeight 由外层调用点的
// .layoutWeight(1) 负责,这里内部只需撑满自己那一格)。
MotionBase({ pressEnabled: true }) {
Column({ space: 3 }) { Column({ space: 3 }) {
Image(this.icon) Image(this.icon)
.width(22) .width(22)
.height(22) .height(22)
.fillColor(this.active ? this.accent : this.muted) .fillColor(this.active ? this.accent : this.muted)
// 选中态的颜色迁移必须写在子节点上:父容器的 .animation()
// 只驱动它自己的属性,不会下传给 Image / Text。
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.draggable(false) .draggable(false)
Text(this.label) Text(this.label)
.fontSize(10) .fontSize(10)
// fontWeight 不可插值,切换时仍是跳变,只对颜色做过渡
.fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal) .fontWeight(this.active ? FontWeight.Medium : FontWeight.Normal)
.fontColor(this.active ? this.accent : this.muted) .fontColor(this.active ? this.accent : this.muted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.maxLines(1) .maxLines(1)
} }
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center) .alignItems(HorizontalAlign.Center)
.width('100%') .width('100%')
.height(48) .height(48)
.animation({ duration: 200, curve: Curve.EaseOut }) }
} }
} }
@ -325,7 +335,7 @@ struct Index {
// 所有页面滑动时隐藏导航栏 // 所有页面滑动时隐藏导航栏
.translate({ y: !this.navVisible ? 130 : 0 }) .translate({ y: !this.navVisible ? 130 : 0 })
.opacity(!this.navVisible ? 0 : 1) .opacity(!this.navVisible ? 0 : 1)
.animation({ duration: 400, curve: Curve.EaseOut }) .animation({ duration: ANIM_SLOW, curve: Curve.EaseOut })
// 导航栏本身不吃触摸空白区,避免遮住下层内容点击 // 导航栏本身不吃触摸空白区,避免遮住下层内容点击
.hitTestBehavior(HitTestMode.Transparent) .hitTestBehavior(HitTestMode.Transparent)

View File

@ -5,6 +5,8 @@ import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry
import { PluginRow, PluginDetail, emptyPluginDetail } from '../model/Model'; import { PluginRow, PluginDetail, emptyPluginDetail } 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 } from '../common/Constants';
import { RADIUS_LG, RADIUS_MD, RADIUS_SM, RADIUS_PILL, COLOR_ERROR } 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 { PageTopBar, NavFloatOverlay, NavFloatRow, GlassShell, FloatIconButton } from '../components/PageTopBar';
import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage'; import { SubPageLayer, PlainCard, markSubPageOpen, subPageParam } from '../components/SubPage';
import { SettingsEditor } from '../components/SettingsEditor'; import { SettingsEditor } from '../components/SettingsEditor';
@ -184,11 +186,17 @@ export struct PluginsPage {
}; };
rows.push(row); rows.push(row);
} }
// 列表整体重建(ForEach key 含 loaded/disabled,启停会整行重挂载):
// 放进 animateTo 让新旧行走 transition 交叉淡入,而不是硬切一帧。
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.plugins = rows; this.plugins = rows;
});
} catch (e) { } catch (e) {
this.showToast(userMessage('plugins.load', e), true); this.showToast(userMessage('plugins.load', e), true);
} }
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.loading = false; this.loading = false;
});
} }
/** 徽标状态:与 GUI 一致 —— 已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。 */ /** 徽标状态:与 GUI 一致 —— 已加载绿 / 禁用待生效黄 / 已禁用红 / 未加载灰。 */
@ -239,7 +247,9 @@ export struct PluginsPage {
await apiClient.post('/plugins', bodyObj); await apiClient.post('/plugins', bodyObj);
this.showToast('安装请求已发送', false); this.showToast('安装请求已发送', false);
this.installUrl = ''; this.installUrl = '';
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.showInstallForm = false; this.showInstallForm = false;
});
this.loadPlugins(); this.loadPlugins();
} catch (e) { } catch (e) {
this.showToast(userMessage('plugins.install', e), true); this.showToast(userMessage('plugins.install', e), true);
@ -293,7 +303,10 @@ export struct PluginsPage {
d.deprecated = o['deprecated'] as boolean ?? false; d.deprecated = o['deprecated'] as boolean ?? false;
d.tags = this.strArray(o['tags']); d.tags = this.strArray(o['tags']);
d.files = this.strArray(o['files']); d.files = this.strArray(o['files']);
// 详情字段一次性落地:条件卡片在 animateTo 帧内挂载,V1 给它们默认透明度过渡
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.detail = d; this.detail = d;
});
} catch (e) { } catch (e) {
// 内置插件没有清单,属于预期情况,不当成错误刷红 // 内置插件没有清单,属于预期情况,不当成错误刷红
const row: PluginRow | null = this.findRow(name); const row: PluginRow | null = this.findRow(name);
@ -303,12 +316,15 @@ export struct PluginsPage {
d.version = row.version ?? ''; d.version = row.version ?? '';
d.description = row.description ?? ''; d.description = row.description ?? '';
} }
const errText: string = (row !== null && row.external) ? userMessage('plugins.detail', e) : '';
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.detail = d; this.detail = d;
if (row !== null && row.external) { this.detailError = errText;
this.detailError = userMessage('plugins.detail', e); });
}
} }
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.detailLoading = false; this.detailLoading = false;
});
} }
private strArray(raw: Object | undefined): string[] { private strArray(raw: Object | undefined): string[] {
@ -351,10 +367,15 @@ export struct PluginsPage {
} }
private showToast(msg: string, isError: boolean): void { private showToast(msg: string, isError: boolean): void {
this.toastMsg = msg; // 颜色标志必须在 animateTo 闭包之外先落地,否则第一帧配色是上一次的
this.toastIsError = isError; this.toastIsError = isError;
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = msg;
});
setTimeout(() => { setTimeout(() => {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = ''; this.toastMsg = '';
});
}, 2500); }, 2500);
} }
@ -404,6 +425,8 @@ export struct PluginsPage {
.width(20) .width(20)
.height(20) .height(20)
.fillColor(Color.White) .fillColor(Color.White)
.rotate({ angle: this.showInstallForm ? 45 : 0 })
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
} }
.width(50) .width(50)
.height(50) .height(50)
@ -411,7 +434,9 @@ export struct PluginsPage {
.backgroundColor(this.palette().accent) .backgroundColor(this.palette().accent)
.shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 }) .shadow({ radius: 24, color: this.palette().shadow, offsetY: 8 })
.onClick(() => { .onClick(() => {
this.getUIContext().animateTo({ duration: ANIM_ENTER, curve: Curve.EaseOut }, () => {
this.showInstallForm = !this.showInstallForm; this.showInstallForm = !this.showInstallForm;
});
}) })
} }
} }
@ -510,6 +535,9 @@ export struct PluginsPage {
.border({ width: 1, color: this.palette().navBarBorder }) .border({ width: 1, color: this.palette().navBarBorder })
.shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 }) .shadow({ radius: 20, color: this.palette().shadow, offsetY: 6 })
.alignItems(VerticalAlign.Center) .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) .height(32)
.color(this.palette().accent) .color(this.palette().accent)
.margin({ top: 40 }) .margin({ top: 40 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} }
if (!apiClient.hasConnection()) { if (!apiClient.hasConnection()) {
@ -528,6 +557,7 @@ export struct PluginsPage {
.fontSize(13) .fontSize(13)
.fontColor(this.palette().textMuted) .fontColor(this.palette().textMuted)
.padding(20) .padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} }
if (!this.loading && this.plugins.length === 0 && apiClient.hasConnection()) { if (!this.loading && this.plugins.length === 0 && apiClient.hasConnection()) {
@ -535,6 +565,7 @@ export struct PluginsPage {
.fontSize(13) .fontSize(13)
.fontColor(this.palette().textMuted) .fontColor(this.palette().textMuted)
.padding(20) .padding(20)
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} }
} }
@ -545,6 +576,11 @@ export struct PluginsPage {
@Builder @Builder
PluginList() { PluginList() {
ForEach(this.plugins, (plugin: PluginRow) => { ForEach(this.plugins, (plugin: PluginRow) => {
// 外层 Column 只为承载 transition:.transition() 不能直接挂在自定义组件
// 调用点上(会生成 __Common__ 包装节点)。按压缩放由 MotionBase 统一提供,
// 每行自带独立按压态,不再需要 pressedName 这种"哪一行被按"的手工记账。
Column() {
MotionBase({ pressEnabled: true }) {
Row() { Row() {
Column({ space: 3 }) { Column({ space: 3 }) {
Row({ space: 6 }) { Row({ space: 6 }) {
@ -555,6 +591,7 @@ export struct PluginsPage {
? this.palette().accent : this.palette().textPrimary) ? this.palette().accent : this.palette().textPrimary)
.maxLines(1) .maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis }) .textOverflow({ overflow: TextOverflow.Ellipsis })
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
if (plugin.version !== undefined && plugin.version.length > 0) { if (plugin.version !== undefined && plugin.version.length > 0) {
Text('v' + plugin.version) Text('v' + plugin.version)
.fontSize(10) .fontSize(10)
@ -584,6 +621,7 @@ export struct PluginsPage {
.height(15) .height(15)
.fillColor(this.activeName === plugin.name .fillColor(this.activeName === plugin.name
? this.palette().accent : this.palette().textMuted) ? this.palette().accent : this.palette().textMuted)
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.draggable(false) .draggable(false)
} }
.width('100%') .width('100%')
@ -596,11 +634,20 @@ export struct PluginsPage {
color: this.activeName === plugin.name color: this.activeName === plugin.name
? this.palette().accent : this.palette().glassBorder, ? this.palette().accent : this.palette().glassBorder,
}) })
.margin({ bottom: 10 }) // 选中态的底色/描边渐变:MotionBase 的 .animation() 到不了这里
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.alignItems(VerticalAlign.Center) .alignItems(VerticalAlign.Center)
.onClick(() => { .onClick(() => {
this.openDetail(plugin.name); this.openDetail(plugin.name);
}) })
}
}
.width('100%')
.margin({ bottom: 10 })
// 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' : '')) }, (plugin: PluginRow) => plugin.name + (plugin.loaded ? 'L' : '') + (plugin.disabled ? 'D' : ''))
} }
@ -621,6 +668,7 @@ export struct PluginsPage {
.width('100%') .width('100%')
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.padding({ top: 30, bottom: 30 }) .padding({ top: 30, bottom: 30 })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} }
if (this.detailError.length > 0) { if (this.detailError.length > 0) {
@ -628,6 +676,7 @@ export struct PluginsPage {
.fontSize(12) .fontSize(12)
.fontColor(COLOR_ERROR) .fontColor(COLOR_ERROR)
.padding({ left: 4, bottom: 12 }) .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() .fontColor(this.activeIsDisabled()
? this.palette().textSecondary : '#D99A2B') ? this.palette().textSecondary : '#D99A2B')
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.onClick(() => { .onClick(() => {
const r: PluginRow | null = this.activeRow(); const r: PluginRow | null = this.activeRow();
if (r !== null) { if (r !== null) {
@ -806,6 +856,7 @@ export struct PluginsPage {
this.removePlugin(r); this.removePlugin(r);
} }
}) })
.transition(TransitionEffect.OPACITY.animation({ duration: ANIM_FAST, curve: Curve.EaseOut }))
} }
} }
.width('100%') .width('100%')
@ -892,6 +943,9 @@ export struct PluginsPage {
.justifyContent(FlexAlign.End) .justifyContent(FlexAlign.End)
.padding({ right: 20 }) .padding({ right: 20 })
.margin({ bottom: 166 }) .margin({ bottom: 166 })
.transition(TransitionEffect.OPACITY
.combine(TransitionEffect.translate({ y: 12 }))
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
} }
} }

View File

@ -5,6 +5,8 @@ import { handleNavOnScroll } from '../common/NavBarController';
import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry'; import { registerNavStack, unregisterNavStack } from '../common/NavStackRegistry';
import { ConnectionConfig, AppSettings, emptySettings } from '../model/Model'; 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 { 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 { picker, fileIo } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit'; import { common } from '@kit.AbilityKit';
import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar'; import { PageTopBar, NavFloatOverlay, NavFloatRow, FloatIconButton } from '../components/PageTopBar';
@ -572,10 +574,15 @@ export struct SettingsPage {
} }
private showToast(msg: string, isError: boolean): void { private showToast(msg: string, isError: boolean): void {
this.toastMsg = msg; // 颜色标记必须在 animateTo 之外先定好,否则第一帧会用上一条 toast 的配色
this.toastIsError = isError; this.toastIsError = isError;
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = msg;
});
setTimeout(() => { setTimeout(() => {
this.getUIContext().animateTo({ duration: ANIM_NORMAL, curve: Curve.EaseOut }, () => {
this.toastMsg = ''; this.toastMsg = '';
});
}, 2500); }, 2500);
} }
@ -748,6 +755,8 @@ export struct SettingsPage {
.justifyContent(FlexAlign.End) .justifyContent(FlexAlign.End)
.padding({ right: 20 }) .padding({ right: 20 })
.margin({ bottom: 166 }) .margin({ bottom: 166 })
// if 控制的节点靠 transition 做进出场,配合 showToast 里的 animateTo
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.translate({ y: 12 })).animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut }))
} }
} }
@ -1139,24 +1148,32 @@ export struct SettingsPage {
@Builder @Builder
ThemeOption(label: string, mode: string) { ThemeOption(label: string, mode: string) {
// 按压缩放由 MotionBase 统一;flexWeight: 1 让三枚选项在 Row 里继续等分。
MotionBase({ pressEnabled: true, flexWeight: 1 }) {
Column() { Column() {
Text(label) Text(label)
.fontSize(12) .fontSize(12)
.fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary) .fontColor(this.themeMode === mode ? Color.White : this.palette().textSecondary)
// 子节点的颜色迁移要自己声明:父容器的 .animation() 不下传
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
} }
.justifyContent(FlexAlign.Center) .justifyContent(FlexAlign.Center)
.layoutWeight(1) .width('100%')
.height(34) .height(34)
.borderRadius(RADIUS_MD) .borderRadius(RADIUS_MD)
.backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover) .backgroundColor(this.themeMode === mode ? this.palette().accent : this.palette().bgHover)
// 三选一的选中态迁移:底色与描边一起过渡。写在 .border 之后、
// 覆盖它上面的所有状态驱动属性。
.border({ .border({
width: 1, width: 1,
color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder, color: this.themeMode === mode ? this.palette().accent : this.palette().btnGhostBorder,
}) })
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
.onClick(() => { .onClick(() => {
this.applyTheme(mode); this.applyTheme(mode);
}) })
} }
}
@Builder @Builder
SettingCard(entry: SettingEntry) { SettingCard(entry: SettingEntry) {
@ -1200,6 +1217,7 @@ export struct SettingsPage {
Text(entry.value === 'true' ? 'true' : 'false') Text(entry.value === 'true' ? 'true' : 'false')
.fontSize(12) .fontSize(12)
.fontColor(entry.value === 'true' ? '#17A964' : this.palette().textMuted) .fontColor(entry.value === 'true' ? '#17A964' : this.palette().textMuted)
.animation({ duration: ANIM_NORMAL, curve: Curve.EaseOut })
Blank() Blank()
Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' }) Toggle({ type: ToggleType.Switch, isOn: entry.value === 'true' })
.selectedColor(this.palette().accent) .selectedColor(this.palette().accent)
@ -1223,6 +1241,8 @@ export struct SettingsPage {
.margin({ right: 6, bottom: 6 }) .margin({ right: 6, bottom: 6 })
.backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover) .backgroundColor(entry.value === opt ? this.palette().accent : this.palette().bgHover)
.fontColor(entry.value === opt ? Color.White : this.palette().textSecondary) .fontColor(entry.value === opt ? Color.White : this.palette().textSecondary)
// 选中项迁移:底色与字色一起过渡,避免整排选项同时硬切
.animation({ duration: ANIM_FAST, curve: Curve.EaseOut })
.onClick(() => { .onClick(() => {
this.saveSetting(entry, opt); this.saveSetting(entry, opt);
}) })

View File

@ -24,9 +24,11 @@
"name": "EntryAbility", "name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets", "srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "HomeAgent main entry", "description": "HomeAgent main entry",
"icon": "$media:app_icon", "icon": "$media:layered_image",
"label": "$string:app_name", "label": "$string:app_name",
"startWindowIcon": "$media:app_icon", // 启动页图标用透明底字形,配合随主题切换的 start_window_background,
// 浅色/深色模式下遮罩与图标都能对上,不再恒为深色样式。
"startWindowIcon": "$media:start_icon",
"startWindowBackground": "$color:start_window_background", "startWindowBackground": "$color:start_window_background",
"exported": true, "exported": true,
"skills": [ "skills": [

View File

@ -2,7 +2,7 @@
"color": [ "color": [
{ {
"name": "start_window_background", "name": "start_window_background",
"value": "#0B1020" "value": "#F1F3F5"
} }
] ]
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -0,0 +1,8 @@
{
"color": [
{
"name": "start_window_background",
"value": "#000000"
}
]
}