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