Practical Development of HarmonyOS NEXT: Implementing Efficient Pull-Down Refresh and Pull-Up Load Components (Part II) Core Logic of Refresh and Integration with Empty Pages
Foreword:
In the previous article, we delved into how to implement a fully functional empty page component in HarmonyOS. Now, we will move on to the core logic implementation of pull-down refresh and pull-up load features. This is not just about technical implementation, but more importantly, it is about a deep understanding of user experience. This article will detail how to integrate empty pages with the logic of pull-down refresh and pull-up load to create an efficient and user-friendly interactive experience.
I. Building the Core Logic
When developing pull-down refresh and pull-up load features, we first need to define several key fields: the total number of pages, the starting page number, and the number of data entries per page. These fields are the foundation for pagination requests and the starting point for our logic implementation.
So in our core utility class, we need to make the network request into a function parameter for external requests.
Code Example:
private requestData:(currentPage:number,pageSize:number)=>void
Next, we need to implement external call listeners so that our loading logic can communicate with the outside. This includes refresh completion, load completion, empty data listening, etc.
Code Example:
export interface PullRefreshListener<T> {
refreshCompleted:()=>void;
loadMoreCompleted:()=>void;
emptyPage:()=>void;
setData:(data:T[], isRefreshLast:boolean)=>void;
lastData:()=>void;
moreLoadFail:(error:BaseError)=>void;
onLoadFail:(error:BaseError)=>void;
}
II. Implementation of Pull-Down Refresh and Pull-Up Load
When implementing pull-down refresh and pull-up load, we need to consider various states, including empty data and load errors. The core logic includes determining whether the data is empty, whether the last page has been reached, and how to handle load errors.
Core Logic Code:
import { BaseError } from '@kangraoo/baselibrary/src/main/ets/exception/NetworkError';
import { Log } from '@kangraoo/utils';
export interface PullRefreshListener<T> {
refreshCompleted:()=>void;
loadMoreCompleted:()=>void;
emptyPage:()=>void;
setData:(data:T[], isRefreshLast:boolean)=>void;
lastData:()=>void;
moreLoadFail:(error:BaseError)=>void;
onLoadFail:(error:BaseError)=>void;
}
export class PullRefreshList<T>{
// Total number of pages
readonly PAGE_COUNT_SIZE:number = 10
// Current page number
readonly CURRENT_PAGE:number = 1
private isRefreshLast:boolean = true
// Start
private currentPage:number
// Page number
private pageSize: number
// Network request data
private requestData:(currentPage:number,pageSize:number)=>void
private pullRefreshListener:PullRefreshListener<T>;
constructor(requestData: (currentPage: number, pageSize: number) => void, pullRefreshListener: PullRefreshListener<T>
,currentPage?: number, pageSize?: number) {
this.currentPage = currentPage??this.CURRENT_PAGE;
this.pageSize = pageSize??this.PAGE_COUNT_SIZE;
this.requestData = requestData;
this.pullRefreshListener = pullRefreshListener;
}
private makeCurrentPage(){
this.currentPage++;
Log.debug(`Current ${this.currentPage}`);
this.isRefreshLast = false;
}
// Refresh
refreshData() {
this.isRefreshLast = true;
this.currentPage = 1;
this.requestData(this.currentPage,this.pageSize);
}
/// Refresh the already loaded data (only when data exists)
refreshLoadData(){
this.isRefreshLast = true;
Log.debug(`Current${this.currentPage} Total ${this.pageSize}*${this.currentPage}`);
this.requestData(1,this.pageSize*(this.currentPage--));
}
/// Load more
loadMore() {
this.isRefreshLast = false;
this.requestData(this.currentPage,this.pageSize);
}
dataError(error:BaseError) {
this.pullRefreshListener.loadMoreCompleted();
this.pullRefreshListener.refreshCompleted();
if (this.isRefreshLast) {
this.pullRefreshListener.onLoadFail(error);
} else {
this.pullRefreshListener.moreLoadFail(error);
}
}
dataSucces(data:T[]|null, total:number) {
this.pullRefreshListener.loadMoreCompleted();
this.pullRefreshListener.refreshCompleted();
if (total === 0) {
if (this.isRefreshLast) {
this.pullRefreshListener.setData([], this.isRefreshLast);
this.pullRefreshListener.emptyPage();
}
} else {
if (data === null || data.length===0) {
if (this.isRefreshLast) {
this.pullRefreshListener.setData([], this.isRefreshLast);
this.pullRefreshListener.emptyPage();
}
} else {
Log.debug(`page${this.currentPage},total${total}`);
this.pullRefreshListener.setData(data, this.isRefreshLast);
if (this.pageSize * this.currentPage >= total) {
this.pullRefreshListener.lastData();
}
this.makeCurrentPage();
}
}
}
}
III. Component Selection and Basic Logic
Choosing the right components is crucial for implementing pull-down refresh and pull-up load. We have chosen the system component Refresh, which provides natural pull-down refresh handling and page customization features.
To achieve this, we need to integrate the empty page from the previous article with the Refresh component.
First, let's familiarize ourselves with a few variables:
- Empty page state:
layoutType
- Pull-up refresh completion:
finished
- Pull-up loading:
loading
- Pull-down refresh:
isRefreshing
- Refresh status:
refreshStatus
Next, let's get to know a few methods:
- The content wrapped by pull-down refresh, usually a list or other list-like component:
content
- The method called on pull-down:
onRefreshing
- The method called when the refresh button on the empty page is clicked:
onButtonRefreshing
Core Code:
@Preview
@Component
export struct PullRefreshWidget {
public mCmpController: PullRefreshController|null = null;
aboutToAppear(): void {
if (this.mCmpController!=null) {
this.mCmpController.attach(this); // Bind the controller
}
}
@State isRefreshing: boolean = false
@State
refreshStatus: RefreshStatus = RefreshStatus.Inactive
@Link finished: boolean
@Link loading: boolean
@Link moreLoadFail: boolean
@BuilderParam
content:()=>void
onRefreshing?:()=>void
onButtonRefreshing?:()=>void
@Builder
baseRefresh(){
Refresh({
refreshing : $$this.isRefreshing,
builder: this.customRefreshComponent()
}){
this.content()
}.onRefreshing(()=>{
if(this.onRefreshing){
this.onRefreshing()
}
})
.onStateChange(async (status) => {
this.refreshStatus = status
})
.height("100%")
}
@State
layoutType : EmptyStatus = EmptyStatus.none
build() {
EmptyWidget({
child : ()=>{
this.baseRefresh()
},
layoutType : this.layoutType,
refresh : ()=>{
if(this.onButtonRefreshing){
this.onButtonRefreshing()
}
}
})
}
@Builder
customRefreshComponent()
{
Stack()
{
Row()
{
LoadingProgress().height(32)
Text(this.getTextByStatus()).fontSize(16).margin({left:20})
}
.alignItems(VerticalAlign.Center)
}
.align(Alignment.Center)
.clip(true)
.constraintSize({minHeight:32}) // Set minimum height constraint to ensure the custom component's height does not fall below minHeight when the refresh area's height changes
.width("100%")
}
getTextByStatus() {
switch (this.refreshStatus) {
case RefreshStatus.Drag:
return Application.getInstance().resourceManager.getStringSync($r("app.string.continue_pull_down").id)
case RefreshStatus.OverDrag:
return Application.getInstance().resourceManager.getStringSync($r("app.string.release_to_load").id)
case RefreshStatus.Refresh:
return Application.getInstance().resourceManager.getStringSync($r("app.string.loading").id)
}
return ""
}
}
export class PullRefreshController{
private mComponent: PullRefreshWidget | null = null;
attach(component: PullRefreshWidget) {
this.mComponent = component;
}
refreshCompleted(){
if(this.mComponent!=null){
this.mComponent.isRefreshing = false;
}
}
loadMoreCompleted() {
if(this.mComponent!=null){
this.mComponent.finished = false
this.mComponent.moreLoadFail = false
this.mComponent.loading = false
}
}
lastData(){
if(this.mComponent!=null){
this.mComponent.finished = true
}
}
moreLoadFail(){
if(this.mComponent!=null){
this.mComponent.moreLoadFail = true;
}
}
emptyPage(){
if(this.mComponent!=null){
this.mComponent.layoutType = EmptyStatus.nodata
}
}
nonePage(){
if(this.mComponent!=null){
this.mComponent.layoutType = EmptyStatus.none
}
}
onLoadFail(){
if(this.mComponent!=null){
this.mComponent.layoutType = EmptyStatus.fail
}
}
}
IV. Data Source Selection and Implementation
In business terms, to use a waterfall flow, I adopted WaterFlow as the data component. At the same time, we implemented a data source class to support dynamic data loading and updating.
First, since I used LazyForEach for the data source, I need to create a wrapper class.
BasicDataSource
implements IDataSource
to handle data listeners.
// Basic implementation of IDataSource to handle data listener
export class BasicDataSource<T> implements IDataSource {
private listeners: DataChangeListener[] = [];
private originDataArray: T[] = [];
public totalCount(): number {
return this.originDataArray.length;
}
public getData(index: number): T {
return this.originDataArray[index];
}
// Register a data change listener
registerDataChangeListener(listener: DataChangeListener): void {
if (this.listeners.indexOf(listener) < 0) {
this.listeners.push(listener)
}
}
// Unregister a data change listener
unregisterDataChangeListener(listener: DataChangeListener): void {
const pos = this.listeners.indexOf(listener)
if (pos >= 0) {
this.listeners.splice(pos, 1)
}
}
// Notify the listener of data reload
notifyDataReload(): void {
this.listeners.forEach(listener => {
listener.onDataReloaded()
})
}
// Notify the listener of data addition
notifyDataAdd(index: number): void {
this.listeners.forEach(listener => {
listener.onDataAdd(index)
})
}
// Notify the listener of data change
notifyDataChange(index: number): void {
this.listeners.forEach(listener => {
listener.onDataChange(index)
})
}
// Notify the listener of data deletion
notifyDataDelete(index: number): void {
this.listeners.forEach(listener => {
listener.onDataDelete(index)
})
}
// Notify the listener of data movement
notifyDataMove(from: number, to: number): void {
this.listeners.forEach(listener => {
listener.onDataMove(from, to)
})
}
// Add a data entry at the specified position
public addData(index: number, data: T): void {
this.originDataArray.splice(index, 0, data);
this.notifyDataAdd(index);
}
// Add a data entry at the first position
public add1stItem(data: T): void {
this.addData(0,data)
}
// Add a data entry
public pushData(data: T): void {
this.originDataArray.push(data);
this.notifyDataAdd(this.originDataArray.length - 1);
}
// Delete a data entry at the specified index
public deleteItem(index: number): void {
this.originDataArray.splice(index, 1)
this.notifyDataDelete(index)
}
// Delete the first data entry
public delete1stItem(): void {
this.deleteItem(0)
}
// Delete the last data entry
public deleteLastItem(): void {
this.originDataArray.splice(-1, 1)
this.notifyDataDelete(this.originDataArray.length)
}
// Clear data
public clearData () {
this.originDataArray = []
this.notifyDataReload()
}
// Set new data
public setData(dataArray: T[]){
this.originDataArray = dataArray
this.notifyDataReload()
}
// Add list data
public addDatas(dataArray: T[]){
let l = this.originDataArray.length
this.originDataArray.push(...dataArray)
this.notifyDataAdd(l-1)
// this.originDataArray.push(...dataArray)
// this.notifyDataReload()
}
}
My data class is ExperienceListResponse
, and I need to implement this data source.
class WaterFlowDataSource extends BasicDataSource<ExperienceListResponse> {
}
V. Complete Pull-Down Refresh Implementation
Finally, we integrate all components and logic to implement a complete pull-down refresh feature. This includes data loading, state updates, and handling user interactions.
Complete Code:
@Component
export struct MyPullRefreshWidget{
@State list:ExperienceListResponse[] = []
dataSource: WaterFlowDataSource = new WaterFlowDataSource()
mCmpController: PullRefreshController = new PullRefreshController()
pullRefreshList :PullRefreshList<ExperienceListResponse> = new PullRefreshList((currentPage,pageSize)=>{
setTimeout(()=>{
QuickResponsitory.getInstance().experienceList(currentPage,pageSize).then(value=>{
LibLoading.hide();
if (value instanceof SuccessData) {
let data = value as (SuccessData<ApiResult<ExperienceListResponse[]>>)
this.list = data.data?.data ?? []
this.pullRefreshList.dataSucces(this.list,data.data?.page?.totalCount??0)
} else if (value instanceof ErrorData) {
this.pullRefreshList.dataError(value.error)
}
})
},1000)
},{
refreshCompleted:()=>{
this.mCmpController.refreshCompleted()
},
loadMoreCompleted:()=> {
this.mCmpController.loadMoreCompleted()
},
emptyPage:()=> {
this.mCmpController.emptyPage()
},
setData:(data:ExperienceListResponse[], isRefreshLast:boolean)=>{
if(isRefreshLast){
this.mCmpController.nonePage()
this.dataSource.setData(data)
}else{
this.dataSource.addDatas(data)
}
},
lastData:()=> {
this.mCmpController.lastData()
},
moreLoadFail:(error:BaseError)=>{
this.mCmpController.moreLoadFail()
},
onLoadFail:(error:BaseError)=>{
this.mCmpController.onLoadFail()
}
})
aboutToAppear(): void {
LibLoading.show();
this.pullRefreshList.refreshData()
}
@State finished: boolean = false // Whether the loading is complete
@State loading: boolean = false
@State moreLoadFail: boolean = false
@Builder
itemFoot() {
if (this.finished) {
Row() {
Text($r("app.string.no_more_data"))
.fontSize(12)
}
.width("100%")
.height(40)
.justifyContent(FlexAlign.Center)
} else {
if (this.loading) {
// Loading in progress
Row({ space: 10 }) {
Text($r("app.string.loading_data"))
.fontSize(12)
LoadingProgress()
.width(20)
.height(20)
}
.width("100%")
.height(40)
.justifyContent(FlexAlign.Center)
}else {
if(this.moreLoadFail){
Row() {
Text($r("app.string.data_loading_failed"))
.fontSize(12)
}
.width("100%")
.height(40)
.justifyContent(FlexAlign.Center)
}
}
}
}
@Builder
dataContent(){
WaterFlow({footer:this.itemFoot()}){
LazyForEach(this.dataSource,(item:ExperienceListResponse,index:number)=>{
FlowItem(){
ExperienceListItem({experience:item}).padding(4)
}
},(item:ExperienceListResponse,index:number)=>{
return item.id
})
}
.layoutDirection(FlexDirection.Column)
.columnsTemplate("1fr 1fr")
.onReachEnd(()=>{
// Valve control
if (!this.loading && !this.finished) {
this.loading = true
this.pullRefreshList.loadMore()
}
})
}
build() {
PullRefreshWidget({
mCmpController:this.mCmpController,
content:()=>{
this.dataContent()
},
onRefreshing:()=>{
this.pullRefreshList.refreshData()
},
onButtonRefreshing:()=>{
LibLoading.show();
this.pullRefreshList.refreshData()
}
,finished:this.finished,loading:this.loading,moreLoadFail:this.moreLoadFail})
}
}
V. In-Depth Analysis and Experience Sharing
During the implementation of pull-down refresh and pull-up load, I encountered some challenges, such as how to ensure the smoothness of data loading, how to handle network request exceptions, and how to effectively integrate with empty pages. Through continuous testing and optimization, we found some solutions that make the entire component not only powerful but also provide a good user experience.
Summary:
Through this article, we have not only learned how to implement the core logic of pull-down refresh and pull-up load in HarmonyOS, but also understood how to integrate these logics with the empty page component to provide a richer and smoother user experience.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.