# 9.7:自定义下拉刷新(RefreshList)

下拉刷新是一个很常用的功能,绝大多数的 APP 都有该功能,比如新闻类 APP 使用下拉刷新更新最新新闻资讯等场景,笔者在第六章 第 5 小节 介绍过 ArkUI 开发框架提供的下拉刷新组件 Refresh 的使用,本节笔者简单实现一个基于 List 的自定义下拉刷新组件 RefreshList,该组件的运行效果如下图所示:

9_7_1

# 9.7.1:RefreshList布局拆分

下拉刷新组件都是分为上下两部分,上边是刷新头:refreshHead,该刷新头根据手指的下滑距离提示是否达到刷新条件;下边是刷新体:refreshContent,当触发下拉刷新条件后对外回调,从而实现内容更新。笔者实现的 RefreshList 也是按照以上布局实现的,简化图如下所示:

9_7_1_1

默认情况下 refreshHead 是布局在 RefreshList 可视区域外边,笔者在第三章 第 1 小节 讲解过可以使用 position() 方法实现布局定位,简化代码如下所示:

@Component struct RefreshList {
  build() {
    Column() {
      Row() {
        // header布局
      }
      .id("refresh_header")
      .width("100%")
      .height(50)
      .position({ // 利用该属性,把refresh_header布局在 Column 顶部
        x: 0,
        y: -50
      })

      Column() {
        // content 布局
      }
      .id("refresh_content")
      .width("100%")
      .height("100%")
      .position({ // 利用该属性,把refresh_content布局向上做偏移
        x: 0,
        y: 0
      })
    }
    .id("refresh_list")
    .width("100%")
    .height("100%")
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# 9.7.2:RefreshList滑动处理

ArkUI 开发框架对于手势事件的处理遵循 W3C 标准,首先是目标捕获阶段,然后再是事件冒泡阶段,下拉刷新的操作就是在事件冒泡阶段处理的,因此直接实现 refresh_listonTouch() 方法即可,在该方法内根据手指的滑动距离动态实现 refreshHeader 和 refreshContent 的布局定位即可,简化代码如下所示:

@Component struct RefreshList {

  private refreshHeaderHeight: number = 50;
  private offsetY: number = -this.refreshHeaderHeight;
  private lastX: number;
  private lastY: number;
  private downY: number;

  build() {
    Column() {
      Row()
      .id("refresh_header")
      .width("100%")
      .height(this.refreshHeaderHeight)
      .backgroundColor("#bbaacc")
      .position({
        x: 0,
        y: this.offsetY
      })

      Column() {
      }
      .id("refresh_content")
      .width("100%")
      .height("100%")
      .backgroundColor("#aabbcc")
      .position({
        x: 0,
        y: this.offsetY + this.refreshHeaderHeight
      })
    }
    .id("refresh_list")
    .width("100%")
    .height("100%")
    .onTouch((event) => {
      if (event.type == TouchType.Down) {
        // 处理 down 事件
        this.onTouchDown(event);
      } else if (event.type == TouchType.Move) {
        // 处理 move 事件
        this.onTouchMove(event);
      } else if (event.type == TouchType.Cancel || event.type == TouchType.Up) {
        // 处理 up 事件
        this.onTouchUp(event);
      }
    })
  }

  private onTouchDown(event: TouchEvent) {
    this.lastX = event.touches[0].screenX;
    this.lastY = event.touches[0].screenY;
    this.downY = this.lastY;
  }

  private onTouchMove(event: TouchEvent) {
    let currentX = event.touches[0].screenX;
    let currentY = event.touches[0].screenY;
    let deltaX = currentX - this.lastX;
    let deltaY = currentY - this.lastY;

    if (Math.abs(deltaX) < Math.abs(deltaY) && Math.abs(deltaY) > 5) {
      // 达到滑动条件
    }
  }

  private onTouchUp(event: TouchEvent) {
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

# 9.7.3:RefreshList滑动冲突

由于 refreshContent 内部包含的是 List 组件,该组件比较特殊,它会默认响应手势的滑动操作,在处理外层滑动的时候该 List 也会跟着一起滑动,这种体验是非常不友好的,因此可以在 ListonScrollBegin()方法中处理滑动冲突,简化代码如下所示:

@Component struct RefreshList {

  build() {
    Column() {
      Row()
      .id("refresh_header")
      .position({
        x: 0,
        y: this.offsetY
      })

      Column() {
        List({scroller: this.listScroller}) {
        }
        .edgeEffect(EdgeEffect.None)
        .onScrollBegin((dx: number, dy: number) => { // 处理滑动冲突
          dy = this.listScrollable ? dy : 0;
          return {dxRemain: dx, dyRemain: dy}
        })
      }
      .id("refresh_content")
      .position({
        x: 0,
        y: this.offsetY + this.refreshHeaderHeight
      })
    }
    .id("refresh_list")
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

listScrollable 属性表示 List 是否可以滚动,当在处理外部滑动的时候禁止内部的 List 滑动,此时 让 onScrollBegin() 方法返回的 dyRemain0 即可。

# 9.7.4:RefreshList完整代码

动态布局完 refreshHeaderrefreshContent 后,在 refresh_listonTouch() 方法内处理滑动操作,一个下拉刷新的轮廓基本就形成了,笔者实现的 RefreshList 完整代码如下所示:

export class Constant {
  static readonly REFRESH_PULL_TO_REFRESH = "下拉刷新";
  static readonly REFRESH_FREE_TO_REFRESH = "释放立即刷新";
  static readonly REFRESH_REFRESHING      = "正在刷新";
  static readonly REFRESH_SUCCESS         = "刷新成功";
}

@Component
export struct RefreshList {

  @BuilderParam
  itemLayout?: (item: Object, index: number) => void;

  @Watch("notifyRefreshingChanged")
  @Link refreshing: boolean;
  @Link dataSet: Array<Object>;

  onRefresh?: () => void;
  onStatusChanged?: (status: RefreshStatus) => void;

  private headHeight: number = 55;
  private lastX: number = 0;
  private lastY: number = 0;
  private downY: number = 0;

  private flingFactor: number = 0.75;
  private touchSlop: number = 2;
  private offsetStep: number = 10;
  private intervalTime: number = 20;
  private listScrollable: boolean = true;
  private dragging: boolean = false;

  private refreshStatus: RefreshStatus = RefreshStatus.Inactive;

  @Watch("notifyOffsetYChanged")
  @State offsetY: number = -this.headHeight;

  @State refreshHeadIcon: Resource = $r("app.media.icon_refresh_down");
  @State refreshHeadText: string = Constant.REFRESH_PULL_TO_REFRESH;
  @State refreshContentH: number = 0;
  @State touchEnabled: boolean = true;
  @State headerVisibility: Visibility = Visibility.None;

  private listScroller: Scroller = new Scroller();

  private notifyRefreshingChanged() {
    if (this.refreshing) {
      this.showRefreshingStatus();
    } else {
      this.finishRefresh();
    }
  }

  private notifyOffsetYChanged() {
    this.headerVisibility = (this.offsetY == -this.headHeight) ? Visibility.None : Visibility.Visible;
  }

  @Builder headLayout() {
    Row() {
      Blank()
      Image(this.refreshHeadIcon)
        .width(30)
        .aspectRatio(1)
        .objectFit(ImageFit.Contain)

      Text(this.refreshHeadText)
        .fontSize(16)
        .width(150)
        .textAlign(TextAlign.Center)

      Blank()
    }
    .width("100%")
    .height(this.headHeight)
    .backgroundColor("#44bbccaa")
    .visibility(this.headerVisibility)
    .position({
      x: 0,
      y: this.offsetY
    })
  }

  build() {
    Column() {
      this.headLayout()

      Column() {
        List({scroller: this.listScroller}) {
          if (this.dataSet) {
            ForEach(this.dataSet, (item: Object, index: number) => {
              ListItem() {
                if (this.itemLayout) {
                  this.itemLayout(item, index)
                }
              }
              .width("100%")
            }, (item: Object, index: number) => item.toString())
          }
        }
        .width("100%")
        .height("100%")
        .edgeEffect(EdgeEffect.None)
        // .onScrollBegin((dx: number, dy: number) => {
        //   dy = this.listScrollable ? dy : 0;
        //   return {dxRemain: dx, dyRemain: dy}
        // })
        .onScrollFrameBegin((offset: number, state: ScrollState) => {
          offset = this.listScrollable ? offset : 0;
          return {offsetRemain: offset}
        })
      }
      .width("100%")
      .layoutWeight(1)
      .backgroundColor(Color.Pink)
      .position({
        x: 0,
        y: this.offsetY + this.headHeight
      })

    }
    .width("100%")
    .height("100%")
    .enabled(this.touchEnabled)
    .onAreaChange((oldArea, newAre) => {
      console.log("Refresh height: " + newAre.height);
      this.refreshContentH = Number(newAre.height);
    })
    .clip(true)
    .onTouch((event) => {
      if (event.touches.length != 1) {
        this.logD("TOUCHES LENGTH INVALID: " + JSON.stringify(event.touches))
        event.stopPropagation();
        return
      }
      switch (event.type) {
        case TouchType.Down:
          this.onTouchDown(event);
          break;
        case TouchType.Move:
          this.onTouchMove(event);
          break;
        case TouchType.Up:
        case TouchType.Cancel:
          this.onTouchUp(event);
          break;
      }
      event.stopPropagation();
    })
  }

  private setRefreshStatus(status: RefreshStatus) {
    this.refreshStatus = status;
    this.refreshing = (status == RefreshStatus.Refresh);
    this.touchEnabled = (status != RefreshStatus.Refresh && status != RefreshStatus.Done);
    this.notifyStatusChanged();
  }

  private canRefresh() {
    return this.listScroller.currentOffset().yOffset == 0;
  }

  private onTouchDown(event: TouchEvent) {
    this.lastX = event.touches[0].screenX;
    this.lastY = event.touches[0].screenY;
    this.downY = this.lastY;
    this.dragging = false;
    this.listScrollable = true;

    this.logD("Touch DOWN: " + event.touches[0].screenX.toFixed(2) + " x " + event.touches[0].screenY.toFixed(2) + ", offset: " + this.offsetY);
  }

  private onTouchMove(event: TouchEvent) {
    let currentX = event.touches[0].screenX;
    let currentY = event.touches[0].screenY;
    let deltaX = currentX - this.lastX;
    let deltaY = currentY - this.lastY;
    if (this.dragging) {
      this.logD("offsetY: " + this.offsetY.toFixed(2) + ",  head: " + (-this.headHeight));
      if (deltaY < 0) {
        // 手势向上拖动
        if (this.offsetY > -this.headHeight) {
          this.logD("手指向上拖动还未到达临界值,不让 list 滚动")
          this.offsetY = this.offsetY + px2vp(deltaY) * this.flingFactor;
          this.listScrollable = false;
        } else {
          this.logD("手指向上拖动到达临界值了,开始让 list 滚动")
          this.offsetY = -this.headHeight;
          this.listScrollable = true;
          this.downY = this.lastY;
        }
      } else {
        // 手势向下拖动
        this.logD("手指向下拖动中")
        if (this.canRefresh()) {
          this.offsetY = this.offsetY + px2vp(deltaY) * this.flingFactor;
          this.listScrollable = false;
        } else {
          this.listScrollable = true;
        }
      }
      this.lastX = currentX;
      this.lastY = currentY;
    } else {
      if (Math.abs(deltaX) < Math.abs(deltaY) && Math.abs(deltaY) > this.touchSlop) {
        if (deltaY > 0 && this.canRefresh()) {
          this.dragging = true;
          this.listScrollable = false
          this.lastX = currentX;
          this.lastY = currentY;
          this.logD("Touch MOVE: 手指向下滑动,达到了拖动条件")
        }
      }
    }

    if(this.dragging) {
      if (currentY >= this.downY) {
        if (this.offsetY >= 0 || (this.headHeight - Math.abs(this.offsetY)) > this.headHeight * 4 / 5) {
          this.refreshHeadText = Constant.REFRESH_FREE_TO_REFRESH;
          this.refreshHeadIcon = $r("app.media.icon_refresh_up");
          this.setRefreshStatus(RefreshStatus.OverDrag);
        } else {
          this.refreshHeadText = Constant.REFRESH_PULL_TO_REFRESH;
          this.refreshHeadIcon = $r("app.media.icon_refresh_down");
          this.setRefreshStatus(RefreshStatus.Drag);
        }
      }
    }

    // this.logD("Touch MOVE: " + event.touches[0].screenX + " x " + event.touches[0].screenY + ", offset: " + this.offsetY);
  }

  private onTouchUp(event: TouchEvent) {
    this.logD("Touch   UP: " + event.touches[0].screenX.toFixed(2) + " x " + event.touches[0].screenY.toFixed(2) + ", offset: " + this.offsetY);

    if (this.dragging) {
      // 手指最终向下滑动
      if (this.offsetY >= 0 || (this.headHeight - Math.abs(this.offsetY)) > this.headHeight * 4 / 5) {
        this.logD("Touch   UP: 最终达到下拉刷新条件")
        this.refreshHeadIcon = $r("app.media.icon_refresh_loading");
        this.refreshHeadText = Constant.REFRESH_REFRESHING;
        this.setRefreshStatus(RefreshStatus.Refresh);
        this.scrollToTop();

        this.notifyRefreshStart();
      } else {
        this.logD("Touch   UP: 最终未达到下拉刷新条件")
        this.refreshHeadIcon = $r("app.media.icon_refresh_down");
        this.refreshHeadText = Constant.REFRESH_PULL_TO_REFRESH;
        this.setRefreshStatus(RefreshStatus.Drag);
        this.scrollByTop();
      }
    }
  }

  private scrollToTop() {
    this.offsetY = 0;
  }

  private scrollByTop() {
    if (this.offsetY != -this.headHeight) {
      this.logD("scrollByTop() start, offsetY: " + this.offsetY.toFixed(2));
      let intervalId = setInterval(() => {
        if(this.offsetY <= -this.headHeight) {
          this.resetRefreshStatus();
          clearInterval(intervalId);
          this.logD("scrollByTop() finish, offsetY: " + this.offsetY.toFixed(2));
        } else {
          this.offsetY = ((this.offsetY - this.offsetStep) < -this.headHeight) ? (-this.headHeight) : (this.offsetY - this.offsetStep);
        }
      }, this.intervalTime);
    } else {
      this.logD("scrollByTop(): already scrolled to top edge")
    }
  }

  private resetRefreshStatus() {
    this.offsetY = -this.headHeight;
    this.refreshHeadIcon = $r("app.media.icon_refresh_down");
    this.refreshHeadText = Constant.REFRESH_PULL_TO_REFRESH;
    this.setRefreshStatus(RefreshStatus.Inactive);
  }

  private finishRefresh(): void {
    this.refreshHeadText = Constant.REFRESH_SUCCESS;
    this.refreshHeadIcon = $r("app.media.icon_refresh_success");
    this.setRefreshStatus(RefreshStatus.Done);
    setTimeout(() => {
      this.scrollByTop();
    }, 1500);
  }

  aboutToAppear() {
    if (this.refreshing) {
      this.showRefreshingStatus();
    }
  }

  private showRefreshingStatus() {
    this.offsetY = 0;
    this.refreshHeadIcon = $r("app.media.icon_refresh_loading");
    this.refreshHeadText = Constant.REFRESH_REFRESHING;
    this.setRefreshStatus(RefreshStatus.Refresh);
  }

  private notifyRefreshStart() {
    if (this.onRefresh) {
      this.onRefresh();
    }
  }

  private notifyStatusChanged() {
    if (this.onStatusChanged) {
      this.onStatusChanged(this.refreshStatus);
    }
  }

  private logD(msg: string) {
    console.log(msg + ", canRefresh: " + this.canRefresh() + ", dragging: " + this.dragging + ", listScrollable: " + this.listScrollable + ", refreshing: " + this.refreshing);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320

# 9.7.5:RefreshList完整样例

笔者实现的 RefreshList 很简单,使用方式也和 Refresh 的用法保持了一致,唯一需要注意的就是想要自己实现 List 的每一个 item 局部,样例代码如下所示:

import {RefreshList} from "./widget/refresh_list"

@Entry @Component struct ArkUIClubRefreshListTest {

  @State dataSet: Array<string> = [];
  @State refreshing: boolean = false;

  @Builder itemLayout(item: Object, index: number) {
    Text("item:" + item + ", index: " + index)
      .width('100%')
      .height(100)
      .margin({top: 10})
      .fontSize(16)
      .textAlign(TextAlign.Center)
      .borderRadius(10)
      .backgroundColor('#bbccaa')
  }

  build() {
    Column() {
      Row({space: 10}) {
        Button("开始刷新")
          .onClick(() => {
            this.refreshing = true;
          })
        Button("结束刷新")
          .onClick(() => {
            this.refreshing = false;
          })
      }
      .width("100%")
      .height(50)

      Column() {
        RefreshList({
          refreshing: $refreshing,
          dataSet: $dataSet,
          itemLayout: (item: Object, index: number) => {
            this.itemLayout(item, index);
          },
          onRefresh: () => {
            this.doRefresh();
          },
          onStatusChanged: (status) => {
            console.log("current status: " + status);
          }
        })
      }
      .width("100%")
      .layoutWeight(1)
    }
    .width("100%")
    .width('100%')
  }

  aboutToAppear() {
    this.initDataSet(0, 10);
  }

  private doRefresh() {
    setTimeout(() => {
      console.log("finish refresh")
      this.initDataSet((Math.random() * 100), 30);
      this.refreshing = false;
    }, 2500);
  }

  private initDataSet(start: number, count: number) {
    let dataSet = new Array<string>();
    for (let i = 0; i < count; i++) {
      dataSet.push((i + start).toFixed(2));
    }
    this.dataSet = dataSet;
  }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76

# 9.7.6:小结

本节笔者简单实现了一个下拉刷新控件 RefreshList,主要是利用了组件的 position() 方法实现动态布局,结合 onTouch() 方法实现下拉刷新,另外在 ListonScrollBegin() 方法内控制滑动值来解决滑动冲突,具体其它细节读者可自行阅读代码,笔者也期待读者能扩展出更多的功能,比如自定义刷新头,上拉加载更多等功能,或者读者自己实现一个 RefreshGrid 刷新控件……

(adsbygoogle = window.adsbygoogle || []).push({});
请作者喝杯咖啡

津公网安备 12011402001367号

津ICP备2020008934号-2

中央网信办互联网违法和不良信息举报中心

天津市互联网违法和不良信息举报中心