组件就该长在手上?ArkTS 自定义组件和动画,凭什么不能又稳又灵?
你是不是也在想——“鸿蒙这么火,我能不能学会?”答案是:当然可以!这个专栏专为零基础小白设计,不需要编程基础,也不需要懂原理、背术语。我们会用最通俗易懂的语言、最贴近生活的案例,手把手带你从安装开发工具开始,一步步学会开发自己的鸿蒙应用。不管你是学生、上班族、打算转行,还是单纯对技术感兴趣,只要你愿意花一点时间,就能在这里搞懂鸿蒙开发,并做出属于自己的App!📌 关注本专栏《零基础学鸿蒙开发》,
你是不是也在想——“鸿蒙这么火,我能不能学会?”
答案是:当然可以!
这个专栏专为零基础小白设计,不需要编程基础,也不需要懂原理、背术语。我们会用最通俗易懂的语言、最贴近生活的案例,手把手带你从安装开发工具开始,一步步学会开发自己的鸿蒙应用。
不管你是学生、上班族、打算转行,还是单纯对技术感兴趣,只要你愿意花一点时间,就能在这里搞懂鸿蒙开发,并做出属于自己的App!
📌 关注本专栏《零基础学鸿蒙开发》,一起变强!
每一节内容我都会持续更新,配图+代码+解释全都有,欢迎点个关注,不走丢,我是小白酷爱学习,我们一起上路 🚀
全文目录:
前言
说句掏心窝子的:写 UI 最怕啥?不是 API 难记,而是“做着做着就像在贴牛皮癣”——复用不顺手,动画一慢就掉帧,交互还总差点意思。ArkTS(HarmonyOS 的 TypeScript 增强方言)这套声明式 UI,其实已经把“状态—视图同步”这条大动脉铺好了。剩下的,是组件抽象和动画节奏:怎么把复用件做得“拿来即用”,又不至于“慢吞吞”?这篇我不端着——上来就是能跑能改的自定义组件与动画方案,一路从原理讲到工程落地,再把那些“堪比苟且”的优化招式摊开讲个痛快。👨💻🎯
目录
- 为什么要在 ArkTS 里自己“抡起组件大锤”
- 组件心智模型:状态、属性、订阅、上下文四件套
- 从 0 到 1:一个“会呼吸”的 Button(自定义组件)
- 进阶:复合组件、插槽与样式扩展(Builder/子组件通信)
- 动画体系全景:隐式、显式、过渡、物理、关键帧
- 实战一:列表项“进场+滑动删除”的顺滑手感
- 实战二:可配置的 Spring 弹性卡片(交互驱动)
- 性能与稳定性:掉帧狙击、重组减压、无障碍与低端机策略
- 工程化与可测试性:可视化参数、Story-like 用法、回归点
- 收尾清单 + 常见翻车现场复盘
为什么要在 ArkTS 里自己“抡起组件大锤”
官方基础件再多,也兜不住业务千奇百怪的“定制味儿”。把“业务习惯 + 动画语义 + 交互细节”沉进自定义组件,你会得到三件宝物:
- 一致性:统一边距/圆角/动效节拍,设计系统化。
- 可维护:状态边界清晰,升级不用“人肉全局替换”。
- 可测性:参数可视化、动画脚本化,联调代价低。
组件心智模型:状态、属性、订阅、上下文四件套
ArkTS 的声明式范式,核心是“状态驱动 UI”。常用装饰器语义如下(口语化版):
@State:组件内部私有状态,变了就刷新自己。@Prop:从父组件传入的只读属性(单向数据流)。@Link:父子双向绑定(父传引用,子改父随之改,慎用)。@Provide/@Consume:组件树上下文注入(主题、语言、布局密钥等)。@Observed/@ObjectLink:对象级监听/引用,做细粒度刷新。
拿捏好边界:能
@Prop就别@Link;共享状态尽量用@Provide做只读下发,必要时配合回调上行。
从 0 到 1:一个“会呼吸”的 Button(自定义组件)
需求:按钮有三态(默认/按下/禁用),按下时略缩放并阴影加深;支持传入主题色与圆角;提供点击回调。
// ButtonBreath.ets
@Component
export struct ButtonBreath {
@Prop text: string = 'Push me';
@Prop primaryColor: Color = 0xFF4B5; // 你自己的主色
@Prop radius: number = 12;
@Prop disabled: boolean = false;
@State pressed: boolean = false; // 内部状态
@State hover: boolean = false;
// 对外事件(父组件以属性回调形式传入)
@Prop onTap?: () => void;
private get scale(): number {
if (this.disabled) return 1.0;
return this.pressed ? 0.96 : (this.hover ? 1.02 : 1.0);
}
private get bg(): Color {
return this.disabled ? '#C9CCD3' : this.primaryColor;
}
private get elevation(): number {
return this.pressed ? 8 : (this.hover ? 12 : 6);
}
build() {
Row() {
Text(this.text)
.fontSize(17)
.fontWeight(FontWeight.Medium)
.fontColor(Color.White)
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
}
.backgroundColor(this.bg)
.borderRadius(this.radius)
.shadow({ radius: this.elevation, color: '#33000000', offsetX: 0, offsetY: 4 })
.scale({ x: this.scale, y: this.scale }) // 绑定属性 -> 隐式动画的抓手
.opacity(this.disabled ? 0.6 : 1.0)
// 交互
.gesture(
TapGesture().onAction(() => {
if (this.disabled) return;
this.onTap && this.onTap();
})
)
.onHover((isHover) => {
if (this.disabled) return;
// 隐式动画:属性变化放进 animateTo
animateTo({ duration: 120, curve: Curve.EaseOut }, () => {
this.hover = isHover;
});
})
.onTouch((ev: TouchEvent) => {
if (this.disabled) return;
const isDown = ev.type === TouchType.Down || ev.type === TouchType.Move;
animateTo({ duration: 90, curve: Curve.Linear }, () => {
this.pressed = isDown;
});
})
}
}
要点速记:
scale/opacity/shadow等修饰器属性改动时,配合animateTo就能走隐式补间。- 交互里只改“状态”,UI 自会重绘;动效节拍在
animateTo的参数里约束。 - 把颜色、圆角、禁用态做成
@Prop,这就是“复用的诚意”。
进阶:复合组件、插槽与样式扩展(Builder/子组件通信)
场景:做一个可插槽的卡片,有头有脚,中间插任意内容;卡片支持“折叠/展开”动画。
// FancyCard.ets
@Styles function CardContainer(isExpanded: boolean, radius: number) {
.borderRadius(radius)
.backgroundColor('#FFFFFF')
.shadow({ radius: isExpanded ? 16 : 6, color: '#22000000', offsetX: 0, offsetY: isExpanded ? 8 : 4 })
.padding(12)
}
@Builder function Header(title: string, onToggle: () => void, expanded: boolean) {
Row({ space: 8 }) {
Text(title).fontSize(18).fontWeight(FontWeight.Bold)
Blank()
Image(expanded ? $r('app.media.ic_arrow_up') : $r('app.media.ic_arrow_down'))
.width(20).height(20)
}
.onClick(() => onToggle())
}
@Component
export struct FancyCard {
@Prop title: string;
@State expanded: boolean = true;
@Prop radius: number = 14;
// 插槽:让使用者传中间的“任意内容”
@BuilderParam contentBuilder: () => void;
build() {
Column() {
this.Header(this.title, () => {
animateTo({ duration: 160, curve: Curve.EaseInOut }, () => {
this.expanded = !this.expanded;
});
}, this.expanded)
// 展开/折叠区域
if (this.expanded) {
Column() {
this.contentBuilder()
}
.opacity(1.0)
.height('auto')
.transition(TransitionEffect.OPACITY) // 过渡动画(显式定义效果)
} else {
// 占位避免布局跳动(也可以不占位)
Blank().height(0).opacity(0).transition(TransitionEffect.OPACITY)
}
}
.CardContainer(this.expanded, this.radius)
}
}
使用方式:
// ExamplePage.ets
@Entry
@Component
struct ExamplePage {
@State count: number = 0;
build() {
Column({ space: 12 }) {
FancyCard({ title: 'Dashboard', contentBuilder: () => {
Row({ space: 8 }) {
Text(`Counter: ${this.count}`).fontSize(16)
ButtonBreath({ text: 'Add', onTap: () => this.count++ })
}
}})
FancyCard({ title: 'Settings', contentBuilder: () => {
Text('Here goes your settings...')
}})
}
.padding(16)
.backgroundColor('#F5F6F8')
}
}
插槽(
@BuilderParam)= “我规定盒子,你决定放啥”。动画则通过状态切换 + 过渡效果控制“节奏感”。
动画体系全景:隐式、显式、过渡、物理、关键帧
- 隐式动画(animateTo):把属性变化包裹进
animateTo;适合小互动、短补间。 - 过渡动画(transition/TransitionEffect):元素显隐/插拔时定义效果,如
Slide/Opacity/Move/Scale。 - 显式控制(AnimationController):手动创建控制器,能暂停/继续/绑定手势进度。
- 物理动画(Spring):基于张力/阻尼的自然动效;更“手感化”。
- 关键帧:自定义复杂节奏,如
0%→60%→100%的不均匀曲线(视觉叙事)。
经验:70% 的场景用隐式 + 过渡就够了;涉及“拖拽跟随/播控进度”的,再上控制器或 Spring。
实战一:列表项“进场+滑动删除”的顺滑手感
目标:
- 列表项初次出现:自下而上 + 轻微淡入。
- 左滑露出“删除”按钮:内容跟随 + 回弹,释放到阈值外执行删除。
// SwipeItem.ets
@Component
export struct SwipeItem {
@Prop title: string;
@State offsetX: number = 0;
private maxReveal: number = 88; // 删除按钮宽度
private threshold: number = 44;
build() {
Stack() {
// 背面操作区
Row() {
Blank()
ButtonBreath({ text: 'Delete', primaryColor: '#E84545', onTap: () => {
// 真实删除由父组件接管,这里可发事件
}})
}
.padding(12)
// 正面内容
Row() {
Text(this.title).fontSize(16)
}
.backgroundColor('#FFFFFF')
.borderRadius(12)
.shadow({ radius: 8, color: '#1A000000', offsetX: 0, offsetY: 2 })
.translate({ x: -Math.min(Math.max(0, this.offsetX), this.maxReveal), y: 0 })
.gesture(
PanGesture({ direction: PanDirection.Horizontal }).onActionUpdate((ev) => {
const dx = -ev.offsetX; // 左滑为正
// 不要直接赋值大跳,做一点阻尼
const next = Math.min(Math.max(0, this.offsetX + dx * 0.9), this.maxReveal + 16);
this.offsetX = next;
}).onActionEnd(() => {
// 回弹策略
const snap = (this.offsetX > this.threshold) ? this.maxReveal : 0;
animateTo({ duration: 180, curve: Curve.EaseOut }, () => {
this.offsetX = snap;
});
})
)
// 初次装载进场
.transition(TransitionEffect.asymmetric(
TransitionEffect.OPACITY.combine(TransitionEffect.Move(0, 12)), // appear
TransitionEffect.OPACITY, // disappear
TransitionEffect.OPACITY // change
))
}
.height('auto')
}
}
实战二:可配置的 Spring 弹性卡片(交互驱动)
场景:卡片按下时缩放到 0.95,抬起用弹簧回弹到 1.0;参数可调(张力、阻尼),用于设计评审“调味”。
// SpringCard.ets
@Component
export struct SpringCard {
@Prop radius: number = 16;
@State scaleVal: number = 1.0;
// 可视化参数(设计/测试可调)
@Prop tension: number = 220; // 张力
@Prop friction: number = 22; // 阻尼
@BuilderParam content: () => void;
private springTo(val: number) {
// 简化示例:用自定义 spring 动画器(伪代码/示意)
// 实际可用动画控制器或框架内置 Spring,绑定 curve: Curve.Spring(xxx)
animateTo({ duration: 260, curve: Curve.Spring }, () => {
this.scaleVal = val;
})
}
build() {
Column() {
this.content()
}
.borderRadius(this.radius)
.backgroundColor('#FFFFFF')
.shadow({ radius: 12, color: '#1A000000', offsetX: 0, offsetY: 6 })
.scale({ x: this.scaleVal, y: this.scaleVal })
.onTouch((e) => {
if (e.type === TouchType.Down) {
animateTo({ duration: 90, curve: Curve.EaseOut }, () => {
this.scaleVal = 0.95;
});
} else if (e.type === TouchType.Up || e.type === TouchType.Cancel) {
this.springTo(1.0);
}
})
}
}
在页面中调参:
@Entry
@Component
struct SpringLab {
@State t: number = 220;
@State f: number = 22;
build() {
Column({ space: 12 }) {
Slider({ value: this.t, min: 100, max: 400 })
.onChange((v) => this.t = Math.round(v))
Text(`Tension: ${this.t}`)
Slider({ value: this.f, min: 10, max: 40 })
.onChange((v) => this.f = Math.round(v))
Text(`Friction: ${this.f}`)
SpringCard({ tension: this.t, friction: this.f, content: () => {
Column({ space: 8 }) {
Text('Press me').fontSize(18).fontWeight(FontWeight.Bold)
Text('Tune the spring above')
}.padding(20)
}})
}.padding(16)
}
}
小感慨:把参数“外露”为可视化控件,能极大缩短“设计 ⇄ 工程”对齐时间;评审会上直接调给大家看,谁还不服?😉
性能与稳定性:掉帧狙击、重组减压、无障碍与低端机策略
-
减少不必要重组:
- 把易变状态尽量放到叶子组件;父层传基本类型
@Prop,对象用@Observed精准监听。 - 避免在
build()里创建大对象或做重计算;把纯函数/样式抽到@Styles。
- 把易变状态尽量放到叶子组件;父层传基本类型
-
动画时间线:
- 高频动效(小交互)≤ 160ms,微动效 90–120ms;进场 180–240ms。
- 合并动画:多个属性同帧内改变,包一层
animateTo。
-
帧预算:
- 目标 60fps,每帧预算 ~16.67ms;复杂动效时减少阴影半径/模糊层数。
-
阴影与圆角:
- 大半径 + 高阴影很烧;滚动中可降级阴影为 0 或减小
radius,停止后再恢复(滚动监听)。
- 大半径 + 高阴影很烧;滚动中可降级阴影为 0 或减小
-
列表复用:
- 使用懒加载列表并避免在项中持有大图对象;图片使用占位 + 缓存策略。
-
无障碍(A11y):
- 动画应遵守“减少动态效果”系统设置;提供无动画模式(跳过
animateTo)。
- 动画应遵守“减少动态效果”系统设置;提供无动画模式(跳过
-
低端机兜底:
- 通过“设备等级”或帧率监测降级:阴影→描边、模糊→透明、弹簧→线性。
工程化与可测试性:可视化参数、Story-like 用法、回归点
- 组件 Demo 页:每个自定义组件附带一个“参数操控面板”,便于设计和测试联调。
- 契约测试:对关键交互导出快照(状态→样式映射),回归时比对差异。
- 动画回归:把关键动效的时长/曲线写入常量,变更走评审与 Changelog。
- 主题适配:颜色、圆角、阴影深度从主题 Token读取,保障全局一致。
常见翻车现场复盘(你不是一个人在战斗)
- 状态抖动:onHover + onTouch 同时改
@State,导致 scale 来回跳。
解法:统一入口,建立交互状态机:Idle / Hover / Pressed / Disabled,由状态机派生 scale。 - 动画拖泥带水:多个
animateTo套娃,曲线互相“打架”。
解法:同一交互周期合并到一次 animateTo;或用控制器统一时序。 - 列表卡顿:每项都带复杂阴影/模糊 + 不必要重组。
解法:滑动中降级阴影,避免在build()创建临时数组/大对象。 - 双向绑定过度:滥用
@Link导致父子状态耦合、难以排查。
解法:默认单向(@Prop+ 回调上行),确需同步再用@Link,且建立边界。
速查:动画套路清单
- 小交互:
animateTo({ duration: 90~140, curve: EaseOut }, () => change()) - 显隐:
.transition(TransitionEffect.OPACITY.combine(TransitionEffect.Move(0, 12))) - 拖拽跟随:
PanGesture改 translate/scale,松手animateTo吸附 - 关键帧叙事:拆成若干次
animateTo或使用自定义控制器进度 - 弹性回弹:
Curve.Spring或自定义 spring 参数(张力/阻尼)
结语:组件是“表达力”,动画是“情绪值”
写 ArkTS 自定义组件,说白了就是约束与自由的平衡:用 @Prop 承接外部世界,用 @State 管好自己的心跳;动画则是把“逻辑瞬间”变成“视觉语言”。当你的组件“会呼吸、懂分寸、可复用”,你会突然发现——产品质感是可以工程化地稳定产出的。
所以,下一次评审有人问:“这个过渡能不能再顺点?”你不妨笑着反问:“要不现在就调?张力 220 还是 260?” 😎
❤️ 如果本文帮到了你…
- 请点个赞,让我知道你还在坚持阅读技术长文!
- 请收藏本文,因为你以后一定还会用上!
- 如果你在学习过程中遇到bug,请留言,我帮你踩坑!
更多推荐


所有评论(0)