前言

短视频应用的核心交互是上下滑动切换视频。用户期望流畅的滑动体验、自动播放控制、以及便捷的整理功能。本文将深入解析一个基于 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

这种设计的优势:

  1. 连续变换: 手势拖动时 scrollPos 实时更新,视频位置平滑过渡
  2. 虚拟末尾: EndCard 作为虚拟的"最后一项",索引为 videos.length
  3. 物理动画: 配合弹簧曲线,实现自然的惯性滚动

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'}`)

关键变换:

  1. translate.y: (idx - scrollPos) * 800

    • 中心视频 idx = scrollPos 时,y = 0
    • 下一个视频 idx = scrollPos + 1 时,y = 800(屏幕下方)
    • 上一个视频 idx = scrollPos - 1 时,y = -800(屏幕上方)
  2. opacity: 1 - Math.abs(idx - scrollPos) * 0.3

    • 中心视频完全不透明 opacity = 1.0
    • 相邻视频半透明 opacity = 0.7
  3. 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);
    })
)

实现细节:

  1. onActionStart: 记录手势起始位置

    this.gestureBasePos = this.scrollPos;
    
  2. onActionUpdate: 实时更新 scrollPos,实现跟手效果

    const rawPos = this.gestureBasePos - event.offsetY / 800;
    
    • event.offsetY 是手指垂直偏移量(px)
    • 除以 800 转换为视频索引单位(每个视频高度800)
    • 向上滑动 offsetY > 0,scrollPos 增加,显示下一个视频
    • 向下滑动 offsetY < 0,scrollPos 减少,显示上一个视频
  3. 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);
  }
}

关键逻辑:

  1. 跳过已删除视频: 如果目标位置的视频已删除,自动查找下一个
  2. 暂停所有视频: 切换前先暂停,避免多个视频同时播放
  3. 弹簧动画: springMotion(0.38, 0.90) 提供自然的物理回弹
  4. 延迟播放: 等待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);
}

关键点:

  1. 防重入: isDeleting 标志防止快速连续删除导致状态混乱
  2. 标记而非移除: 设置 isDeleted = true,不从数组中删除,保持索引稳定
  3. 自动切换: 删除后自动跳转到下一个未删除的视频
  4. 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 居中显示
  • 使用相同的 translateopacity 变换,保证过渡流畅

6.2 触发时机

有两种情况会跳转到 EndCard:

  1. 删除后无下一个视频:
const nextIdx = this.findNextVisibleIndex(idx);
if (nextIdx === -1) {
  setTimeout(() => {
    this.snapTo(this.videos.length); // 跳转到 EndCard
  }, 200);
}
  1. 手势滑动到末尾:
.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);
  }
}

流程:

  1. 标记当前组已浏览
  2. 收集选中的视频URI
  3. 请求 WRITE_IMAGEVIDEO 权限
  4. 查询 PhotoAsset 对象
  5. 调用 deleteAssets 批量删除
  6. 加载下一组

八、分组切换

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 可扩展方向

  1. 手势增强: 支持双击点赞、长按保存等
  2. 评论互动: 底部评论区,支持滑动展开
  3. 推荐算法: 根据观看时长、点赞等推荐相似视频
  4. 离线缓存: 预加载下一个视频,提升播放流畅度
  5. 多速播放: 支持0.5x、1.0x、1.5x、2.0x倍速

技术栈: HarmonyOS Next | ArkTS | ArkUI | VideoController | PhotoAccessHelper
关键词: 短视频 | 上下滑动 | 视频播放 | EndCard过渡 | 批量删除

如果这篇文章对你有帮助,欢迎点赞收藏。有问题可以在评论区交流~

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐