前言

图片浏览、设计稿预览、文档查看,这几类页面有一个共同点,内容尺寸通常都会超过可视区域。用户想看细节,就得放大;内容放大之后,又得能自由拖动浏览。过去做这类交互,开发者往往要自己接 PinchGesture、算缩放中心、处理滚动和缩放之间的手势冲突,代码写起来不轻松,调试成本也不低。鸿蒙 6 在 API 20 这条线上把 Scroll 的手势缩放能力补进来了,相关接口已经进入 ArkUI 的新增能力范围。

这套能力落到工程里,主要解决两件事。第一,缩放这件事终于成了 Scroll 的原生能力,不需要继续靠外层手势和内部状态机拼接。第二,缩放后的自由滚动、边界回弹、状态回调都被统一进了同一套组件接口里,页面行为会稳定很多。

一、先把这套缩放能力的接口看清楚

Scroll 在 API 20 之后,和缩放直接相关的接口有四个属性和三个事件。四个属性分别是 maxZoomScaleminZoomScalezoomScaleenableBouncesZoom。其中 maxZoomScaleminZoomScale 控制缩放范围,zoomScale 用来设置或同步当前缩放比例,enableBouncesZoom 控制超出边界时是否保留缩放回弹效果。相关 API 已经列进 ArkUI 6.0.0(20) Beta3 的变更清单。

三个事件分别是 onZoomStartonDidZoomonZoomStop。这组事件的职责很清楚,开始缩放时通知一次,缩放过程中持续回调,结束时再通知一次。对开发者来说,这刚好覆盖了完整的交互生命周期,埋点、状态同步、缩放提示、工具栏联动都能接在这里。

这套能力还有一个前提条件,scrollable 必须设置成 ScrollDirection.FREE。原因也很直接,内容缩放之后,通常要同时支持横向和纵向滚动,自由滚动模式才能承接这种场景。自由滚动模式下拿到的内容总大小,是子组件缩放后的总大小。

如果项目里还要做版本兼容,这里还要多记一件事。华为给出的 API 兼容性判断说明里提到,只有当设备版本满足 API Level≥20 时,Scroll 才会设置这些缩放相关属性。也就是说,这套能力虽然写法统一,但项目如果要兼容更低版本,最好还是做一层 API 保护判断。

二、先把最小可用版本接起来

这类能力上手并不复杂,最小可用版本通常就是一个 Scroll 包一层内容组件,再把缩放范围和回调接进去。图片浏览是最典型的场景,直接看代码会更清楚。

@Entry
@Component
struct ZoomableImageViewer {
  @State currentScale: number = 1.0

  build() {
    Column() {
      Scroll() {
        Image($r('app.media.sample_landscape'))
          .width('100%')
          .objectFit(ImageFit.Contain)
      }
      .width('100%')
      .height('100%')
      .scrollable(ScrollDirection.FREE)
      .minZoomScale(0.5)
      .maxZoomScale(3.0)
      .enableBouncesZoom(true)
      .zoomScale(this.currentScale)
      .onZoomStart(() => {
        console.info('开始缩放')
      })
      .onDidZoom((scale) => {
        this.currentScale = scale
        console.info(`当前缩放比例: ${scale}`)
      })
      .onZoomStop(() => {
        console.info('结束缩放')
      })
    }
    .width('100%')
    .height('100%')
  }
}

这段代码接完之后,双指捏合缩放就已经能跑起来了。minZoomScale(0.5) 允许内容缩小到原始尺寸的一半,maxZoomScale(3.0) 允许放大到三倍,enableBouncesZoom(true) 则把超界后的回弹保留下来。对图片浏览这类页面来说,这一套默认行为已经够用了。

这里有一个很实用的点,zoomScale 可以和状态变量绑定。这样一来,页面既能在手势缩放时被动接收比例变化,也能主动通过代码修改比例。比如双击放大、点击恢复原始尺寸,这种交互就很好接。写法和普通动画状态切换没什么差别。

@State currentScale: number = 1.0

private zoomIn(): void {
  animateTo({ duration: 300 }, () => {
    this.currentScale = 2.0
  })
}

private resetZoom(): void {
  animateTo({ duration: 300 }, () => {
    this.currentScale = 1.0
  })
}

这类程序化缩放很适合文档预览和图片浏览器。用户双击一下放大,再双击一下恢复,交互会自然很多。

三、性能和滚动协同

手势缩放接起来很快,真正容易出问题的地方通常有两个。一个是 onDidZoom 回调太频繁,另一个是缩放和滚动同时存在时,页面状态收不住。

先说第一个。onDidZoom 在缩放过程中会持续触发,频率很高。这里如果直接塞重逻辑,比如图片解码、远程上报、复杂布局重算,页面很容易掉帧。更稳的处理方式,是让这个回调只做轻量级状态同步,把重逻辑放进防抖或节流处理里。

@State currentScale: number = 1.0
private zoomDebounceTimer: number = -1

private handleScaleChange(scale: number): void {
  this.currentScale = scale

  if (this.zoomDebounceTimer !== -1) {
    clearTimeout(this.zoomDebounceTimer)
  }

  this.zoomDebounceTimer = setTimeout(() => {
    console.info(`稳定后的缩放比例: ${scale}`)
    this.zoomDebounceTimer = -1
  }, 200)
}

这种处理方式很适合设计稿预览、长文档查看和高分辨率图片浏览。用户在缩放过程中保持流畅,业务逻辑在停下来后再补。

再说第二个。Scroll 这套能力默认已经把缩放和滚动的关系处理到比较顺了,正常情况下不需要开发者自己去管优先级。项目如果确实有特殊场景,比如缩放时暂时不希望滚动手势介入,可以用 enableScrollInteraction(false) 来临时关掉滚动交互。这个属性在 Scroll 通用接口里本来就是现成能力。

@Entry
@Component
struct ZoomStateDemo {
  @State isZooming: boolean = false

  build() {
    Scroll() {
      Image($r('app.media.sample_landscape'))
        .width('100%')
        .objectFit(ImageFit.Contain)
    }
    .scrollable(ScrollDirection.FREE)
    .minZoomScale(0.8)
    .maxZoomScale(3.0)
    .enableScrollInteraction(!this.isZooming)
    .onZoomStart(() => {
      this.isZooming = true
    })
    .onZoomStop(() => {
      this.isZooming = false
    })
  }
}

不过这类做法更适合引导型场景,普通图片浏览和文档预览一般没必要这么做。用户放大完内容,马上开始拖动查看,这是很自然的行为,强行截断滚动反而会破坏体验。

四、实际场景运用

这套能力最适合的场景其实很集中,图片浏览器、设计稿预览、PDF 或长图查看、地图类页面都很适合。关键点不在能不能缩放,在缩放之后内容是不是还好操作。

图片浏览器最简单,内容本身就是单一大图,直接放进 Scroll 里就能跑。设计稿预览则要更注意边界,通常需要更大的最大缩放比例,回弹也经常要关掉,这样方便用户看像素级细节。电子书和文档预览这类页面则更适合把缩放结果映射到字体、行高或整体容器比例上,而不是简单放大一张内容图。

如果是设计稿预览,下面这种写法会更稳一点。缩放范围更大,回弹关闭,预览行为也更可控。

@Entry
@Component
struct MockupPreviewPage {
  @State previewScale: number = 1.0

  build() {
    Scroll() {
      Image($r('app.media.design_mockup'))
        .width('100%')
        .objectFit(ImageFit.Contain)
    }
    .scrollable(ScrollDirection.FREE)
    .minZoomScale(0.25)
    .maxZoomScale(4.0)
    .zoomScale(this.previewScale)
    .enableBouncesZoom(false)
    .onDidZoom((scale) => {
      this.previewScale = scale
    })
  }
}

如果是文档阅读器,则更适合把缩放比例收成字体缩放系数,而不是只把整个内容区域拉大。这样处理之后,阅读体验会更稳定,文字也更容易保持清晰。

@Entry
@Component
struct ReaderPage {
  @State fontScale: number = 1.0
  private chapters: string[] = [
    '这是第一段内容。',
    '这是第二段内容。',
    '这是第三段内容。'
  ]

  build() {
    Scroll() {
      Column({ space: 12 }) {
        ForEach(this.chapters, (chapter: string) => {
          Text(chapter)
            .fontSize(16 * this.fontScale)
            .lineHeight(24 * this.fontScale)
        })
      }
      .width('100%')
      .padding(16)
    }
    .scrollable(ScrollDirection.FREE)
    .minZoomScale(0.8)
    .maxZoomScale(2.0)
    .zoomScale(this.fontScale)
    .onDidZoom((scale) => {
      this.fontScale = scale
    })
  }
}

这种写法更适合长文本场景,缩放结果直接体现在字体排版上,用户感知也更自然。

总结

鸿蒙 6 API 20 给 Scroll 补进的这套手势缩放能力,真正有价值的地方很明确。缩放范围有了原生属性,缩放状态有了完整回调,滚动协同也被统一进了组件本身。过去需要手工处理的缩放中心、优先级冲突、回弹边界,这一轮都被收进了组件接口里。

先把 scrollable 设成 ScrollDirection.FREE,再把缩放范围和回弹策略定下来,最后把 onDidZoom 里的逻辑压轻,别让高频回调拖慢界面。这样写下来,图片浏览、设计稿预览、文档查看这类页面会顺很多,代码也会比过去好维护得多。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐