适用环境:HarmonyOS Next(API 12+)|ArkTS / ArkUI|Video 组件 + VideoController

一、功能描述

Video 组件自带的控制栏(.controls(true))是一套"标准播放器 UI":进度条长什么样、放在哪、拖拽手感如何,都无法定制。而短视频信息流里,播放控件需要完全融入卡片 UI——一条贴着卡片底部的 2vp 细进度线、长按时在屏幕中心弹出的"3x"标识、松手即定位的拖拽进度。

本文基于一个自研相册 App 的短视频浏览功能,拆解两个完全自定义的播放控件:

  • 长按 3 倍速:长按视频 400ms 立即 3 倍速播放,屏幕中心弹出"3x"徽标;松开恢复 1 倍速——快速略过无关节奏的利器;
  • 进度条自定义拖拽:自绘细进度条(2vp),按下瞬间弹性变粗到 10vp 并出现滑块,拖拽过程实时跟手显示,松手 seek 到对应时间点,进度条回缩;
  • 看门狗兜底:即使拖拽事件被外层滚动手势"抢走",拖拽状态也能自动闭环,绝不卡死。

请添加图片描述

二、原理介绍

2.1 整体状态模型:两个"进度来源"

进度条在两种情况下都要显示进度:

  • 非拖拽时:实时播放进度 currentTime / duration
  • 拖拽时:手指位置(不能被实时进度"抢回去",否则进度条会在手指下抖)。

所以引入一个哨兵值状态 dragDisplayRatio

@State private isDragging: boolean = false;
@State private dragDisplayRatio: number = -1; // -1 表示未拖拽,显示播放进度

用一个 getter 统一"进度来源选择":

// 当前进度比例:拖拽中显示拖拽值,否则显示实时播放进度
private currentProgressRatio(): number {
  if (this.dragDisplayRatio >= 0) {
    return this.dragDisplayRatio;
  }
  if (this.duration > 0) {
    return this.currentTime / this.duration;
  }
  return 0;
}

-1 作为哨兵值很讲究:比例合法域是 [0,1],-1 天然表示"不存在拖拽值",>= 0 一次判断就完成来源切换,进度条和滑块共用这一个函数,两者位置永远同步。

2.2 长按 3 倍速:currentProgressRate + LongPressGesture

Video 组件自带倍速属性 currentProgressRate(float,默认 1),倍速只需要状态绑定

Video({ src: this.video.uri, controller: this.controller, currentProgressRate: this.playbackRate })

手势侧用 LongPressGesture

  • duration: 400——按住 400ms 认定为长按;
  • repeat: false——只触发一次不重复(只需要"开始 3 倍速"这一个时刻);
  • onAction——长按触发时回调 → 切 3 倍速;
  • onActionEnd——手指抬起时回调 → 恢复 1 倍速。

与单击播放/暂停的关系:400ms 内未触发长按则判定为单击,走 onClick 的播放/暂停逻辑,两条手势路径天然互斥,不会"点了播放又触发倍速"。

2.3 进度条双层架构:视觉层与触摸层分离

进度条常态只有 2vp 高——指尖(接触面 30~40vp)根本不可能精准点中。如果把 onTouch 直接挂在细条上,拖拽命中率会低到不可用。

所以把进度条拆成两个独立图层

图层尺寸职责命中行为
视觉层100% 宽 × barHeight(2~10vp)画轨道 / 进度 / 滑块HitTestMode.None,对触摸完全透明
触摸热区层100% 宽 × 36vp接收 onTouch、计算拖拽比例正常接收触摸

36vp 的热区上下大比例覆盖进度条位置,粗手指也落在热区内;视觉层只负责画、不拦截触摸,两层互不干扰。分离还带来一个隐性好处:视觉层高度做变粗动画时,热区几何完全不变,触摸面积稳定。

请添加图片描述

2.4 坐标计算:touch.x ÷ barWidth

触摸热区宽度与卡片同宽,TouchEvent.touches[0].x 的本地坐标是 vp 单位。差一个分母——热区实际的 vp 宽度,用 onAreaChange 测量:

.onAreaChange((oldValue: Area, newValue: Area) => {
  this.barWidth = Number(newValue.width);
})

于是比例计算就是:

const ratio = this.barWidth > 0 ? x / this.barWidth : 0;
this.dragDisplayRatio = Math.max(0, Math.min(1, ratio));

Math.max/min 夹到 [0,1],手指滑出屏幕左右边缘时滑块不会飞出。

2.5 拖拽状态机与看门狗

一次完整拖拽是三步状态机:

TouchType.Down      → beginBarDrag()   isDragging=true,初始化拖拽值,条变粗
TouchType.Move      → updateBarDrag()  实时更新拖拽比例
TouchType.Up/Cancel → endBarDrag()     seek 定位,重置状态,条回缩

但真实场景有一个边界:外层列表挂着纵向 PanGesture(短视频上下滑)。用户手指在进度条上按下后若转为纵向滑动,外层手势可能接管触摸流,热区的 onTouch收不到 Up——拖拽状态卡死:条一直粗着、进度停在拖拽值、永远不 seek。

看门狗的思路:把视频 onUpdate 播放进度回调当心跳(播放中周期性触发,默认间隔 250ms)。拖拽期间发现"最后一次触摸 Move 距现在已超 1 秒",判定触摸流被抢走 → 主动调 endBarDrag() 完成闭环:

.onUpdate((event) => {
  this.currentTime = event.time;
  // 看门狗:若拖拽的 Up/Cancel 事件被手势抢占而丢失,1 秒后自动结束拖拽
  if (this.isDragging && Date.now() - this.lastDragMoveTime > 1000) {
    this.endBarDrag();
  }
})

lastDragMoveTime 在每次 Down/Move 时刷新,一行判断完成"活性检测"。

三、实战实现

3.1 状态定义

VideoCard 组件(短视频信息流中每张卡片一个 VideoCard,中心卡片自动播放)的全部相关状态:

@State private currentTime: number = 0;
@State private duration: number = 0;
@State private isPlaying: boolean = false;

// 进度条拖拽相关状态
@State private isDragging: boolean = false;
@State private dragDisplayRatio: number = -1; // -1 表示未拖拽,显示播放进度
@State private barHeight: number = 2;         // 进度条高度:常态 2vp,拖拽 10vp
@State private playbackRate: number = 1;      // 播放倍速
@State private rateBadgeScale: number = 0.5;  // 3x 徽标弹出缩放
private barWidth: number = 0;                 // 触摸热区实测宽度(非响应式)
private lastDragMoveTime: number = 0;         // 看门狗计时基准

几个细节:

  1. barWidthlastDragMoveTime普通变量而非 @State——它们只参与计算,不驱动 UI,没必要付出响应式追踪开销;
  2. barHeight@State——它既驱动视觉渲染,又参与"围绕中心变粗"的 translate 计算;
  3. playbackRate@State——驱动 VideocurrentProgressRate 参数与"3x 徽标"的条件渲染。

3.2 长按 3 倍速:手势绑定 + 徽标动画

// 长按视频以 3 倍速播放,松开恢复 1 倍速
private onVideoLongPress(): void {
  if (!this.isPlaying) {
    this.controller.start();   // 暂停中的视频长按 → 先起播再倍速
  }
  this.playbackRate = 3;
  // 3x 标识居中弹出动画:从 0.3 弹到 1
  this.rateBadgeScale = 0.3;
  animateTo({ curve: curves.springMotion(0.4, 0.7) }, () => {
    this.rateBadgeScale = 1;
  });
}

private onVideoLongPressEnd(): void {
  this.playbackRate = 1;
}

Video 上的手势挂载:

Video({ src: this.video.uri, controller: this.controller, currentProgressRate: this.playbackRate })
  .width('100%')
  .height('100%')
  .objectFit(ImageFit.Contain)
  .autoPlay(false)
  .loop(true)
  .controls(false)                       // 关键:关掉系统控制栏,全自定义
  .muted(this.isMuted)
  .gesture(
    LongPressGesture({ repeat: false, duration: 400 })
      .onAction(() => this.onVideoLongPress())
      .onActionEnd(() => this.onVideoLongPressEnd())
  )
  .onClick(() => {
    if (this.isPlaying) {
      this.controller.pause();
    } else {
      this.controller.start();
    }
  })

"3x"徽标的条件渲染(居中 + 弹性缩放):

// 3x 倍速标识:长按视频时在居中位置弹出
if (this.playbackRate >= 3) {
  Text('3x')
    .fontSize(22)
    .fontWeight(FontWeight.Bold)
    .fontColor(Color.White)
    .padding({ left: 18, right: 18, top: 8, bottom: 8 })
    .backgroundColor('rgba(0, 0, 0, 0.45)')
    .borderRadius(12)
    .scale({ x: this.rateBadgeScale, y: this.rateBadgeScale })
    .zIndex(50)
}

scale 从 0.3 弹到 1 配合 springMotion,徽标有"弹出"的质感而非渐显。松手时 playbackRate 回 1,条件渲染直接摘除节点——不需要反向动画,因为长按结束本身已是明确的"退场"信号。

暂停状态长按的处理值得注意:if (!this.isPlaying) this.controller.start()——如果视频停在某帧,长按应当"起播 + 倍速"一起完成,而不是让用户再点一次。

3.3 进度条拖拽:三步状态机

private onProgressTouch(event: TouchEvent): void {
  if (event.type === TouchType.Down) {
    this.beginBarDrag();
  } else if (event.type === TouchType.Move) {
    const touch = event.touches[0];
    if (touch) {
      this.updateBarDrag(touch.x);
    }
  } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
    this.endBarDrag();
  }
}

private beginBarDrag(): void {
  this.isDragging = true;
  // 以当前播放进度为起点,避免按下瞬间进度条跳变
  this.dragDisplayRatio = this.duration > 0 ? this.currentTime / this.duration : 0;
  this.lastDragMoveTime = Date.now();
  // 拖拽时进度条弹性变粗,便于滑动
  animateTo({ curve: curves.springMotion(0.45, 0.8) }, () => {
    this.barHeight = 10;
  });
}

private updateBarDrag(x: number): void {
  if (!this.isDragging) {
    return;
  }
  this.lastDragMoveTime = Date.now();
  const ratio = this.barWidth > 0 ? x / this.barWidth : 0;
  this.dragDisplayRatio = Math.max(0, Math.min(1, ratio));
}

private endBarDrag(): void {
  if (!this.isDragging) {
    return;
  }
  const targetRatio = this.dragDisplayRatio;
  this.dragDisplayRatio = -1;   // 复位哨兵:回到实时进度
  this.isDragging = false;
  // seek 到拖拽位置
  if (this.duration > 0 && targetRatio >= 0) {
    this.controller.setCurrentTime(targetRatio * this.duration, SeekMode.ClosestKeyframe);
  }
  animateTo({ curve: curves.springMotion(0.45, 0.8) }, () => {
    this.barHeight = 2;
  });
}

几个值得注意的实现决策:

按下即取当前进度beginBarDragcurrentTime / duration)——如果直接从 0 开始,手指还没动进度条就跳到开头,体感是"误触"。

SeekMode.ClosestKeyframe——seek 到"最接近的关键帧"。视频编码是 I/P/B 帧结构,精确到毫秒的 seek 可能落在 P/B 帧上导致画面花屏;ClosestKeyframe 让解码器吸附到最近的 I 帧,定位又快又稳(B 帧密集的视频上差异尤其明显)。

endBarDrag 的幂等保护——if (!this.isDragging) return。这个函数有两个入口:触摸 Up/Cancel 和看门狗。两者可能先后到达(比如看门狗刚判了超时,Up 又补了一个),没有幂等保护就会重复 seek 两次。

3.4 视觉层:围绕中心对称变粗

// 进度条视觉层(背景轨道 + 进度 + 拖拽滑块)
Stack({ alignContent: Alignment.Start }) {
  // 背景轨道
  Row()
    .width('100%')
    .height(this.barHeight)
    .backgroundColor(this.isDragging ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.3)')
    .borderRadius(this.barHeight / 2)

  // 进度(宽度 = 比例)
  Row()
    .width(`${this.currentProgressRatio() * 100}%`)
    .height(this.barHeight)
    .backgroundColor(Color.White)
    .borderRadius(this.barHeight / 2)

  // 拖拽滑块(仅拖拽时渲染)
  if (this.isDragging) {
    Circle({ width: 14, height: 14 })
      .fill(Color.White)
      .position({ x: `${this.currentProgressRatio() * 100}%`, y: '50%' })
      .markAnchor({ x: 7, y: 7 })   // 以圆心为锚点
  }
}
.width('100%')
.position({ x: 0, y: '100%' })
// 高度增长时整体上移一半:进度条围绕中心线上下对称变粗
.translate({ y: -(100 + (this.barHeight - 2) / 2) })
.hitTestBehavior(HitTestMode.None)

围绕中心对称变粗是关键的一笔:进度条底部对齐卡片底边时,barHeight 从 2 涨到 10 如果只改高度,条会"往下撑"——底部溢出卡片、视觉上跳一下。用 translate y: -(100 + (barHeight - 2) / 2) 把增量的一半向上补偿,变粗过程就像从中心线向上下"膨胀",非常稳。

滑块的 markAnchor({ x: 7, y: 7 })——position.x 给的是百分比(滑块中心对应的比例位置),但 position 默认以组件左上角为基准。14vp 圆的中心偏移正好是 (7,7),用 markAnchor 把锚点从左上角改到圆心,滑块中心才精准落在进度比例点上。

hitTestBehavior(HitTestMode.None)——视觉层对触摸完全透明,触摸全部交给下面的热区层。

3.5 触摸热区层:36vp + onAreaChange 实测宽度

// 透明触摸热区(独立层,负责拖拽,不干扰视觉层渲染)
Stack()
  .width('100%')
  .height(36)
  .position({ x: 0, y: '100%' })
  .translate({ y: -104 })
  .onTouch((event: TouchEvent) => this.onProgressTouch(event))
  .onAreaChange((oldValue: Area, newValue: Area) => {
    this.barWidth = Number(newValue.width);
  })

热区 36vp 高、透明无背景,onAreaChange 在首帧布局完成后给出真实 vp 宽度,后续 updateBarDrag 的除法分母就有了。translate({ y: -104 }) 让热区中心与进度条位置对齐(视觉层在 -100 附近),上下各留 ~18vp 余量。

3.6 看门狗接线

前文 2.5 节已给出核心判断,这里看它在 VideoCard 中的完整落位:

.onUpdate((event) => {
  this.currentTime = event.time;
  // 看门狗:若拖拽的 Up/Cancel 事件被手势抢占而丢失,1 秒后自动结束拖拽,恢复实时进度
  if (this.isDragging && Date.now() - this.lastDragMoveTime > 1000) {
    this.endBarDrag();
  }
})

onUpdate 默认 250ms 回调一次,所以看门狗的实际生效延迟在 1000~1250ms 之间——比"人感知到卡死"的阈值(约 1s)略晚一点,恰好是"用户没察觉,状态已自愈"的区间。

onUpdate 同时是 currentTime 的唯一来源——非拖拽态的实时进度完全由它驱动。播放暂停时 onUpdate 停发,看门狗也停跳,这与"暂停时不需要看门狗"的语义自洽(暂停时拖拽结束后条也不会再动,不存在卡死风险)。

四、手势共存:一个组件上的四路手势

VideoCard 上同时挂着四类手势,能不打架的原因值得复盘:

手势挂载位置触发条件冲突处理
单击 播放/暂停Video .onClick400ms 内抬起长按未触发时才判定
长按 3 倍速Video .gesture(LongPressGesture)按住 400ms触发后独占,单击不生效
进度条拖拽热区 .onTouch触摸落在 36vp 热区区域隔离,不与 Video 手势重叠判定
外层上下滑列表 PanGesture(父级)纵向位移超阈值与进度条横拖冲突时,看门狗兜底

设计顺序是先区域隔离,再时长隔离,最后状态机兜底——能靠几何不冲突的就不靠时序,时序兜不住的就靠状态机自愈。

五、工程化细节

5.1 为什么不用 Slider?

ArkUI 有现成的 Slider,但短视频卡片的进度条有几个硬需求它满足不了:

需求Slider自绘方案
常态 2vp 细线样式改造成本高原生 Row 高度直接 2vp
按下变粗动画无此能力animateTo 改 barHeight
拖拽中不 seek,松手才 seek需额外拦截状态机天然支持
与卡片布局绝对定位对齐需包一层容器position/translate 直接对齐

自绘总成本约 150 行,换来的定制自由度是决定性的——控件是 UI 的一部分,不是 UI 里嵌的一个控件

5.2 常见坑

原因解法
进度条拖拽跟手但松手后跳回拖拽中误用了实时进度渲染哨兵值 dragDisplayRatio 分离两个来源
拖拽后进度条卡住不恢复Up 被外层手势吞掉看门狗 + 幂等 endBarDrag
按下进度条先跳到 0beginBarDrag 没初始化拖拽值currentTime/duration 为起点
细条根本按不到热区 = 视觉高度36vp 独立热区层 + HitTestMode.None
滑块位置偏移半个滑块宽position 默认左上角锚点markAnchor({ x: 7, y: 7 }) 圆心锚点
变粗时进度条下沉高度增长无补偿translate y 补 (barHeight-2)/2
seek 后画面花屏落到 P/B 帧SeekMode.ClosestKeyframe
长按同时触发了单击手势未互斥LongPressGesture 触发即独占 onClick

5.3 性能要点

  1. 非响应式变量barWidthlastDragMoveTime 不参与渲染,用普通属性省掉依赖追踪;
  2. 热区零渲染成本:透明 Stack 无背景色,仅承担事件分发;
  3. 动画集中在两个时刻:Down 变粗、Up 回缩各一次 animateTo,Move 过程零动画(纯状态更新);
  4. 徽标条件渲染if (playbackRate >= 3) 只在长按期间存在节点,平时不占布局树。

六、总结

长按路径:
  按住 400ms → onVideoLongPress → playbackRate=3 + 徽标弹出
  松手       → onVideoLongPressEnd → playbackRate=1 + 徽标摘除

拖拽路径:
  Down → beginBarDrag(取当前进度为起点,条 2→10vp 弹性变粗)
  Move → updateBarDrag(touch.x / barWidth,夹取 [0,1])
  Up   → endBarDrag(setCurrentTime + ClosestKeyframe,条 10→2vp 回缩)
  [看门狗:onUpdate 心跳检测 1s 无触摸 → 自动 endBarDrag]
技术点实现方式关键 API
倍速播放状态绑定倍速参数currentProgressRate
长按手势时长阈值 + 触发/结束回调LongPressGesture(400ms)
徽标动画条件渲染 + 弹簧缩放animateTo + springMotion
拖拽进度哨兵值双源切换dragDisplayRatio = -1
精准热区视觉/触摸双层分离HitTestMode.None + onTouch
宽度实测布局回调取 vp 宽度onAreaChange
松手定位关键帧对齐 seeksetCurrentTime(ratio*duration, SeekMode.ClosestKeyframe)
事件丢失兜底播放心跳 + 活性计时onUpdate + Date.now()

核心收获

哨兵值分离进度来源——-1 一个值解决"拖拽值 vs 实时值"的切换,双 UI 共用一个 getter;
视觉与触摸分层——2vp 视觉 + 36vp 热区,HitTestMode.None 让两层互不感知;
围绕中心变粗——translate 补偿一半增量,进度条"膨胀"而不是"下沉";
看门狗自愈——手势冲突无法 100% 避免时,用播放心跳做状态机的活性检测,1 秒自愈;
关键帧 seek——ClosestKeyframe 是短视频定位的稳定性底线。

延伸方向:currentProgressRate 支持 0.5~4 的任意值,做倍速菜单(0.5x/1x/1.5x/2x/3x)只需把 playbackRate 的赋值来源从长按换成菜单选择;拖拽中也可以加时间预览气泡dragDisplayRatio * duration 格式化为 mm:ss,浮在滑块上方)。


技术栈:HarmonyOS Next | ArkTS | ArkUI | Video | VideoController | LongPressGesture
关键词:HarmonyOS | 短视频 | 倍速播放 | 进度条 | 自定义拖拽 | 手势冲突 | 视频播放

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

Logo

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

更多推荐