• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/*
2 * Copyright (c) 2025 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
16/**
17 * 使用pullToRefresh组件样例
18 *
19 * 核心组件:
20 * 1. pullToRefresh
21 *
22 * 实现步骤:
23 * 1. 使用一个@Builder修饰的方法实现LazyForEach列表懒加载。
24 * 2. 使用pullToRefresh组件实现上拉下滑加载数据,将UI方法传入组件中。
25 */
26
27import util from '@ohos.util';
28import { PullToRefresh, PullToRefreshConfigurator } from '@ohos/pulltorefresh/index';
29import { BasicDataSource } from '../viewModel/BasicDataSource';
30
31const NEWS_TITLE_MAX_LINES: number = 1;
32const NEWS_TITLE_TEXT_FONT_WEIGHT: number = 500;
33const NEWS_CONTENT_MAX_LINES: number = 2;
34const NEWS_TIME_MAX_LINES: number = 1;
35const NEWS_PAGE_TEXT_FONT_WEIGHT: number = 600;
36const CURRENT_DATA_RESOLVE_SUCCESS: string = '刷新当前数据成功';
37const NEW_DATA_RESOLVE_SUCCESS: string = '更新数据成功';
38const CURRENT_DATA_TIP: string = '释放刷新当前页面数据';
39const NEW_DATA_TIP: string = '释放更新下页数据';
40const NEWS_MOCK_DATA_COUNT: number = 8; // 每份模拟数据所含的新闻个数为8
41const MOCK_DATA_FILE_ONE_DIR: string = 'mockDataOne.json'; // 模拟数据路径
42const MOCK_DATA_FILE_TWO_DIR: string = 'mockDataTwo.json'; // 模拟数据路径
43const NEWS_REFRESH_TIME: number = 1500; // 数据刷新时间
44const LOAD_DEFAULT_TEXT: string = '正在玩命加载中...'; // 上拉加载默认文本
45const LOAD_STOP_TEXT: string = '已经到底啦'; // 上拉加载到底提示文本
46const LOAD_TEXT_PULL_UP_1: string = '正在上拉刷新...'; // 上拉1阶段文本
47const LOAD_TEXT_PULL_UP_2: string = '放开刷新'; // 上拉2阶段文本
48const NEWS_MAX_LIST: number = 4; // 数据列表总页数
49const LOAD_PULL_STATE_CHANGE: number = 0.75; // 根据组件动画配置的上拉阶段改变临界值
50const CHANGE_PAGE_STATE: number = 0.99 //下拉改变刷新状态的临界值
51const PAGE_LIST_ID: string = 'pageList'; // 列表ID
52
53@Component
54export struct PullToRefreshNewsComponent {
55  // 创建用于懒加载的数据对象
56  @State newsData: NewsDataSource = new NewsDataSource();
57  // 需绑定列表或宫格组件
58  private scroller: Scroller = new Scroller();
59  // 模拟数据列表页
60  @State newsDataListIndex: number = 1;
61  private refreshConfigurator: PullToRefreshConfigurator = new PullToRefreshConfigurator();
62  @State isPullUp: boolean = true;
63  @State isLoading: boolean = false;
64  @State private angle1?: number | string = 0;
65  @State private angle2?: number | string = 0;
66  @State loadText: string = '';
67  @State pullHeightValue: number = 0;
68  // 每页与数据的对应关系
69  private currentPageMap: Map<number, string> = new Map();
70  // 当前页
71  @State currentPage: number = 0;
72  // 是否更换页面
73  @State isChangePage: boolean = false;
74
75  aboutToAppear() {
76    this.currentPageMap.set(0, MOCK_DATA_FILE_ONE_DIR);
77    this.currentPageMap.set(1, MOCK_DATA_FILE_TWO_DIR);
78    const newsModelMockData: NewsData[] = getNews(MOCK_DATA_FILE_ONE_DIR);
79    for (let j = 0; j < NEWS_MOCK_DATA_COUNT; j++) {
80      this.newsData.pushData(newsModelMockData[j]);
81    }
82  }
83
84  build() {
85    Column() {
86      Text($r('app.string.pull_refresh_page_title'))
87        .fontSize($r('app.integer.pull_refresh_page_text_font_size'))
88        .fontWeight(NEWS_PAGE_TEXT_FONT_WEIGHT)
89        .textAlign(TextAlign.Start)
90        .lineHeight($r('app.integer.pull_refresh_page_text_line_height'))
91        .padding({ left: $r('app.string.pull_refresh_page_text_padding_left') })
92        .width($r('app.string.pull_refresh_page_text_width'))
93        .height($r('app.string.pull_refresh_page_text_height'))
94        .backgroundColor($r('app.color.pull_refresh_listColor'))
95
96      Column() {
97        PullToRefresh({
98          // TODO: 知识点:使用PullToRefresh组件时需要先绑定数据与主体布局。若使用LazyForEach组件渲染列表时,需将UI方法使用@Builder修饰并传入customList属性中。
99          // 必传项,列表组件所绑定的数据
100          data: $newsData,
101          // 必传项,需绑定传入主体布局内的列表或宫格组件
102          scroller: this.scroller,
103          // 必传项,自定义主体布局,内部有列表或宫格组件
104          customList: () => {
105            // 一个用@Builder修饰过的UI方法
106            this.getListView();
107          },
108          // 组件属性配置,具有默认值
109          refreshConfigurator: this.refreshConfigurator,
110          // TODO: 知识点:设置onRefresh下拉刷新回调方法,该方法必须返回一个Promise类型。
111          onRefresh: () => {
112            return new Promise<string>((resolve, reject) => {
113              setTimeout(() => {
114                if (this.isChangePage) {
115                  // 计算改变页面
116                  this.currentPage = (this.currentPage + 1) % 2;
117                }
118                this.newsData.clear();
119                let newsModelMockData: NewsData[] = [];
120                newsModelMockData = getNews(this.currentPageMap.get(this.currentPage));
121                for (let j = 0; j < NEWS_MOCK_DATA_COUNT; j++) {
122                  this.newsData.pushData(newsModelMockData[j]);
123                }
124                if (this.isChangePage) {
125                  resolve(NEW_DATA_RESOLVE_SUCCESS);
126                } else {
127                  resolve(CURRENT_DATA_RESOLVE_SUCCESS);
128                }
129                // 页码归零
130                this.newsDataListIndex = 1;
131              }, NEWS_REFRESH_TIME);
132            });
133          },
134          // TODO: 知识点:设置onLoadMore上滑加载更多数据回调方法,该方法必须返回一个Promise类型。
135          onLoadMore: () => {
136            return new Promise<string>((resolve, reject) => {
137              // 模拟数据列表页超过4页后已到达底部,无法继续加载
138              if (this.newsDataListIndex < NEWS_MAX_LIST) {
139                // 模拟网络请求操作,请求网络1.5秒后得到数据,通知组件变更列表数据
140                setTimeout(() => {
141                  let newsModelMockData: NewsData[] = getNews(MOCK_DATA_FILE_ONE_DIR);
142                  for (let j = 0; j < NEWS_MOCK_DATA_COUNT; j++) {
143                    this.newsData.pushData(newsModelMockData[j]);
144                  }
145                  this.newsDataListIndex++;
146                  resolve('');
147                }, NEWS_REFRESH_TIME);
148              } else {
149                // 如果已满4页,更改上拉提示信息提示已经加载完所有数据
150                setTimeout(() => {
151                  resolve('');
152                }, NEWS_REFRESH_TIME);
153              }
154            });
155          },
156          customLoad: () => this.customLoad(),
157          customRefresh: () => this.customRefresh(),
158          onAnimPullDown: (value) => {
159            this.pullHeightValue = value;
160          },
161          onAnimRefreshing: (value, width, height) => {
162            if (value !== undefined && width !== undefined && height !== undefined) {
163              if (value) {
164                this.angle2 = value * 360;
165                if (this.pullHeightValue > LOAD_PULL_STATE_CHANGE && this.pullHeightValue <= CHANGE_PAGE_STATE) {
166                  this.isChangePage = false;
167                } else {
168                  // 当下拉到最顶部时,触发更新页面,不再刷新当前页。
169                  this.isChangePage = true;
170                }
171              }
172            }
173          },
174          onAnimPullUp: (value, width, height) => {
175            if (value !== undefined && width !== undefined && height !== undefined) {
176              if (value) {
177                this.isLoading = false;
178                this.isPullUp = true;
179                // 判断上拉拖拽过程中高度是否超过阶段临界值
180                if (value < LOAD_PULL_STATE_CHANGE) {
181                  // 归零角度,保持箭头朝上
182                  this.angle1 = 0;
183                  // 改变提示文本为上拉1阶段
184                  this.loadText = LOAD_TEXT_PULL_UP_1;
185                } else {
186                  // 翻转角度,保持箭头朝下
187                  this.angle1 = 180;
188                  // 改变提示文本为上拉2阶段
189                  this.loadText = LOAD_TEXT_PULL_UP_2;
190                }
191              }
192            }
193          },
194          onAnimLoading: (value, width, height) => {
195            if (value !== undefined && width !== undefined && height !== undefined) {
196              if (value) {
197                this.isPullUp = false;
198                this.isLoading = true;
199                // 更改角度使加载图片保持旋转
200                this.angle2 = value * 360;
201                // 判读页码是否为最后一页
202                if (this.newsDataListIndex !== NEWS_MAX_LIST) {
203                  this.loadText = LOAD_DEFAULT_TEXT;
204                } else {
205                  // 最后一页更换文本提示已经到底了
206                  this.loadText = LOAD_STOP_TEXT;
207                }
208              }
209            }
210          }
211        })
212      }
213      .backgroundColor($r('app.color.pull_refresh_listColor'))
214    }
215    .height($r('app.string.pull_refresh_page_height'))
216  }
217
218  // 上滑加载提示
219  @Builder
220  private customLoad() {
221    Row() {
222      Stack() {
223        // 上拉1阶段箭头图片
224        Image($r('app.media.pull_icon_up'))
225          .width($r('app.string.pull_refresh_load_width'))
226          .height($r('app.string.pull_refresh_load_height'))
227          .objectFit(ImageFit.Contain)
228          .visibility(this.isPullUp ? Visibility.Visible : Visibility.Hidden)
229          .rotate({
230            z: 1,
231            angle: this.angle1 !== undefined ? this.angle1 : 0
232          })
233        // 加载时图片
234        Image($r('app.media.pull_icon_load'))
235          .width($r('app.string.pull_refresh_load_width'))
236          .height($r('app.string.pull_refresh_load_height'))
237          .objectFit(ImageFit.Contain)
238          .visibility(this.isLoading ? Visibility.Visible : Visibility.Hidden)
239          .rotate({
240            z: 1,
241            angle: this.angle2 !== undefined ? this.angle2 : 0
242          })
243      }
244      // 最后一页加载时隐藏加载图片
245      .width(this.newsDataListIndex === NEWS_MAX_LIST && this.isLoading ? 0 :
246      this.refreshConfigurator.getLoadImgHeight())
247      .height(this.newsDataListIndex === NEWS_MAX_LIST && this.isLoading ? 0 :
248      this.refreshConfigurator.getLoadImgHeight())
249
250      // 上拉过程与加载时提示文本
251      Text(this.loadText)
252        .height($r('app.string.pull_refresh_load_height'))
253        .textAlign(TextAlign.Center)
254        .margin({ left: this.newsDataListIndex === NEWS_MAX_LIST && this.isLoading ? 0 : 8 })
255        .fontColor(this.refreshConfigurator !== undefined ? this.refreshConfigurator.getLoadTextColor() : 0)
256        .fontSize(this.refreshConfigurator !== undefined ? this.refreshConfigurator.getLoadTextSize() : 0)
257    }
258    .height($r('app.string.pull_refresh_load_height'))
259  }
260
261  // 下拉刷新提示
262  @Builder
263  private customRefresh() {
264    Row() {
265      // 下滑加载图片
266      Image($r('app.media.pull_icon_load'))
267        .width($r('app.string.pull_refresh_load_width'))
268        .height($r('app.string.pull_refresh_load_height'))
269        .objectFit(ImageFit.Contain)
270        .rotate({
271          z: 1,
272          angle: this.angle2 !== undefined ? this.angle2 : 0
273        })
274        .width(this.refreshConfigurator.getLoadImgHeight())
275        .height(this.refreshConfigurator.getLoadImgHeight())
276
277      // 下拉时提示文本
278      Stack() {
279        Text(CURRENT_DATA_TIP)
280          .height($r('app.string.pull_refresh_load_height'))
281          .textAlign(TextAlign.Center)
282          .margin({ left: this.newsDataListIndex === NEWS_MAX_LIST && this.isLoading ? 0 : 8 })
283          .fontColor(this.refreshConfigurator !== undefined ? this.refreshConfigurator.getLoadTextColor() : 0)
284          .fontSize(this.refreshConfigurator !== undefined ? this.refreshConfigurator.getLoadTextSize() : 0)
285          .visibility(this.pullHeightValue <= CHANGE_PAGE_STATE ? Visibility.Visible : Visibility.Hidden)
286        Text(NEW_DATA_TIP)
287          .height($r('app.string.pull_refresh_load_height'))
288          .textAlign(TextAlign.Center)
289          .margin({ left: this.newsDataListIndex === NEWS_MAX_LIST && this.isLoading ? 0 : 8 })
290          .fontColor(this.refreshConfigurator !== undefined ? this.refreshConfigurator.getLoadTextColor() : 0)
291          .fontSize(this.refreshConfigurator !== undefined ? this.refreshConfigurator.getLoadTextSize() : 0)
292          .visibility(this.pullHeightValue > CHANGE_PAGE_STATE ? Visibility.Visible : Visibility.Hidden)
293      }
294    }
295    .height($r('app.string.pull_refresh_load_height'))
296  }
297
298  // 必须使用@Builder修饰方法
299  @Builder
300  private getListView() {
301    List({
302      space: 3, scroller: this.scroller
303    }) {
304      // TODO: 性能知识点:使用懒加载组件渲染数据
305      LazyForEach(this.newsData, (item: NewsData) => {
306        ListItem() {
307          newsItem({
308            newsTitle: item.newsTitle,
309            newsContent: item.newsContent,
310            newsTime: item.newsTime
311          })
312        }
313        .backgroundColor($r('app.color.pull_refresh_white'))
314        .margin({
315          bottom: $r('app.string.pull_refresh_list_margin_bottom'),
316          left: $r('app.string.pull_refresh_list_item_margin_left')
317        })
318        .borderRadius($r('app.integer.pull_refresh_list_border_radius'))
319      }, (item: NewsData, index?: number) => JSON.stringify(item) + index);
320    }
321    .id(PAGE_LIST_ID)
322    .width($r('app.string.pull_refresh_List_width'))
323    .backgroundColor($r('app.color.pull_refresh_listColor'))
324    // TODO: 知识点:必须设置列表为滑动到边缘无效果,否则无法触发pullToRefresh组件的上滑下拉方法。
325    .edgeEffect(EdgeEffect.None)
326  }
327
328  aboutToDisappear() {
329    this.newsData.clear();
330  }
331}
332
333// 单一列表样式组件
334@Component
335struct newsItem {
336  private newsTitle: string | Resource = '';
337  private newsContent: string | Resource = '';
338  private newsTime: string | Resource = '';
339
340  build() {
341    Column() {
342      Row() {
343        Image($r('app.media.pull_refresh_news'))
344          .width($r('app.string.pull_refresh_title_image_width'))
345          .height($r('app.string.pull_refresh_title_image_height'))
346          .objectFit(ImageFit.Contain)
347        Text(this.newsTitle)
348          .fontSize($r('app.integer.pull_refresh_title_text_font_size'))
349          .fontColor($r('app.color.pull_refresh_title_fontColor'))
350          .width($r('app.string.pull_refresh_title_text_width'))
351          .maxLines(NEWS_TITLE_MAX_LINES)
352          .margin({ left: $r('app.string.pull_refresh_title_text_margin_left') })
353          .textOverflow({ overflow: TextOverflow.Ellipsis })
354          .fontWeight(NEWS_TITLE_TEXT_FONT_WEIGHT)
355      }
356      .alignItems(VerticalAlign.Center)
357      .height($r('app.string.pull_refresh_title_row_height'))
358      .margin({
359        top: $r('app.string.pull_refresh_title_row_margin_top'),
360        left: $r('app.string.pull_refresh_title_image_margin_left')
361      })
362
363      Text(this.newsContent)
364        .fontSize($r('app.integer.pull_refresh_content_font_size'))
365        .lineHeight($r('app.integer.pull_refresh_content_font_line_height'))
366        .fontColor($r('app.color.pull_refresh_content_fontColor'))
367        .height($r('app.string.pull_refresh_content_height'))
368        .width($r('app.string.pull_refresh_content_width'))
369        .maxLines(NEWS_CONTENT_MAX_LINES)
370        .margin({
371          left: $r('app.string.pull_refresh_content_margin_left'),
372          top: $r('app.string.pull_refresh_content_margin_top')
373        })
374        .textOverflow({ overflow: TextOverflow.Ellipsis })
375      Text(this.newsTime)
376        .fontSize($r('app.integer.pull_refresh_time_font_size'))
377        .fontColor($r('app.color.pull_refresh_time_fontColor'))
378        .height($r('app.string.pull_refresh_time_height'))
379        .width($r('app.string.pull_refresh_time_width'))
380        .maxLines(NEWS_TIME_MAX_LINES)
381        .margin({
382          left: $r('app.string.pull_refresh_time_margin_left'),
383          top: $r('app.string.pull_refresh_time_margin_top')
384        })
385        .textOverflow({ overflow: TextOverflow.None })
386    }
387    .alignItems(HorizontalAlign.Start)
388  }
389}
390
391// 新闻数据对象
392class NewsData {
393  newsId: string
394  newsTitle: string | Resource
395  newsContent: string | Resource
396  newsTime: string | Resource
397
398  toString(): string {
399    return this.newsId + ' ' + this.newsTitle + ' ' + this.newsContent + ' ' + this.newsTime;
400  }
401
402  constructor(id: string, title: string | Resource, content: string | Resource, time: string | Resource) {
403    this.newsId = id;
404    this.newsTitle = title;
405    this.newsContent = content;
406    this.newsTime = time;
407  }
408}
409
410// 懒加载列表对象
411class NewsDataSource extends BasicDataSource {
412  dataArray: Array<NewsData> = [];
413
414  public totalCount(): number {
415    return this.dataArray.length;
416  }
417
418  public getData(index: number): Object {
419    return this.dataArray[index];
420  }
421
422  public addData(index: number, data: NewsData): void {
423    this.dataArray.splice(index, 0, data);
424    this.notifyDataAdd(index);
425  }
426
427  public pushData(data: NewsData): void {
428    this.dataArray.push(data);
429    this.notifyDataAdd(this.dataArray.length - 1);
430  }
431
432  public clear(): void {
433    this.dataArray = [];
434  }
435}
436
437class JsonObjType {
438  newsList: Array<NewsData> = [];
439}
440
441class JsonObject {
442  private jsonFileDir: string = '';
443
444  constructor(jsonFileDir: string) {
445    this.jsonFileDir = jsonFileDir;
446  }
447
448  // 获取数据
449  getNewsData(): Array<NewsData> {
450    // 从本地文件中获取数据
451    const value = getContext().resourceManager.getRawFileContentSync(this.jsonFileDir);
452    // 解码为utf-8格式
453    const textDecoder = util.TextDecoder.create('utf-8', {
454      ignoreBOM: true
455    });
456    const textDecoderResult = textDecoder.decodeWithStream(new Uint8Array(value.buffer));
457    const jsonObj: JsonObjType = JSON.parse(textDecoderResult) as JsonObjType;
458    const newsModelBuckets: NewsData[] = [];
459    // 映射json数据为NewsModel对象
460    const newsModelObj = jsonObj.newsList;
461    for (let i = 0; i < newsModelObj.length; i++) {
462      const contactTemp = new NewsData(newsModelObj[i].newsId, newsModelObj[i].newsTitle,
463        newsModelObj[i].newsContent, newsModelObj[i].newsTime);
464      newsModelBuckets.push(contactTemp);
465    }
466    return newsModelBuckets;
467  }
468}
469
470function getNews(mockFileDir: string): Array<NewsData> {
471  const jsonObj: JsonObject = new JsonObject(mockFileDir);
472  const newsModelMockData: NewsData[] = jsonObj.getNewsData();
473  return newsModelMockData;
474}
475