本案例基于ArkTS的声明式开发范式,介绍了数据请求和onTouch事件的使用。包含以下功能:
- 数据请求。
- 列表下拉刷新。
- 列表上拉加载。
网络数据请求需要权限:ohos.permission.INTERNET
一、案例效果截图
操作说明:
- 点击应用进入主页面,页面使用tabBar展示新闻分类,tabContent展示新闻列表,新闻分类和新闻列表通过请求nodejs服务端获取。
- 点击页签或左右滑动页面,切换标签并展示对应新闻类型的数据。
- 新闻列表页面,滑动到新闻列表首项数据,接着往下滑动会触发下拉刷新操作,页面更新初始4条新闻数据,滑动到新闻列表最后一项数据,往上拉会触发上拉加载操作,新闻列表会在后面加载4条新闻数据。
二、案例运用到的知识点
- 核心知识点
- List组件:列表包含一系列相同宽度的列表项。
- Tabs:通过页签进行内容视图切换。
- TabContent:仅在Tabs中使用,对应一个切换页签的内容视图。
- 数据请求:提供HTTP数据请求能力。
- 触摸事件onTouch:手指触摸动作触发该回调。
- 其他知识点
- ArkTS 语言基础
- V2版状态管理:@ComponentV2/@Local/@Provider/@Consumer
- 渲染控制:if/ForEach
- 自定义组件和组件生命周期
- 自定义构建函数@Builder
- @Extend:定义扩展样式
- Navigation:导航组件
- 内置组件:Stack/Progress/Image/Column/Row/Text/Button
- 常量与资源分类的访问
- MVVM模式
三、代码结构
├──entry/src/main/ets // ArkTS代码区
│ ├──common
│ │ ├──constant
│ │ │ └──CommonConstant.ets // 公共常量类
│ │ └──utils
│ │ ├──HttpUtil.ets // 网络请求方法
│ │ ├──Logger.ets // 日志打印工具
│ │ ├──PullDownRefresh.ets // 下拉刷新方法
│ │ └──PullUpLoadMore.ets // 上拉加载更多方法
│ ├──entryability
│ │ └──EntryAbility.ets // 程序入口类
│ ├──pages
│ │ └──Index.ets // 入口文件
│ ├──view
│ │ ├──CustomRefreshLoadLayout.ets // 下拉刷新、上拉加载布局文件
│ │ ├──LoadMoreLayout.ets // 上拉加载布局封装
│ │ ├──NewsItem.ets // 新闻数据
│ │ ├──NewsList.ets // 新闻列表
│ │ ├──NoMoreLayout.ets // 没有更多数据封装
│ │ ├──RefreshLayout.ets // 下拉刷新布局封装
│ │ └──TabBar.ets // 新闻类型页签
│ └──viewmodel
│ ├──NewsData.ets // 新闻数据实体类
│ ├──NewsModel.ets // 新闻数据模块信息
│ ├──NewsTypeModel.ets // 新闻类型实体类
│ ├──NewsViewModel.ets // 新闻数据获取模块
│ └──ResponseResult.ets // 请求结果实体类
└──entry/src/main/resources // 资源文件目录
四、公共文件与资源
本案例涉及到的常量类和工具类代码如下:
- 通用常量类
// entry/src/main/ets/common/constants/CommonConstants.ets
import NewsTypeModel from '../../viewmodel/NewsTypeModel'
// 服务器的主机地址
export class CommonConstant {
static readonly SERVER: string = 'http://192.168.31.150:3000'
// 获取新闻类型
static readonly GET_NEWS_TYPE: string = 'news/getNewsType'
// 获取新闻列表
static readonly GET_NEWS_LIST: string = 'news/getNewsList'
// 请求成功的状态码
static readonly SERVER_CODE_SUCCESS: string = 'success'
// 偏移系数
static readonly Y_OFF_SET_COEFFICIENT: number = 0.1
// 页面大小
static readonly PAGE_SIZE: number = 4
// 刷新和加载的高度
static readonly CUSTOM_LAYOUT_HEIGHT: number = 70
// HTTP 请求成功状态码
static readonly HTTP_CODE_200: number = 200
// 动画延迟时间
static readonly DELAY_ANIMATION_DURATION: number = 300
// 延迟时间
static readonly DELAY_TIME: number = 1000
// 动画持续时间
static readonly ANIMATION_DURATION: number = 2000
// HTTP 超时时间
static readonly HTTP_READ_TIMEOUT: number = 10000
// 宽度占满
static readonly FULL_WIDTH: string = '100%'
// 高度占满
static readonly FULL_HEIGHT: string = '100%'
// TabBars 相关常量
static readonly TabBars_UN_SELECT_TEXT_FONT_SIZE: number = 18
static readonly TabBars_SELECT_TEXT_FONT_SIZE: number = 24
static readonly TabBars_UN_SELECT_TEXT_FONT_WEIGHT: number = 400
static readonly TabBars_SELECT_TEXT_FONT_WEIGHT: number = 700
static readonly TabBars_BAR_HEIGHT: string = '7.2%'
static readonly TabBars_HORIZONTAL_PADDING: string = '2.2%'
static readonly TabBars_BAR_WIDTH: string = '100%'
static readonly TabBars_DEFAULT_NEWS_TYPES: Array<NewsTypeModel> = [
{ id: 0, name: '全部' },
{ id: 1, name: '国内' },
{ id: 2, name: '国际' },
{ id: 3, name: '娱乐' },
{ id: 4, name: '军事' },
{ id: 5, name: '体育' },
{ id: 6, name: '科技' },
{ id: 7, name: '财经' }
]
// 新闻列表相关常量
static readonly NewsListConstant_LIST_DIVIDER_STROKE_WIDTH: number = 0.5
static readonly NewsListConstant_GET_TAB_DATA_TYPE_ONE: number = 1
static readonly NewsListConstant_ITEM_BORDER_RADIUS: number = 16
static readonly NewsListConstant_NONE_IMAGE_SIZE: number = 120
static readonly NewsListConstant_NONE_TEXT_opacity: number = 0.6
static readonly NewsListConstant_NONE_TEXT_size: number = 16
static readonly NewsListConstant_NONE_TEXT_margin: number = 12
static readonly NewsListConstant_ITEM_MARGIN_TOP: string = '1.5%'
static readonly NewsListConstant_LIST_MARGIN_LEFT: string = '3.3%'
static readonly NewsListConstant_LIST_MARGIN_RIGHT: string = '3.3%'
static readonly NewsListConstant_ITEM_HEIGHT: string = '32%'
static readonly NewsListConstant_LIST_WIDTH: string = '93.3%'
// 新闻标题相关常量
static readonly NewsTitle_TEXT_MAX_LINES: number = 3
static readonly NewsTitle_TEXT_FONT_SIZE: number = 20
static readonly NewsTitle_TEXT_FONT_WEIGHT: number = 500
static readonly NewsTitle_TEXT_MARGIN_LEFT: string = '2.4%'
static readonly NewsTitle_TEXT_MARGIN_TOP: string = '7.2%'
static readonly NewsTitle_TEXT_HEIGHT: string = '9.6%'
static readonly NewsTitle_TEXT_WIDTH: string = '78.6%'
static readonly NewsTitle_IMAGE_MARGIN_LEFT: string = '3.5%'
static readonly NewsTitle_IMAGE_MARGIN_TOP: string = '7.9%'
static readonly NewsTitle_IMAGE_HEIGHT: string = '8.9%'
static readonly NewsTitle_IMAGE_WIDTH: string = '11.9%'
// 新闻内容相关常量
static readonly NewsContent_WIDTH: string = '93%'
static readonly NewsContent_HEIGHT: string = '16.8%'
static readonly NewsContent_MARGIN_LEFT: string = '3.5%'
static readonly NewsContent_MARGIN_TOP: string = '3.4%'
static readonly NewsContent_MAX_LINES: number = 2
static readonly NewsContent_FONT_SIZE: number = 15
// 新闻来源相关常量
static readonly NewsSource_MAX_LINES: number = 1
static readonly NewsSource_FONT_SIZE: number = 12
static readonly NewsSource_MARGIN_LEFT: string = '3.5%'
static readonly NewsSource_MARGIN_TOP: string = '3.4%'
static readonly NewsSource_HEIGHT: string = '7.2%'
static readonly NewsSource_WIDTH: string = '93%'
// 新闻网格相关常量
static readonly NewsGrid_MARGIN_LEFT: string = '3.5%'
static readonly NewsGrid_MARGIN_RIGHT: string = '3.5%'
static readonly NewsGrid_MARGIN_TOP: string = '5.1%'
static readonly NewsGrid_WIDTH: string = '93%'
static readonly NewsGrid_HEIGHT: string = '31.5%'
static readonly NewsGrid_ASPECT_RATIO: number = 4
static readonly NewsGrid_COLUMNS_GAP: number = 5
static readonly NewsGrid_ROWS_TEMPLATE: string = '1fr'
static readonly NewsGrid_IMAGE_BORDER_RADIUS: number = 8
// 刷新布局相关常量
static readonly RefreshLayout_MARGIN_LEFT: string = '40%'
static readonly RefreshLayout_TEXT_MARGIN_BOTTOM: number = 1
static readonly RefreshLayout_TEXT_MARGIN_LEFT: number = 7
static readonly RefreshLayout_TEXT_FONT_SIZE: number = 17
static readonly RefreshLayout_IMAGE_WIDTH: number = 18
static readonly RefreshLayout_IMAGE_HEIGHT: number = 18
// 无更多内容布局相关常量
static readonly NoMoreLayoutConstant_NORMAL_PADDING: number = 8
static readonly NoMoreLayoutConstant_TITLE_FONT: string = '16fp'
// 刷新相关常量
static readonly RefreshConstant_DELAY_PULL_DOWN_REFRESH: number = 50
static readonly RefreshConstant_CLOSE_PULL_DOWN_REFRESH_TIME: number = 150
static readonly RefreshConstant_DELAY_SHRINK_ANIMATION_TIME: number = 500
}
// 刷新状态枚举
export const enum RefreshState {
DropDown = 0,
Release = 1,
Refreshing = 2,
Success = 3,
Fail = 4
}
// 新闻列表状态枚举
export const enum PageState {
Loading = 0,
Success = 1,
Fail = 2
}
// 刷新和加载类型
export const enum LoadingType {
Loading = 0,
Refresh = 1,
LoadMore = 2,
}
// 请求内容类型枚举
export const enum ContentType {
JSON = 'application/json'
}
本案例涉及到的资源文件如下:
- string.json
// entry/src/main/resources/base/element/string.json
{
"string": [
{
"name": "module_desc",
"value": "description"
},
{
"name": "EntryAbility_desc",
"value": "description"
},
{
"name": "EntryAbility_label",
"value": "newsData"
},
{
"name": "pull_up_load_text",
"value": "加载中..."
},
{
"name": "pull_down_refresh_text",
"value": "下拉刷新"
},
{
"name": "release_refresh_text",
"value": "松开刷新"
},
{
"name": "refreshing_text",
"value": "正在刷新"
},
{
"name": "refresh_success_text",
"value": "刷新成功"
},
{
"name": "refresh_fail_text",
"value": "刷新失败"
},
{
"name": "http_error_message",
"value": "网络请求失败,请稍后尝试!"
},
{
"name": "page_none_msg",
"value": "网络加载失败"
},
{
"name": "prompt_message",
"value": "没有更多数据了"
},
{
"name": "dependency_reason",
"value": "允许应用在新闻数据加载场景使用Internet网络。"
}
]
}
- color.json
// entry/src/main/resources/base/element/color.json
{
"color": [
{
"name": "start_window_background",
"value": "#FFFFFF"
},
{
"name": "white",
"value": "#FFFFFF"
},
{
"name": "color_index",
"value": "#1E67DC"
},
{
"name": "fontColor_text",
"value": "#000000"
},
{
"name": "fontColor_text1",
"value": "#8A8A8A"
},
{
"name": "fontColor_text2",
"value": "#FF989898"
},
{
"name": "fontColor_text3",
"value": "#182431"
},
{
"name": "listColor",
"value": "#F1F3F5"
},
{
"name": "dividerColor",
"value": "#E2E2E2"
}
]
}
其他资源请到源码中获取。
五、界面搭建
- 主页面
// entry/src/main/ets/pages/Index.ets
import TabBar from '../view/TabBar'
import { CommonConstant as Const } from '../common/constant/CommonConstant'
/**
* Index 应用程序的入口点。
*/
@Entry
@ComponentV2
struct Index {
build() {
Column() {
// TabBar单独抽离构建
TabBar()
}
.width(Const.FULL_WIDTH)
.backgroundColor($r('app.color.listColor'))
.justifyContent(FlexAlign.Center)
}
}
- TabBar组件
// entry/src/main/ets/view/TabBar.ets
import NewsList from '../view/NewsList'
import { CommonConstant as Const } from '../common/constant/CommonConstant'
import NewsTypeModel from '../viewmodel/NewsTypeModel'
import NewsViewModel from '../viewmodel/NewsViewModel'
/**
* tabBar 组件,提供新闻类别的导航功能。
*/
@ComponentV2
export default struct TabBar {
// 存储新闻类别数组,默认为 NewsViewModel 提供的默认类别列表
@Local tabBarArray: NewsTypeModel[] = NewsViewModel.getDefaultTypeList()
// 记录当前选中的 tab 索引
@Local currentIndex: number = 0
// 记录当前的页面编号(用于分页)
@Local currentPage: number = 1
/**
* 构建单个 Tab 组件的 UI。
* @param {number} index - tab 的索引
*/
@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 // 成功获取数据后更新 tabBarArray
}).catch((typeList: NewsTypeModel[]) => {
this.tabBarArray = typeList // 失败时也使用返回的列表
})
}
/**
* 构建 Tab 组件的 UI 结构。
*/
build() {
Tabs() {
// 遍历 tabBarArray 数组,创建对应的 TabContent
ForEach(this.tabBarArray, (tabsItem: NewsTypeModel) => {
TabContent() {
Column() {
NewsList({ currentIndex: this.currentIndex }) // 显示新闻列表
}
}
.tabBar(this.TabBuilder(tabsItem.id)) // 使用 TabBuilder 构建 tabBar
}, (item: NewsTypeModel) => JSON.stringify(item))
}
.barHeight(Const.TabBars_BAR_HEIGHT)
.barMode(BarMode.Scrollable) // 设置 TabBar 为可滚动模式
.barWidth(Const.TabBars_BAR_WIDTH) // 设置 TabBar 宽度
.onChange((index: number) => {
this.currentIndex = index // 更新当前选中的 tab 索引
this.currentPage = 1 // 重置当前页码
})
.vertical(false) // 设定 TabBar 水平排列
}
}
关键代码说明:
- NewsViewModel.getNewsTypeList(),获取TabBar名字的数组。
- NewsList({currentIndex: this.currentIndex}),每个Tab的页面内容均由NewsList显示。组件接收当前Tab的索引。这个组件将在下一节进行详细解读。
- 新闻TabBar内容模型
// entry/src/main/ets/viewmodel/NewsTypeModel.ets
export default class NewsTypeModel {
id: number = 0
name: ResourceStr = ''
}
- 新闻TabBar内容加载
// entry/src/main/ets/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 {
/**
* 从服务器获取新闻类型列表。
*
* @return 新闻类型列表(NewsTypeBean[])。
*/
getNewsTypeList(): Promise<NewsTypeModel[]> {
return new Promise((resolve: Function, reject: Function) => {
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)
})
})
}
/**
* 获取默认的新闻类型列表。
*
* @return 新闻类型列表(NewsTypeBean[])。
*/
getDefaultTypeList(): NewsTypeModel[] {
return Const.TabBars_DEFAULT_NEWS_TYPES
}
}
let newsViewModel = new NewsViewModel()
export default newsViewModel as NewsViewModel
关键代码说明:
- getNewsTypeList(): Promise<NewsTypeModel[]>,方法返回Promise。Promise成功或失败都返回NewsTypeModel类型的数组,成功返回后端接口数据,失败返回默认值(Const.TabBars_DEFAULT_NEWS_TYPES)。
- httpRequestGet(url),获取数据的方法,返回Promise。
- HTTP数据请求工具类
// entry/src/main/ets/common/utils/HttpUtils.ets
import { http } from '@kit.NetworkKit'
import ResponseResult from '../../viewmodel/ResponseResult'
import { CommonConstant as Const, ContentType } from '../constant/CommonConstant'
/**
* 向指定 URL 发起 HTTP GET 请求。
* @param url 请求的 URL。
* @returns Promise<ResponseResult> 返回服务器响应数据。
*/
export function httpRequestGet(url: string): Promise<ResponseResult> {
let httpRequest = http.createHttp() // 创建 HTTP 请求实例
// 发起 GET 请求
let responseResult = httpRequest.request(url, {
method: http.RequestMethod.GET, // 请求方法为 GET
readTimeout: Const.HTTP_READ_TIMEOUT, // 读取超时时间
header: {
'Content-Type': ContentType.JSON // 设置请求头,指定内容类型为 JSON
},
connectTimeout: Const.HTTP_READ_TIMEOUT, // 连接超时时间
extraData: {} // 额外数据,当前未使用
})
let serverData: ResponseResult = new ResponseResult() // 创建返回结果对象
// 处理服务器响应数据
return responseResult.then((value: http.HttpResponse) => {
if (value.responseCode === Const.HTTP_CODE_200) { // 判断 HTTP 状态码是否为 200
let result = `${value.result}` // 获取返回的数据
let resultJson: ResponseResult = JSON.parse(result) // 解析 JSON 数据
// 判断服务器返回的业务状态码
if (resultJson.code === Const.SERVER_CODE_SUCCESS) {
serverData.data = resultJson.data // 设置数据字段
}
serverData.code = resultJson.code // 设置状态码
serverData.msg = resultJson.msg // 设置返回消息
} else {
serverData.msg // 处理 HTTP 错误信息
= `${$r('app.string.http_error_message')}&${value.responseCode}`
}
return serverData // 返回处理后的数据
}).catch(() => {
serverData.msg = $r('app.string.http_error_message') // 处理请求异常
return serverData // 返回错误信息
})
}
- 网络请求返回的数据结构
/** 网络请求返回的数据。 */
export default class ResponseResult {
/** 网络请求返回的状态码:成功、失败。 */
code: string
/** 网络请求返回的消息。 */
msg: string | Resource
/** 网络请求返回的数据。 */
data: string | Object | ArrayBuffer
constructor() {
this.code = ''
this.msg = ''
this.data = ''
}
}
六、列表数据请求
- 新闻列表组件
// entry/src/main/ets/view/NewsList.ets
import { promptAction } from '@kit.ArkUI'
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 NewsViewModel from '../viewmodel/NewsViewModel'
import NoMoreLayout from './NoMoreLayout'
import NewsModel from '../viewmodel/NewsModel'
/**
* 新闻列表组件,用于展示新闻内容,并支持下拉刷新和上拉加载更多。
*/
@ComponentV2
export default struct NewsList {
// 维护新闻数据的模型
@Local newsModel: NewsModel = new NewsModel()
// 记录当前选中的新闻类别索引
@Param currentIndex: number = 0
/**
* 监听当前选中的类别索引变化,并更新新闻列表。
*/
@Monitor('currentIndex')
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) => {
promptAction.showToast({
message: err, // 显示错误信息
duration: Const.ANIMATION_DURATION
})
this.newsModel.pageState = PageState.Fail // 设置加载失败状态
})
}
/**
* 组件即将加载时,初始化新闻数据。
*/
aboutToAppear() {
this.changeCategory()
}
/**
* 构建新闻列表 UI。
*/
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)
}
/**
* 显示加载动画的布局。
*/
@Builder LoadingLayout() {
CustomRefreshLoadLayout({
customRefreshLoadClass: new CustomRefreshLoadLayoutClass(
true,
$r('app.media.ic_pull_up_load'),
$r('app.string.pull_up_load_text'),
this.newsModel.pullDownRefreshHeight)
})
}
/**
* 新闻列表布局,包括新闻项、下拉刷新和加载更多。
*/
@Builder ListLayout() {
List() {
// 下拉刷新组件
ListItem() {
RefreshLayout({
refreshLayoutClass: new CustomRefreshLoadLayoutClass(
this.newsModel.isVisiblePullDown,
this.newsModel.pullDownRefreshImage,
this.newsModel.pullDownRefreshText,
this.newsModel.pullDownRefreshHeight)
})
}
// 遍历新闻数据,渲染新闻列表项
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: new CustomRefreshLoadLayoutClass(
this.newsModel.isVisiblePullUpLoad,
this.newsModel.pullUpLoadImage,
this.newsModel.pullUpLoadText,
this.newsModel.pullUpLoadHeight)
})
} 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) // 取消回弹效果
.scrollBar(BarState.Off) // 关闭滚动条
.offset({ x: 0, y: `${this.newsModel.offsetY}px` }) // 处理滚动偏移量
.onScrollIndex((start: number, end: number) => {
this.newsModel.startIndex = start // 监听当前可见列表的索引范围
this.newsModel.endIndex = end
})
}
/**
* 加载失败时的布局。
*/
@Builder FailLayout() {
Image($r('app.media.none'))
.height(Const.NewsListConstant_NONE_IMAGE_SIZE)
.width(Const.NewsListConstant_NONE_IMAGE_SIZE)
Text($r('app.string.page_none_msg'))
.opacity(Const.NewsListConstant_NONE_TEXT_opacity)
.fontSize(Const.NewsListConstant_NONE_TEXT_size)
.fontColor($r('app.color.fontColor_text3'))
.margin({ top: Const.NewsListConstant_NONE_TEXT_margin })
}
}
关键代码解读:
- 在aboutToAppear()方法里获取新闻数据,将数据加载到新闻列表页面ListLayout布局中。
- 根据pageState的值是否为Success、Loading和Fail,来加载新闻列表、Loading动画和失败界面。
- ListLayout构建函数渲染列表,从上至下依次调用RefreshLayout、NewsItem、LoadMoreLayout/NoMoreLayout等组件。
- LoadingLayout组件直接渲染CustomRefreshLoadLayout组件。
- 列表项组件
// entry/src/main/ets/view/NewsItem.ets
import { NewsData, NewsFile } from '../viewmodel/NewsData'
import { CommonConstant as Const } from '../common/constant/CommonConstant'
/**
* NewsItem组件
*/
@ComponentV2
export default struct NewsItem {
// 从父组件接收newsData
@Param newsData: NewsData = new NewsData()
build() {
Column() {
Row() {
Image($r('app.media.news'))
.width(Const.NewsTitle_IMAGE_WIDTH)
.height(Const.NewsTitle_IMAGE_HEIGHT)
.margin({
top: Const.NewsTitle_IMAGE_MARGIN_TOP,
left: Const.NewsTitle_IMAGE_MARGIN_LEFT
})
.objectFit(ImageFit.Fill)
Text(this.newsData.title)
.fontSize(Const.NewsTitle_TEXT_FONT_SIZE)
.fontColor($r('app.color.fontColor_text'))
.height(Const.NewsTitle_TEXT_HEIGHT)
.width(Const.NewsTitle_TEXT_WIDTH)
.maxLines(Const.NewsTitle_TEXT_MAX_LINES)
.margin({
left: Const.NewsTitle_TEXT_MARGIN_LEFT,
top: Const.NewsTitle_TEXT_MARGIN_TOP
})
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontWeight(Const.NewsTitle_TEXT_FONT_WEIGHT)
}
Text(this.newsData.content)
.fontSize(Const.NewsContent_FONT_SIZE)
.fontColor($r('app.color.fontColor_text'))
.height(Const.NewsContent_HEIGHT)
.width(Const.NewsContent_WIDTH)
.maxLines(Const.NewsContent_MAX_LINES)
.margin({
left: Const.NewsContent_MARGIN_LEFT,
top: Const.NewsContent_MARGIN_TOP
})
.textOverflow({ overflow: TextOverflow.Ellipsis })
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))
.columnsGap(Const.NewsGrid_COLUMNS_GAP)
.rowsTemplate(Const.NewsGrid_ROWS_TEMPLATE)
.width(Const.NewsGrid_WIDTH)
.height(Const.NewsGrid_HEIGHT)
.margin({
left: Const.NewsGrid_MARGIN_LEFT,
top: Const.NewsGrid_MARGIN_TOP,
right: Const.NewsGrid_MARGIN_RIGHT
})
Text(this.newsData.source)
.fontSize(Const.NewsSource_FONT_SIZE)
.fontColor($r('app.color.fontColor_text2'))
.height(Const.NewsSource_HEIGHT)
.width(Const.NewsSource_WIDTH)
.maxLines(Const.NewsSource_MAX_LINES)
.margin({
left: Const.NewsSource_MARGIN_LEFT,
top: Const.NewsSource_MARGIN_TOP
})
.textOverflow({ overflow: TextOverflow.None })
}
.alignItems(HorizontalAlign.Start)
}
}
- 下拉刷新组件
// entry/src/main/ets/view/RefreshLayout.ets
import CustomRefreshLoadLayout from './CustomRefreshLoadLayout'
import { CustomRefreshLoadLayoutClass } from '../viewmodel/NewsData'
/**
* RefreshLayout组件。
*/
@ComponentV2
export default struct RefreshLayout {
@Param refreshLayoutClass: CustomRefreshLoadLayoutClass =
new CustomRefreshLoadLayoutClass(
true,
$r('app.media.ic_pull_up_load'),
$r('app.string.pull_up_load_text'),
0
)
build() {
Column() {
if (this.refreshLayoutClass.isVisible) {
CustomRefreshLoadLayout({
customRefreshLoadClass: new CustomRefreshLoadLayoutClass(
this.refreshLayoutClass.isVisible,
this.refreshLayoutClass.imageSrc,
this.refreshLayoutClass.textValue,
this.refreshLayoutClass.heightValue
)
})
}
}
}
}
关键代码说明:
- 由于存在this.refreshLayoutClass.isVisible(刷新组件需要显示)的逻辑,因此单独抽离了这个组件来过渡,最终渲染的是CustomRefreshLoadLayout组件。
- 上拉加载更多组件
// entry/src/main/ets/view/LoadMoarLayout.ets
import CustomRefreshLoadLayout from './CustomRefreshLoadLayout'
import { CustomRefreshLoadLayoutClass } from '../viewmodel/NewsData'
/**
* LoadMoreLayout组件。
*/
@ComponentV2
export default struct LoadMoreLayout {
@Param loadMoreLayoutClass: CustomRefreshLoadLayoutClass =
new CustomRefreshLoadLayoutClass(
true,
$r('app.media.ic_pull_up_load'),
$r('app.string.pull_up_load_text'),
0
)
build() {
Column() {
if (this.loadMoreLayoutClass.isVisible) {
CustomRefreshLoadLayout({
customRefreshLoadClass: new CustomRefreshLoadLayoutClass(
this.loadMoreLayoutClass.isVisible,
this.loadMoreLayoutClass.imageSrc,
this.loadMoreLayoutClass.textValue,
this.loadMoreLayoutClass.heightValue
)
})
} else {
CustomRefreshLoadLayout({
customRefreshLoadClass: new CustomRefreshLoadLayoutClass(
this.loadMoreLayoutClass.isVisible,
this.loadMoreLayoutClass.imageSrc,
this.loadMoreLayoutClass.textValue,
0
)
})
}
}
}
}
关键代码说明:
- 由于存在this.loadMoreLayoutClass.isVisible(加载更多组件需要显示)的逻辑,因此单独抽离了这个组件来过渡,最终渲染的是CustomRefreshLoadLayout组件。
- 新闻数据模型
// entry/src/main/ets/viewmodel/NewsData.ets
/**
* 新闻列表项信息
*/
@ObservedV2
export class NewsData {
@Trace title: string = '' // 新闻列表项标题
@Trace content: string = '' // 新闻列表项内容
@Trace imagesUrl: Array<NewsFile> = [new NewsFile()] // 新闻列表项图片地址
@Trace source: string = '' // 新闻列表项来源
}
/**
* 新闻图片列表项信息
*/
export class NewsFile {
id: number = 0 // 新闻图片列表项 ID
url: string = '' // 新闻图片列表项 URL
type: number = 0 // 新闻图片列表项类型
newsId: number = 0 // 新闻图片列表项新闻 ID
}
/**
* 自定义刷新加载布局数据
*/
@ObservedV2
export class CustomRefreshLoadLayoutClass {
@Trace isVisible: boolean // 自定义刷新加载布局是否可见
@Trace imageSrc: Resource // 自定义刷新加载布局图片资源
@Trace textValue: Resource // 自定义刷新加载布局文本资源
@Trace heightValue: number // 自定义刷新加载布局高度值
constructor(
isVisible: boolean,
imageSrc: Resource,
textValue: Resource,
heightValue: number
) {
this.isVisible = isVisible
this.imageSrc = imageSrc
this.textValue = textValue
this.heightValue = heightValue
}
}
关键代码说明:
- NewsData类通过@ObservedV2装饰,类中的属性通过@Trace装饰,使得该类可观察,属性可追踪。
- CustomRefreshLoadLayoutClass类通过@ObservedV2装饰,类中的属性通过@Trace装饰,使得该类可观察,属性可追踪。
- 获取新闻列表
// entry/src/main/ets/viewmodel/NewsViewModel.ets
// ...
import { CommonConstant as Const } from '../common/constant/CommonConstant'
import { NewsData } from './NewsData'
import { httpRequestGet } from '../common/utils/HttpUtil'
import Logger from '../common/utils/Logger'
import ResponseResult from './ResponseResult'
class NewsViewModel {
// ...
/**
* 从服务器获取新闻列表。
*
* @param currentPage 当前页码。
* @param pageSize 每页新闻条数。
* @param path 请求接口路径。
* @return 新闻数据列表(NewsData[])。
*/
getNewsList(
currentPage: number,
pageSize: number,
path: string
): Promise<NewsData[]> {
return new Promise(async (resolve: Function, reject: Function) => {
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('获取新闻列表失败', JSON.stringify(data))
reject($r('app.string.page_none_msg'))
}
}).catch((err: Error) => {
Logger.error('获取新闻列表失败', JSON.stringify(err))
reject($r('app.string.http_error_message'))
})
})
}
}
let newsViewModel = new NewsViewModel()
export default newsViewModel as NewsViewModel
- 新闻数据列表类
// entry/src/main/ets/viewmodel/NewsModel.ets
import {
CommonConstant as Const,
PageState
} from '../common/constant/CommonConstant'
import { NewsData } from './NewsData'
@ObservedV2
class NewsModel {
@Trace newsData: Array<NewsData> = []
@Trace currentPage: number = 1
@Trace pageSize: number = Const.PAGE_SIZE
@Trace pullDownRefreshText: Resource = $r('app.string.pull_down_refresh_text')
@Trace pullDownRefreshImage: Resource = $r('app.media.ic_pull_down_refresh')
@Trace pullDownRefreshHeight: number = Const.CUSTOM_LAYOUT_HEIGHT
@Trace isVisiblePullDown: boolean = false
@Trace pullUpLoadText: Resource = $r('app.string.pull_up_load_text')
@Trace pullUpLoadImage: Resource = $r('app.media.ic_pull_up_load')
@Trace pullUpLoadHeight: number = Const.CUSTOM_LAYOUT_HEIGHT
@Trace isVisiblePullUpLoad: boolean = false
@Trace offsetY: number = 0
@Trace pageState: number = PageState.Loading
@Trace hasMore: boolean = true
@Trace startIndex = 0
@Trace endIndex = 0
@Trace downY = 0
@Trace lastMoveY = 0
@Trace isRefreshing: boolean = false
@Trace isCanRefresh = false
@Trace isPullRefreshOperation = false
@Trace isLoading: boolean = false
@Trace isCanLoadMore: boolean = false
}
export default NewsModel
七、下拉刷新和上拉加载
- 给列表添加事件
// entry/src/main/ets/view/NewsList.ets
// ...
import { listTouchEvent } from '../common/utils/PullDownRefresh'
@ComponentV2
export default struct NewsList {
// ...
/**
* 构建新闻列表 UI。
*/
build() {
Column() {
// ...
}
// ...
.onTouch((event: TouchEvent | undefined) => {
if (event) {
if (this.newsModel.pageState === PageState.Success) {
listTouchEvent(this.newsModel, event)
}
}
})
}
// ...
}
- 下拉刷新
// entry/src/main/ets/common/utils/PullDownRefresh.ets
import { promptAction } from '@kit.ArkUI'
import { touchMoveLoadMore, touchUpLoadMore } from './PullUpLoadMore'
import {
CommonConstant as Const,
RefreshState
} from '../constant/CommonConstant'
import NewsViewModel from '../../viewmodel/NewsViewModel'
import { NewsData } from '../../viewmodel/NewsData'
import NewsModel from '../../viewmodel/NewsModel'
/**
* 处理列表的触摸事件。
* @param that 当前新闻数据模型。
* @param event 触摸事件对象。
*/
export function listTouchEvent(that: NewsModel, event: TouchEvent) {
switch (event.type) {
case TouchType.Down:
// 记录手指按下时的 Y 轴坐标
that.downY = event.touches[0].y
that.lastMoveY = event.touches[0].y
break
case TouchType.Move:
// 如果当前处于刷新或加载状态,则直接返回
if ((that.isRefreshing === true) || (that.isLoading === true)) {
return
}
let isDownPull = event.touches[0].y - that.lastMoveY > 0
if (((isDownPull === true)
|| (that.isPullRefreshOperation === true))
&& (that.isCanLoadMore === false)) {
// 手指向下滑动,处理下拉刷新
touchMovePullRefresh(that, event)
} else {
// 手指向上滑动,处理上拉加载更多
touchMoveLoadMore(that, event)
}
that.lastMoveY = event.touches[0].y
break
case TouchType.Cancel:
break
case TouchType.Up:
// 处理手指抬起时的刷新或加载逻辑
if ((that.isRefreshing === true) || (that.isLoading === true)) {
return
}
if ((that.isPullRefreshOperation === true)) {
// 触发下拉刷新
touchUpPullRefresh(that)
} else {
// 处理上拉加载更多
touchUpLoadMore(that)
}
break
default:
break
}
}
/**
* 处理下拉刷新时的手指移动事件。
* @param that 当前新闻数据模型。
* @param event 触摸事件对象。
*/
export function touchMovePullRefresh(that: NewsModel, event: TouchEvent) {
if (that.startIndex === 0) {
that.isPullRefreshOperation = true
let height = vp2px(that.pullDownRefreshHeight)
that.offsetY = event.touches[0].y - that.downY
// 判断是否达到刷新条件
if (that.offsetY >= height) {
pullRefreshState(that, RefreshState.Release)
that.offsetY = height + that.offsetY * Const.Y_OFF_SET_COEFFICIENT
} else {
pullRefreshState(that, RefreshState.DropDown)
}
if (that.offsetY < 0) {
that.offsetY = 0
that.isPullRefreshOperation = false
}
}
}
/**
* 处理手指抬起后的下拉刷新操作。
* @param that 当前新闻数据模型。
*/
export function touchUpPullRefresh(that: NewsModel) {
if (that.isCanRefresh === true) {
that.offsetY = vp2px(that.pullDownRefreshHeight)
pullRefreshState(that, RefreshState.Refreshing)
that.currentPage = 1
setTimeout(() => {
let self = that
NewsViewModel.getNewsList(
that.currentPage,
that.pageSize,
Const.GET_NEWS_LIST
).then((data: NewsData[]) => {
if (data.length === that.pageSize) {
self.hasMore = true
self.currentPage++
} else {
self.hasMore = false
}
self.newsData = data
closeRefresh(self, true)
}).catch((err: string | Resource) => {
promptAction.showToast({ message: err })
closeRefresh(self, false)
})
}, Const.DELAY_TIME)
} else {
closeRefresh(that, false)
}
}
/**
* 设置下拉刷新的状态。
* @param that 当前新闻数据模型。
* @param state 下拉刷新的状态值。
*/
export function pullRefreshState(that: NewsModel, state: number) {
switch (state) {
case RefreshState.DropDown:
that.pullDownRefreshText = $r('app.string.pull_down_refresh_text')
that.pullDownRefreshImage = $r("app.media.ic_pull_down_refresh")
that.isCanRefresh = false
that.isRefreshing = false
that.isVisiblePullDown = true
break
case RefreshState.Release:
that.pullDownRefreshText = $r('app.string.release_refresh_text')
that.pullDownRefreshImage = $r("app.media.ic_pull_up_refresh")
that.isCanRefresh = true
that.isRefreshing = false
break
case RefreshState.Refreshing:
that.offsetY = vp2px(that.pullDownRefreshHeight)
that.pullDownRefreshText = $r('app.string.refreshing_text')
that.pullDownRefreshImage = $r("app.media.ic_pull_up_load")
that.isCanRefresh = true
that.isRefreshing = true
break
case RefreshState.Success:
that.pullDownRefreshText = $r('app.string.refresh_success_text')
that.pullDownRefreshImage = $r("app.media.ic_succeed_refresh")
that.isCanRefresh = true
that.isRefreshing = true
break
case RefreshState.Fail:
that.pullDownRefreshText = $r('app.string.refresh_fail_text')
that.pullDownRefreshImage = $r("app.media.ic_fail_refresh")
that.isCanRefresh = true
that.isRefreshing = true
break
default:
break
}
}
/**
* 关闭下拉刷新动画。
* @param that 当前新闻数据模型。
* @param isRefreshSuccess 是否刷新成功。
*/
export function closeRefresh(that: NewsModel, isRefreshSuccess: boolean) {
let self = that
setTimeout(() => {
let delay = Const.RefreshConstant_DELAY_PULL_DOWN_REFRESH
if (self.isCanRefresh === true) {
pullRefreshState(that, isRefreshSuccess
? RefreshState.Success
: RefreshState.Fail)
delay = Const.RefreshConstant_DELAY_SHRINK_ANIMATION_TIME
}
animateTo({
duration: Const.RefreshConstant_CLOSE_PULL_DOWN_REFRESH_TIME,
delay: delay,
onFinish: () => {
pullRefreshState(that, RefreshState.DropDown)
self.isVisiblePullDown = false
self.isPullRefreshOperation = false
}
}, () => {
self.offsetY = 0
})
}, self.isCanRefresh ? Const.DELAY_ANIMATION_DURATION : 0)
}
关键代码解读:
- 创建一个下拉刷新布局CustomLayout,动态传入刷新图片和刷新文字描述。
- 将下拉刷新的布局添加到NewsList.ets文件中新闻列表布局ListLayout里面,监听ListLayout组件的onTouch事件实现下拉刷新。
- 在onTouch事件中,listTouchEvent方法判断触摸事件是否满足下拉条件。
- 在touchMovePullRefresh方法中,对下拉的偏移量与下拉刷新布局的高度进行对比,如果大于布局高度并且在新闻列表的顶部,则表示达到刷新条件。
- 在pullRefreshState方法中,对下拉刷新布局中的状态图片和描述进行改变。当手指松开,才执行刷新操作。
- 上拉加载更多
// entry/src/main/ets/common/utils/PullUpLoadMore.ets
import { promptAction } from '@kit.ArkUI'
import { CommonConstant as Const } from '../constant/CommonConstant'
import NewsViewModel from '../../viewmodel/NewsViewModel'
import { NewsData } from '../../viewmodel/NewsData'
import NewsModel from '../../viewmodel/NewsModel'
/**
* 处理手指移动时的加载更多逻辑。
* 当用户滑动到列表底部,并且滑动距离足够时,触发加载更多。
*
* @param that 当前新闻数据模型。
* @param event 触摸事件对象。
*/
export function touchMoveLoadMore(that: NewsModel, event: TouchEvent) {
if (that.endIndex === that.newsData.length - 1 || that.endIndex === that.newsData.length) {
// 计算滑动偏移量
that.offsetY = event.touches[0].y - that.downY
// 判断是否滑动超过一定阈值,触发加载更多
if (Math.abs(that.offsetY) > vp2px(that.pullUpLoadHeight) / 2) {
that.isCanLoadMore = true
that.isVisiblePullUpLoad = true
that.offsetY = -vp2px(that.pullUpLoadHeight) + that.offsetY * Const.Y_OFF_SET_COEFFICIENT
}
}
}
/**
* 处理手指抬起时的加载更多逻辑。
* 如果满足加载条件并且还有更多数据,则加载新的新闻数据。
*
* @param that 当前新闻数据模型。
*/
export function touchUpLoadMore(that: NewsModel) {
let self = that
// 执行滑动动画,重置偏移量
animateTo({
duration: Const.ANIMATION_DURATION,
}, () => {
self.offsetY = 0
})
// 如果可以加载更多并且还有数据可加载
if ((self.isCanLoadMore === true) && (self.hasMore === true)) {
self.isLoading = true
// 模拟网络请求,延迟加载数据
setTimeout(() => {
closeLoadMore(that)
NewsViewModel.getNewsList(self.currentPage, self.pageSize, Const.GET_NEWS_LIST).then((data: NewsData[]) => {
if (data.length === self.pageSize) {
self.currentPage++
self.hasMore = true
} else {
self.hasMore = false
}
// 追加新数据到新闻列表
self.newsData = self.newsData.concat(data)
}).catch((err: string | Resource) => {
// 处理请求失败情况,显示错误提示
promptAction.showToast({ message: err })
})
}, Const.DELAY_TIME)
} else {
// 关闭加载更多动画
closeLoadMore(self)
}
}
/**
* 关闭加载更多动画,重置相关状态。
*
* @param that 当前新闻数据模型。
*/
export function closeLoadMore(that: NewsModel) {
that.isCanLoadMore = false
that.isLoading = false
that.isVisiblePullUpLoad = false
}
八、服务端搭建流程
- 搭建nodejs环境:本篇Codelab的服务端是基于nodejs实现的,需要安装nodejs,如果您本地已有nodejs环境可以跳过此步骤。
-
- 检查本地是否安装nodejs:打开命令行工具(如Windows系统的cmd和Mac电脑的Terminal,这里以Mac为例),输入node -v,如果可以看到版本信息,说明已经安装nodejs。
-
- 如果本地没有nodejs环境,您可以去nodejs官网上下载所需版本进行安装配置。
- 配置完环境变量后,重新打开命令行工具,输入node -v,如果可以看到版本信息,说明已安装成功。
- 构建局域网环境:测试本Codelab时要确保运行服务端代码的电脑和测试机连接的是同一局域网下的网络,您可以用您的手机开一个个人热点,然后将测试机和运行服务端代码的电脑都连接您的手机热点进行测试。
- 运行服务端代码:在本项目的HttpServerOfNews目录下打开命令行工具,输入npm install 安装服务端依赖包,安装成功后输入npm start点击回车。看到“服务器启动成功!”则表示服务端已经在正常运行。
- 连接服务器地址:打开命令行工具,Mac电脑输入ifconfig,Windows电脑输入ipconfig命令查看本地ip,将本地ip地址和端口号(默认3000),添加到src/main/ets/common/constant/CommonConstants.ets文件里:
// entry/src/main/ets/common/constant/CommonCanstants.ets
export class CommonConstant {
/**
* 服务器的主机地址
*/
static readonly SERVER: string = 'http://192.168.31.150:3000'
// ...
}
// ...