本文同步发表于 微信公众号,微信搜索 程语新视界 即可关注,每个工作日都有文章更新

   嵌套滚动指在页面中多个独立滚动区域协同工作的交互模式。当用户在Web组件区域滚动时,能够联动其他ArkUI滚动区域,实现上下左右全方位的流畅滑动。

支持的滚动容器

Web组件可内嵌于以下滚动容器:

  • Grid(网格布局)

  • List(列表)

  • Scroll(滚动容器)

  • Swiper(轮播)

  • Tabs(选项卡)

  • WaterFlow(瀑布流)

  • Refresh(下拉刷新)

  • bindSheet(底部工作表)

前提

当Web组件使用全量展开模式layoutMode = WebLayoutMode.FIT_CONTENT)时,必须显式设置渲染模式:

Web({ 
  src: '...',
  controller: this.controller 
})
.layoutMode(WebLayoutMode.FIT_CONTENT)
.renderMode(RenderMode.SYNC_RENDER)  // 必须设置!

二、两种实现方式

特性 1:nestedScroll属性 2:滚动偏移量统一派发
实现复杂度 简单 复杂
控制粒度 粗粒度(方向优先) 细粒度(像素级控制)
适用场景 简单联动需求 复杂自定义控制
推荐度 优先考虑 特殊需求使用

建议

  • 简单联动:使用方案1(nestedScroll属性)

  • 复杂控制:使用方案2(手动派发偏移量)

三、方案1:使用nestedScroll属性实现嵌套滚动

3.1 原理

通过设置Web组件的nestedScroll属性,定义滚动优先级,系统自动处理滚动事件分发。

3.2 属性:NestedScrollMode

枚举值 说明
SELF_FIRST 当前组件优先滚动
PARENT_FIRST 父组件优先滚动
SELF_ONLY 仅当前组件滚动
PARENT_ONLY 仅父组件滚动
PARALLEL 父子组件同时滚动
NEVER 不参与嵌套滚动

3.3 代码示例

import { webview } from '@kit.ArkWeb';

@Entry
@ComponentV2
struct NestedScroll {
  private scrollerForScroll: Scroller = new Scroller();
  private listScroller: Scroller = new Scroller();
  controller: webview.WebviewController = new webview.WebviewController();
  
  @Local arr: Array<number> = [];
  
  aboutToAppear(): void {
    for (let i = 0; i < 10; i++) {
      this.arr.push(i);
    }
  }
  
  build() {
    Scroll(this.scrollerForScroll) {
      Column() {
        // Web组件:设置嵌套滚动策略
        Web({ 
          src: $rawfile('scroll.html'), 
          controller: this.controller 
        })
        .nestedScroll({
          scrollUp: NestedScrollMode.PARENT_FIRST,    // 向上:父组件优先
          scrollDown: NestedScrollMode.SELF_FIRST,    // 向下:子组件优先
          // 还可设置:scrollLeft, scrollRight
        })
        .height('100%')
        
        // 其他滚动区域
        Repeat<number>(this.arr)
          .each((item: RepeatItem<number>) => {
            Text('Scroll Area')
              .width('100%')
              .height('40%')
              .backgroundColor(0X330000FF)
              .fontSize(16)
              .textAlign(TextAlign.Center)
          })
      }
    }
  }
}

3.4 运行效果

  • 向上滚动:父Scroll组件先滚动,Web组件后滚动

  • 向下滚动:Web组件先滚动,父Scroll组件后滚动

四、方案2:滚动偏移量由父组件统一派发

4.1 原理

完全手动控制滚动行为:

  1. 禁用Web和List的默认滚动

  2. 在Scroll组件的onScrollFrameBegin中判断滚动状态

  3. 手动将偏移量派发给目标组件

4.2 滚动逻辑

向上滑动(offset > 0):
  1. Web未到底部 → 偏移量派发给Web,Scroll自身不滚

  2. Web已到底部,Scroll未到底部 → 仅Scroll自身滚动

  3. Scroll已到底部 → 偏移量派发给List,Scroll不滚

向下滑动(offset < 0):
  1. List未到顶部 → 偏移量派发给List,Scroll自身不滚

  2. List已到顶部,Scroll未到顶部 → 仅Scroll自身滚动

  3. Scroll已到顶部 → 偏移量派发给Web,Scroll不滚

4.3 代码实现

4.3.1 禁用Web组件滚动
// 1. 设置Web组件禁用触摸滚动
this.webController.setScrollable(false, webview.ScrollType.EVENT);

// 2. 拦截Web组件的滑动手势
.onGestureRecognizerJudgeBegin((
  event: BaseGestureEvent, 
  current: GestureRecognizer,
  otherArray<GestureRecognizer>
) => {
  if (current.isBuiltIn() && 
      current.getType() == GestureControl.GestureType.PAN_GESTURE) {
    return GestureJudgeResult.REJECT;  // 拒绝Web自带手势
  }
  return GestureJudgeResult.CONTINUE;
})
4.3.2 禁用List组件手势
List({ scroller: this.listScroller }) {
  // ... 列表内容
}
.enableScrollInteraction(false)  // 禁用List的手势交互
4.3.3 边界检测方法

List/Scroll组件边界检测

// 滚动到上边界
scroller.currentOffset().yOffset <= 0;

// 滚动到下边界
scroller.isAtEnd() == true;

Web组件边界检测

// 获取Web组件信息
private webHeight: number = 0;

// 获取Web窗口高度
this.webController?.runJavaScriptExt('window.innerHeight', 
  (error, result) => {
    if (result.getType() === webview.JsMessageType.NUMBER) {
      this.webHeight = result.getNumber();
    }
  });

// 判断是否滚动到顶部
webController.getPageOffset().y == 0;

// 判断是否滚动到底部
webController.getPageOffset().y + this.webHeight >= webController.getPageHeight();
4.3.4 滚动控制与偏移量派发

阻止Scroll组件滚动

.onScrollFrameBegin((offset: number, state: ScrollState) => {
  // 通过返回 offsetRemain: 0 阻止Scroll自身滚动
  return { offsetRemain: 0 };
})

向List派发偏移量

this.listScroller.scrollBy(0, offset);

向Web派发偏移量

this.webController.scrollBy(0, offset);

4.4 完整实现

import { webview } from '@kit.ArkWeb';

@Entry
@ComponentV2
struct Index {
  private scroller: Scroller = new Scroller()
  private listScroller: Scroller = new Scroller()
  private webController: webview.WebviewController = new webview.WebviewController()
  private isWebAtEnd: boolean = false
  private webHeight: number = 0
  
  @Local arr: Array<number> = []
  
  aboutToAppear(): void {
    for (let i = 0; i < 10; i++) {
      this.arr.push(i)
    }
  }
  
  // 获取Web窗口高度
  getWebHeight() {
    try {
      this.webController?.runJavaScriptExt('window.innerHeight',
        (error, result) => {
          if (result?.getType() === webview.JsMessageType.NUMBER) {
            this.webHeight = result.getNumber()
          }
        })
    } catch (error) {
      console.error('获取Web高度失败:', error)
    }
  }
  
  // 检查Web是否滚动到底部
  checkScrollBottom() {
    this.isWebAtEnd = false;
    if (this.webController.getPageOffset().y + this.webHeight >= 
        this.webController.getPageHeight()) {
      this.isWebAtEnd = true;
    }
  }
  
  build() {
    Scroll(this.scroller) {
      Column() {
        Web({
          src: $rawfile('scroll.html'),
          controller: this.webController,
        })
        .height('100%')
        .bypassVsyncCondition(WebBypassVsyncCondition.SCROLLBY_FROM_ZERO_OFFSET)
        .onPageEnd(() => {
          // 页面加载完成后禁用Web滚动
          this.webController.setScrollable(false, webview.ScrollType.EVENT);
          this.getWebHeight();
        })
        .onGestureRecognizerJudgeBegin((event, current, others) => {
          // 拦截Web的滑动手势
          if (current.isBuiltIn() && 
              current.getType() == GestureControl.GestureType.PAN_GESTURE) {
            return GestureJudgeResult.REJECT;
          }
          return GestureJudgeResult.CONTINUE;
        })
        
        List({ scroller: this.listScroller }) {
          Repeat<number>(this.arr)
            .each((item: RepeatItem<number>) => {
              ListItem() {
                Text('Scroll Area')
                  .width('100%')
                  .height('40%')
                  .backgroundColor(0X330000FF)
                  .fontSize(16)
                  .textAlign(TextAlign.Center)
              }
            })
        }
        .height('100%')
        .maintainVisibleContentPosition(true)
        .enableScrollInteraction(false)  // 禁用List手势
      }
    }
    .onScrollFrameBegin((offset: number, state: ScrollState) => {
      this.checkScrollBottom();
      
      // 向上滚动逻辑
      if (offset > 0) {
        if (!this.isWebAtEnd) {
          // Web未到底部:滚动Web
          this.webController.scrollBy(0, offset)
          return { offsetRemain: 0 }
        } else if (this.scroller.isAtEnd()) {
          // Scroll已到底部:滚动List
          this.listScroller.scrollBy(0, offset)
          return { offsetRemain: 0 }
        }
      } 
      // 向下滚动逻辑
      else if (offset < 0) {
        if (this.listScroller.currentOffset().yOffset > 0) {
          // List未到顶部:滚动List
          this.listScroller.scrollBy(0, offset)
          return { offsetRemain: 0 }
        } else if (this.scroller.currentOffset().yOffset <= 0) {
          // Scroll已到顶部:滚动Web
          this.webController.scrollBy(0, offset)
          return { offsetRemain: 0 }
        }
      }
      
      // 其他情况:Scroll自身滚动
      return { offsetRemain: offset }
    })
  }
}

五、方案对比

特性 方案1 (nestedScroll) 方案2 (手动派发)
代码复杂度 低(几行代码) 高(需完整控制逻辑)
控制精度 方向级控制 像素级精确控制
性能 系统优化,性能好 需手动优化,易有性能问题
灵活性 有限(预设模式) 极高(完全自定义)
维护成本
适用场景 80%常规需求 20%特殊复杂需求

常见问题

6.1 Web组件滚动卡顿

  • 解决:确保设置了bypassVsyncCondition优化

6.2 边界检测不准确

  • 问题:Web是否到底部判断错误

  • 解决:确保getWebHeight()在页面加载完成后调用

6.3 手势冲突

  • 问题:Web和ArkUI组件同时响应手势

  • 解决:彻底禁用Web手势(setScrollable + onGestureRecognizerJudgeBegin

6.4 内存泄漏

  • 问题:事件监听未清理

  • 解决:在aboutToDisappear中清理所有监听

Logo

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

更多推荐