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.
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 httpmodule — 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 formatview 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
TabBarcomponent 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
NewsViewModelbridges 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
NewsListis 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-rendersNetwork 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: ⭐
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
51CTO HarmonyOS Developer Community
The HarmonyOS Developer Community is a learning-oriented community for developers to learn, communicate, ask questions, and share.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
