在HarmonyOS 6购物比价或电商类应用中,商品详情页常嵌入超长竖图(穿搭指南/说明书/海报)用 ScrollPanGesture自定义拖拽浏览。用户反馈"手指离开屏幕后图片立刻停住,没有惯性滚动感,滑动过程也一顿一顿的",抓 Trace 发现 PanGesture.onActionEnd只更新偏移量却未启动惯性动画(Fling),且每帧直接 this.offsetY = newVal未用 animateTo包裹导致跳变。

官方购物比价行业实践FAQ明确答复:PanGesture 离手时需用 PanGesture.onActionEnd中手指瞬时速度 velocityY计算惯性位移,并用 animateTo({curve:Friction/Spring})做续滚动画,才能产生自然惯性滑动感。本文将完整复现问题并给出标准修复方案(含 Scroll 原生与自定义 Pan 两种写法)。


一、现象:手指抬起长图立刻停 / 滑动有阻滞感

1. 问题现场(自定义 Pan 拖图)

// ❌ 仅有拖拽赋值,离手无惯性
@State offsetY: number = 0;

Column() {
  Image($r('app.media.long_goods_poster'))
    .width('100%')
}
.gesture(
  PanGesture()
    .onActionUpdate((e: GestureEvent) => {
      this.offsetY -= e.offsetY;  // 直接赋值
    })
    .onActionEnd(() => {
      // 空实现 → 抬起立刻停
    })
)
.translate({ x: 0, y: this.offsetY })

表现:

  • 手指拖得动,松手瞬间图像凝固

  • 快速甩动也无"继续滑一段"的惯性

  • 部分机型感觉微卡顿(每帧跳变未用动画)

2. 根因揭秘

问题

原因

松手立刻停

onActionEnd未根据 velocityY计算惯性位移并启动 animateTo

拖拽微卡顿

offsetY -= e.offsetY直接赋值(虽触发刷新但无插值),快速滑动时跳变更明显;包 animateTo({duration:0})或至少用状态驱动即可,关键是有无过重计算阻塞UI线程

Scroll 内嵌长图也微卡

图片未设 renderMode(ItemRenderMode.ALWAYS)或图片尺寸超大未做分块/缩采样;Scroll 原生已带惯性,一般不需自定义 Pan


二、解决方案一(推荐):直接用 Scroll + Image.renderMode

绝大多数商品长图场景不需要自定义 PanGesture,用 ScrollImage即可获得原生惯性:

// pages/LongImageViewPage.ets
@Entry
@Component
struct LongImageViewPage {
  build() {
    Column {
      // 顶部栏
      Row {
        Text('商品详情长图')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
      }
      .padding({ left: 16, top: 12, bottom: 8 })

      // ✅ Scroll 自带惯性 Fling
      Scroll() {
        Image($r('app.media.long_goods_poster'))
          .width('100%')
          // 关键:声明按内容重绘(长图推荐)
          .renderMode(ImageRenderMode.ORIGINAL)
      }
      .layoutWeight(1)
      .edgeEffect(EdgeEffect.Spring)  // 到顶/底回弹
      .backgroundColor('#F5F5F5')
    }
    .width('100%')
    .height('100%')
  }
}

若长图分辨率极高(>8000px高),建议服务端提供多档倍率或做瓦片切分,否则内存/首次渲染会有压力——但与惯性动画无关。


三、解决方案二(自定义查看器/地图平移):PanGesture + 离手惯性 Fling

当你需自定义缩放+平移控制器(如 Pinch+Pan 组合查看大图),不能用 Scroll 时,须自行实现惯性:

// components/ZoomPanViewer.ets
import { common } from '@kit.AbilityKit';

@Entry
@Component
struct ZoomPanViewer {
  // 平移偏移(vp)
  @State offsetY: number = 0;
  @State offsetX: number = 0;

  // 内容区高(可 onAreaChange 动态取)
  private contentH: number = 2400;
  private viewH: number = 680;     // 可视区高 vp

  // 惯性动画最大时长 ms
  private FLING_DURATION = 600;
  // 摩擦力系数(越小越快停)
  private FRICTION = 0.92;

  // 边界钳位
  private clampY(y: number): number {
    const maxY = Math.max(0, this.contentH - this.viewH);
    return Math.max(-maxY, Math.min(0, y));
  }

  build() {
    Stack() {
      Image($r('app.media.long_goods_poster'))
        .width('100%')
        .height(this.contentH)
        .objectFit(ImageFit.FitWidth)
    }
    .width('100%')
    .height(this.viewH)
    .clip(true)
    .translate({ x: this.offsetX, y: this.offsetY })
    // ===== 自定义 Pan =====
    .gesture(
      PanGesture({ direction: PanDirection.All })
        // 拖拽:实时跟手(不用 animateTo 包,避免拖拽粘滞)
        .onActionUpdate((e: GestureEvent) => {
          this.offsetX += e.offsetX;
          this.offsetY += e.offsetY;
          // 可选:实时边界阻尼(轻回弹可另做)
          this.offsetY = this.clampY(this.offsetY);
        })
        // ✅ 离手:根据手指末速度做惯性 Fling
        .onActionEnd((e: GestureEvent) => {
          // velocityY 单位 vp/s(正值=向下滑)
          let vy = e.velocityY ?? 0;
          // 估算惯性位移:简单物理模型 displacement = vy * t * friction_factor
          // 这里用 animateTo 做衰减位移更直观
          const flingDist = vy * (this.FLING_DURATION / 1000) * 0.35;
          const targetY = this.clampY(this.offsetY + flingDist);

          this.getUIContext().animateTo(
            {
              duration: this.FLING_DURATION,
              curve: Curve.Friction,     // ✅ 摩擦曲线 → 自然减速停
              onFinish: () => {
                // 最终钳位(防浮点越界)
                this.offsetY = this.clampY(this.offsetY);
              }
            },
            () => {
              this.offsetY = targetY;
            }
          );
        })
    )
    .backgroundColor('#000')
  }
}

关键实现解读

步骤

做法

跟手拖动

onActionUpdate直接 += offsetX/Y(不包 animateTo,保持零延迟跟手)

离手惯性

onActionEnd(e)e.velocityY→ 估算 flingDist = vy * kanimateTo({curve:Friction})移到目标

边界钳位

clampY()限制 [- (contentH-viewH), 0],在 animateTo 起止值 & onFinish 中都钳位

曲线选择

Curve.Friction(摩擦指数衰减)最接近原生 Scroll 惯性感;也可用 Cubic.bezier(0.25,0.1,0.25,1)微调

到顶/底手感

可 detect targetY === clampY(...)相同时不启动动画或做微 Spring 回弹


四、避坑指南

问题

原因

修复

松手立刻停

onActionEnd空实现或无 animateTo

按上文用 velocityY+ animateTo(Curve.Friction)做 Fling

拖拽微跳变卡顿

每帧大对象重建 / 图片 renderMode 默认未设

Image .renderMode(ImageRenderMode.ORIGINAL);避免在 onActionUpdate 中做重计算

惯性冲出边界

未钳位 targetY

clampY()同时用于起始/目标/ onFinish 修正

长图内存暴增

单张 Bitmap 超 4096×4096 某些设备

服务端提供合适分辨率;或 Web 组件嵌长图文(enableWholeWebPageDrawing)

想同时支持双指缩放

PinchGesture .followedBy(PanGesture)组合,缩放状态时 disable Pan 的 onActionEnd Fling

GestureMask.IgnoreInternal或状态标志位区分


五、总结:长图浏览卡顿 SOP

  1. 能用 Scroll 就用 Scroll​ → Image.renderMode(ORIGINAL)+ Scroll.edgeEffect(Spring),原生带惯性

  2. 自定义 Pan 查看器(大图/地图)

    • onActionUpdate直接跟手偏移

    • onActionEnd(e)→ 取 e.velocityY/XanimateTo({curve:Curve.Friction, duration~600ms})做惯性位移

    • 始终 clampY()边界约束

  3. 严禁onActionEnd只赋值不启动动画 → 必现"松手立刻停"

核心法则:HarmonyOS 6 中自定义拖拽长图惯性 = "PanGesture.onActionEnd 取 velocity → animateTo(Friction) 做 Fling 位移,onActionUpdate 只跟手不包动画",普通图文详情优先用 Scroll 原生惯性。

©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任。

Logo

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

更多推荐