HarmonyOS 短视频滑动交互实现:打造流畅的上下切换体验
短视频应用的核心交互是上下滑动切换视频。用户期望流畅的滑动体验、自动播放控制、以及便捷的整理功能。上下滑动切换视频,完全跟手自动播放中心视频,离开自动暂停左滑删除标记待删除视频EndCard过渡浏览完毕后平滑进入确认页面批量确认二次确认删除,支持取消勾选这个短视频浏览组件的设计有几个值得借鉴的地方:✅连续滚动位置scrollPos浮点值实现流畅跟手,无跳变✅虚拟末尾项: EndCard 作为索引,
前言
短视频应用的核心交互是上下滑动切换视频。用户期望流畅的滑动体验、自动播放控制、以及便捷的整理功能。本文将深入解析一个基于 HarmonyOS ArkUI 实现的短视频浏览组件,它实现了:
- 上下滑动切换视频,完全跟手
- 自动播放中心视频,离开自动暂停
- 左滑删除标记待删除视频
- EndCard过渡浏览完毕后平滑进入确认页面
- 批量确认二次确认删除,支持取消勾选

一、架构设计
1.1 核心思想:虚拟滚动位置
与传统的分页切换不同,这个组件采用连续浮点滚动位置 scrollPos 作为核心状态:
@State private scrollPos: number = 0;
@State private currentIndex: number = 0;
private gestureBasePos: number = 0;
工作原理:
scrollPos = 0.0→ 第0个视频居中显示scrollPos = 1.5→ 第1、2个视频之间,正在滑动scrollPos = videos.length→ 特殊位置,显示 EndCard
这种设计的优势:
- 连续变换: 手势拖动时
scrollPos实时更新,视频位置平滑过渡 - 虚拟末尾: EndCard 作为虚拟的"最后一项",索引为
videos.length - 物理动画: 配合弹簧曲线,实现自然的惯性滚动
1.2 组件分层
VideoListView (容器层)
├── 状态管理: videos列表、scrollPos、删除记录
├── 业务逻辑: 分组加载、删除、切换下一组
├── VideoCard (视频卡片)
│ ├── Video组件 + VideoController
│ ├── 播放/暂停控制
│ └── 进度条显示
└── EndCard (结束卡片)
├── 缩略图网格
├── 勾选状态管理
└── 确认/继续按钮
1.3 数据模型
interface VideoItem extends PhotoModel {
isDeleted: boolean;
thumbnailPixelMap?: image.PixelMap;
}
class DeletedRecord {
index: number;
constructor(index: number) {
this.index = index;
}
}
关键状态:
@State private videos: VideoItem[] = []; // 当前组视频列表
@State private scrollPos: number = 0; // 连续滚动位置
@State private currentIndex: number = 0; // 当前中心视频索引
private deletedIndices: Set<number> = new Set(); // 已删除索引集合
private deletedHistory: DeletedRecord[] = []; // 删除历史栈(撤回用)
private videoControllers: VideoController[] = []; // 视频控制器数组
二、可见视频窗口
2.1 动态渲染策略
不渲染所有视频,只渲染当前可见范围内的视频:
private visibleIndices(): number[] {
const lo = Math.max(0, Math.floor(this.scrollPos) - 1);
const hi = Math.min(this.videos.length, Math.ceil(this.scrollPos) + 1);
const arr: number[] = [];
for (let i = lo; i <= hi; i++) {
arr.push(i);
}
return arr;
}
窗口范围: [floor(scrollPos)-1, ceil(scrollPos)+1]
- 当
scrollPos = 2.3时,渲染索引[1, 2, 3] - 当
scrollPos = videos.length时,渲染 EndCard
性能优化:
- 使用
floor/ceil而非round,避免半整数位置时频繁增删 - 窗口最多包含3个视频 + 1个 EndCard
- 配合
ForEach的 key 机制,复用已渲染的视频组件
2.2 视频卡片布局
ForEach(this.visibleIndices(), (idx: number) => {
if (idx < this.videos.length) {
// 渲染视频卡片
VideoCard({
video: this.videoAt(idx),
controller: this.videoControllers[idx],
isCenter: Math.abs(idx - this.scrollPos) < 0.5,
isDeleted: this.deletedIndices.has(idx),
isMuted: this.isMuted,
onDelete: () => this.onSwipeLeft(idx),
onUndo: () => this.onUndo()
})
.translate({ y: (idx - this.scrollPos) * 800 })
.opacity(1 - Math.abs(idx - this.scrollPos) * 0.3)
.zIndex(idx === Math.round(this.scrollPos) ? 10 : 1)
} else {
// 渲染 EndCard
EndCard({
deletedIndicesSet: this.deletedIndices,
videos: this.videos,
isVisible: Math.abs(idx - this.scrollPos) < 0.5,
onFinish: (selectedIndices: Set<number>) => this.onFinish(selectedIndices),
onContinueNext: () => this.onContinueNextGroup()
})
.translate({ y: (idx - this.scrollPos) * 800 })
.opacity(1 - Math.abs(idx - this.scrollPos) * 0.3)
.zIndex(idx === Math.round(this.scrollPos) ? 10 : 1)
}
}, (idx: number) => `${idx}_${this.videos[idx]?.uri ?? 'end'}`)
关键变换:
-
translate.y:
(idx - scrollPos) * 800- 中心视频
idx = scrollPos时,y = 0 - 下一个视频
idx = scrollPos + 1时,y = 800(屏幕下方) - 上一个视频
idx = scrollPos - 1时,y = -800(屏幕上方)
- 中心视频
-
opacity:
1 - Math.abs(idx - scrollPos) * 0.3- 中心视频完全不透明
opacity = 1.0 - 相邻视频半透明
opacity = 0.7
- 中心视频完全不透明
-
zIndex: 中心视频
zIndex = 10,其他为1
三、手势交互实现
3.1 垂直滑动手势
使用 PanGesture 监听垂直滑动:
.gesture(
PanGesture({ direction: PanDirection.Vertical, distance: 10 })
.onActionStart(() => {
this.gestureBasePos = this.scrollPos;
})
.onActionUpdate((event: GestureEvent) => {
const rawPos = this.gestureBasePos - event.offsetY / 800;
this.scrollPos = Math.max(0, Math.min(this.videos.length, rawPos));
})
.onActionEnd((event: GestureEvent) => {
const velocityContrib = -(event.velocityY / 800) * 0.2;
const target = Math.round(this.scrollPos + velocityContrib);
this.snapTo(target);
})
)
实现细节:
-
onActionStart: 记录手势起始位置
this.gestureBasePos = this.scrollPos; -
onActionUpdate: 实时更新
scrollPos,实现跟手效果const rawPos = this.gestureBasePos - event.offsetY / 800;event.offsetY是手指垂直偏移量(px)- 除以
800转换为视频索引单位(每个视频高度800) - 向上滑动
offsetY > 0,scrollPos增加,显示下一个视频 - 向下滑动
offsetY < 0,scrollPos减少,显示上一个视频
-
onActionEnd: 根据滑动速度计算惯性,吸附到最近的整数位置
const velocityContrib = -(event.velocityY / 800) * 0.2; const target = Math.round(this.scrollPos + velocityContrib);velocityY是滑动速度(px/s)- 乘以
0.2作为惯性系数,快速滑动可跳过多个视频
3.2 弹簧吸附动画
private snapTo(target: number): void {
let clamped = Math.max(0, Math.min(this.videos.length, target));
// 如果目标视频已删除,查找下一个未删除的视频
if (clamped < this.videos.length && this.videos[clamped].isDeleted) {
const nextIdx = this.findNextVisibleIndex(clamped);
clamped = nextIdx !== -1 ? nextIdx : this.videos.length;
}
// 暂停所有视频
this.videoControllers.forEach(controller => {
controller.pause();
});
// 弹簧动画吸附
animateTo({ curve: curves.springMotion(0.38, 0.90) }, () => {
this.scrollPos = clamped;
});
this.currentIndex = clamped;
// 播放中心视频
if (clamped < this.videos.length) {
setTimeout(() => {
if (this.videoControllers[clamped]) {
this.videoControllers[clamped].start();
}
}, 100);
}
}
关键逻辑:
- 跳过已删除视频: 如果目标位置的视频已删除,自动查找下一个
- 暂停所有视频: 切换前先暂停,避免多个视频同时播放
- 弹簧动画:
springMotion(0.38, 0.90)提供自然的物理回弹 - 延迟播放: 等待100ms确保动画开始后再播放视频
四、视频播放控制
4.1 VideoController 管理
为每个视频创建独立的控制器:
private videoControllers: VideoController[] = [];
private initVideoControllers() {
this.videoControllers = this.videos.map(() => new VideoController());
}
4.2 自动播放逻辑
在 VideoCard 组件中,根据 isCenter 属性控制播放:
@Component
struct VideoCard {
@Prop video: PhotoModel | null = null;
controller: VideoController = new VideoController();
@Prop isCenter: boolean = false;
@Prop isMuted: boolean = true;
@State private isPrepared: boolean = false;
@State private isPlaying: boolean = false;
build() {
Stack() {
Video({ src: this.video.uri, controller: this.controller })
.autoPlay(false)
.loop(true)
.muted(this.isMuted)
.onPrepared((event) => {
this.duration = event.duration;
this.isPrepared = true;
// 如果是中心视频,自动播放
if (this.isCenter) {
setTimeout(() => {
this.controller.start();
this.isPlaying = true;
}, 50);
}
})
.onStart(() => {
this.isPlaying = true;
})
.onPause(() => {
this.isPlaying = false;
})
.onClick(() => {
if (this.isPlaying) {
this.controller.pause();
} else {
this.controller.start();
}
})
}
}
}
播放策略:
autoPlay: false- 禁用自动播放,手动控制loop: true- 循环播放onPrepared- 视频准备完成后,如果是中心视频则自动播放onClick- 点击切换播放/暂停状态
4.3 Tab切换暂停
当用户切换到其他Tab时,暂停所有视频:
private onTabChange() {
if (this.currentTabIndex !== 1) {
// 离开视频Tab,暂停所有视频
this.videoControllers.forEach(controller => {
controller.pause();
});
} else if (this.videoControllers[this.currentIndex]) {
// 回到视频Tab,播放当前视频
this.videoControllers[this.currentIndex].start();
}
}
4.4 进度条显示
使用自定义 Stack 实现进度条:
// 进度条
Stack({ alignContent: Alignment.Start }) {
// 背景
Row()
.width('100%')
.height(2)
.backgroundColor('rgba(0, 0, 0, 0.3)')
// 进度
Row()
.width(`${this.duration > 0 ? (this.currentTime / this.duration * 100) : 0}%`)
.height(2)
.backgroundColor(Color.White)
}
.width('100%')
.position({ x: 0, y: '100%' })
.translate({ y: -100 })
实现原理:
- 背景条固定宽度
100% - 进度条宽度根据
currentTime / duration计算百分比 - 使用
position+translate定位到底部上方100px
五、删除机制
5.1 软删除设计
采用"标记删除 + 延迟提交"策略:
private deletedIndices: Set<number> = new Set(); // 已删除索引集合
private deletedHistory: DeletedRecord[] = []; // 删除历史栈
@State private deleteCount: number = 0; // 删除计数
private isDeleting: boolean = false; // 删除锁
为什么用 Set 而非数组?
Set天然去重,避免重复删除has()查询时间复杂度 O(1)add()/delete()操作高效
5.2 左滑删除实现
private onSwipeLeft(idx: number): void {
// 防重入检查
if (this.isDeleting || idx >= this.videos.length || this.videos[idx].isDeleted) {
return;
}
this.isDeleting = true;
// 标记删除
this.videos[idx].isDeleted = true;
this.deletedIndices.add(idx);
this.deletedHistory.push(new DeletedRecord(idx));
this.deleteCount = this.deletedIndices.size;
// 查找下一个未删除的视频
const nextIdx = this.findNextVisibleIndex(idx);
if (nextIdx !== -1) {
this.snapTo(nextIdx);
} else {
// 没有下一个视频,跳转到 EndCard
setTimeout(() => {
this.snapTo(this.videos.length);
}, 200);
}
// 释放删除锁
setTimeout(() => {
this.isDeleting = false;
}, 500);
}
关键点:
- 防重入:
isDeleting标志防止快速连续删除导致状态混乱 - 标记而非移除: 设置
isDeleted = true,不从数组中删除,保持索引稳定 - 自动切换: 删除后自动跳转到下一个未删除的视频
- EndCard触发: 当没有下一个视频时,跳转到
scrollPos = videos.length
5.3 查找下一个可见视频
private findNextVisibleIndex(currentIdx: number): number {
for (let i = currentIdx + 1; i < this.videos.length; i++) {
if (!this.videos[i].isDeleted) {
return i;
}
}
return -1; // 没有下一个,应显示 EndCard
}
逻辑:
- 从
currentIdx + 1开始向后查找 - 返回第一个
isDeleted = false的索引 - 如果全部删除,返回
-1,触发 EndCard
5.4 撤回功能
private onUndo(): void {
if (this.deletedHistory.length === 0) {
return;
}
// 从历史栈弹出最后一条
const last = this.deletedHistory.pop()!;
// 恢复视频状态
this.videos[last.index].isDeleted = false;
this.deletedIndices.delete(last.index);
this.deleteCount = this.deletedIndices.size;
// 跳转回该视频
this.snapTo(last.index);
}
特点:
- 使用栈结构,支持多次撤回(LIFO)
- 直接修改
isDeleted标志,无需重新加载 - 自动跳转回被恢复的视频位置
六、EndCard 过渡
6.1 虚拟末尾项
EndCard 作为虚拟的"最后一项",索引为 videos.length:
ForEach(this.visibleIndices(), (idx: number) => {
if (idx < this.videos.length) {
VideoCard({ /* ... */ })
} else {
// idx === videos.length,渲染 EndCard
EndCard({
deletedIndicesSet: this.deletedIndices,
videos: this.videos,
isVisible: Math.abs(idx - this.scrollPos) < 0.5,
onFinish: (selectedIndices: Set<number>) => this.onFinish(selectedIndices),
onContinueNext: () => this.onContinueNextGroup()
})
.translate({ y: (idx - this.scrollPos) * 800 })
.opacity(1 - Math.abs(idx - this.scrollPos) * 0.3)
}
}, (idx: number) => `${idx}_${this.videos[idx]?.uri ?? 'end'}`)
关键设计:
visibleIndices()返回范围包含videos.length- 当
scrollPos = videos.length时,EndCard 居中显示 - 使用相同的
translate和opacity变换,保证过渡流畅
6.2 触发时机
有两种情况会跳转到 EndCard:
- 删除后无下一个视频:
const nextIdx = this.findNextVisibleIndex(idx);
if (nextIdx === -1) {
setTimeout(() => {
this.snapTo(this.videos.length); // 跳转到 EndCard
}, 200);
}
- 手势滑动到末尾:
.onActionUpdate((event: GestureEvent) => {
const rawPos = this.gestureBasePos - event.offsetY / 800;
this.scrollPos = Math.max(0, Math.min(this.videos.length, rawPos));
// 允许 scrollPos 达到 videos.length
})
6.3 EndCard 可见性控制
@Component
struct EndCard {
@Prop @Watch('onVisibilityChange') isVisible: boolean = false;
@State private deletedIndices: number[] = [];
onVisibilityChange() {
if (this.isVisible && this.deletedIndices.length === 0) {
this.loadData();
}
}
private loadData() {
this.deletedIndices = Array.from(this.deletedIndicesSet);
this.selectedIndices = new Set(this.deletedIndices);
this.loadThumbnails();
}
}
优化策略:
- 使用
@Watch监听isVisible变化 - 只有当 EndCard 可见时才加载缩略图
- 避免提前加载浪费资源
七、EndCard 实现细节
7.1 缩略图加载
@State private thumbnails: Map<number, image.PixelMap> = new Map();
private async loadThumbnails() {
const ctx = getContext() as common.UIAbilityContext;
const helper = photoAccessHelper.getPhotoAccessHelper(ctx);
for (const idx of this.deletedIndices) {
try {
const pred = new dataSharePredicates.DataSharePredicates();
pred.equalTo(photoAccessHelper.PhotoKeys.URI, this.videos[idx].uri);
const fetchOpts: photoAccessHelper.FetchOptions = {
fetchColumns: [],
predicates: pred
};
const result = await helper.getAssets(fetchOpts);
const objs = await result.getAllObjects();
if (objs.length > 0) {
const thumbnail = await objs[0].getThumbnail({
width: 200,
height: 200
});
this.thumbnails.set(idx, thumbnail);
}
await result.close();
} catch (e) {
Logger.error('[EndCard] 加载缩略图失败 index:', idx);
}
}
}
优化点:
- 使用
Map<number, PixelMap>存储,以索引为key快速查找 - 只加载已删除视频的缩略图,不加载全部
- 异步加载,不阻塞UI渲染
7.2 勾选状态管理
@State private selectedIndices: Set<number> = new Set();
Grid() {
ForEach(this.deletedIndices, (idx: number) => {
GridItem() {
Stack() {
if (this.thumbnails.has(idx)) {
Image(this.thumbnails.get(idx))
.width('100%')
.height('100%')
.objectFit(ImageFit.Cover)
}
Checkbox()
.select(this.selectedIndices.has(idx))
.position({ x: '90%', y: 0 })
.translate({ x: -18, y: 0 })
.onChange((value: boolean) => {
if (value) {
this.selectedIndices.add(idx);
} else {
this.selectedIndices.delete(idx);
}
// 强制触发更新
this.selectedIndices = new Set(this.selectedIndices);
})
}
.clickEffect({level: ClickEffectLevel.HEAVY})
.onClick(() => {
// 点击整个卡片也能切换勾选状态
if (this.selectedIndices.has(idx)) {
this.selectedIndices.delete(idx);
} else {
this.selectedIndices.add(idx);
}
this.selectedIndices = new Set(this.selectedIndices);
})
}
}, (idx: number) => idx.toString())
}
.columnsTemplate('1fr 1fr 1fr 1fr') // 4列网格
.rowsGap(8)
.columnsGap(8)
交互设计:
- 默认全选:
this.selectedIndices = new Set(this.deletedIndices) - 支持 Checkbox 和整个卡片点击
- 使用
new Set()触发响应式更新
7.3 批量删除确认
private onFinish(selectedIndices?: Set<number>): void {
this.manager!.markGroupBrowsed(this.currentGroupId);
this.doDelete(selectedIndices);
}
private async doDelete(selectedIndices?: Set<number>): Promise<void> {
const indices = selectedIndices ?? this.deletedIndices;
const toDelete = Array.from(indices).map(idx => this.videos[idx].uri);
if (toDelete.length === 0) {
await this.onContinueNextGroup();
return;
}
// 请求删除权限
const hasPermission = await this.requestPermission();
if (!hasPermission) {
return;
}
try {
const ctx = getContext() as common.UIAbilityContext;
const helper = photoAccessHelper.getPhotoAccessHelper(ctx);
const assets: photoAccessHelper.PhotoAsset[] = [];
// 查询所有待删除的资源
for (const uri of toDelete) {
const pred = new dataSharePredicates.DataSharePredicates();
pred.equalTo(photoAccessHelper.PhotoKeys.URI, uri);
const fetchOpts: photoAccessHelper.FetchOptions = {
fetchColumns: [],
predicates: pred
};
const result = await helper.getAssets(fetchOpts);
const objs = await result.getAllObjects();
if (objs.length > 0) {
assets.push(objs[0]);
}
await result.close();
}
// 批量删除
if (assets.length > 0) {
await photoAccessHelper.MediaAssetChangeRequest.deleteAssets(ctx, assets);
}
await this.onContinueNextGroup();
} catch (e) {
Logger.error('[VideoListView] 删除失败:', e);
}
}
流程:
- 标记当前组已浏览
- 收集选中的视频URI
- 请求
WRITE_IMAGEVIDEO权限 - 查询 PhotoAsset 对象
- 调用
deleteAssets批量删除 - 加载下一组
八、分组切换
8.1 加载下一组
private async onContinueNextGroup(): Promise<void> {
this.manager!.markGroupBrowsed(this.currentGroupId);
this.groups = await this.manager!.loadMoreAndRefresh();
if (this.groups.length > 0) {
this.loadCurrentGroup();
this.snapTo(0);
} else {
this.videos = [];
}
}
private loadCurrentGroup() {
const group = this.groups[0];
this.currentGroupId = group.id;
// 重置视频列表
this.videos = group.videos.map(v => {
return {
uri: v.uri,
displayName: v.displayName,
size: v.size,
dateModified: v.dateModified,
width: v.width,
height: v.height,
duration: v.duration,
isDeleted: false
} as VideoItem;
});
// 重置状态
this.deletedIndices.clear();
this.deleteCount = 0;
this.deletedHistory = [];
this.currentIndex = 0;
this.scrollPos = 0;
// 重新初始化控制器
this.initVideoControllers();
// 延迟启动第一个视频
setTimeout(() => {
if (this.videoControllers[0] && this.currentTabIndex === 1) {
this.videoControllers[0].start();
}
}, 300);
}
关键点:
- 清空删除记录,重置所有状态
- 重新创建 VideoController 数组
- 延迟300ms启动第一个视频,确保组件已渲染
九、总结与思考
9.1 核心亮点
这个短视频浏览组件的设计有几个值得借鉴的地方:
✅ 连续滚动位置: scrollPos 浮点值实现流畅跟手,无跳变
✅ 虚拟末尾项: EndCard 作为 videos.length 索引,平滑过渡
✅ 智能播放控制: 中心视频自动播放,离开自动暂停
✅ 软删除机制: Set 存储索引,支持撤回和批量确认
✅ 可见窗口优化: 只渲染3个视频,性能高效
✅ 分组管理: 浏览完一组自动加载下一组
9.2 技术要点回顾
| 技术点 | 实现方式 | 关键API |
|---|---|---|
| 垂直滚动 | scrollPos + translate.y | PanGesture, animateTo |
| 视频播放 | VideoController数组 | VideoController, Video组件 |
| 删除管理 | Set + 历史栈 | Set.add(), Set.delete() |
| EndCard过渡 | 虚拟末尾项 | visibleIndices() |
| 批量删除 | PhotoAsset数组 | deleteAssets() |
| 缩略图加载 | Map<number, PixelMap> | getThumbnail() |
9.3 与照片浏览器的对比
| 特性 | 照片浏览器 | 视频浏览器 |
|---|---|---|
| 滑动方向 | 水平(左右) | 垂直(上下) |
| 内容类型 | 静态图片 | 动态视频 |
| 播放控制 | 无需控制 | VideoController管理 |
| 删除方式 | 左滑/右滑 | 左滑 + 按钮 |
| EndCard | PhotoEndCard | EndCard |
| 性能考虑 | 图片预加载 | 视频按需播放 |
9.4 可扩展方向
- 手势增强: 支持双击点赞、长按保存等
- 评论互动: 底部评论区,支持滑动展开
- 推荐算法: 根据观看时长、点赞等推荐相似视频
- 离线缓存: 预加载下一个视频,提升播放流畅度
- 多速播放: 支持0.5x、1.0x、1.5x、2.0x倍速
技术栈: HarmonyOS Next | ArkTS | ArkUI | VideoController | PhotoAccessHelper
关键词: 短视频 | 上下滑动 | 视频播放 | EndCard过渡 | 批量删除
如果这篇文章对你有帮助,欢迎点赞收藏。有问题可以在评论区交流~
更多推荐

所有评论(0)