本文基于 HarmonyOS API 20 / ArkTS,实现一个手感接近抖音刷视频的照片浏览组件,涵盖弹簧阻尼动画、手势方向锁、虚拟窗口渲染、模糊背景等核心技术。
一、效果概览
| 交互 | 行为 |
|---|---|
| 上下滑动 | 切换上一张 / 下一张照片,松手后弹簧回弹 |
| 首尾边缘继续拖动 | 明显橡皮筋阻尼感,无法越界 |
| 左滑 | 触发删除(飞出动画) |
| 右滑 | 触发收藏(飞出动画) |
| 背景 | 当前照片极低分辨率 + 高斯模糊全屏铺底 |
| 图片尺寸 | 按原始宽高比 contain 显示,不拉伸不裁切 |
二、核心难点分析
2.1 全量加载 = 必崩
最常见的错误写法:
typescript
// ❌ 危险:所有照片同时作为 Image 节点进入渲染树
Column() {
ForEach(this.photos, (photo: PhotoItem) => {
Image(photo.uri)
.width('100%')
.height('100%')
})
}
媒体库 URI 对应的原图动辄 5~20MB,100 张同时解码就是数 GB 内存,系统直接 OOM 杀进程。
正确做法:虚拟窗口渲染,任意时刻只解码当前页及前后各一张:
typescript
// ✅ 通过 inWindow prop 控制是否真正渲染 Image
PhotoCard({
inWindow: Math.abs(index - this.currentIndex) <= 1 // 只渲染 ±1 范围
})
窗口外的卡片只渲染一个透明的空 Stack,内存占用极低。
2.2 图片解码防崩
HEIF 格式、超大分辨率图直接解码会触发 62980116 / 62980106 错误导致 crash,三行代码解决:
typescript
Image(this.uri)
.sourceSize({ width: 1080, height: 1080 }) // 超出自动降采样
.syncLoad(false) // 异步解码,不卡 UI
.onError(() => { this.loadError = true; }) // 单张失败不影响整体
三、弹簧阻尼动画实现
3.1 ArkUI 的三种弹簧曲线
typescript
// 翻页弹簧:response 小 = 响应快,dampingFraction ≈ 0.82 = 临界阻尼无震荡
private readonly cFlip = curves.springMotion(0.38, 0.82);
// 回弹弹簧:欠阻尼(0.72),松手未达阈值时有轻微过冲感
private readonly cBack = curves.springMotion(0.45, 0.72);
// 边缘弹簧:过阻尼(1.0),首尾越界时干净弹回,无震荡
private readonly cEdge = curves.springMotion(0.5, 1.0);
三条曲线对应三种场景,参数调校逻辑:
response(弹簧刚度)越小,弹簧越硬、动画越快dampingFraction< 1 欠阻尼有震荡,= 1 临界无震荡,> 1 过阻尼慢速归位
3.2 边缘橡皮筋效果
到达首张或末张继续拖动时,施加阻尼系数让位移"变重":
typescript
.onActionUpdate((event: GestureEvent) => {
const dy = event.offsetY;
const atTop = this.currentIndex === 0 && dy > 0;
const atBottom = this.currentIndex === this.photos.length - 1 && dy < 0;
// 边缘阻尼:实际位移只有手指位移的 25%,明显感受到阻力
const factor = (atTop || atBottom) ? 0.25 : 1.0;
this.dragOffsetY = dy * factor;
})
松手后用过阻尼弹簧归位,无震荡:
typescript
animateTo({ curve: this.cEdge }, () => {
this.dragOffsetY = 0;
});
3.3 速度感知翻页
同时判断位移 和速度,满足任意一条即触发翻页,轻扫也能切换:
typescript
.onActionEnd((event: GestureEvent) => {
const d = this.dragOffsetY;
const v = event.velocityY; // 单位:vp/s
if (d < -80 || v < -600) {
this.goNext(); // 向下翻
} else if (d > 80 || v > 600) {
this.goPrev(); // 向上翻
} else {
this.snapVBack(); // 回弹
}
})
四、手势方向锁
单个 PanGesture(All) 监听所有方向,在手势开始后累积到 12vp 死区外再决定方向,之后锁死------这是流畅体验的关键,避免斜向滑动时方向漂移:
typescript
type DragDir = 'none' | 'vertical' | 'horizontal';
private dragDir: DragDir = 'none';
.onActionStart(() => {
this.dragDir = 'none'; // 每次手势重置方向锁
})
.onActionUpdate((event: GestureEvent) => {
const dx = event.offsetX, dy = event.offsetY;
// 死区内不响应,超出死区后一次性决定方向并锁定
if (this.dragDir === 'none') {
const ax = Math.abs(dx), ay = Math.abs(dy);
if (ax > ay && ax > 12) { this.dragDir = 'horizontal'; }
else if (ay > ax && ay > 12) { this.dragDir = 'vertical'; }
else return;
}
// 锁定后只响应对应方向,另一轴完全忽略
})
五、翻页动画架构
选用 Column 长列表 + translate 整体偏移 方案,不用 Swiper 组件的原因是 Swiper 无法精细控制阻尼曲线:
typescript
Column() {
ForEach(this.photos, (photo: PhotoItem, index: number) => {
PhotoCard({ ... })
.height(this.cardH()) // 每页高度 = 组件实际高度
})
}
.translate({
// 核心公式:整体上移 N 页 + 手势实时偏移
y: -(this.currentIndex * this.cardH()) + this.dragOffsetY
})
翻页时把 currentIndex++ 和 dragOffsetY = 0 放在同一个 animateTo 闭包内,ArkUI 会对这两个状态变化同时插值,产生自然的页面滚动动画:
typescript
animateTo({ curve: this.cFlip }, () => {
this.currentIndex++; // 跳到下一页基准位置
this.dragOffsetY = 0; // 同帧清零拖动偏移
});
六、图片尺寸 Contain 算法
不用 objectFit: Contain 是因为需要精确控制显示尺寸(带 padding 和圆角),自己实现双向约束:
typescript
private calcSize(): [number, number] {
const mw = this.screenW - 15 * 2; // 最大宽:屏宽 - 左右 padding
const mh = this.maxH; // 最大高:组件实际可用高度
const ratio = this.photoH / this.photoW; // 高宽比
const hIfFillW = mw * ratio; // 按最大宽度撑开后对应的高度
if (hIfFillW <= mh) {
return [mw, hIfFillW]; // 宽先触边:宽 = maxW,高按比例
} else {
return [mh / ratio, mh]; // 高先触边:高 = maxH,宽按比例
}
}
maxH 通过 onAreaChange 获取组件真实渲染高度,精确减去上下栏后传入:
typescript
.onAreaChange((_old, area) => {
const raw = area.height;
const h = typeof raw === 'number' ? raw : parseFloat(raw as string);
if (h > 0 && h !== this.componentH) {
this.componentH = h;
}
})
七、模糊背景
背景层与主内容层分离,放在父级 Stack 的最底层。极低分辨率解码(120px)+ blur(40),性能几乎为零:
typescript
// Index.ets - 最底层
Image(this.currentUri)
.width('100%').height('100%')
.objectFit(ImageFit.Cover)
.sourceSize({ width: 120, height: 120 }) // 背景只要色彩参考,不需清晰度
.syncLoad(false)
.blur(40)
.opacity(0.9)
// 压色遮罩,防止浅色图背景过曝
Stack()
.width('100%').height('100%')
.backgroundColor('#55000000')
翻页时同步更新 currentUri,背景随内容切换:
typescript
onIndexChange: (index: number) => {
this.currentIndex = index;
this.currentUri = this.photos[index].uri;
}
八、ArkTS 踩坑记录
8.1 struct 不支持 getter
typescript
// ❌ ArkTS 编译报错,silent 返回 undefined
private get displayW(): number { return this.screenW - 30; }
// ✅ 改为普通方法
private calcDisplayW(): number { return this.screenW - 30; }
8.2 泛型推断限制
typescript
// ❌ 报错:arkts-no-inferred-generic-params
Array.from({ length: n }, (_v, i) => i)
// ✅ 显式标注泛型和参数类型
Array.from<number>({ length: n }, (_v: number, i: number) => i)
8.3 onAreaChange 返回 Length 类型
typescript
// ❌ 不健壮,某些情况下返回字符串导致 NaN
const h = area.height as number;
// ✅ 同时处理 number 和 string
const raw = area.height;
const h = typeof raw === 'number' ? raw : parseFloat(raw as string);
8.4 固定 ForEach key 导致图片不刷新
虚拟三卡片方案中,如果 key 固定为 slot_0/1/2,ArkUI 复用节点实例,URI prop 变更时 Image 不会重新加载。
正确做法:回归 Column 长列表,key 绑定 photo.id,节点与照片绑定:
typescript
// ✅ key 随数据走,URI 变更时 Image 正常刷新
ForEach(this.photos, ..., (photo: PhotoItem) => `photo_${photo.id}`)
九、完整参数速查
| 常量 | 值 | 说明 |
|---|---|---|
V_DIST_THRESHOLD |
80vp | 触发翻页的最小位移 |
V_VEL_THRESHOLD |
600vp/s | 触发翻页的最小速度 |
H_DIST_THRESHOLD |
90vp | 触发左右操作的最小位移 |
H_VEL_THRESHOLD |
500vp/s | 触发左右操作的最小速度 |
EDGE_DAMPING |
0.25 | 首尾边缘阻尼系数 |
H_DAMPING |
0.72 | 水平拖拽阻尼系数 |
FLY_OUT_DIST |
420vp | 左右飞出距离 |
IMG_MAX_DECODE |
1080px | 图片最大解码边长 |
H_PAD |
15vp | 图片左右 padding |
V_PAD |
30vp | 图片上下 padding |
十、总结
| 技术点 | 方案 |
|---|---|
| 翻页动画 | Column + translate,animateTo 同帧修改 index 和 offset |
| 阻尼曲线 | curves.springMotion 三档参数对应三种场景 |
| 边缘橡皮筋 | 手势实时乘以 0.25 系数 + 过阻尼弹簧归位 |
| 防 OOM | inWindow 虚拟窗口,只渲染 ±1 范围 |
| 防解码崩溃 | sourceSize 限制 + onError 兜底 |
| 图片尺寸 | 自实现 contain 算法,双向约束 maxW × maxH |
| 模糊背景 | 120px 低分辨率 + blur(40),与主内容层分离 |
| 手势冲突 | 单 PanGesture + 方向锁,12vp 死区后锁死方向 |