已合并
feat: support nested scroll for PullToRefresh #88
feat: support nested scroll for PullToRefresh #88
已合并
linsanjiu创建于 5月21日
共 11 个文件变更+512-29
MREADME.md+33-8
@@ -2,7 +2,7 @@
2 2 
3## Introduction3## Introduction
4 4 
5-PullToRefresh is a pull-to-refresh and load-more component available in the OpenHarmony environment. It supports setting various properties for built-in animations, supports custom animations, and supports using LazyForEach data as a data source.5+PullToRefresh is a pull-to-refresh and pull-up-to-load-more component for OpenHarmony ArkUI. It supports built-in animation configuration, custom animations, and LazyForEach data sources.
6 6 
7## Demo7## Demo
8 8 
@@ -37,7 +37,7 @@ Verified in the following versions:
37## Usage Example37## Usage Example
38 38 
39```typescript39```typescript
40-// How to use the V1 decorator 40+// Usage with @Component
41import { PullToRefresh } from '@ohos/pulltorefresh'41import { PullToRefresh } from '@ohos/pulltorefresh'
42 42 
43// Create a scroller instance.43// Create a scroller instance.
@@ -77,7 +77,7 @@ PullToRefresh({
77 customRefresh: null,77 customRefresh: null,
78})78})
79 79
80-// How to use the V2 decorator 80+// Usage with @ComponentV2
81import { PullToRefreshV2 } from '@ohos/pulltorefresh'81import { PullToRefreshV2 } from '@ohos/pulltorefresh'
82 82 
83// Create a scroller instance.83// Create a scroller instance.
@@ -120,15 +120,39 @@ PullToRefreshV2({
120 120 
121For more examples, please see [example code](https://gitcode.com/openharmony-sig/ohos_pull_to_refresh/tree/master/entry/src/main/ets/pages).121For more examples, please see [example code](https://gitcode.com/openharmony-sig/ohos_pull_to_refresh/tree/master/entry/src/main/ets/pages).
122 122 
123+### bindSheet Pull-Down Dismiss
124+ 
125+When `PullToRefresh` is used inside a half-modal `bindSheet` and the sheet needs to be dismissed by pulling down, disable pull-down refresh with the existing configuration: `new PullToRefreshConfigurator().setHasRefresh(false)`. After pull-down refresh is disabled, the component handles only pull-up loading gestures and does not intercept downward pan gestures.
126+ 
127+ArkUI also requires the scrollable container in the sheet to configure `nestedScroll`. `PullToRefresh` / `PullToRefreshV2` provide the boolean property `enableNestedScroll`. When set to `true`, the internal main `Scroll` applies the default nestedScroll configuration (`scrollForward` and `scrollBackward` are both `NestedScrollMode.SELF_FIRST`).
128+ 
129+```typescript
130+private refreshConfigurator = new PullToRefreshConfigurator()
131+ .setHasRefresh(false);
132+ 
133+PullToRefresh({
134+ data: $data,
135+ scroller: this.scroller,
136+ enableNestedScroll: true,
137+ refreshConfigurator: this.refreshConfigurator,
138+ customList: () => { this.getContentView(); },
139+ // ...
140+})
141+```
142+ 
143+See `pages/bindSheetPullRefresh.ets` in the entry module for a full sample.
144+ 
145+**Note:** Pull-down refresh and pull-down sheet dismissal both use downward gestures. In sheet-dismiss scenarios, disable pull-down refresh (`setHasRefresh(false)`). Pull-up loading uses an upward gesture and is not affected; it remains functional via `Scroll.onReachEnd` when `enableNestedScroll` is enabled. `setHasRefresh(false)` does not enable nested scrolling by itself. To coordinate nested scrolling between `bindSheet` and the internal Scroll, set `enableNestedScroll: true`. When enabled, pull-down gestures based on `scroller` are not handled; `customList` is wrapped by the internal `Scroll`, and do not bind the same `scroller` to another `List`, `Scroll`, or other scrollable container inside `customList`. V2 sample: `pages/bindSheetPullRefreshV2.ets`.
146+ 
123## Usage Instructions147## Usage Instructions
124 148 
125-### PullToRefresh Component (V1 Decorator)149+### PullToRefresh Component (@Component)
126 150 
127-The PullToRefresh component is used to wrap List, Scroll, Tabs, Grid, or WaterFlow components to provide pull-down refresh and pull-up load more functionality.151+The PullToRefresh component wraps List, Scroll, Tabs, Grid, or WaterFlow components to provide pull-down refresh and pull-up load-more functionality.
128 152 
129-### PullToRefreshV2 Component (V2 Decorator)153+### PullToRefreshV2 Component (@ComponentV2)
130 154 
131-The PullToRefreshV2 component has similar functionality to PullToRefresh but is adapted to the new API specification.155+PullToRefreshV2 provides the same refresh and load-more capabilities as PullToRefresh and targets @ComponentV2 state management.
132 156 
133### LazyForEach Data Source Support157### LazyForEach Data Source Support
134 158 
@@ -176,9 +200,10 @@ For specific usage, please refer to the OpenHarmony official documentation: [Laz
176 200 
177| Property | Type | Description | Default | Required | OpenHarmony Platform Support |201| Property | Type | Description | Default | Required | OpenHarmony Platform Support |
178|----------|------|-------------|---------|----------|------------------------------|202|----------|------|-------------|---------|----------|------------------------------|
179-| data | Object[] \| undefined | Data bound to the list or grid component | undefined | Partial (required in PullToRefresh, not required in PullToRefreshV2) | Yes |203+| data | Object[] \| undefined | Data bound to the list or grid component | undefined | Yes for PullToRefresh; No for PullToRefreshV2 | Yes |
180| scroller | Scroller | Scroller object bound to the list or grid component | undefined | Yes | Yes |204| scroller | Scroller | Scroller object bound to the list or grid component | undefined | Yes | Yes |
181| customList | () => void | Custom main layout, containing list or grid component | undefined | Yes | Yes |205| customList | () => void | Custom main layout, containing list or grid component | undefined | Yes | Yes |
206+| enableNestedScroll | boolean | Whether to enable the built-in nested scroll mode; when enabled, the main area uses `Scroll` and applies the default nestedScroll configuration (`scrollForward` and `scrollBackward` are both `NestedScrollMode.SELF_FIRST`). Pull-down refresh is disabled; pull-up loading is supported via `Scroll.onReachEnd` | false | No | Yes |
182| refreshConfigurator | PullToRefreshConfigurator | Component property configuration | PullToRefreshConfigurator | No | Yes |207| refreshConfigurator | PullToRefreshConfigurator | Component property configuration | PullToRefreshConfigurator | No | Yes |
183| mWidth | Length | Container width | undefined (adaptive) | No | Yes |208| mWidth | Length | Container width | undefined (adaptive) | No | Yes |
184| mHeight | Length | Container height | undefined (adaptive) | No | Yes |209| mHeight | Length | Container height | undefined (adaptive) | No | Yes |
@@ -2,7 +2,7 @@
2 2 
3## 简介3## 简介
4 4 
5-PullToRefresh 是一款 OpenHarmony 环境下可用的下拉刷新、上拉加载组件。支持设置内置动画的各种属性,支持设置自定义动画,支持 LazyForEach 的数据作为数据源。5+PullToRefresh 是基于 OpenHarmony ArkUI 的下拉刷新、上拉加载组件。支持配置内置动画属性、自定义动画,以及 LazyForEach 数据源。
6 6 
7## 效果展示7## 效果展示
8 8 
@@ -37,7 +37,7 @@ OpenHarmony ohpm 环境配置等更多内容,请参考[如何安装 OpenHarmon
37## 使用示例37## 使用示例
38 38 
39```typescript39```typescript
40-// V1装饰器下的使用方式40+// @Component 组件的使用方式
41import { PullToRefresh } from '@ohos/pulltorefresh'41import { PullToRefresh } from '@ohos/pulltorefresh'
42 42 
43// 需绑定列表或宫格组件43// 需绑定列表或宫格组件
@@ -77,7 +77,7 @@ customLoad: null,
77customRefresh: null,77customRefresh: null,
78})78})
79 79 
80-// V2装饰器下的使用方式80+// @ComponentV2 组件的使用方式
81import { PullToRefreshV2 } from '@ohos/pulltorefresh'81import { PullToRefreshV2 } from '@ohos/pulltorefresh'
82 82 
83// 需绑定列表或宫格组件83// 需绑定列表或宫格组件
@@ -120,15 +120,39 @@ PullToRefreshV2({
120 120 
121更多示例请查看 [示例代码](https://gitcode.com/openharmony-sig/ohos_pull_to_refresh/tree/master/entry/src/main/ets/pages)。121更多示例请查看 [示例代码](https://gitcode.com/openharmony-sig/ohos_pull_to_refresh/tree/master/entry/src/main/ets/pages)。
122 122 
123+### 半模态 bindSheet 下拉关闭
124+ 
125+在半模态 `bindSheet` 内使用 `PullToRefresh` 时,若需要下拉关闭 Sheet,可通过已有配置关闭下拉刷新:`new PullToRefreshConfigurator().setHasRefresh(false)`。关闭后,组件仅处理上拉加载手势,不会拦截向下拖动手势。
126+ 
127+同时,ArkUI 要求半模态中的滚动容器配置 `nestedScroll`。`PullToRefresh` / `PullToRefreshV2` 提供布尔属性 `enableNestedScroll`;设置为 `true` 后,组件内部主体 `Scroll` 会应用默认 nestedScroll 配置(`scrollForward` 与 `scrollBackward` 均为 `NestedScrollMode.SELF_FIRST`)。
128+ 
129+```typescript
130+private refreshConfigurator = new PullToRefreshConfigurator()
131+ .setHasRefresh(false);
132+ 
133+PullToRefresh({
134+ data: $data,
135+ scroller: this.scroller,
136+ enableNestedScroll: true,
137+ refreshConfigurator: this.refreshConfigurator,
138+ customList: () => { this.getContentView(); },
139+ // ...
140+})
141+```
142+ 
143+完整示例见 entry 中的 `pages/bindSheetPullRefresh.ets`。
144+ 
145+**说明**:下拉刷新与下拉关闭 Sheet 均为向下手势,半模态关闭场景建议关闭下拉刷新(`setHasRefresh(false)`)。上拉加载为向上手势,不受影响,开启 `enableNestedScroll` 后仍可通过 `Scroll.onReachEnd` 正常触发。`setHasRefresh(false)` 不会自动启用嵌套滚动;若需要 `bindSheet` 与内部 Scroll 协同,需设置 `enableNestedScroll: true`。开启后组件不再基于 `scroller` 处理下拉手势,`customList` 由内部 `Scroll` 承载,且不要在 `customList` 内将同一 `scroller` 绑定到 `List`、`Scroll` 等滚动容器。V2 示例见 `pages/bindSheetPullRefreshV2.ets`。
146+ 
123## 使用说明147## 使用说明
124 148 
125-### PullToRefresh 组件(V1 装饰器)149+### PullToRefresh 组件(@Component)
126 150 
127-PullToRefresh 组件用于包装 List、Scroll、Tabs、Grid 或 WaterFlow 组件,提供下拉刷新和上拉加载更多功能。151+PullToRefresh 用于包装 List、Scroll、Tabs、Grid 或 WaterFlow 组件,提供下拉刷新和上拉加载更多功能。
128 152 
129-### PullToRefreshV2 组件(V2 装饰器)153+### PullToRefreshV2 组件(@ComponentV2)
130 154 
131-PullToRefreshV2 组件功能与 PullToRefresh 类似,但适配了新的 API 规范。155+PullToRefreshV2 提供与 PullToRefresh 相同的刷新与加载能力,面向 @ComponentV2 状态管理能力。
132 156 
133### LazyForEach 数据源支持157### LazyForEach 数据源支持
134 158 
@@ -176,14 +200,15 @@ interface DataChangeListener {
176 200 
177| 属性 | 类型 | 描述 | 默认值 | 必填 | OpenHarmony 平台支持 |201| 属性 | 类型 | 描述 | 默认值 | 必填 | OpenHarmony 平台支持 |
178|-----|------|------|--------|------|-------------------|202|-----|------|------|--------|------|-------------------|
179-| data | Object[] \| undefined | 列表或宫格组件所绑定的数据 | undefined | 部分(在PullToRefresh中是必填项,在PullToRefreshV2中不是必填项) | 是 |203+| data | Object[] \| undefined | 列表或宫格组件所绑定的数据 | undefined | PullToRefresh:是;PullToRefreshV2:否 | 是 |
180| scroller | Scroller | 列表或宫格组件所绑定的 Scroller 对象 | undefined | 是 | 是 |204| scroller | Scroller | 列表或宫格组件所绑定的 Scroller 对象 | undefined | 是 | 是 |
181| customList | () => void | 自定义主体布局,内部有列表或宫格组件 | undefined | 是 | 是 |205| customList | () => void | 自定义主体布局,内部有列表或宫格组件 | undefined | 是 | 是 |
206+| enableNestedScroll | boolean | 是否启用组件内置嵌套滚动;开启后主体使用 `Scroll` 承载,并应用默认 nestedScroll 配置(`scrollForward` 与 `scrollBackward` 均为 `NestedScrollMode.SELF_FIRST`)。下拉刷新不生效,上拉加载通过 `Scroll.onReachEnd` 触发 | false | 否 | 是 |
182| refreshConfigurator | PullToRefreshConfigurator | 组件属性配置 | PullToRefreshConfigurator | 否 | 是 |207| refreshConfigurator | PullToRefreshConfigurator | 组件属性配置 | PullToRefreshConfigurator | 否 | 是 |
183| mWidth | Length | 容器宽度 | undefined(自适应) | 否 | 是 |208| mWidth | Length | 容器宽度 | undefined(自适应) | 否 | 是 |
184| mHeight | Length | 容器高度 | undefined(自适应) | 否 | 是 |209| mHeight | Length | 容器高度 | undefined(自适应) | 否 | 是 |
185-| onRefresh | () => Promise\<string\> | 下拉刷新回调 | 1秒后后结束下拉刷新动画并提示"刷新失败" | 否 | 是 |210+| onRefresh | () => Promise\<string\> | 下拉刷新回调 | 1 秒后结束下拉刷新动画并提示"刷新失败" | 否 | 是 |
186-| onLoadMore | () => Promise\<string\> | 上拉加载更多回调 | 1秒后后结束上拉加载动画 | 否 | 是 |211+| onLoadMore | () => Promise\<string\> | 上拉加载更多回调 | 1 秒后结束上拉加载动画 | 否 | 是 |
187| customRefresh | () => void | 自定义下拉刷新动画布局 | undefined | 否 | 是 |212| customRefresh | () => void | 自定义下拉刷新动画布局 | undefined | 否 | 是 |
188| onAnimPullDown | (value?: number, width?: number, height?: number) => void \| undefined | 下拉中回调 | undefined | 否 | 是 |213| onAnimPullDown | (value?: number, width?: number, height?: number) => void \| undefined | 下拉中回调 | undefined | 否 | 是 |
189| onAnimRefreshing | (value?: number, width?: number, height?: number) => void \| undefined | 刷新中回调 | undefined | 否 | 是 |214| onAnimRefreshing | (value?: number, width?: number, height?: number) => void \| undefined | 刷新中回调 | undefined | 否 | 是 |
@@ -0,0 +1,123 @@
1+/*
2+ * Copyright (C) 2026 Huawei Device Co., Ltd.
3+ * Licensed under the Apache License, Version 2.0 (the "License");
4+ * you may not use this file except in compliance with the License.
5+ * You may obtain a copy of the License at
6+ *
7+ * http://www.apache.org/licenses/LICENSE-2.0
8+ *
9+ * Unless required by applicable law or agreed to in writing, software
10+ * distributed under the License is distributed on an "AS IS" BASIS,
11+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ * See the License for the specific language governing permissions and
13+ * limitations under the License.
14+ */
15+import { PullToRefresh, PullToRefreshConfigurator } from '@ohos/pulltorefresh'
16+ 
17+@Entry
18+@Component
19+struct BindSheetPullRefresh {
20+ @State sheetVisible: boolean = false;
21+ @State useBindSheetMode: boolean = false;
22+ @State data: Array<string> = ['item 1', 'item 2', 'item 3', 'item 4', 'item 5'];
23+ // 嵌套滚动模式:仅交给 PullToRefresh 内部 Scroll,customList 内不可再绑定 scroller
24+ private nestedScrollScroller: Scroller = new Scroller();
25+ // 未配置模式:PullToRefresh 与 customList 内 List 共用同一 scroller
26+ private listModeScroller: Scroller = new Scroller();
27+ private defaultConfigurator: PullToRefreshConfigurator = new PullToRefreshConfigurator();
28+ private bindSheetConfigurator: PullToRefreshConfigurator = new PullToRefreshConfigurator()
cpf-manager
cpf-managercpf-manager6月5日

【AI-Review】【一般】【业务功能】【功能实现错误】示例页面未展示 enableNestedScroll + 上拉加载场景

● 问题:bindSheetConfigurator 设置了 .setHasLoadMore(false),当 useBindSheetMode 为 true 时,onReachEnd 中的 this.refreshConfigurator?.getHasLoadMore() 为 false,上拉加载不会触发。但 PR 描述中明确说明"开启后,上拉加载改为通过 Scroll.onReachEnd 自动触发(onLoadMore 仍可用)",示例未能展示该核心功能场景。

● 影响:一般。开发者无法从示例中了解如何在 enableNestedScroll 模式下使用上拉加载功能,可能导致误用。

● 建议:增加一个 enableNestedScroll + setHasLoadMore(true) 的示例场景,或者在现有示例中将 bindSheetConfigurator 的 setHasLoadMore 设为 true,以完整展示嵌套滚动下的上拉加载能力。

likedislike
29+ .setHasRefresh(false);
30+ 
31+ build() {
32+ Column({ space: 16 }) {
33+ Button('未配置模式(原始 List)')
34+ .onClick(() => {
35+ this.useBindSheetMode = false;
36+ this.sheetVisible = true;
37+ })
38+ Button('开启嵌套滚动模式')
39+ .onClick(() => {
40+ this.useBindSheetMode = true;
41+ this.sheetVisible = true;
cpf-manager
cpf-managercpf-manager6月5日

【AI-Review】【一般】【业务功能】【功能实现错误】示例页面bindSheetConfigurator设置setHasLoadMore(false)导致无法验证onReachEnd加载功能

● 问题:在示例页面 bindSheetPullRefresh.ets 和 bindSheetPullRefreshV2.ets 中,嵌套滚动模式使用的 bindSheetConfigurator 同时设置了 setHasRefresh(false) 和 setHasLoadMore(false)。而 onReachEnd 回调中的加载触发条件是 this.refreshConfigurator?.getHasLoadMore() && this.onLoadMore,由于 getHasLoadMore() 返回 false,onReachEnd 中的加载逻辑永远不会被执行。这意味着:

  1. 示例页面无法验证 enableNestedScroll + onReachEnd + onLoadMore 的集成是否正常工作
  2. 如果该集成存在 Bug(如加载动画显示异常、onLoadMore 不触发等),在当前示例中无法被发现
  3. 用户参考此示例时,无法了解如何在嵌套滚动模式下正确使用上拉加载功能

● 影响:一般。示例页面未能完整演示嵌套滚动模式的核心功能(上拉加载 via onReachEnd),可能导致该路径的 Bug 未被覆盖,也会误导开发者认为嵌套滚动模式下不支持上拉加载。

● 建议:修改示例中嵌套滚动模式的 configurator,保留 setHasRefresh(false) 但去掉 setHasLoadMore(false)(或显式设为 true),使 onReachEnd 加载路径可被验证:

private bindSheetConfigurator: PullToRefreshConfigurator = new PullToRefreshConfigurator()
  .setHasRefresh(false);

或者,如果确实需要展示"仅滚动、无加载"的场景,建议增加第三个按钮来演示"嵌套滚动+上拉加载"模式。

likedislike
42+ })
43+ }
44+ .width('100%')
45+ .height('100%')
46+ .justifyContent(FlexAlign.Center)
47+ .bindSheet(this.sheetVisible, this.sheetContent(), {
48+ height: SheetSize.MEDIUM,
49+ dragBar: true,
50+ onDisappear: () => {
51+ this.sheetVisible = false;
52+ }
53+ })
54+ }
55+ 
56+ @Builder
57+ private sheetContent() {
58+ Column() {
59+ PullToRefresh({
60+ data: $data,
61+ scroller: this.useBindSheetMode ? this.nestedScrollScroller : this.listModeScroller,
62+ enableNestedScroll: this.useBindSheetMode,
63+ refreshConfigurator: this.useBindSheetMode ? this.bindSheetConfigurator : this.defaultConfigurator,
64+ customList: () => {
65+ if (this.useBindSheetMode) {
66+ this.sheetColumn();
67+ } else {
68+ this.sheetList();
69+ }
70+ },
cpf-manager
cpf-managercpf-manager6月10日

【AI-Review】【一般】【基础代码问题】【代码逻辑错误】bindSheetPullRefresh 示例中 List 和 PullToRefresh 共享 scroller 导致冲突

● 问题: bindSheetPullRefresh.ets 中 sheetList() 创建了一个 List({ space: 12, scroller: this.scroller }),而外部 PullToRefresh 也使用了同一个 this.scroller。在 nestedScroll 模式下,PullToRefresh 内部创建 Scroll(this.scroller),而 List 本身也是滚动组件且使用了同一个 scroller。这会导致 scroller 被两个滚动容器共享,scrollTo、scrollEdge、currentOffset、isAtEnd 等操作的行为将不可预测——PullToRefresh 中通过 this.scroller!.currentOffset() 获取的可能是 List 的偏移量,也可能是 Scroll 的偏移量。

● 影响: 一般。作为示例代码,可能误导使用者。在实际使用中,当 enableNestedScroll 为 true 时,不应在 customList 内部的 List 上设置 scroller,因为 PullToRefresh 已经在外部 Scroll 上使用了该 scroller。

● 建议: 在 nestedScroll 模式下,内部的 List 不应设置 scroller,因为滚动由外部的 Scroll 管理。建议在示例中区分两种模式的 List 创建方式:

@Builder
private sheetList() {
  List({ space: 12 }) {
    // nestedScroll 模式下不传 scroller
    // ...
  }
  .edgeEffect(EdgeEffect.None)
}

或在文档中明确说明:当 enableNestedScroll 为 true 时,customList 中的滚动组件不应使用与 PullToRefresh 相同的 scroller。

likedislike
71+ onLoadMore: () => {
72+ return new Promise<string>((resolve) => {
73+ setTimeout(() => {
74+ this.data = [...this.data, `more ${this.data.length}`];
75+ resolve('');
76+ }, 800);
cpf-manager
cpf-managercpf-manager6月3日

【AI-Review】【一般】【基础代码问题】【代码逻辑错误】示例代码 sheetList() 在非嵌套模式下 scroller 双重绑定,与 README 警告矛盾

● 问题:示例 bindSheetPullRefresh.ets 中,sheetList() 将同一个 this.scroller 同时传给 PullToRefresh({ scroller: this.scroller, ... }) 和内部的 List({ space: 12, scroller: this.scroller })。这正是 README 中警告要避免的场景:"customList 内部不要再将同一个 scroller 绑定到 List、Scroll 或其他滚动容器"。但示例代码在非嵌套模式(useBindSheetMode=false)下恰恰是这样做的。这会给开发者传递矛盾的信息:README 说不要双重绑定,但官方示例就在双重绑定。

实际上,在非嵌套模式下,由于组件内部没有创建 Scroll 容器,scroller 只绑定到 List 一处,这是原有设计——用户传入 scroller,组件用它来检测偏移和执行 scrollEdge/scrollTo,List 也用这个 scroller 做滚动控制,两者操作的是同一个滚动容器,所以不冲突。但问题在于,当切换到嵌套滚动模式时,useBindSheetMode 从 false 变为 true,同一组件实例的 scroller 被额外绑定到了内部的 Scroll,而 sheetList() 中的 List 仍然持有该 scroller,导致双重绑定。

● 影响:一般。示例代码作为开发者参考存在设计缺陷,在模式切换场景下可能引发 scroller 冲突。同时,README 的警告措辞需要更精确——不是"不要双重绑定",而是"在嵌套滚动模式下不要双重绑定"。

● 建议:1) 修改示例代码,为嵌套滚动模式使用独立的 scroller:

private scrollScroller: Scroller = new Scroller();  // 给内部 Scroll 用
private listScroller: Scroller = new Scroller();     // 给 List 用
  1. 更新 README 中的警告说明,明确限定为嵌套滚动模式下避免双重绑定: "When enableNestedScroll is enabled, the component's internal Scroll uses the provided scroller. Do not bind the same scroller to a List, Scroll, or other scrollable container inside customList in this mode."
likedislike
77+ });
78+ },
79+ customLoad: null,
80+ customRefresh: null,
81+ })
82+ }
83+ .width('100%')
cpf-manager
cpf-managercpf-manager6月5日

【AI-Review】【一般】【业务功能】【功能实现错误】示例页面在嵌套滚动模式下仍传入 onLoadMore 回调

● 问题:bindSheetPullRefresh.ets 和 bindSheetPullRefreshV2.ets 中,onLoadMore 回调始终被传入 PullToRefresh 组件,无论 useBindSheetMode(即 enableNestedScroll)的值如何。根据 README 文档说明,启用 enableNestedScroll 后 "onRefresh/onLoadMore 回调不会被触发"。示例中当 useBindSheetMode 为 true 时传入的 onLoadMore 永远不会执行,这段死代码可能误导开发者认为在嵌套滚动模式下上拉加载仍可正常工作。

● 影响:一般。示例作为开发者参考,传入无效回调会误导使用者对 enableNestedScroll 行为的理解,可能导致开发者在实际项目中错误配置。

● 建议:在嵌套滚动模式下不传入 onLoadMore,或使用条件判断:

PullToRefresh({
  data: $data,
  scroller: this.useBindSheetMode ? this.nestedScrollScroller : this.listModeScroller,
  enableNestedScroll: this.useBindSheetMode,
  refreshConfigurator: this.useBindSheetMode ? this.bindSheetConfigurator : this.defaultConfigurator,
  customList: () => { /* ... */ },
  ...(this.useBindSheetMode ? {} : {
    onLoadMore: () => { /* ... */ }
  }),
  customLoad: null,
  customRefresh: null,
})

或至少在示例中添加注释说明该回调在嵌套滚动模式下不会触发。

likedislike
84+ .height('100%')
85+ }
86+ 
87+ @Builder
88+ private sheetList() {
89+ List({ space: 12, scroller: this.listModeScroller }) {
90+ ForEach(this.data, (item: string) => {
91+ ListItem() {
92+ Text(item)
93+ .width('100%')
cpf-manager
cpf-managercpf-manager6月5日

【AI-Review】【严重】【基础代码问题】【稳定性问题】示例页面中 data.push() 直接修改数组不触发 UI 刷新

● 问题:在 bindSheetPullRefresh.ets 第93行和 bindSheetPullRefreshV2.ets 第93行的 onLoadMore 回调中,使用 this.data.push(\more ${this.data.length}`) 直接修改数组。在 ArkTS 中,@State/@Local修饰的数组变量通过push方法修改时不会触发状态变化通知,UI 不会更新显示新增的条目。虽然 PullToRefresh 组件内部通过@Link data/@Param data` 接收数据,但直接 push 仍然不会触发框架的变更检测。

● 影响:严重。上拉加载更多后,新条目不会在列表中显示,用户看到的列表不会增长,功能完全失效。

● 建议:使用数组重新赋值触发状态更新:

onLoadMore: () => {
  return new Promise<string>((resolve) => {
    setTimeout(() => {
      this.data = [...this.data, `more ${this.data.length}`];
      resolve('');
    }, 800);
  });
}
likedislike
94+ .height(100)
95+ .fontSize(18)
96+ .textAlign(TextAlign.Center)
97+ .backgroundColor('#95efd2')
98+ }
99+ })
100+ }
101+ .width('100%')
102+ .height('100%')
103+ .backgroundColor('#eeeeee')
104+ .divider({ strokeWidth: 1, color: 0x222222 })
105+ .edgeEffect(EdgeEffect.None)
106+ }
cpf-managercpf-manager
cpf-managercpf-manager5月22日

【AI-Review】【一般】【业务功能】【示例设计问题】 ● 问题: sheetColumn() Builder 使用 Column 布局来展示数据,但 Column 组件本身不可滚动。当启用嵌套滚动模式时,PullToRefresh 内部会创建一个 Scroll 组件包裹 customList,但这里的 customList 返回的是一个不可滚动的 Column,导致:1) Scroll 包裹不可滚动的内容,没有实际滚动效果;2) onLoadMore 加载更多逻辑依赖 scroller 的滚动位置检测,但 Column 不支持滚动,可能导致加载更多功能失效;3) 示例无法正确展示 nestedScroll 功能的使用场景。

● 影响: 一般。示例页面无法正确演示嵌套滚动模式的功能,可能导致开发者误用该功能。同时,同一个 scroller 对象在 sheetList() 和 sheetColumn() 两种布局中混用,在切换模式时可能导致 scroller 状态不一致。

● 建议: 修改 sheetColumn() 使用可滚动的组件,例如使用 List 或 Grid:

@Builder
private sheetColumn() {
  List({ scroller: this.scroller }) {
    ForEach(this.data, (item: string) => {
      ListItem() {
        Text(item)
          .width('100%')
          .height(100)
          .fontSize(18)
          .textAlign(TextAlign.Center)
          .backgroundColor('#95efd2')
      }
    })
  }
  .width('100%')
  .backgroundColor('#eeeeee')
  .listDirection(Axis.Vertical)
}

或者考虑为两种模式使用不同的 scroller 对象,避免状态冲突:

private listScroller: Scroller = new Scroller();
private columnScroller: Scroller = new Scroller();

这样可以更好地展示 nestedScroll 模式下 Scroll 组件与内部可滚动组件的正确协作方式。

likedislike
cpf-managercpf-manager6月4日

【AI-Review】【一般】【基础代码问题】【代码逻辑错误】示例 sheetColumn 使用不可滚动 Column 导致上拉加载误触发

● 问题:bindSheetPullRefresh.ets 示例中,嵌套滚动模式下的 sheetColumn() 使用 Column 布局(不可滚动组件)。当用户向上滑动时,onActionUpdate 中的 this.scroller.isAtEnd() 对绑定到外层 Scroll 的 scroller 调用,由于 Column 内容不可滚动且外层 Scroll 内容高度有限,isAtEnd() 可能始终返回 true。这会导致在列表未真正到底时就触发上拉加载状态(IS_PULL_UP_1/IS_PULL_UP_2),用户任何向上滑动都可能误触发 onLoadMore。

● 影响:一般。示例页面中上拉加载功能在嵌套滚动模式下会被误触发,无法正确演示该功能的使用方式,可能误导开发者。

● 建议:sheetColumn() 应使用可滚动组件(如 List)替代 Column,并且根据 README 文档的说明"customList 内部不应再绑定同一个 scroller",内层列表不应绑定 scroller:

@Builder
private sheetColumn() {
  List({ space: 12 }) {
    ForEach(this.data, (item: string) => {
      ListItem() {
        Text(item)
          .width('100%')
          .height(100)
          .fontSize(18)
          .textAlign(TextAlign.Center)
          .backgroundColor('#95efd2')
      }
    })
  }
  .width('100%')
  .backgroundColor('#eeeeee')
}

这样外层 Scroll 的 scroller 能正确获取滚动位置,isAtEnd() 和 currentOffset() 的判断才有意义。

likedislike
107+ 
108+ @Builder
109+ private sheetColumn() {
110+ Column({ space: 12 }) {
cpf-managercpf-manager
cpf-managercpf-manager6月3日

【AI-Review】【一般】【基础代码问题】【代码逻辑错误】示例中 sheetColumn() 缺少 .height('100%'),嵌套滚动模式下 Scroll 包裹不可滚动内容导致上拉加载失效

● 问题:bindSheetPullRefresh.ets 示例中,sheetList() 设置了 .height('100%'),但 sheetColumn() 未设置高度。当启用嵌套滚动模式(enableNestedScroll: true)时,组件内部 Scroll(this.scroller) 包裹 sheetColumn() 返回的 Column。由于 Column 没有设置高度,它只会按内容高度渲染。如果数据较少未填满 Scroll 可视区域,Scroll 内容不可滚动,导致:1) scroller.isAtEnd() 可能始终返回 true;2) 但 listOffsetOld 和 listOffsetNew 始终为 0(因为无法滚动),listOffsetOld != 0 条件不满足,上拉加载实际不会触发。虽然该检测逻辑避免了误触发,但示例中提供的 onLoadMore 回调在嵌套滚动模式下实质上是死代码,容易误导开发者。

● 影响:一般。示例代码作为开发者参考,存在功能缺陷可能误导使用者认为上拉加载在嵌套滚动模式下可以正常工作。同时 sheetColumn() 与 sheetList() 的布局设置不一致,增加了理解难度。

● 建议:为 sheetColumn() 添加 .height('100%') 以确保 Column 填满 Scroll 可视区域,或者改用 List 等可滚动组件替代 Column 以正确展示嵌套滚动模式下的上拉加载功能:

@Builder
private sheetColumn() {
  Column({ space: 12 }) {
    ForEach(this.data, (item: string) => {
      Text(item)
        .width('100%')
        .height(100)
        .fontSize(18)
        .textAlign(TextAlign.Center)
        .backgroundColor('#95efd2')
    })
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#eeeeee')
}
likedislike
cpf-managercpf-manager6月8日

【AI-Review】【一般】【基础代码问题】【性能和效率问题】sheetColumn()使用Column而非List,大数据量下无懒加载

● 问题:bindSheetPullRefresh.ets 和 bindSheetPullRefreshV2.ets 中的 sheetColumn() Builder 使用 Column + ForEach 渲染所有列表项。Column 不支持懒加载,当数据量增大时,所有列表项会一次性全部创建和渲染,导致内存占用增加和首帧渲染耗时增长。而 sheetList() 使用了 List 组件,天然支持懒加载。

● 影响:一般。在数据量较小(如demo中的5项)时影响不大,但作为公开示例代码,可能误导开发者认为 Column 是推荐的实现方式。在实际业务中数据量可能较大,导致性能问题。

● 建议:将 sheetColumn() 改为使用 List 组件实现,以保持与 sheetList() 一致的懒加载能力,同时也能正确配合 Scroll 的 onReachEnd 触发上拉加载:

@Builder
private sheetColumn() {
  List({ space: 12 }) {
    ForEach(this.data, (item: string) => {
      ListItem() {
        Text(item)
          .width('100%')
          .height(100)
          .fontSize(18)
          .textAlign(TextAlign.Center)
          .backgroundColor('#95efd2')
      }
    })
  }
  .width('100%')
  .backgroundColor('#eeeeee')
  .listDirection(Axis.Vertical)
}

或者,如果 sheetColumn 是为了展示非 List 场景(如 Column 布局),则应在注释中明确说明这是受限场景的演示,并提醒开发者大数据量时应使用 List。

likedislike
111+ ForEach(this.data, (item: string) => {
112+ Text(item)
113+ .width('100%')
114+ .height(100)
115+ .fontSize(18)
116+ .textAlign(TextAlign.Center)
117+ .backgroundColor('#95efd2')
118+ })
119+ }
120+ .width('100%')
cpf-manager
cpf-managercpf-manager6月8日

【AI-Review】【建议】【基础代码问题】【验证问题】bindSheetPullRefresh 示例中 sheetColumn 缺少高度约束

● 问题: 在 sheetColumn() Builder 中(第 110-123 行),Column 仅设置了 .width('100%') 而未设置 .height('100%')。当 enableNestedScroll=true 时,此 Column 作为 PullToRefresh 内部 Scroll 的子内容。如果数据项不足以填满 Sheet 可视区域,Column 的高度将仅等于内容高度,导致 .backgroundColor('#eeeeee') 不会填充整个可视区域,出现底部空白。相比之下,sheetList() 的 List 设置了 .height('100%'),两者表现不一致。

● 影响: 建议。在数据项较少时,Sheet 内嵌套滚动模式的背景色不会填满可视区域,视觉效果不一致。

● 建议: 为 sheetColumn() 的 Column 添加 .height('100%'),使其与 sheetList() 的视觉效果保持一致。同时 PullToRefreshV2 版本的 demo(bindSheetPullRefreshV2.ets)也存在同样的问题。

likedislike
121+ .backgroundColor('#eeeeee')
122+ }
123+}
@@ -0,0 +1,123 @@
1+/*
2+ * Copyright (C) 2026 Huawei Device Co., Ltd.
3+ * Licensed under the Apache License, Version 2.0 (the "License");
4+ * you may not use this file except in compliance with the License.
5+ * You may obtain a copy of the License at
6+ *
7+ * http://www.apache.org/licenses/LICENSE-2.0
8+ *
9+ * Unless required by applicable law or agreed to in writing, software
10+ * distributed under the License is distributed on an "AS IS" BASIS,
11+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ * See the License for the specific language governing permissions and
13+ * limitations under the License.
14+ */
15+import { PullToRefreshV2, PullToRefreshConfigurator } from '@ohos/pulltorefresh'
16+ 
17+@Entry
18+@ComponentV2
19+struct BindSheetPullRefreshV2 {
20+ @Local sheetVisible: boolean = false;
21+ @Local useBindSheetMode: boolean = false;
22+ @Local data: Array<string> = ['item 1', 'item 2', 'item 3', 'item 4', 'item 5'];
23+ // 嵌套滚动模式:仅交给 PullToRefreshV2 内部 Scroll,customList 内不可再绑定 scroller
24+ private nestedScrollScroller: Scroller = new Scroller();
25+ // 未配置模式:PullToRefreshV2 与 customList 内 List 共用同一 scroller
26+ private listModeScroller: Scroller = new Scroller();
27+ private defaultConfigurator: PullToRefreshConfigurator = new PullToRefreshConfigurator();
28+ private bindSheetConfigurator: PullToRefreshConfigurator = new PullToRefreshConfigurator()
29+ .setHasRefresh(false);
30+ 
31+ build() {
32+ Column({ space: 16 }) {
33+ Button('未配置模式(原始 List)')
34+ .onClick(() => {
35+ this.useBindSheetMode = false;
36+ this.sheetVisible = true;
37+ })
38+ Button('开启嵌套滚动模式')
39+ .onClick(() => {
40+ this.useBindSheetMode = true;
41+ this.sheetVisible = true;
42+ })
43+ }
44+ .width('100%')
45+ .height('100%')
46+ .justifyContent(FlexAlign.Center)
47+ .bindSheet(this.sheetVisible, this.sheetContent(), {
48+ height: SheetSize.MEDIUM,
49+ dragBar: true,
50+ onDisappear: () => {
51+ this.sheetVisible = false;
52+ }
53+ })
54+ }
55+ 
56+ @Builder
57+ private sheetContent() {
58+ Column() {
59+ PullToRefreshV2({
60+ data: this.data,
61+ scroller: this.useBindSheetMode ? this.nestedScrollScroller : this.listModeScroller,
62+ enableNestedScroll: this.useBindSheetMode,
63+ refreshConfigurator: this.useBindSheetMode ? this.bindSheetConfigurator : this.defaultConfigurator,
64+ customList: () => {
65+ if (this.useBindSheetMode) {
66+ this.sheetColumn();
67+ } else {
68+ this.sheetList();
69+ }
70+ },
71+ onLoadMore: () => {
72+ return new Promise<string>((resolve) => {
73+ setTimeout(() => {
74+ this.data = [...this.data, `more ${this.data.length}`];
75+ resolve('');
76+ }, 800);
77+ });
78+ },
79+ customLoad: null,
80+ customRefresh: null,
81+ })
82+ }
83+ .width('100%')
84+ .height('100%')
85+ }
86+ 
87+ @Builder
88+ private sheetList() {
89+ List({ space: 12, scroller: this.listModeScroller }) {
90+ ForEach(this.data, (item: string) => {
91+ ListItem() {
92+ Text(item)
93+ .width('100%')
94+ .height(100)
95+ .fontSize(18)
96+ .textAlign(TextAlign.Center)
97+ .backgroundColor('#95efd2')
98+ }
99+ })
100+ }
101+ .width('100%')
102+ .height('100%')
103+ .backgroundColor('#eeeeee')
104+ .divider({ strokeWidth: 1, color: 0x222222 })
105+ .edgeEffect(EdgeEffect.None)
106+ }
107+ 
108+ @Builder
109+ private sheetColumn() {
110+ Column({ space: 12 }) {
111+ ForEach(this.data, (item: string) => {
112+ Text(item)
113+ .width('100%')
114+ .height(100)
115+ .fontSize(18)
116+ .textAlign(TextAlign.Center)
117+ .backgroundColor('#95efd2')
118+ })
119+ }
120+ .width('100%')
121+ .backgroundColor('#eeeeee')
122+ }
123+}
@@ -49,6 +49,11 @@ struct Index {
49 .onClick(() => {49 .onClick(() => {
50 router.pushUrl({ url: 'pages/lazyForEachGuide' })50 router.pushUrl({ url: 'pages/lazyForEachGuide' })
51 })51 })
52+ Button($r("app.string.bindSheetPullRefresh"))
53+ .margin({ top: 10 })
54+ .onClick(() => {
55+ router.pushUrl({ url: 'pages/bindSheetPullRefresh' })
56+ })
52 }57 }
53 .width('100%')58 .width('100%')
54 }59 }
@@ -49,6 +49,11 @@ struct Index {
49 .onClick(() => {49 .onClick(() => {
50 router.pushUrl({ url: 'pages/lazyForEachGuideV2' })50 router.pushUrl({ url: 'pages/lazyForEachGuideV2' })
51 })51 })
52+ Button($r("app.string.bindSheetPullRefresh"))
53+ .margin({ top: 10 })
54+ .onClick(() => {
55+ router.pushUrl({ url: 'pages/bindSheetPullRefreshV2' })
56+ })
52 }57 }
53 .width('100%')58 .width('100%')
54 }59 }
@@ -83,6 +83,10 @@
83 {83 {
84 "name": "Loading",84 "name": "Loading",
85 "value": "Loading..."85 "value": "Loading..."
86+ },
87+ {
88+ "name": "bindSheetPullRefresh",
89+ "value": "bindSheet + PullToRefresh"
86 }90 }
87 ]91 ]
88}92}
@@ -16,6 +16,8 @@
16 "pages/fullScreenV2",16 "pages/fullScreenV2",
17 "pages/interface.testV2",17 "pages/interface.testV2",
18 "pages/demoV1",18 "pages/demoV1",
19- "pages/demoV2"19+ "pages/demoV2",
20+ "pages/bindSheetPullRefresh",
21+ "pages/bindSheetPullRefreshV2"
20 ]22 ]
21}23}
@@ -71,6 +71,10 @@
71 {71 {
72 "name": "Loading",72 "name": "Loading",
73 "value": "加载中..."73 "value": "加载中..."
74+ },
75+ {
76+ "name": "bindSheetPullRefresh",
77+ "value": "半模态+bindSheet"
74 }78 }
75 ]79 ]
76}80}
@@ -29,6 +29,11 @@ export struct PullToRefresh {
29 @Link data: Object[];29 @Link data: Object[];
30 scroller: Scroller = new Scroller();30 scroller: Scroller = new Scroller();
31 @BuilderParam customList?: () => void;31 @BuilderParam customList?: () => void;
32+ /**
33+ * 启用后组件主体使用内置 `Scroll` 承载内容并应用默认 `.nestedScroll(...)` 配置。
34+ * customList 内部不应再使用同一个 scroller 绑定 List/Scroll 等滚动容器。
35+ */
36+ enableNestedScroll?: boolean = false;
cpf-manager
cpf-managercpf-manager6月10日

【AI-Review】【建议】【基础代码问题】【验证问题】PullToRefresh V1 中 enableNestedScroll 无响应式装饰器,动态变更不会触发 UI 更新

● 问题: 在 PullToRefresh(V1 @Component)中,enableNestedScroll 声明为普通可选属性 enableNestedScroll?: boolean = false,没有使用 @Prop 或 @State 装饰器。这意味着如果父组件动态改变 enableNestedScroll 的值(例如通过状态绑定切换模式),PullToRefresh 内部的 useNestedScrollMode() 不会重新计算,UI 不会从 Column 模式切换到 Scroll 模式。

对比 PullToRefreshV2(@ComponentV2)中使用了 @Param enableNestedScroll?: boolean = false,V2 版本能正确响应变化。

● 影响: 建议。在 bindSheetPullRefresh 示例中,useBindSheetMode 是动态切换的,但通过 enableNestedScroll: this.useBindSheetMode 传给 PullToRefresh V1 时,V1 组件不会响应这个变化。当前示例使用的是 V1 组件,实际效果是每次打开 Sheet 时都是初始模式的值,切换按钮只在重新打开 Sheet 时生效。

● 建议: 将 PullToRefresh V1 中的 enableNestedScroll 添加 @Prop 装饰器:

@Prop enableNestedScroll?: boolean = false;

这样父组件动态改变时,组件能正确响应并切换渲染模式。

likedislike
32 refreshConfigurator?: PullToRefreshConfigurator;37 refreshConfigurator?: PullToRefreshConfigurator;
cpf-manager
cpf-managercpf-manager6月5日

【AI-Review】【严重】【基础代码问题】【代码逻辑错误】PullToRefresh V1 中 enableNestedScroll 无状态装饰器,无法响应父组件更新

● 问题:在 PullToRefresh(V1 @Component)中,enableNestedScroll 声明为普通可选属性 enableNestedScroll?: boolean = false,没有 @Prop 或 @Link 装饰器。而 V2 组件 PullToRefreshV2 中相同属性使用了 @Param enableNestedScroll?: boolean = false。V1 中,如果父组件动态改变 enableNestedScroll 的值(例如通过状态变量绑定),PullToRefresh 组件内部不会感知到变化,build() 中的 if (this.enableNestedScroll === true) 分支不会重新渲染,导致组件行为与预期不一致。

● 影响:严重。V1 组件中 enableNestedScroll 实际上变成了只读初始值,后续任何动态变更都不会生效。这与 V2 组件的行为不一致,违反了 V1/V2 提供一致能力的设计目标。开发者在 V1 中动态切换 enableNestedScroll 时会得到静默失败的结果。

● 建议:将 V1 组件中的 enableNestedScroll 声明为 @Prop enableNestedScroll?: boolean = false,确保能响应父组件的状态变更。与 V2 的 @Param 保持对等的能力。

likedislike
33 mWidth?: Length = '100%';38 mWidth?: Length = '100%';
34 mHeight?: Length = '100%';39 mHeight?: Length = '100%';
@@ -81,6 +86,8 @@ export struct PullToRefresh {
81 private refreshPoint2x?: number = 0;86 private refreshPoint2x?: number = 0;
82 private refreshPoint2y?: number = 0;87 private refreshPoint2y?: number = 0;
83 private panOption: PanGestureOptions = new PanGestureOptions({ direction: PanDirection.Up | PanDirection.Down })88 private panOption: PanGestureOptions = new PanGestureOptions({ direction: PanDirection.Up | PanDirection.Down })
89+ private isLoadingMore: boolean = false;
90+ private isDestroyed: boolean = false;
84 uiContext: UIContext | undefined = undefined;91 uiContext: UIContext | undefined = undefined;
85 92 
86 aboutToAppear() {93 aboutToAppear() {
cpf-manager
cpf-managercpf-manager5月22日

【AI-Review】【一般】【稳定性问题】【资源管理问题】timer 未在组件销毁时清理

● 问题:组件中使用 setInterval 创建的 timer,在 closeRefresh() 和 closeLoad() 方法中通过 clearInterval(this.timer) 清理。但是组件缺少 aboutToDisappear 生命周期方法,如果组件在动画执行期间被销毁(例如页面跳转、Sheet 关闭),timer 可能未被清理,导致内存泄漏或后台继续执行无用逻辑。

● 影响:一般。可能导致内存泄漏,特别是在频繁切换页面或快速关闭 Sheet 的场景下。

● 建议:添加 aboutToDisappear 方法,在组件销毁时清理 timer:

aboutToDisappear() {
  if (this.timer !== undefined) {
    clearInterval(this.timer);
    this.timer = undefined;
  }
}

同时,在 closeRefresh() 和 closeLoad() 方法中,清理后将 timer 设置为 undefined,避免重复清理。

likedislike
@@ -90,6 +97,14 @@ export struct PullToRefresh {
90 }97 }
91 }98 }
cpf-managercpf-manager
cpf-managercpf-manager6月8日

【AI-Review】【一般】【基础代码问题】【验证问题】enableNestedScroll 与 hasRefresh/hasLoadMore 不兼容时缺乏运行时校验

● 问题: 当 enableNestedScroll=true 且 hasRefresh=true 或 hasLoadMore=true 时,组件不会给出任何提示,但 PanGesture 被 early-return 完全禁用,导致刷新/加载更多功能静默失效。用户可能花费大量时间调试才能发现此配置组合不可用。

● 影响: 一般。配置不兼容时功能静默失效,降低库的可用性和调试体验。虽然 README 提到需配合 setHasRefresh(false) 使用,但作为公开 API 的三方库,运行时校验是必要的防御性编程。

● 建议: 在 aboutToAppear 中添加配置校验,当检测到不兼容配置时输出警告日志。例如在 PullToRefresh.ets 的 aboutToAppear 方法中,当 enableNestedScroll 为 true 时检查 refreshConfigurator 的 hasRefresh 和 hasLoadMore,若为 true 则通过 LogUtil 输出警告信息,提示用户调用 setHasRefresh(false) 或 setHasLoadMore(false)。PullToRefreshV2.ets 中也应添加同样的校验逻辑。

likedislike
cpf-managercpf-manager6月10日

【AI-Review】【严重】【基础代码问题】【稳定性问题】组件缺少 aboutToDisappear 清理 timer

● 问题: PullToRefresh 组件在 onActionEnd 中使用 setInterval 创建了 this.timer(约第 447 行),但组件中没有 aboutToDisappear 生命周期方法来清除这个定时器。当组件被销毁时(例如 bindSheet 关闭),setInterval 仍会继续执行回调,尝试访问已销毁组件的 this.value、this.trYTop 等状态变量,可能导致异常或内存泄漏。PullToRefreshV2 同样存在此问题。

● 影响: 严重。在 bindSheet 场景下关闭 Sheet 后,定时器仍在运行,每 10ms 执行一次回调,持续修改已销毁组件的状态,可能导致运行时异常或不可预期的行为。对于频繁打开/关闭 Sheet 的用户,还可能累积多个未清除的定时器。

● 建议: 添加 aboutToDisappear 生命周期方法,清除所有定时器:

aboutToDisappear() {
  if (this.timer !== undefined) {
    clearInterval(this.timer);
    this.timer = undefined;
  }
}

同时,在 onRefresh().then() 和 onLoadMore().then() 的回调中,应在执行前检查组件是否已销毁,避免在组件销毁后修改状态。

likedislike
92 99 
100+ aboutToDisappear() {
cpf-manager
cpf-managercpf-manager6月10日

【AI-Review】【严重】【基础代码问题】【资源使用问题】aboutToDisappear 未追踪和清理 setTimeout,组件销毁后可能执行回调

● 问题: 新增的 aboutToDisappear()(第 100-106 行)仅清理了 this.timer(setInterval),但代码中存在两处未被追踪的 setTimeout:

  1. closeRefresh() 中的 setTimeout(第 690 行)— 在刷新完成后延迟重置状态
  2. onActionEnd 中 onRefresh().then() 内的 setTimeout(第 502 行)— 在刷新成功后延迟关闭

这些 setTimeout 没有被存储到变量中,也无法在 aboutToDisappear 中清除。当组件销毁后(如 Sheet 关闭),这些 setTimeout 仍会执行,尝试设置 this.state、this.refreshText 等状态,导致对已销毁组件的无效操作。

● 影响: 严重。组件销毁后 setTimeout 回调仍执行,可能引发运行时异常。在 bindSheet 快速开关场景下尤其容易触发。

● 建议: 将所有 setTimeout 的返回值保存到实例变量中,并在 aboutToDisappear 中统一清除:

private refreshTimeout?: number;

// closeRefresh 中
this.refreshTimeout = setTimeout(() => {
  if (this.isDestroyed) return;
  this.state = IS_FREE;
  this.refreshText = '';
}, this.refreshConfigurator.getAnimDuration());

// aboutToDisappear 中
aboutToDisappear() {
  this.isDestroyed = true;
  if (this.timer !== undefined) {
    clearInterval(this.timer);
    this.timer = undefined;
  }
  if (this.refreshTimeout !== undefined) {
    clearTimeout(this.refreshTimeout);
    this.refreshTimeout = undefined;
  }
}

PullToRefreshV2.ets(第 688 行同样有未追踪的 setTimeout)也需要同步修复。

likedislike
101+ this.isDestroyed = true;
102+ if (this.timer !== undefined) {
103+ clearInterval(this.timer);
104+ this.timer = undefined;
105+ }
106+ }
107+ 
93 private initCanvas(): void {108 private initCanvas(): void {
94 if (this.refreshRingOx == 0) {109 if (this.refreshRingOx == 0) {
95 110 
@@ -121,13 +136,73 @@ export struct PullToRefresh {
121 .backgroundColor(this.refreshConfigurator !== undefined ? this.refreshConfigurator.getRefreshBackgroundColor() : 0)136 .backgroundColor(this.refreshConfigurator !== undefined ? this.refreshConfigurator.getRefreshBackgroundColor() : 0)
122 137 
123 // 主体列表部分138 // 主体列表部分
cpf-managercpf-manager
cpf-managercpf-manager6月3日

【AI-Review】【一般】【基础代码问题】【稳定性问题】嵌套滚动模式下 Scroll 缺少 edgeEffect 配置

● 问题:在嵌套滚动模式中,内部 Scroll(this.scroller) 未设置 .edgeEffect()。非嵌套模式下示例中的 List 使用了 .edgeEffect(EdgeEffect.None),而嵌套模式的内部 Scroll 使用默认 edgeEffect(通常为弹簧效果)。默认弹簧效果在回弹期间可能导致 currentOffset().yOffset 出现非零中间值,干扰 onActionUpdate 中 isTop 的判断逻辑(依赖 yOffset == 0)。虽然当前主要使用场景(bindSheet + setHasRefresh(false))不涉及下拉刷新检测,但如果用户同时启用 enableNestedScroll: true 和 hasRefresh: true,弹簧回弹期间 isTop 可能短暂为 false,导致下拉刷新手势识别不稳定。

● 影响:一般。在边缘场景下(enableNestedScroll=true + hasRefresh=true),下拉刷新手势检测可能不稳定,出现偶发性无法触发刷新的问题。同时,与非嵌套模式的 edgeEffect 配置不一致,行为不可预测。

● 建议:为嵌套滚动模式的内部 Scroll 显式设置 .edgeEffect(EdgeEffect.None),与非嵌套模式保持一致:

if (this.useNestedScrollMode()) {
  Scroll(this.scroller) {
    if (this.customList !== undefined) {
      this.customList()
    }
  }
  .nestedScroll(this.getNestedScrollOptions())
  .edgeEffect(EdgeEffect.None)
  .layoutWeight(1)
}
likedislike
cpf-managercpf-manager6月5日

【AI-Review】【一般】【软件设计】【冗余重复代码】PullToRefresh 与 PullToRefreshV2 的 nestedScroll 分支逻辑完全重复

● 问题:PullToRefresh.ets:137-197 和 PullToRefreshV2.ets:135-194 中,enableNestedScroll 分支的 Scroll + nestedScroll + onReachEnd 逻辑完全相同(约60行代码),包括加载动画的 setInterval 循环、drawLoadView 调用、onAnimLoading 回调、以及 onLoadMore 的 Promise 处理链。两个组件本就存在大量重复代码(onActionUpdate、onActionEnd、drawRefreshView 等),本次新增的 nestedScroll 分支进一步加剧了代码重复。当需要修复 nestedScroll 相关 bug 时(如上条评论中的 .catch() 缺失),必须在两处同步修改,极易遗漏。

● 影响:一般。增加维护成本,未来修改 nestedScroll 相关逻辑时需要同步修改两处,遗漏概率高。

● 建议:考虑将 nestedScroll 相关逻辑提取为共享函数或通过组合模式复用。短期可在代码中添加明确注释标记该段逻辑需要与另一组件同步维护,并确保本次 PR 中两组件的 nestedScroll 逻辑完全一致(当前 PullToRefresh.ets:179 有 .catch() 而 V2 也有,但 onActionEnd 中的 onLoadMore 两边均缺失 .catch(),需一并修复)。

likedislike
124- Column() {139+ if (this.enableNestedScroll === true) {
125- if (this.customList !== undefined) {140+ Scroll(this.scroller) {
cpf-managercpf-manager
cpf-managercpf-manager6月3日

【AI-Review】【严重】【基础代码问题】【代码逻辑错误】嵌套滚动模式下 scroller 双重绑定导致 currentOffset() 返回值不可预测

● 问题:当 enableNestedScroll=true 时,组件内部创建 Scroll(this.scroller) 承载 customList,而示例代码 sheetList() 中又用同一个 this.scroller 绑定到内部 List({ scroller: this.scroller })。此时 scroller 同时关联到两个滚动容器,this.scroller.currentOffset() 返回的是哪个容器的偏移量取决于 ArkUI 的内部实现,行为不可预测。在 onActionUpdate 中,代码依赖 currentOffset().yOffset 判断列表是否滚动到顶部(isTop),以及 scroller.isAtEnd() 判断是否到底部。双重绑定下,yOffset 可能始终为 0(取到了内部 Scroll 的偏移而非 List 的偏移),导致:1) 即使列表未到顶部,isTop 也会被判定为 true,下拉刷新会意外触发;2) 或者 yOffset 始终取到 Scroll 的值(通常为 0),导致非嵌套滚动模式下的正常判断逻辑在嵌套模式下失效。

● 影响:严重。在 bindSheet + enableNestedScroll=true 的实际使用场景中,如果用户在 customList 内部使用了 List 并绑定了同一个 scroller(这是常见用法),手势检测逻辑会完全失效,可能导致下拉刷新误触发或无法触发、上拉加载失效。README 仅用一行注释提醒"不要绑定同一个 scroller",但这与组件原有的 scroller 传参设计直接冲突——现有用户升级后若启用 enableNestedScroll,代码会静默产生行为异常。

● 建议:方案一(推荐):当启用嵌套滚动模式时,不应将 scroller 传给内部 Scroll,而是新建内部专用 scroller,外部传入的 scroller 仅用于手势检测时操作 customList 内部的滚动容器。方案二:在 README 和代码注释中更显著地警告此限制,并在组件初始化时(aboutToAppear)检测 enableNestedScroll=true 时打印警告日志,提醒开发者 customList 内部不应使用同一个 scroller。同时修改示例代码 sheetColumn() 使用独立的 scroller,展示正确的嵌套滚动用法。

likedislike
linsanjiu
linsanjiu
6月4日 评论:
cpf-managercpf-manager6月8日

【AI-Review】【严重】【基础代码问题】【代码逻辑错误】enableNestedScroll 模式下 Scroll 缺少 edgeEffect(EdgeEffect.None),导致 bindSheet 无法通过滚动关闭

● 问题:enableNestedScroll === true 时新增的 Scroll(this.scroller) 组件未设置 .edgeEffect(),默认使用 EdgeEffect.Spring。Spring 弹性效果会在 Scroll 到达边界时消耗滚动事件(产生回弹动画),阻止嵌套滚动事件向上传递给父级 bindSheet 容器。具体表现为:当用户在 Sheet 内滚动到内容顶部后继续向下拉动,期望 Sheet 跟随下滑关闭,但实际上 Scroll 的 Spring 回弹消费了手势事件,Sheet 无法响应拖拽关闭。

● 影响:严重。这是 bindSheet + PullToRefresh 场景的核心交互缺陷——用户无法通过在内容顶部向下滚动来关闭 Sheet,只能通过 Sheet 顶部 dragBar 操作,严重影响用户体验。

● 建议:在 enableNestedScroll 模式下的 Scroll 组件添加 .edgeEffect(EdgeEffect.None)。EdgeEffect.None 使 Scroll 到达边界后不再消费滚动事件,配合 nestedScroll({ scrollForward: NestedScrollMode.SELF_FIRST, scrollBackward: NestedScrollMode.SELF_FIRST }),当内部 Scroll 无法处理时,事件会正确传递给父级 bindSheet,实现 Sheet 的拖拽关闭:

if (this.enableNestedScroll === true) {
  Scroll(this.scroller) {
    if (this.customList !== undefined) {
      this.customList()
    }
  }
  .edgeEffect(EdgeEffect.None)
  .nestedScroll({
    scrollForward: NestedScrollMode.SELF_FIRST,
    scrollBackward: NestedScrollMode.SELF_FIRST
  })
  .width('100%')
  .height(...)
}

同样适用于 PullToRefreshV2.ets 中的相同代码段。

likedislike
cpf-managercpf-manager6月8日

【AI-Review】【严重】【基础代码问题】【代码逻辑错误】enableNestedScroll模式下Scroll与customList内List的scroller冲突风险

● 问题:当 enableNestedScroll === true 时,PullToRefresh 内部创建了 Scroll(this.scroller)(第140行),其中 this.scroller 是外部传入的同一个 scroller 实例。注释(第34行)虽然说明"customList内部不应再使用同一个scroller绑定List/Scroll等滚动容器",但这是一个纯文字约束,代码层面没有任何校验或防护。如果使用者不小心在 customList 中将同一个 scroller 绑定到 List,会导致 Scroll 和 List 争抢同一个 scroller 实例,产生不可预期的滚动行为。

● 影响:严重。scroller 冲突会导致滚动位置跳跃、滚动卡顿、甚至功能失效。这个问题在 bindSheet 场景下更容易出现,因为用户需要区分两种模式的 scroller 用法。

● 建议:方案一(推荐):在 enableNestedScroll 模式下,PullToRefresh 内部创建一个独立的 scroller 用于 Scroll 容器,不使用外部传入的 this.scroller,避免冲突。外部 scroller 仍供 customList 内部使用:

// 在 PullToRefresh 内部新增
private nestedScroller: Scroller = new Scroller();

// enableNestedScroll 模式下使用内部 scroller
if (this.enableNestedScroll === true) {
  Scroll(this.nestedScroller) { // 使用内部独立 scroller
    if (this.customList !== undefined) {
      this.customList()
    }
  }
  // ...
}

方案二:在 aboutToAppear 中检测 enableNestedScroll === true 时打印警告日志,提醒开发者不要在 customList 中复用 scroller。

likedislike
126- this.customList()141+ if (this.customList !== undefined) {
cpf-manager
cpf-managercpf-manager6月3日

【AI-Review】【一般】【基础代码问题】【代码逻辑错误】嵌套滚动模式下高度计算方式不一致,可能导致上下拉动画位移偏差

● 问题:非嵌套滚动模式下,主体区域使用精确高度计算:height((this.mHeightNumber !== undefined ? this.mHeightNumber : 0) - (this.trYTop !== undefined ? this.trYTop : 0) + (this.trYBottom !== undefined ? this.trYBottom : 0))。但嵌套滚动模式改用 .layoutWeight(1),让 Scroll 占据父 Column 剩余空间。这两种方式的实际高度可能存在差异——精确计算中 mHeightNumber 是组件整体高度,减去 trYTop/trYBottom 得到主体区域高度;而 layoutWeight(1) 在父 Column 中与 header/footer 的 Stack 共享空间,实际高度取决于 Stack 占用后的剩余值。当 trYTop 或 trYBottom 变化时(如下拉动画过程中),精确计算会同步调整主体高度,而 layoutWeight(1) 的 Scroll 高度由布局引擎自动调整,但调整时机和数值可能不同步,导致下拉动画过程中内容区域出现视觉抖动或位移偏差。

● 影响:一般。在启用 enableNestedScroll 后执行下拉刷新或上拉加载动画时,主体区域高度变化可能与非嵌套模式有细微差异,导致动画不够流畅或出现视觉抖动。

● 建议:保持两种模式下高度计算方式一致,嵌套滚动模式也使用精确高度计算而非 layoutWeight:

if (this.useNestedScrollMode()) {
  Scroll(this.scroller) {
    if (this.customList !== undefined) {
      this.customList()
    }
  }
  .nestedScroll(this.getNestedScrollOptions())
  .width('100%')
  .height((this.mHeightNumber !== undefined ? this.mHeightNumber : 0) -
    (this.trYTop !== undefined ? this.trYTop : 0) + (this.trYBottom !== undefined ? this.trYBottom : 0))
} 
likedislike
142+ this.customList()
143+ }
cpf-managercpf-manager
cpf-managercpf-manager6月8日

【AI-Review】【严重】【基础代码问题】【稳定性问题】onLoadMore回调在组件销毁后仍会执行

● 问题: 在onReachEnd回调中,this.onLoadMore()返回Promise,当组件销毁(如bindSheet被用户关闭)时,aboutToDisappear仅清除了timer,但未阻止onLoadMore的Promise回调继续执行。.then(() => { this.closeLoad(); this.isLoadingMore = false; })和.catch()回调会在已销毁的组件上调用this.closeLoad(),修改已销毁组件的状态,可能导致运行时异常或未定义行为。

● 影响: 严重。在bindSheet场景下,用户在加载更多过程中关闭Sheet,回调会在已销毁的组件上执行,可能导致应用崩溃或状态异常。

● 建议: 添加一个isDestroyed标志位,在aboutToDisappear中设置为true,在Promise回调中检查该标志:

private isDestroyed: boolean = false;

aboutToDisappear() {
  this.isDestroyed = true;
  if (this.timer !== undefined) {
    clearInterval(this.timer);
    this.timer = undefined;
  }
}

// 在onReachEnd回调中:
this.onLoadMore().then(() => {
  if (this.isDestroyed) return;
  this.closeLoad();
  this.isLoadingMore = false;
}).catch((error: Error) => {
  if (this.isDestroyed) return;
  LogUtil.error(`onLoadMore error: ${error}`);
  this.closeLoad();
  this.isLoadingMore = false;
});

同时建议对onRefresh()的Promise回调也做相同的防护。

likedislike
cpf-managercpf-manager6月8日

【AI-Review】【一般】【基础代码问题】【代码逻辑错误】onReachEnd在内容不足以滚动时可能立即触发加载更多

● 问题: 当enableNestedScroll为true且customList使用Column布局(如demo中的sheetColumn())时,如果内容高度小于Scroll视口高度,Scroll组件会在初始渲染时就认为自己处于"已到达底部"状态,导致onReachEnd立即触发,产生非预期的加载更多操作。虽然isLoadingMore标志可以防止连续触发,但第一次触发是不期望的。

● 影响: 一般。在数据量较少的场景下(如demo中初始仅5条数据),如果Sheet高度足够容纳所有内容,用户一打开Sheet就会看到加载更多的动画,影响用户体验。

● 建议: 在onReachEnd回调中增加内容可滚动性检查,确认Scroll确实可滚动后才触发加载:

.onReachEnd(() => {
  if (this.refreshConfigurator?.getHasLoadMore() && this.onLoadMore && !this.isLoadingMore) {
    // 检查Scroll是否可滚动(内容是否超出视口)
    let scrollable = this.scroller.isAtEnd() && this.scroller.currentOffset().yOffset > 0;
    if (!scrollable && this.enableNestedScroll === true) {
      return; // 内容不足一屏,不触发加载更多
    }
    this.isLoadingMore = true;
    // ...existing logic
  }
})

或者更稳健的方式是使用onScrollIndex结合onReachEnd来确认用户确实进行了滚动操作。

likedislike
cpf-managercpf-manager6月8日

【AI-Review】【一般】【软件设计】【冗余重复代码】onReachEnd中加载动画逻辑与onActionEnd大量重复

● 问题: onReachEnd回调中包含了约40行加载动画代码(setInterval定时器、value动画、drawLoadView绘制、onAnimLoading回调),与onActionEnd()方法中处理"加载更多"分支(IS_PULL_UP_2状态后进入IS_LOADING)的代码逻辑几乎完全一致,仅部分细节(如trYBottom的赋值方式)略有不同。两个V1/V2组件中也各自重复了相同的代码。

● 影响: 一般。重复代码增加了维护成本,如果后续修改加载动画逻辑(如调整动画步长、添加新的动画回调),容易遗漏其中一处,导致两种触发路径的行为不一致。

● 建议: 将加载更多动画逻辑提取为独立方法:

private startLoadMoreAnimation(): void {
  this.state = IS_LOADING;
  let maxTranslate = this.refreshConfigurator?.getMaxTranslate();
  if (maxTranslate !== undefined) {
    this.trYBottom = -maxTranslate * 0.75;
  }
  clearInterval(this.timer);
  this.value = 0;
  this.timer = setInterval(() => {
    // ...existing animation logic
  }, 10);
}

然后在onReachEnd和onActionEnd中都调用此方法,避免重复。

likedislike
cpf-managercpf-manager6月10日

【AI-Review】【严重】【基础代码问题】【代码逻辑错误】enableNestedScroll 为 true 时 Scroll 模式下 trYBottom 无法正常更新导致加载更多功能异常

● 问题: 当 enableNestedScroll === true 时,组件使用 Scroll(this.scroller) 包裹内容列表,Scroll 的高度通过 .layoutWeight(1) 设置。然而 onActionUpdate 中检测"滚动到底部"的逻辑依赖 this.scroller.isAtEnd() 和 this.scroller.currentOffset().yOffset(第 299-345 行),但在 nestedScroll 模式下 Scroll 使用了 NestedScrollMode.SELF_FIRST,这意味着 Scroll 先消费滚动事件再将剩余传递给父容器。当用户在 bindSheet 中向上滑动时,Scroll 先消费滚动、到末尾后将剩余滑动传递给父级 Sheet,而 PanGesture 和 scroller.isAtEnd() 的判断时机可能不同步——PanGesture 的 offset 是从手势开始累积的,而非当前滚动位置,导致 isAtEnd() 在手势触发的 onActionUpdate 中可能返回 false(因为 Scroll 正在消费滚动),使得加载更多功能在 nestedScroll 模式下不可用。

● 影响: 严重。在 bindSheet + enableNestedScroll 场景下,如果 hasLoadMore 为 true,加载更多功能可能无法触发。用户滑到底部后不会出现上拉加载更多效果。

● 建议: 在 nestedScroll 模式下,应该使用 Scroll 组件的 onReachEnd 回调来触发加载更多,而非依赖 PanGesture 检测。或者在 useNestedScrollMode() 返回 true 时,明确将 hasLoadMore 设为 false 并在文档中说明限制。推荐在 Scroll 上添加 onReachEnd 回调:

Scroll(this.scroller) {
  if (this.customList !== undefined) {
    this.customList()
  }
}
.nestedScroll(this.getNestedScrollOptions())
.layoutWeight(1)
.onReachEnd(() => {
  if (this.refreshConfigurator?.getHasLoadMore() && this.state === IS_FREE) {
    // 触发加载更多
  }
})
likedislike
127 }144 }
145+ .edgeEffect(EdgeEffect.None)
146+ .onReachEnd(() => {
cpf-manager
cpf-managercpf-manager6月10日

【AI-Review】【严重】【业务功能】【功能实现错误】enableNestedScroll 模式下 onReachEnd 自动触发加载更多,丧失了原有的"上拉阈值触发"用户体验

● 问题:当 enableNestedScroll === true 时,onReachEnd 在滚动到底部时立即触发 onLoadMore,无需用户主动上拉超过阈值。而原始手势模式(onActionEnd)要求用户上拉距离达到 maxTranslate * 0.75 才触发加载。这两种模式的触发行为完全不同:

  • 原始模式:用户主动上拉 → 超过阈值 → 触发加载(用户控制)
  • 嵌套滚动模式:滚动到底部 → 自动触发加载(无用户控制)

这意味着在嵌套滚动模式下,只要列表内容不够填满视口或用户快速滑动到底部,就会自动加载更多数据,用户无法预览当前内容而不触发加载。

● 影响:严重。这是一个未在 API 文档或注释中说明的行为差异,可能导致:1)用户在 bindSheet 场景中不经意间触发大量加载;2)与原有"上拉加载更多"的交互预期不一致;3)无法通过 enableNestedScroll API 预知此行为变化。

● 建议:1)在 enableNestedScroll 的 JSDoc 注释中明确说明此行为差异;2)考虑提供一个配置项(如 autoLoadMore)让调用方选择是自动触发还是保留上拉阈值触发;3)或者使用 Scroll.onScrollEdge 代替 onReachEnd,结合滑动方向判断,实现更精确的触发控制。

likedislike
147+ if (this.refreshConfigurator?.getHasLoadMore() && this.onLoadMore && !this.isLoadingMore) {
148+ this.isLoadingMore = true;
cpf-manager
cpf-managercpf-manager6月10日

【AI-Review】【一般】【业务功能】【功能实现错误】onReachEnd 直接跳到 IS_LOADING 状态,跳过上拉加载的两阶段过渡,footer UI 出现突兀

● 问题: 在 enableNestedScroll 模式下,onReachEnd(第 148 行)直接将 state 设为 IS_LOADING,跳过了非嵌套滚动模式中的 IS_PULL_UP_1 → IS_PULL_UP_2 → IS_LOADING 两阶段过渡。这导致 footer 区域的 icon_up 图片永远不会显示,loading 动画瞬间出现,用户缺少视觉过渡提示。相比之下,非嵌套模式通过手势逐步展示上拉提示和释放加载动画,体验更为平滑。

● 影响: 一般。enableNestedScroll 模式下加载更多的 footer 出现过于突兀,缺少过渡动画,用户体验不如非嵌套模式。

● 建议: 在 onReachEnd 触发时,先短暂展示 IS_PULL_UP_2 状态(显示释放加载提示),再过渡到 IS_LOADING,提供视觉连续性:

.onReachEnd(() => {
  if (this.refreshConfigurator?.getHasLoadMore() && this.onLoadMore && !this.isLoadingMore) {
    this.isLoadingMore = true;
    this.state = IS_PULL_UP_2;
    setTimeout(() => {
      if (this.isDestroyed) return;
      this.state = IS_LOADING;
      // ...后续加载动画逻辑
    }, 150);
  }
})

PullToRefreshV2.ets 的 onReachEnd 同样适用。

likedislike
149+ this.state = IS_LOADING;
150+ let maxTranslate = this.refreshConfigurator.getMaxTranslate();
151+ if (maxTranslate !== undefined) {
152+ let translateY = -maxTranslate * 0.75;
153+ this.uiContext?.animateTo({ duration: 0 }, () => {
154+ this.trYBottom = translateY;
155+ });
156+ }
157+ clearInterval(this.timer);
158+ this.value = 0;
159+ this.timer = setInterval(() => {
160+ if (this.value !== undefined) {
161+ if (this.value >= 1) {
162+ this.value -= 1;
163+ } else {
164+ this.value += 0.01;
165+ }
166+ this.value = Math.round(this.value * 100) / 100;
167+ if (!this.customLoad) {
168+ this.drawLoadView(false, this.value);
169+ }
170+ }
171+ if (this.onAnimLoading) {
172+ if (this.trYBottom !== undefined) {
173+ this.onAnimLoading(this.value, this.mWidthNumber, -this.trYBottom);
174+ }
175+ }
176+ }, 10);
177+ this.onLoadMore().then(() => {
178+ if (this.isDestroyed) return;
179+ this.closeLoad();
180+ this.isLoadingMore = false;
181+ }).catch((error: Error) => {
182+ if (this.isDestroyed) return;
183+ LogUtil.error(`onLoadMore error: ${error}`);
184+ this.closeLoad();
185+ this.isLoadingMore = false;
186+ });
187+ }
188+ })
189+ .nestedScroll({
190+ scrollForward: NestedScrollMode.SELF_FIRST,
191+ scrollBackward: NestedScrollMode.SELF_FIRST
192+ })
193+ .width('100%')
194+ .height((this.mHeightNumber !== undefined ? this.mHeightNumber : 0) -
195+ (this.trYTop !== undefined ? this.trYTop : 0) + (this.trYBottom !== undefined ? this.trYBottom : 0))
196+ } else {
197+ Column() {
198+ if (this.customList !== undefined) {
199+ this.customList()
200+ }
201+ }
202+ .width('100%')
203+ .height((this.mHeightNumber !== undefined ? this.mHeightNumber : 0) -
204+ (this.trYTop !== undefined ? this.trYTop : 0) + (this.trYBottom !== undefined ? this.trYBottom : 0))
128 }205 }
129- .width('100%')
130- .height((this.mHeightNumber !== undefined ? this.mHeightNumber : 0) - (this.trYTop !== undefined ? this.trYTop : 0) + (this.trYBottom !== undefined ? this.trYBottom : 0))
131 206 
132 // 上拉加载动画部分207 // 上拉加载动画部分
133 Stack() {208 Stack() {
@@ -146,16 +221,25 @@ export struct PullToRefresh {
146 .parallelGesture(221 .parallelGesture(
cpf-managercpf-manager
cpf-managercpf-manager6月5日

【AI-Review】【一般】【基础代码问题】【性能和效率问题】enableNestedScroll 模式下 parallelGesture 仍被注册,无意义地拦截手势事件

● 问题:当 enableNestedScroll === true 时,所有 PanGesture 回调(onActionStart、onActionUpdate、onActionEnd)均立即 return,但 .parallelGesture(PanGesture(...)) 仍被附加到组件上。parallelGesture 会在手势识别阶段就参与事件分发,即便回调不做任何处理,手势仍被并行识别和消费。这会与内部 Scroll 的嵌套滚动以及 bindSheet 的下拉关闭手势产生不必要的竞争,可能导致手势响应延迟或滚动不跟手。

● 影响:一般。在 bindSheet + enableNestedScroll 场景下,多余的 parallelGesture 注册会增加手势仲裁开销,可能导致下拉关闭 Sheet 时手势识别不够灵敏,或滚动时出现轻微卡顿。

● 建议:当 enableNestedScroll 为 true 时,跳过 parallelGesture 的注册。可以通过条件判断在 build() 中使用两个不同的 Column 分支:enableNestedScroll 为 true 时不附加 parallelGesture,为 false 时正常注册。PullToRefreshV2.ets 同理。

likedislike
cpf-managercpf-manager6月5日

【AI-Review】【一般】【基础代码问题】【性能和效率问题】enableNestedScroll 为 true 时 parallelGesture 仍注册但永不生效,造成不必要的手势识别开销

● 问题:当 enableNestedScroll 为 true 时,parallelGesture(PanGesture(...)) 仍然绑定在根 Column 上(第167行),所有回调均立即 return。PanGesture 仍然会被识别和处理——每次滚动操作都会触发 onActionStart、onActionUpdate、onActionEnd 三个回调——但它们都不执行任何有效逻辑。这意味着在 nestedScroll 模式下,每个滑动手势都经历了一次无意义的 PanGesture 识别和回调调用。

此外,panOption 的方向固定为 PanDirection.Up | PanDirection.Down(第88行),未根据 enableNestedScroll 动态调整,导致即使不需要 PanGesture,手势识别器仍然处于活跃状态。

● 影响:一般。每次滚动操作多出三次无效回调调用,以及不必要的 PanGesture 识别开销。在极端情况下,parallelGesture 与内部 Scroll 的手势可能产生微妙的竞争,虽然 parallelGesture 设计为并行工作,但在 ArkUI 的特定版本中不能完全排除手势冲突的可能。

● 建议:当 enableNestedScroll 为 true 时,动态设置 panOption 方向为无效值以禁用 PanGesture 识别:

aboutToAppear() {
  this.uiContext = this.getUIContext();
  if (!this.refreshConfigurator) {
    this.refreshConfigurator = new PullToRefreshConfigurator();
  }
  if (this.enableNestedScroll === true) {
    this.panOption.setDirection(PanDirection.None);
  }
}

或在 enableNestedScroll 为 true 时完全移除 parallelGesture 绑定(需使用条件属性链式调用)。

PullToRefreshV2.ets 存在相同问题。

likedislike
147 PanGesture(this.panOption)222 PanGesture(this.panOption)
148 .onActionStart((event?: GestureEvent) => {223 .onActionStart((event?: GestureEvent) => {
224+ if (this.enableNestedScroll === true) {
225+ return;
226+ }
149 if (event !== undefined) {227 if (event !== undefined) {
150 this.touchYOld = event.offsetY;228 this.touchYOld = event.offsetY;
151 }229 }
152 })230 })
153 .onActionUpdate((event?: GestureEvent) => {231 .onActionUpdate((event?: GestureEvent) => {
232+ if (this.enableNestedScroll === true) {
233+ return;
234+ }
154 if (event !== undefined) {235 if (event !== undefined) {
155 this.onActionUpdate(event);236 this.onActionUpdate(event);
156 }237 }
157 })238 })
158 .onActionEnd(() => {239 .onActionEnd(() => {
240+ if (this.enableNestedScroll === true) {
241+ return;
242+ }
159 this.onActionEnd();243 this.onActionEnd();
160 })244 })
161 )245 )
@@ -29,6 +29,11 @@ export struct PullToRefreshV2 {
29 @Param data?: Object[] | undefined = undefined;29 @Param data?: Object[] | undefined = undefined;
30 @Param scroller: Scroller = new Scroller();30 @Param scroller: Scroller = new Scroller();
31 @BuilderParam customList?: () => void;31 @BuilderParam customList?: () => void;
32+ /**
33+ * 启用后组件主体使用内置 `Scroll` 承载内容并应用默认 `.nestedScroll(...)` 配置。
34+ * customList 内部不应再使用同一个 scroller 绑定 List/Scroll 等滚动容器。
35+ */
36+ @Param enableNestedScroll?: boolean = false;
32 @Param refreshConfigurator?: PullToRefreshConfigurator = new PullToRefreshConfigurator();37 @Param refreshConfigurator?: PullToRefreshConfigurator = new PullToRefreshConfigurator();
33 @Param mWidth?: Length = '100%';38 @Param mWidth?: Length = '100%';
34 @Param mHeight?: Length = '100%';39 @Param mHeight?: Length = '100%';
@@ -81,6 +86,8 @@ export struct PullToRefreshV2 {
81 private refreshPoint2x?: number = 0;86 private refreshPoint2x?: number = 0;
82 private refreshPoint2y?: number = 0;87 private refreshPoint2y?: number = 0;
83 private panOption: PanGestureOptions = new PanGestureOptions({ direction: PanDirection.Up | PanDirection.Down })88 private panOption: PanGestureOptions = new PanGestureOptions({ direction: PanDirection.Up | PanDirection.Down })
89+ private isLoadingMore: boolean = false;
90+ private isDestroyed: boolean = false;
84 uiContext: UIContext | undefined = undefined;91 uiContext: UIContext | undefined = undefined;
85 92 
86 aboutToAppear() {93 aboutToAppear() {
cpf-manager
cpf-managercpf-manager5月22日

【AI-Review】【一般】【稳定性问题】【资源管理问题】PullToRefreshV2 缺少 aboutToDisappear 清理 timer

● 问题:PullToRefreshV2 组件同样使用了 setInterval 创建的 timer(第429、474行),用于动画循环。但是组件缺少 aboutToDisappear 生命周期方法来清理 timer。如果组件在动画执行期间被销毁,timer 会继续运行,导致内存泄漏。

● 影响:一般。可能导致内存泄漏,特别是在使用 @ComponentV2 的场景下,组件生命周期管理更严格,应确保资源正确释放。

● 建议:与 PullToRefresh V1 组件保持一致,添加 aboutToDisappear 方法:

aboutToDisappear() {
  if (this.timer !== undefined) {
    clearInterval(this.timer);
    this.timer = undefined;
  }
}
likedislike
@@ -90,6 +97,14 @@ export struct PullToRefreshV2 {
90 // }97 // }
91 }98 }
cpf-managercpf-manager
cpf-managercpf-manager6月8日

【AI-Review】【一般】【基础代码问题】【验证问题】enableNestedScroll 与 hasRefresh/hasLoadMore 不兼容时缺乏运行时校验

● 问题: 当 enableNestedScroll=true 且 hasRefresh=true 或 hasLoadMore=true 时,组件不会给出任何提示,但 PanGesture 被 early-return 完全禁用,导致刷新/加载更多功能静默失效。此问题与 PullToRefresh.ets 相同。

● 影响: 一般。配置不兼容时功能静默失效,降低库的可用性和调试体验。

● 建议: 在 aboutToAppear 中添加与 PullToRefresh.ets 相同的配置校验逻辑,当 enableNestedScroll 为 true 时检查 refreshConfigurator 的 hasRefresh 和 hasLoadMore,若为 true 则通过 LogUtil 输出警告信息。

likedislike
cpf-managercpf-manager6月10日

【AI-Review】【严重】【基础代码问题】【稳定性问题】组件缺少 aboutToDisappear 清理 timer

● 问题: PullToRefreshV2 组件同样在 onActionEnd 中使用 setInterval 创建了 this.timer,但没有 aboutToDisappear 生命周期方法来清除定时器。当组件被销毁时,setInterval 回调仍会执行,尝试修改已销毁组件的状态,可能导致异常或内存泄漏。

● 影响: 严重。与 PullToRefresh V1 相同的问题,在 bindSheet 等动态创建/销毁组件的场景下尤为危险。

● 建议: 添加 aboutToDisappear 生命周期方法,清除所有定时器:

aboutToDisappear() {
  if (this.timer !== undefined) {
    clearInterval(this.timer);
    this.timer = undefined;
  }
}
likedislike
92 99 
100+ aboutToDisappear() {
cpf-manager
cpf-managercpf-manager6月10日

【AI-Review】【严重】【基础代码问题】【资源使用问题】PullToRefreshV2 aboutToDisappear 同样未追踪 setTimeout

● 问题: 与 PullToRefresh.ets 相同的问题。PullToRefreshV2.ets 的 closeRefresh() 中存在未追踪的 setTimeout(第 688 行),aboutToDisappear()(第 100-106 行)仅清理了 this.timer(setInterval),未清理 setTimeout。组件销毁后 setTimeout 仍会执行,设置 this.state、this.refreshText。

● 影响: 严重。组件销毁后回调仍执行,可能引发异常。

● 建议: 将 setTimeout 返回值保存到实例变量,在 aboutToDisappear 中 clearTimeout,并在 setTimeout 回调内添加 isDestroyed 检查。

likedislike
101+ this.isDestroyed = true;
102+ if (this.timer !== undefined) {
103+ clearInterval(this.timer);
104+ this.timer = undefined;
105+ }
106+ }
107+ 
93 private initCanvas(): void {108 private initCanvas(): void {
94 if (this.refreshRingOx == 0) {109 if (this.refreshRingOx == 0) {
95 if (this.canvasRefresh !== undefined && this.refreshConfigurator !== undefined) {110 if (this.canvasRefresh !== undefined && this.refreshConfigurator !== undefined) {
@@ -120,13 +135,72 @@ export struct PullToRefreshV2 {
120 .backgroundColor(this.refreshConfigurator !== undefined ? this.refreshConfigurator.getRefreshBackgroundColor() : 0)135 .backgroundColor(this.refreshConfigurator !== undefined ? this.refreshConfigurator.getRefreshBackgroundColor() : 0)
121 136 
122 // 主体列表部分137 // 主体列表部分
123- Column() {138+ if (this.enableNestedScroll === true) {
124- if (this.customList !== undefined) {139+ Scroll(this.scroller) {
cpf-manager
cpf-managercpf-manager6月8日

【AI-Review】【严重】【基础代码问题】【代码逻辑错误】enableNestedScroll 模式下 Scroll 缺少 edgeEffect(EdgeEffect.None),导致 bindSheet 无法通过滚动关闭

● 问题:enableNestedScroll === true 时新增的 Scroll(this.scroller) 组件未设置 .edgeEffect(),默认使用 EdgeEffect.Spring。Spring 弹性效果会在 Scroll 到达边界时消耗滚动事件(产生回弹动画),阻止嵌套滚动事件向上传递给父级 bindSheet 容器。具体表现为:当用户在 Sheet 内滚动到内容顶部后继续向下拉动,期望 Sheet 跟随下滑关闭,但实际上 Scroll 的 Spring 回弹消费了手势事件,Sheet 无法响应拖拽关闭。

● 影响:严重。这是 bindSheet + PullToRefresh 场景的核心交互缺陷——用户无法通过在内容顶部向下滚动来关闭 Sheet,只能通过 Sheet 顶部 dragBar 操作,严重影响用户体验。

● 建议:在 enableNestedScroll 模式下的 Scroll 组件添加 .edgeEffect(EdgeEffect.None)。EdgeEffect.None 使 Scroll 到达边界后不再消费滚动事件,配合 nestedScroll({ scrollForward: NestedScrollMode.SELF_FIRST, scrollBackward: NestedScrollMode.SELF_FIRST }),当内部 Scroll 无法处理时,事件会正确传递给父级 bindSheet,实现 Sheet 的拖拽关闭:

if (this.enableNestedScroll === true) {
  Scroll(this.scroller) {
    if (this.customList !== undefined) {
      this.customList()
    }
  }
  .edgeEffect(EdgeEffect.None)
  .nestedScroll({
    scrollForward: NestedScrollMode.SELF_FIRST,
    scrollBackward: NestedScrollMode.SELF_FIRST
  })
  .width('100%')
  .height(...)
}

同样适用于 PullToRefresh.ets 中的相同代码段。

likedislike
linsanjiu
linsanjiu
6月8日 评论:
125- this.customList()140+ if (this.customList !== undefined) {
141+ this.customList()
cpf-manager
cpf-managercpf-manager6月3日

【AI-Review】【一般】【基础代码问题】【稳定性问题】PullToRefreshV2 嵌套滚动模式下 Scroll 同样缺少 edgeEffect 配置

● 问题:与 PullToRefresh.ets 相同,PullToRefreshV2 的嵌套滚动模式中内部 Scroll(this.scroller) 也未设置 .edgeEffect(EdgeEffect.None)。默认弹簧效果在回弹期间可能导致 currentOffset().yOffset 出现非零中间值,干扰 onActionUpdate 中的 isTop 判断逻辑。

● 影响:一般。与 PullToRefresh.ets 同理,在 enableNestedScroll=true + hasRefresh=true 的边缘场景下,下拉刷新手势检测可能不稳定。

● 建议:为嵌套滚动模式的内部 Scroll 显式设置 .edgeEffect(EdgeEffect.None):

if (this.useNestedScrollMode()) {
  Scroll(this.scroller) {
    if (this.customList !== undefined) {
      this.customList()
    }
  }
  .nestedScroll(this.getNestedScrollOptions())
  .edgeEffect(EdgeEffect.None)
  .layoutWeight(1)
}
likedislike
142+ }
cpf-manager
cpf-managercpf-manager6月8日

【AI-Review】【一般】【基础代码问题】【稳定性问题】PullToRefreshV2的onLoadMore回调同样存在组件销毁后执行问题

● 问题: PullToRefreshV2组件的onReachEnd回调中存在与PullToRefresh V1相同的问题:this.onLoadMore()返回的Promise在组件销毁后仍会执行.then()/.catch()回调,在已销毁的组件上调用this.closeLoad()和修改this.isLoadingMore。对于@ComponentV2组件,在销毁后修改@Local状态变量可能导致更严重的运行时错误。

● 影响: 一般。与V1相同,在bindSheet场景下关闭Sheet时可能导致运行时异常。由于V2使用@ComponentV2装饰器,其生命周期管理更严格,销毁后修改状态的风险更高。

● 建议: 与V1相同,添加isDestroyed标志位,在aboutToDisappear中设置为true,在Promise回调中检查后再执行状态修改。

likedislike
126 }143 }
144+ .edgeEffect(EdgeEffect.None)
145+ .onReachEnd(() => {
146+ if (this.refreshConfigurator?.getHasLoadMore() && this.onLoadMore && !this.isLoadingMore) {
147+ this.isLoadingMore = true;
148+ this.state = IS_LOADING;
149+ let maxTranslate = this.refreshConfigurator.getMaxTranslate();
150+ if (maxTranslate !== undefined) {
151+ let translateY = -maxTranslate * 0.75;
152+ this.uiContext?.animateTo({ duration: 0 }, () => {
153+ this.trYBottom = translateY;
154+ });
155+ }
156+ clearInterval(this.timer);
157+ this.value = 0;
158+ this.timer = setInterval(() => {
159+ if (this.value !== undefined) {
160+ if (this.value >= 1) {
161+ this.value -= 1;
162+ } else {
163+ this.value += 0.01;
164+ }
165+ this.value = Math.round(this.value * 100) / 100;
166+ if (!this.customLoad) {
167+ this.drawLoadView(false, this.value);
168+ }
169+ }
170+ if (this.onAnimLoading) {
171+ if (this.trYBottom !== undefined) {
172+ this.onAnimLoading(this.value, this.mWidthNumber, -this.trYBottom);
173+ }
174+ }
175+ }, 10);
176+ this.onLoadMore().then(() => {
177+ if (this.isDestroyed) return;
178+ this.closeLoad();
179+ this.isLoadingMore = false;
180+ }).catch((error: Error) => {
181+ if (this.isDestroyed) return;
182+ LogUtil.error(`onLoadMore error: ${error}`);
183+ this.closeLoad();
184+ this.isLoadingMore = false;
185+ });
186+ }
187+ })
188+ .nestedScroll({
189+ scrollForward: NestedScrollMode.SELF_FIRST,
190+ scrollBackward: NestedScrollMode.SELF_FIRST
191+ })
192+ .width('100%')
193+ .height((this.mHeightNumber !== undefined ? this.mHeightNumber : 0) -
194+ (this.trYTop !== undefined ? this.trYTop : 0) + (this.trYBottom !== undefined ? this.trYBottom : 0))
195+ } else {
196+ Column() {
197+ if (this.customList !== undefined) {
198+ this.customList()
199+ }
200+ }
201+ .width('100%')
202+ .height((this.mHeightNumber !== undefined ? this.mHeightNumber : 0) - (this.trYTop !== undefined ? this.trYTop : 0) + (this.trYBottom !== undefined ? this.trYBottom : 0))
127 }203 }
128- .width('100%')
129- .height((this.mHeightNumber !== undefined ? this.mHeightNumber : 0) - (this.trYTop !== undefined ? this.trYTop : 0) + (this.trYBottom !== undefined ? this.trYBottom : 0))
130 204 
131 // 上拉加载动画部分205 // 上拉加载动画部分
132 Stack() {206 Stack() {
@@ -145,16 +219,25 @@ export struct PullToRefreshV2 {
145 .parallelGesture(219 .parallelGesture(
146 PanGesture(this.panOption)220 PanGesture(this.panOption)
147 .onActionStart((event?: GestureEvent) => {221 .onActionStart((event?: GestureEvent) => {
222+ if (this.enableNestedScroll === true) {
223+ return;
224+ }
148 if (event !== undefined) {225 if (event !== undefined) {
149 this.touchYOld = event.offsetY;226 this.touchYOld = event.offsetY;
150 }227 }
151 })228 })
152 .onActionUpdate((event?: GestureEvent) => {229 .onActionUpdate((event?: GestureEvent) => {
230+ if (this.enableNestedScroll === true) {
231+ return;
232+ }
153 if (event !== undefined) {233 if (event !== undefined) {
154 this.onActionUpdate(event);234 this.onActionUpdate(event);
155 }235 }
156 })236 })
157 .onActionEnd(() => {237 .onActionEnd(() => {
238+ if (this.enableNestedScroll === true) {
239+ return;
240+ }
158 this.onActionEnd();241 this.onActionEnd();
159 })242 })
160 )243 )
cpf-manager
cpf-managercpf-manager6月5日

【AI-Review】【建议】【软件设计】【冗余重复代码】PullToRefresh 与 PullToRefreshV2 的 getPanDirection 逻辑完全重复

● 问题:getPanDirection() 方法在 PullToRefresh.ets 和 PullToRefreshV2.ets 中完全重复实现,逻辑和代码完全一致。两个组件中大量手势处理逻辑也高度重复。当需要修改手势方向逻辑时,必须在两处同步修改,容易遗漏导致行为不一致。

● 影响:建议。增加了维护成本和引入不一致 bug 的风险。

● 建议:考虑将 getPanDirection() 等公共逻辑抽取到独立的工具类或 mixin 中,两个组件共同引用。如果当前架构不支持共享,至少在注释中标注两处需同步修改。

likedislike