Mobile Development 33 min read

Building a Complete HarmonyOS News Client: Network Requests, Pull-to-Refresh & Load More Implementation

This tutorial walks through building a full-featured HarmonyOS news module with category tabs, list rendering, custom pull-to-refresh and load-more logic using touch events and offset animations, network request encapsulation with Promise-based error handling, and a layered architecture separating view, viewmodel, and common utilities.

51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
51CTO HarmonyOS Developer Community
Building a Complete HarmonyOS News Client: Network Requests, Pull-to-Refresh & Load More Implementation

Why Build This News Loading Feature Manually?

Almost every news or content app needs to fetch data from a server, display it, and support refresh and pagination. While it seems simple — just call a network request and put data into a List — real implementation reveals many details: how to encapsulate network requests, handle loading states, implement pull-to-refresh and load-more without built-in components, avoid duplicate triggers during fast scrolling, and reset list state when switching categories.

Final Effect Preview

Horizontally scrollable category tab bar (All, Domestic, International, Entertainment, etc.)

Each tab corresponds to a news list

Each news item contains title, content summary, image grid, source info

Pull down at list top to refresh data

Pull up at list bottom to load more

UI states for loading, failure, and no more data

Tech Stack Overview

Network Request :

@kit.NetworkKit
http

module — GET request + Promise encapsulation

List Rendering : List + ListItem + ForEach — High-performance scrollable list

Pull-to-Refresh : Touch events + offset animation — Manual implementation, fully controllable

Load More : Touch events + scroll position judgment — Symmetric with pull-to-refresh

State Management : @State + @Link + AppStorage — Cross-component state sync

Page Structure : Tabs + TabContent — Scrollable category tabs

Project Structure & Layered Design

entry/src/main/ets/
├── common/
│   ├── constant/
│   │   └── CommonConstant.ets          // Constants (network address, page size, etc.)
│   └── utils/
│       ├── HttpUtil.ets                // Network request encapsulation
│       ├── Logger.ets                  // Logging utility
│       ├── PullDownRefresh.ets         // Pull-to-refresh core logic
│       └── PullUpLoadMore.ets          // Load-more core logic
├── entryability/
│   └── EntryAbility.ets                // App entry
├── pages/
│   └── Index.ets                       // Main page (entry)
├── view/
│   ├── TabBar.ets                      // Category tab bar component
│   ├── NewsList.ets                    // News list component
│   ├── NewsItem.ets                    // Single news card component
│   ├── RefreshLayout.ets               // Pull-to-refresh layout
│   ├── LoadMoreLayout.ets              // Load-more layout
│   ├── NoMoreLayout.ets                // No more data layout
│   └── CustomRefreshLoadLayout.ets     // Generic refresh/load layout
└── viewmodel/
    ├── NewsData.ets                    // News data entity
    ├── NewsModel.ets                   // List state model
    ├── NewsTypeModel.ets               // Category type entity
    ├── NewsViewModel.ets               // Business logic layer
    └── ResponseResult.ets              // Unified API response format

view layer : Only responsible for UI presentation and user interaction, no business logic

viewmodel layer : Handles data fetching and state management, calls common layer utilities to interact with server

common layer : Provides common utilities (network, constants, logging), business-agnostic

Benefit: If data source changes (e.g., REST API to WebSocket), only viewmodel layer needs modification; view layer remains unaffected.

Entry Point: Main Page Skeleton

Index.ets: App Root Page

Main page is minimal: reads status bar / bottom safe area heights from AppStorage, then loads TabBar component.

// pages/Index.ets
import TabBar from '../view/TabBar';
import { CommonConstant as Const } from '../common/constant/CommonConstant';

@Entry
@Component
struct Index {
  @StorageLink('statusBarHeight') statusBarHeight: number = 0;
  @StorageLink('bottomHeight') bottomHeight: number = 0;

  build() {
    Column() {
      TabBar()
    }
    .padding({
      top: this.statusBarHeight,
      bottom: this.bottomHeight
    })
    .width(Const.FULL_WIDTH)
    .backgroundColor($r('app.color.listColor'))
    .justifyContent(FlexAlign.Center)
  }
}

Key Points: @StorageLink binds bidirectionally with global AppStorage. Status bar height is computed in EntryAbility and stored in AppStorage; here it's directly consumed.

Uses padding instead of margin to avoid status bar and bottom navigation bar, because padding is internal component space and doesn't affect child layout calculations.

TabBar.ets: Category Tab Bar

TabBar

component fetches news categories, then renders scrollable tabs using Tabs component.

// view/TabBar.ets
import NewsList from './NewsList';
import { CommonConstant as Const } from '../common/constant/CommonConstant';
import NewsTypeModel from '../viewmodel/NewsTypeModel';
import NewsViewModel from '../viewmodel/NewsViewModel';

@Component
export default struct TabBar {
  @State tabBarArray: NewsTypeModel[] = NewsViewModel.getDefaultTypeList();
  @State currentIndex: number = 0;
  @State currentPage: number = 1;

  @Builder
  TabBuilder(index: number) {
    Column() {
      Text(this.tabBarArray[index].name)
        .height(Const.FULL_HEIGHT)
        .padding({ left: Const.TabBars_HORIZONTAL_PADDING, right: Const.TabBars_HORIZONTAL_PADDING })
        .fontSize(this.currentIndex === index ? Const.TabBars_SELECT_TEXT_FONT_SIZE : Const.TabBars_UN_SELECT_TEXT_FONT_SIZE)
        .fontWeight(this.currentIndex === index ? Const.TabBars_SELECT_TEXT_FONT_WEIGHT : Const.TabBars_UN_SELECT_TEXT_FONT_WEIGHT)
        .fontColor($r('app.color.fontColor_text3'))
    }
  }

  aboutToAppear() {
    NewsViewModel.getNewsTypeList().then((typeList: NewsTypeModel[]) => {
      this.tabBarArray = typeList;
    }).catch((typeList: NewsTypeModel[]) => {
      this.tabBarArray = typeList;
    });
  }

  build() {
    Tabs() {
      ForEach(this.tabBarArray, (tabsItem: NewsTypeModel) => {
        TabContent() {
          Column() {
            NewsList({ currentIndex: $currentIndex })
          }
        }
        .tabBar(this.TabBuilder(tabsItem.id))
      }, (item: NewsTypeModel) => JSON.stringify(item));
    }
    .barHeight(Const.TabBars_BAR_HEIGHT)
    .barMode(BarMode.Scrollable)
    .barWidth(Const.TabBars_BAR_WIDTH)
    .onChange((index: number) => {
      this.currentIndex = index;
      this.currentPage = 1;
    })
    .vertical(false)
  }
}

Core Design:

Default category fallback : In aboutToAppear, calls getNewsTypeList(); on success uses server data, on failure falls back to default categories. Ensures users see content even with poor network.

@Link currentIndex passed down : NewsList receives currentIndex via @Link; when category switches, NewsList detects change and automatically re-fetches news for that category.

BarMode.Scrollable : When categories exceed screen width, tab bar becomes horizontally scrollable — common mobile news app pattern.

Network Request Encapsulation: HttpUtil & ViewModel

HttpUtil.ets: Unified GET Request

Network requests are the data layer foundation. A generic GET method handles timeout, errors, and response parsing uniformly.

// common/utils/HttpUtil.ets
import { http } from '@kit.NetworkKit';
import ResponseResult from '../../viewmodel/ResponseResult';
import { CommonConstant as Const, ContentType } from '../constant/CommonConstant';

/**
 * GET request
 * @param url Request URL
 * @returns Promise<ResponseResult> Unified response format
 */
export async function httpRequestGet(url: string): Promise<ResponseResult> {
  let httpRequest = http.createHttp();
  let serverData: ResponseResult = new ResponseResult();

  return httpRequest.request(url, {
    method: http.RequestMethod.GET,
    readTimeout: Const.HTTP_READ_TIMEOUT,
    header: {
      'Content-Type': ContentType.JSON
    },
    connectTimeout: Const.HTTP_READ_TIMEOUT,
    extraData: {}
  }).then((value: http.HttpResponse) => {
    if (value.responseCode === Const.HTTP_CODE_200) {
      let result = `${value.result}`;
      let resultJson: ResponseResult = JSON.parse(result);
      if (resultJson.code === Const.SERVER_CODE_SUCCESS) {
        serverData.data = resultJson.data;
      }
      serverData.code = resultJson.code;
      serverData.msg = resultJson.msg;
    } else {
      serverData.msg = `Network error, status code: ${value.responseCode}`;
    }
    return serverData;
  }).catch(() => {
    serverData.msg = 'Network request failed, please check connection';
    return serverData;
  });
}

Design Highlights:

Uses Promise encapsulation; callers use .then().catch() chaining — more elegant than callbacks.

Dual status code validation: HTTP 200 + business code success ensures data reliability.

Errors don't throw exceptions; instead return ResponseResult with msg, preventing caller crashes from unhandled exceptions.

NewsViewModel.ets: Business Logic Layer

NewsViewModel

bridges network and UI. Provides three core methods: fetch category list, get default categories, fetch news list (paginated).

// viewmodel/NewsViewModel.ets
import { CommonConstant as Const } from '../common/constant/CommonConstant';
import { NewsData } from './NewsData';
import NewsTypeModel from './NewsTypeModel';
import { httpRequestGet } from '../common/utils/HttpUtil';
import Logger from '../common/utils/Logger';
import ResponseResult from './ResponseResult';

class NewsViewModel {
  getNewsTypeList(): Promise<NewsTypeModel[]> {
    return new Promise((resolve, reject) => {
      let url = `${Const.SERVER}/${Const.GET_NEWS_TYPE}`;
      httpRequestGet(url).then((data: ResponseResult) => {
        if (data.code === Const.SERVER_CODE_SUCCESS) {
          resolve(data.data);
        } else {
          reject(Const.TabBars_DEFAULT_NEWS_TYPES);
        }
      }).catch(() => {
        reject(Const.TabBars_DEFAULT_NEWS_TYPES);
      });
    });
  }

  getDefaultTypeList(): NewsTypeModel[] {
    return Const.TabBars_DEFAULT_NEWS_TYPES;
  }

  getNewsList(currentPage: number, pageSize: number, path: string): Promise<NewsData[]> {
    return new Promise(async (resolve, reject) => {
      let url = `${Const.SERVER}/${path}`;
      url += '?currentPage=' + currentPage + '&pageSize=' + pageSize;
      httpRequestGet(url).then((data: ResponseResult) => {
        if (data.code === Const.SERVER_CODE_SUCCESS) {
          resolve(data.data);
        } else {
          Logger.error('getNewsList failed', JSON.stringify(data));
          reject($r('app.string.page_none_msg'));
        }
      }).catch((err: Error) => {
        Logger.error('getNewsList failed', JSON.stringify(err));
        reject($r('app.string.http_error_message'));
      });
    });
  }
}

let newsViewModel = new NewsViewModel();
export default newsViewModel as NewsViewModel;

Note: Uses singleton pattern (module-level exported instance), ensuring only one NewsViewModel instance across the app for convenient state sharing.

News List Core: NewsList Component

State-Driven UI Switching

NewsList

is the core component managing three list states: loading, success, failure — rendering different UI per state.

// view/NewsList.ets
import { CommonConstant as Const, PageState } from '../common/constant/CommonConstant';
import NewsItem from './NewsItem';
import LoadMoreLayout from './LoadMoreLayout';
import RefreshLayout from './RefreshLayout';
import CustomRefreshLoadLayout from './CustomRefreshLoadLayout';
import { CustomRefreshLoadLayoutClass, NewsData } from '../viewmodel/NewsData';
import { listTouchEvent } from '../common/utils/PullDownRefresh';
import NewsViewModel from '../viewmodel/NewsViewModel';
import NoMoreLayout from './NoMoreLayout';
import NewsModel from '../viewmodel/NewsModel';

@Component
export default struct NewsList {
  @State @Watch('newCustom') newsModel: NewsModel = new NewsModel();
  @State customRefreshLoadClass: CustomRefreshLoadLayoutClass = new CustomRefreshLoadLayoutClass(
    true, $r('app.media.ic_pull_up_load'), $r('app.string.pull_up_load_text'), this.newsModel.pullDownRefreshHeight
  );
  @State refreshLayoutClass: CustomRefreshLoadLayoutClass = new CustomRefreshLoadLayoutClass(
    this.newsModel.isVisiblePullDown,
    this.newsModel.pullDownRefreshImage,
    this.newsModel.pullDownRefreshText,
    this.newsModel.pullDownRefreshHeight
  );
  @State loadMoreLayoutClass: CustomRefreshLoadLayoutClass = new CustomRefreshLoadLayoutClass(
    this.newsModel.isVisiblePullUpLoad,
    this.newsModel.pullUpLoadImage,
    this.newsModel.pullUpLoadText,
    this.newsModel.pullUpLoadHeight
  );
  @Link currentIndex: number;

  newCustom() {
    this.refreshLayoutClass = new CustomRefreshLoadLayoutClass(
      this.newsModel.isVisiblePullDown,
      this.newsModel.pullDownRefreshImage,
      this.newsModel.pullDownRefreshText,
      this.newsModel.pullDownRefreshHeight
    );
    this.loadMoreLayoutClass = new CustomRefreshLoadLayoutClass(
      this.newsModel.isVisiblePullUpLoad,
      this.newsModel.pullUpLoadImage,
      this.newsModel.pullUpLoadText,
      this.newsModel.pullUpLoadHeight
    );
  }

  changeCategory() {
    this.newsModel.currentPage = 1;
    NewsViewModel.getNewsList(this.newsModel.currentPage, this.newsModel.pageSize, Const.GET_NEWS_LIST)
      .then((data: NewsData[]) => {
        this.newsModel.pageState = PageState.Success;
        if (data.length === this.newsModel.pageSize) {
          this.newsModel.currentPage++;
          this.newsModel.hasMore = true;
        } else {
          this.newsModel.hasMore = false;
        }
        this.newsModel.newsData = data;
      })
      .catch((err: string | Resource) => {
        this.getUIContext().getPromptAction().showToast({ message: err });
        this.newsModel.pageState = PageState.Fail;
      });
  }

  aboutToAppear() {
    this.changeCategory();
  }

  build() {
    Column() {
      if (this.newsModel.pageState === PageState.Success) {
        this.ListLayout()
      } else if (this.newsModel.pageState === PageState.Loading) {
        this.LoadingLayout()
      } else {
        this.FailLayout()
      }
    }
    .width(Const.FULL_WIDTH)
    .height(Const.FULL_HEIGHT)
    .justifyContent(FlexAlign.Center)
    .onTouch((event: TouchEvent | undefined) => {
      if (event && this.newsModel.pageState === PageState.Success) {
        listTouchEvent(this.newsModel, event);
      }
    })
  }

  @Builder
  LoadingLayout() {
    CustomRefreshLoadLayout({ customRefreshLoadClass: this.customRefreshLoadClass })
  }

  @Builder
  FailLayout() {
    Column() {
      Image($r('app.media.none'))
        .width(Const.NewsListConstant_NONE_IMAGE_SIZE)
        .height(Const.NewsListConstant_NONE_IMAGE_SIZE)
      Text($r('app.string.page_none_msg'))
        .opacity(Const.NewsListConstant_NONE_TEXT_opacity)
        .fontSize(Const.NewsListConstant_NONE_TEXT_size)
        .margin({ top: Const.NewsListConstant_NONE_TEXT_margin })
    }
  }

  @Builder
  ListLayout() {
    List() {
      ListItem() {
        RefreshLayout({ refreshLayoutClass: this.refreshLayoutClass })
      }

      ForEach(this.newsModel.newsData, (item: NewsData) => {
        ListItem() {
          NewsItem({ newsData: item })
        }
        .height(Const.NewsListConstant_ITEM_HEIGHT)
        .backgroundColor($r('app.color.white'))
        .margin({ top: Const.NewsListConstant_ITEM_MARGIN_TOP })
        .borderRadius(Const.NewsListConstant_ITEM_BORDER_RADIUS)
      }, (item: NewsData, index?: number) => JSON.stringify(item) + index)

      ListItem() {
        if (this.newsModel.hasMore) {
          LoadMoreLayout({ loadMoreLayoutClass: this.loadMoreLayoutClass })
        } else {
          NoMoreLayout()
        }
      }
    }
    .width(Const.NewsListConstant_LIST_WIDTH)
    .height(Const.FULL_HEIGHT)
    .margin({ left: Const.NewsListConstant_LIST_MARGIN_LEFT, right: Const.NewsListConstant_LIST_MARGIN_RIGHT })
    .backgroundColor($r('app.color.listColor'))
    .divider({
      color: $r('app.color.dividerColor'),
      strokeWidth: Const.NewsListConstant_LIST_DIVIDER_STROKE_WIDTH,
      endMargin: Const.NewsListConstant_LIST_MARGIN_RIGHT
    })
    .edgeEffect(EdgeEffect.None)
    .offset({ x: 0, y: `${this.newsModel.offsetY}px` })
    .onScrollIndex((start: number, end: number) => {
      this.newsModel.startIndex = start;
      this.newsModel.endIndex = end;
    })
  }
}

Key Design Interpretations:

1. @Watch('newCustom'): When newsModel properties (refresh state, load state) change, automatically calls newCustom() to update refresh/load layout configs. Refresh/load UI reacts in real-time to state changes.

2. offset({ y: ... }): Entire list vertical offset controlled by newsModel.offsetY. Pulling down makes offsetY positive, list moves down revealing refresh area; pulling up makes offsetY negative, list moves up revealing load area. All visual effects for pull-to-refresh and load-more are essentially dynamic modifications of this value via touch events.

3. .edgeEffect(EdgeEffect.None): Disables List component's system bounce effect. Without this, system bounce conflicts with custom pull-to-refresh, causing abnormal UX.

4. State-driven UI : pageState variable (Loading / Success / Fail) controls which layout displays — typical declarative UI pattern.

Single News Card: NewsItem Component

// view/NewsItem.ets
import { NewsData, NewsFile } from '../viewmodel/NewsData';
import { CommonConstant as Const } from '../common/constant/CommonConstant';

@Component
export default struct NewsItem {
  public newsData: NewsData = new NewsData();

  build() {
    Column() {
      Row() {
        Image($r('app.media.news'))
          .width(Const.NewsTitle_IMAGE_WIDTH)
          .height(Const.NewsTitle_IMAGE_HEIGHT)
          .objectFit(ImageFit.Fill)
        Text(this.newsData.title)
          .fontSize(Const.NewsTitle_TEXT_FONT_SIZE)
          .height(Const.NewsTitle_TEXT_HEIGHT)
          .width(Const.NewsTitle_TEXT_WIDTH)
          .maxLines(Const.NewsTitle_TEXT_MAX_LINES)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .fontWeight(Const.NewsTitle_TEXT_FONT_WEIGHT)
          .margin({ left: Const.NewsTitle_TEXT_MARGIN_LEFT })
      }
      .alignItems(VerticalAlign.Top)

      Text(this.newsData.content)
        .fontSize(Const.NewsContent_FONT_SIZE)
        .maxLines(Const.NewsContent_MAX_LINES)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .margin({ top: Const.NewsContent_MARGIN_TOP })

      Grid() {
        ForEach(this.newsData.imagesUrl, (itemImg: NewsFile) => {
          GridItem() {
            Image(Const.SERVER + itemImg.url)
              .objectFit(ImageFit.Cover)
              .borderRadius(Const.NewsGrid_IMAGE_BORDER_RADIUS)
          }
        }, (itemImg: NewsFile, index?: number) => JSON.stringify(itemImg) + index)
      }
      .columnsTemplate('1fr '.repeat(this.newsData.imagesUrl.length))
      .rowsTemplate(Const.NewsGrid_ROWS_TEMPLATE)
      .columnsGap(Const.NewsGrid_COLUMNS_GAP)
      .width(Const.NewsGrid_WIDTH)
      .height(Const.NewsGrid_HEIGHT)
      .margin({ top: Const.NewsGrid_MARGIN_TOP })

      Text(this.newsData.source)
        .fontSize(Const.NewsSource_FONT_SIZE)
        .fontColor($r('app.color.fontColor_text2'))
        .margin({ top: Const.NewsSource_MARGIN_TOP })
    }
    .alignItems(HorizontalAlign.Start)
  }
}

Highlight:

columnsTemplate('1fr '.repeat(this.newsData.imagesUrl.length))

dynamically generates Grid column count based on image quantity. One image → '1fr '; three images → '1fr 1fr 1fr '. Image grid auto-adapts to varying image counts.

Pull-to-Refresh & Load-More Implementation Principles

This is the most complex and essential part. Since HarmonyOS lacks a ready-made pull-to-refresh component, manual implementation is required.

Core Approach

Listen to List component's onTouch events

Record finger press position ( downY) and moving position ( lastMoveY)

Based on finger movement direction and distance , dynamically modify list's offsetY When offsetY exceeds threshold, change refresh/load state (show "release to refresh" prompt)

On finger release, if refresh/load conditions met, fire network request; otherwise animate back to original position

Touch Event Entry: listTouchEvent

// common/utils/PullDownRefresh.ets (core snippet)
import { touchMoveLoadMore, touchUpLoadMore } from './PullUpLoadMore';
import { CommonConstant as Const, RefreshState } from '../constant/CommonConstant';
import NewsModel from '../../viewmodel/NewsModel';

const uiContext: UIContext | undefined = AppStorage.get('uiContext');

export function listTouchEvent(that: NewsModel, event: TouchEvent) {
  switch (event.type) {
    case TouchType.Down:
      that.downY = event.touches[0].y;
      that.lastMoveY = event.touches[0].y;
      break;
    case TouchType.Move:
      if (that.isRefreshing || that.isLoading) return;
      let isDownPull = event.touches[0].y - that.lastMoveY > 0;
      if ((isDownPull || that.isPullRefreshOperation) && !that.isCanLoadMore) {
        touchMovePullRefresh(that, event);
      } else {
        touchMoveLoadMore(that, event);
      }
      that.lastMoveY = event.touches[0].y;
      break;
    case TouchType.Up:
      if (that.isRefreshing || that.isLoading) return;
      if (that.isPullRefreshOperation) {
        touchUpPullRefresh(that);
      } else {
        touchUpLoadMore(that);
      }
      break;
  }
}

Logic Explanation:

Finger down: record starting position.

Finger move: decide pull-to-refresh or load-more based on movement direction. isDownPull true means pulling down. Also considers isPullRefreshOperation flag (whether already in pull-to-refresh operation state).

Finger up: execute refresh or load based on current operation type.

Pull-to-Refresh Move Handling

function touchMovePullRefresh(that: NewsModel, event: TouchEvent) {
  if (that.startIndex === 0) {
    that.isPullRefreshOperation = true;
    let refreshHeightPx = uiContext!.vp2px(that.pullDownRefreshHeight);
    that.offsetY = event.touches[0].y - that.downY;

    if (that.offsetY >= refreshHeightPx) {
      pullRefreshState(that, RefreshState.Release);
      that.offsetY = refreshHeightPx + that.offsetY * Const.Y_OFF_SET_COEFFICIENT;
    } else {
      pullRefreshState(that, RefreshState.DropDown);
    }

    if (that.offsetY < 0) {
      that.offsetY = 0;
      that.isPullRefreshOperation = false;
    }
  }
}

Key Points: startIndex === 0 ensures pull-to-refresh only triggers when list scrolled to very top, preventing accidental triggers mid-list. vp2px converts 70vp design spec to actual pixels, because touch coordinates are in px.

When offsetY exceeds refresh area height, Y_OFF_SET_COEFFICIENT (e.g., 0.1) acts as resistance coefficient, making overscroll feel "heavy" for better tactile feedback.

Pull-to-Refresh Release Handling

function touchUpPullRefresh(that: NewsModel) {
  if (that.isCanRefresh) {
    that.offsetY = uiContext!.vp2px(that.pullDownRefreshHeight);
    pullRefreshState(that, RefreshState.Refreshing);
    that.currentPage = 1;

    setTimeout(() => {
      NewsViewModel.getNewsList(that.currentPage, that.pageSize, Const.GET_NEWS_LIST)
        .then((data) => {
          that.newsData = data;
          that.hasMore = data.length === that.pageSize;
          if (that.hasMore) that.currentPage++;
          closeRefresh(that, true);
        })
        .catch(() => closeRefresh(that, false));
    }, Const.DELAY_TIME);
  } else {
    closeRefresh(that, false);
  }
}

Delayed Animation: setTimeout delays 500ms before network request, letting user see "refreshing" animation for better perceived UX.

Close Refresh (Bounce-Back Animation)

function closeRefresh(that: NewsModel, isRefreshSuccess: boolean) {
  setTimeout(() => {
    let delay = Const.RefreshConstant_DELAY_PULL_DOWN_REFRESH;
    if (that.isCanRefresh) {
      pullRefreshState(that, isRefreshSuccess ? RefreshState.Success : RefreshState.Fail);
      delay = Const.RefreshConstant_DELAY_SHRINK_ANIMATION_TIME;
    }
    uiContext!.animateTo({
      duration: Const.RefreshConstant_CLOSE_PULL_DOWN_REFRESH_TIME,
      delay: delay,
      onFinish: () => {
        pullRefreshState(that, RefreshState.DropDown);
        that.isVisiblePullDown = false;
        that.isPullRefreshOperation = false;
      }
    }, () => {
      that.offsetY = 0;
    })
  }, that.isCanRefresh ? Const.DELAY_ANIMATION_DURATION : 0);
}

Uses animateTo for smooth bounce-back animation; offsetY transitions from current value to 0, list naturally slides back to original position.

Load More

Load-more logic mirrors pull-to-refresh; key differences:

Trigger condition: endIndex >= newsData.length - 1 (list scrolled near bottom) offsetY negative, indicating upward list offset

New data appended via concat to existing data, not replaced

On completion, offsetY animates back to 0

Data Models & State Management

NewsModel: State Center

// viewmodel/NewsModel.ets
import { CommonConstant as Const, PageState } from '../common/constant/CommonConstant';
import { NewsData } from './NewsData';

export default class NewsModel {
  newsData: Array<NewsData> = [];
  currentPage: number = 1;
  pageSize: number = Const.PAGE_SIZE;
  pullDownRefreshText: Resource = $r('app.string.pull_down_refresh_text');
  pullDownRefreshImage: Resource = $r('app.media.ic_pull_down_refresh');
  pullDownRefreshHeight: number = Const.CUSTOM_LAYOUT_HEIGHT;
  isVisiblePullDown: boolean = false;
  pullUpLoadText: Resource = $r('app.string.pull_up_load_text');
  pullUpLoadImage: Resource = $r('app.media.ic_pull_up_load');
  pullUpLoadHeight: number = Const.CUSTOM_LAYOUT_HEIGHT;
  isVisiblePullUpLoad: boolean = false;
  offsetY: number = 0;
  pageState: number = PageState.Loading;
  hasMore: boolean = true;
  startIndex: number = 0;
  endIndex: number = 0;
  downY: number = 0;
  lastMoveY: number = 0;
  isRefreshing: boolean = false;
  isCanRefresh: boolean = false;
  isPullRefreshOperation: boolean = false;
  isLoading: boolean = false;
  isCanLoadMore: boolean = false;
}

This model contains nearly all list-interaction-related state. Modifying its properties triggers automatic UI updates because NewsList decorates newsModel with @State.

State Flow Diagram

User Action -> Touch Events -> Modify newsModel.offsetY / Refresh State -> UI Auto Redraw
                              |
                              v
                      Condition Met -> Fire Network Request
                              |
                              v
              Update newsModel.newsData / hasMore / pageState
                              |
                              v
                      List Re-renders

Network Permission Configuration

In entry/src/main/module.json5 add internet permission:

{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.INTERNET",
        "reason": "$string:dependency_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

Without this permission, all network requests are rejected by the system.

Summary & Extensions

Core Knowledge Points Recap

Network Request : @kit.NetworkKit + Promise encapsulation — Difficulty: ⭐⭐

List Rendering : List + ForEach — Difficulty: ⭐

Pull-to-Refresh : Touch events + offset animation — Difficulty: ⭐⭐⭐

Load More : Symmetric logic + scroll position judgment — Difficulty: ⭐⭐⭐

State Management : @State + @Link + AppStorage — Difficulty: ⭐⭐

Category Switching : Tabs + TabContent — Difficulty: ⭐

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Mobile DevelopmentState ManagementHarmonyOSArkTSNetwork RequestsPull-to-RefreshLoad MoreList Component
51CTO HarmonyOS Developer Community
Written by

51CTO HarmonyOS Developer Community

The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.