文章目录
-
- 每日一句正能量
- 导读
- [一、HarmonyOS 6.x 动画与手势现状:够用,但不够细腻](#一、HarmonyOS 6.x 动画与手势现状:够用,但不够细腻)
- [二、HarmonyOS 7.0 动画系统升级:从"配置"到"物理"](#二、HarmonyOS 7.0 动画系统升级:从"配置"到"物理")
-
- [2.1 物理弹簧动画(Physics-Based Spring)](#2.1 物理弹簧动画(Physics-Based Spring))
- [2.2 关键帧动画(Keyframe Animation)](#2.2 关键帧动画(Keyframe Animation))
- [2.3 布局过渡动画(Layout Transition)](#2.3 布局过渡动画(Layout Transition))
- [三、HarmonyOS 7.0 手势系统升级:从"单点识别"到"组合编排"](#三、HarmonyOS 7.0 手势系统升级:从"单点识别"到"组合编排")
-
- [3.1 手势识别器组合(Gesture Group)](#3.1 手势识别器组合(Gesture Group))
- [3.2 手势冲突仲裁](#3.2 手势冲突仲裁)
- 四、自定义转场动画:共享元素过渡
-
- [4.1 共享元素转场(Shared Element Transition)](#4.1 共享元素转场(Shared Element Transition))
- [4.2 自定义页面转场路径](#4.2 自定义页面转场路径)
- 五、实战项目:沉浸式图片浏览器
-
- [5.1 组件架构](#5.1 组件架构)
- [5.2 与页面转场结合](#5.2 与页面转场结合)
- 六、性能优化建议
-
- [6.1 动画性能 checklist](#6.1 动画性能 checklist)
- [6.2 7.0 可能的性能增强](#6.2 7.0 可能的性能增强)
- 七、结语

每日一句正能量
即使触不到满天星辰,也要奔跑在萤火之森。
接受"理想可能达不到"(触不到星辰),但不接受"什么都不做"。退而求其次不是妥协,而是换一种方式靠近光明。萤火虽小,也是自己的光。
导读
在指导学生开发鸿蒙应用时,我发现一个普遍现象------学生们能很快掌握
Column、Row、List等基础布局,但一旦涉及动画和手势交互,就陷入困境。6.x 的 ArkTS 已经提供了animateTo和基础手势识别,但在复杂场景下(如多指缩放、手势冲突仲裁、物理弹簧效果)仍显不足。HarmonyOS 7.0 的 ArkUI 框架在动画系统和手势引擎上做了显著增强。本文将深入讲解 7.0 中的物理动画 API、手势识别组合、自定义转场动画的实现技巧,并通过一个完整的"沉浸式图片浏览器"实战项目,演示从交互设计到代码落地的全过程。
一、HarmonyOS 6.x 动画与手势现状:够用,但不够细腻
6.1 的 ArkTS 动画能力可以概括为:
| 能力 | 6.x API | 典型限制 |
|---|---|---|
| 属性动画 | animateTo |
仅支持固定时长和缓动曲线,不支持物理参数 |
| 显示动画 | transition |
进入/退出动画配置有限,布局变化无自动过渡 |
| 手势识别 | gesture |
单手势绑定,多手势组合时冲突由系统默认处理 |
| 转场动画 | pageTransition |
仅支持系统预设几种模式,无法自定义贝塞尔路径 |
课堂场景中的典型问题:
- "老师,我想实现类似 iOS 照片应用的弹簧缩放效果,但
animateTo的Curve.Spring感觉不够自然" - "列表项同时支持左滑删除和上下拖动,两个手势会互相打架"
- "页面 A 的图片点击后,如何在页面 B 中从原位放大展开?"
这些问题的根源是:6.x 的动画系统偏"声明式配置",而 7.0 需要向"物理化、可编排、可定制"演进。
二、HarmonyOS 7.0 动画系统升级:从"配置"到"物理"
2.1 物理弹簧动画(Physics-Based Spring)
6.x 的 Curve.Spring 本质上是预计算的插值曲线。7.0 可能引入真正的物理弹簧模型,暴露质量(mass)、刚度(stiffness)、阻尼(damping)三个物理参数:
typescript
// 7.0 推演:物理弹簧动画
import { physicsAnimation } from '@ohos.arkui.physics';
@State scale: number = 1.0;
// 创建一个物理弹簧动画控制器
const spring = physicsAnimation.createSpring({
mass: 1.0, // 质量(kg)
stiffness: 228, // 刚度(N/m)
damping: 22, // 阻尼系数
initialVelocity: 0 // 初速度
});
// 触发弹簧动画到目标值
spring.animateTo(this, 'scale', 2.0, {
onUpdate: (value) => {
this.scale = value;
}
});
与 6.x 的对比:
| 特性 | 6.x Curve.Spring |
7.0 physicsAnimation |
|---|---|---|
| 模型 | 预设插值曲线 | 真实物理仿真 |
| 参数 | 无 | mass / stiffness / damping / initialVelocity |
| 中断处理 | 跳转到目标值 | 继承当前速度继续物理计算 |
| 手势联动 | 手势结束后才开始动画 | 手势速度与动画初速度自然衔接 |
2.2 关键帧动画(Keyframe Animation)
7.0 可能支持多段关键帧控制,实现复杂动画路径:
typescript
// 7.0 推演:关键帧动画
import { keyframeAnimation } from '@ohos.arkui.animation';
@State offsetX: number = 0;
@State offsetY: number = 0;
@State rotation: number = 0;
function playComplexAnimation(): void {
keyframeAnimation.create()
.duration(2000)
.keyframe(0, { offsetX: 0, offsetY: 0, rotation: 0 }) // 起始
.keyframe(0.3, { offsetX: 100, offsetY: -50, rotation: 45 }) // 30%
.keyframe(0.6, { offsetX: 200, offsetY: 0, rotation: 90 }) // 60%
.keyframe(1.0, { offsetX: 300, offsetY: 100, rotation: 180 }) // 结束
.curveAt(0.3, Curve.EaseInOut) // 第一段缓动
.curveAt(0.6, Curve.Spring) // 第二段弹簧
.onUpdate((values) => {
this.offsetX = values.offsetX;
this.offsetY = values.offsetY;
this.rotation = values.rotation;
})
.start();
}
2.3 布局过渡动画(Layout Transition)
6.x 中列表项增删时,其他项的位移是"跳变"的。7.0 可能引入自动布局过渡:
typescript
// 7.0 推演:布局变化自动动画
List() {
ForEach(this.noteList, (item: NoteItem) => {
ListItem() {
NoteCard({ item })
}
.layoutTransition({
type: LayoutTransitionType.SPRING, // 项增删时,其他项弹簧位移
springOption: { stiffness: 300, damping: 30 }
})
})
}
三、HarmonyOS 7.0 手势系统升级:从"单点识别"到"组合编排"
3.1 手势识别器组合(Gesture Group)
6.x 中一个组件只能绑定一个主要手势。7.0 可能支持手势组合与冲突仲裁:
typescript
// 7.0 推演:手势组合与冲突解决
Image(this.imageSrc)
.width('100%')
.height('100%')
.gesture(
GestureGroup(GestureMode.Sequence, // 顺序识别:长按 → 拖拽
LongPressGesture({ duration: 500 })
.onAction(() => this.enterDragMode()),
PanGesture({ direction: PanDirection.All })
.onActionUpdate((event: GestureEvent) => {
this.offsetX += event.offsetX;
this.offsetY += event.offsetY;
})
)
)
.gesture(
GestureGroup(GestureMode.Parallel, // 并行识别:缩放 + 旋转
PinchGesture()
.onActionUpdate((event: GestureEvent) => {
this.scale *= event.scale;
}),
RotationGesture()
.onActionUpdate((event: GestureEvent) => {
this.rotation += event.angle;
})
)
)
3.2 手势冲突仲裁
当多个手势竞争同一触摸事件时,7.0 可能提供显式仲裁机制:
typescript
// 7.0 推演:手势冲突仲裁
Scroll() {
List() {
ForEach(this.items, (item) => {
ListItem() {
Text(item.title)
}
.swipeAction({ end: this.DeleteButton() }) // 右滑删除
.gesture(
PanGesture({ direction: PanDirection.Vertical })
.shouldRecognizeSimultaneously((event, otherGesture) => {
// 垂直拖动与列表滚动冲突时,优先列表滚动
if (otherGesture.type === GestureType.Scroll) {
return false; // 不同时识别
}
return true;
})
)
})
}
}
图1:HarmonyOS 7.0 手势识别与冲突仲裁流程图
图片内容说明(中文):流程图,从上到下。①触摸事件到达→②分发到所有绑定手势识别器→③各识别器评估是否满足触发条件→④冲突检测(多个识别器同时满足?)→⑤是→调用shouldRecognizeSimultaneously仲裁→⑥返回true→同时识别;返回false→优先级高的识别。⑦无冲突→直接触发手势回调。各判断节点用菱形,流程用箭头,仲裁逻辑用橙色高亮。
#mermaid-svg-aTbnEE0RFctlk71G{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aTbnEE0RFctlk71G .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aTbnEE0RFctlk71G .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aTbnEE0RFctlk71G .error-icon{fill:#552222;}#mermaid-svg-aTbnEE0RFctlk71G .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aTbnEE0RFctlk71G .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aTbnEE0RFctlk71G .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aTbnEE0RFctlk71G .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aTbnEE0RFctlk71G .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aTbnEE0RFctlk71G .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aTbnEE0RFctlk71G .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aTbnEE0RFctlk71G .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aTbnEE0RFctlk71G .marker.cross{stroke:#333333;}#mermaid-svg-aTbnEE0RFctlk71G svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aTbnEE0RFctlk71G p{margin:0;}#mermaid-svg-aTbnEE0RFctlk71G .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aTbnEE0RFctlk71G .cluster-label text{fill:#333;}#mermaid-svg-aTbnEE0RFctlk71G .cluster-label span{color:#333;}#mermaid-svg-aTbnEE0RFctlk71G .cluster-label span p{background-color:transparent;}#mermaid-svg-aTbnEE0RFctlk71G .label text,#mermaid-svg-aTbnEE0RFctlk71G span{fill:#333;color:#333;}#mermaid-svg-aTbnEE0RFctlk71G .node rect,#mermaid-svg-aTbnEE0RFctlk71G .node circle,#mermaid-svg-aTbnEE0RFctlk71G .node ellipse,#mermaid-svg-aTbnEE0RFctlk71G .node polygon,#mermaid-svg-aTbnEE0RFctlk71G .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aTbnEE0RFctlk71G .rough-node .label text,#mermaid-svg-aTbnEE0RFctlk71G .node .label text,#mermaid-svg-aTbnEE0RFctlk71G .image-shape .label,#mermaid-svg-aTbnEE0RFctlk71G .icon-shape .label{text-anchor:middle;}#mermaid-svg-aTbnEE0RFctlk71G .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aTbnEE0RFctlk71G .rough-node .label,#mermaid-svg-aTbnEE0RFctlk71G .node .label,#mermaid-svg-aTbnEE0RFctlk71G .image-shape .label,#mermaid-svg-aTbnEE0RFctlk71G .icon-shape .label{text-align:center;}#mermaid-svg-aTbnEE0RFctlk71G .node.clickable{cursor:pointer;}#mermaid-svg-aTbnEE0RFctlk71G .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aTbnEE0RFctlk71G .arrowheadPath{fill:#333333;}#mermaid-svg-aTbnEE0RFctlk71G .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aTbnEE0RFctlk71G .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aTbnEE0RFctlk71G .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aTbnEE0RFctlk71G .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aTbnEE0RFctlk71G .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aTbnEE0RFctlk71G .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aTbnEE0RFctlk71G .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aTbnEE0RFctlk71G .cluster text{fill:#333;}#mermaid-svg-aTbnEE0RFctlk71G .cluster span{color:#333;}#mermaid-svg-aTbnEE0RFctlk71G div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-aTbnEE0RFctlk71G .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aTbnEE0RFctlk71G rect.text{fill:none;stroke-width:0;}#mermaid-svg-aTbnEE0RFctlk71G .icon-shape,#mermaid-svg-aTbnEE0RFctlk71G .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aTbnEE0RFctlk71G .icon-shape p,#mermaid-svg-aTbnEE0RFctlk71G .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aTbnEE0RFctlk71G .icon-shape .label rect,#mermaid-svg-aTbnEE0RFctlk71G .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aTbnEE0RFctlk71G .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aTbnEE0RFctlk71G .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aTbnEE0RFctlk71G :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
true
false
否
触摸事件到达
分发到绑定的手势识别器
各识别器评估触发条件
是否存在冲突?
调用 shouldRecognizeSimultaneously
仲裁结果
同时识别
多个手势并行触发
按优先级选择
高优先级手势触发
直接触发手势回调
执行手势回调
四、自定义转场动画:共享元素过渡
4.1 共享元素转场(Shared Element Transition)
7.0 可能支持类似 Android 的共享元素转场:页面 A 中的图片点击后,在页面 B 中从原位放大到全屏。
typescript
// 7.0 推演:共享元素转场
// 页面 A:缩略图列表
@Entry
@Component
struct GalleryPage {
@State selectedIndex: number = -1;
build() {
Grid() {
ForEach(this.images, (img, index) => {
GridItem() {
Image(img.uri)
.width(100)
.height(100)
.sharedTransition(`image_${index}`, {
duration: 400,
curve: Curve.Spring,
type: SharedTransitionType.Exchange // 交换型过渡
})
.onClick(() => {
this.selectedIndex = index;
router.pushUrl({ url: 'pages/Detail', params: { index, uri: img.uri } });
})
}
})
}
}
}
// 页面 B:详情页大图
@Entry
@Component
struct DetailPage {
@State params: Record<string, Object> = router.getParams() as Record<string, Object>;
index: number = this.params['index'] as number;
uri: string = this.params['uri'] as string;
build() {
Column() {
Image(this.uri)
.width('100%')
.height('100%')
.objectFit(ImageFit.Contain)
.sharedTransition(`image_${this.index}`, {
duration: 400,
curve: Curve.Spring,
type: SharedTransitionType.Exchange
})
}
}
}
4.2 自定义页面转场路径
7.0 可能支持贝塞尔曲线路径转场:
typescript
// 7.0 推演:自定义贝塞尔转场
@Entry
@Component
struct CustomTransitionPage {
build() {
Column() {
// ...
}
.pageTransition({
// 进入动画:沿贝塞尔曲线路径滑入
enter: PageTransitionEffect.custom((progress, fromRect, toRect) => {
const t = progress;
// 三次贝塞尔曲线控制点
const cp1x = fromRect.x + (toRect.x - fromRect.x) * 0.3;
const cp1y = fromRect.y - 200;
const cp2x = fromRect.x + (toRect.x - fromRect.x) * 0.7;
const cp2y = toRect.y + 100;
const x = cubicBezier(t, fromRect.x, cp1x, cp2x, toRect.x);
const y = cubicBezier(t, fromRect.y, cp1y, cp2y, toRect.y);
const scale = lerp(0.5, 1.0, t);
const opacity = lerp(0, 1, t);
return { translateX: x, translateY: y, scale: { x: scale, y: scale }, opacity };
}, { duration: 500, curve: Curve.EaseInOut })
})
}
}
function cubicBezier(t: number, p0: number, p1: number, p2: number, p3: number): number {
const oneMinusT = 1 - t;
return oneMinusT * oneMinusT * oneMinusT * p0 +
3 * oneMinusT * oneMinusT * t * p1 +
3 * oneMinusT * t * t * p2 +
t * t * t * p3;
}
function lerp(start: number, end: number, t: number): number {
return start + (end - start) * t;
}
五、实战项目:沉浸式图片浏览器
以下是一个完整的"沉浸式图片浏览器"组件,综合演示物理动画、手势组合和转场效果。
5.1 组件架构
typescript
// components/ImmersiveImageViewer.ets
import { physicsAnimation } from '@ohos.arkui.physics';
@Component
export struct ImmersiveImageViewer {
@Prop imageUri: string;
@State scale: number = 1.0;
@State offsetX: number = 0;
@State offsetY: number = 0;
@State rotation: number = 0;
@State isDragging: boolean = false;
// 物理弹簧动画实例
private springScale = physicsAnimation.createSpring({ mass: 1, stiffness: 300, damping: 20 });
private springOffset = physicsAnimation.createSpring({ mass: 1, stiffness: 200, damping: 25 });
// 双击缩放状态
private isZoomed: boolean = false;
build() {
Stack() {
Image(this.imageUri)
.width('100%')
.height('100%')
.objectFit(ImageFit.Contain)
.scale({ x: this.scale, y: this.scale })
.translate({ x: this.offsetX, y: this.offsetY })
.rotate({ angle: this.rotation })
// 手势组合:双击 + 捏合缩放 + 拖拽平移
.gesture(
GestureGroup(GestureMode.Exclusive, // 互斥:双击和单击不同时
TapGesture({ count: 2 })
.onAction(() => this.toggleZoom()),
TapGesture({ count: 1 })
.onAction(() => this.onSingleTap())
)
)
.gesture(
GestureGroup(GestureMode.Parallel, // 并行:捏合 + 拖拽 + 旋转
PinchGesture()
.onActionUpdate((event) => {
this.scale = this.clamp(this.scale * event.scale, 0.5, 4.0);
})
.onActionEnd(() => {
// 手势结束,弹簧回弹到合理范围
if (this.scale < 1.0) {
this.springScale.animateTo(this, 'scale', 1.0);
}
}),
PanGesture({ direction: PanDirection.All })
.onActionStart(() => {
this.isDragging = true;
})
.onActionUpdate((event) => {
if (this.scale > 1.0) {
this.offsetX += event.offsetX;
this.offsetY += event.offsetY;
}
})
.onActionEnd((event) => {
this.isDragging = false;
// 带速度的弹簧回弹
this.springOffset.animateTo(this, 'offsetX', 0, {
initialVelocity: event.velocityX / 1000
});
this.springOffset.animateTo(this, 'offsetY', 0, {
initialVelocity: event.velocityY / 1000
});
}),
RotationGesture()
.onActionUpdate((event) => {
this.rotation += event.angle;
})
.onActionEnd(() => {
// 旋转角度吸附到 0/90/180/270
const targetRotation = Math.round(this.rotation / 90) * 90;
animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
this.rotation = targetRotation;
});
})
)
)
}
.width('100%')
.height('100%')
.backgroundColor('#000')
}
private toggleZoom(): void {
if (this.isZoomed) {
// 缩小回原始
this.springScale.animateTo(this, 'scale', 1.0);
this.springOffset.animateTo(this, 'offsetX', 0);
this.springOffset.animateTo(this, 'offsetY', 0);
} else {
// 放大到 2.5 倍
this.springScale.animateTo(this, 'scale', 2.5);
}
this.isZoomed = !this.isZoomed;
}
private onSingleTap(): void {
// 单击显示/隐藏工具栏
// 可结合 @State 控制 UI 元素显隐
}
private clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
}
5.2 与页面转场结合
typescript
// pages/Gallery.ets
@Entry
@Component
struct GalleryPage {
@State images: ImageItem[] = [
{ id: '1', uri: $r('app.media.photo1'), title: '故宫角楼' },
{ id: '2', uri: $r('app.media.photo2'), title: '西湖断桥' },
// ...
];
build() {
WaterFlow() {
ForEach(this.images, (img) => {
FlowItem() {
Image(img.uri)
.width('100%')
.aspectRatio(1)
.sharedTransition(`photo_${img.id}`, {
duration: 400,
curve: Curve.Spring,
type: SharedTransitionType.Exchange
})
.onClick(() => {
router.pushUrl({
url: 'pages/Viewer',
params: { id: img.id, uri: img.uri, title: img.title }
});
})
}
})
}
.columnsTemplate('1fr 1fr 1fr')
.padding(8)
.columnsGap(8)
.rowsGap(8)
}
}
六、性能优化建议
6.1 动画性能 checklist
| 优化项 | 推荐做法 | 避免做法 |
|---|---|---|
| 属性选择 | 优先使用 translate、scale、opacity |
避免动画 width、height(触发重排) |
| 手势响应 | 使用 .onActionUpdate 直接修改状态 |
避免在回调中做复杂计算或同步 I/O |
| 物理动画 | 复用 physicsAnimation 实例 |
避免每次手势都创建新实例 |
| 转场流畅 | 使用 sharedTransition 让 GPU 处理插值 |
避免在转场中加载大量新资源 |
6.2 7.0 可能的性能增强
- GPU 驱动的动画插值:复杂物理计算 offload 到 GPU 或 NPU;
- 手势预测:系统根据手势速度预测下一帧位置,降低输入延迟;
- 动画合成器独立线程:动画更新不占用 UI 线程,避免掉帧。
七、结语
动画和手势是移动应用用户体验的"最后一公里"------功能再完善,如果交互生硬、动画卡顿,用户仍会给出差评。HarmonyOS 6.x 的 ArkTS 动画系统完成了"从无到有"的基础建设,而 7.0 正在向"从有到优"的细腻体验迈进。
物理弹簧动画让交互有了"重量感"和"弹性反馈",手势组合与冲突仲裁让复杂交互场景不再"打架",共享元素转场让页面切换有了空间上的连续感。这些能力组合在一起,使鸿蒙应用的交互品质真正具备了与 iOS 和 Android 顶级应用同台竞技的底气。
对于高校学生开发者,动画与手势是展示技术实力的绝佳舞台。当你在毕业设计中演示"捏合缩放时的弹簧回弹"、"图片点击后的无缝转场"、"多指旋转后的角度吸附"时,评委看到的不仅是代码能力,更是对产品体验的敏锐感知。
转载自:https://blog.csdn.net/u014727709/article/details/162933324
欢迎 👍点赞✍评论⭐收藏,欢迎指正