本案例基于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 = 18static readonly TabBars_SELECT_TEXT_FONT_SIZE: number = 24static readonly TabBars_UN_SELECT_TEXT_FONT_WEIGHT: number = 400static readonly TabBars_SELECT_TEXT_FONT_WEIGHT: number = 700static 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.5static readonly NewsListConstant_GET_TAB_DATA_TYPE_ONE: number = 1static readonly NewsListConstant_ITEM_BORDER_RADIUS: number = 16static readonly NewsListConstant_NONE_IMAGE_SIZE: number = 120static readonly NewsListConstant_NONE_TEXT_opacity: number = 0.6static readonly NewsListConstant_NONE_TEXT_size: number = 16static readonly NewsListConstant_NONE_TEXT_margin: number = 12static 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 = 3static readonly NewsTitle_TEXT_FONT_SIZE: number = 20static readonly NewsTitle_TEXT_FONT_WEIGHT: number = 500static 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 = 2static readonly NewsContent_FONT_SIZE: number = 15// 新聞來源相關常量static readonly NewsSource_MAX_LINES: number = 1static readonly NewsSource_FONT_SIZE: number = 12static 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 = 4static readonly NewsGrid_COLUMNS_GAP: number = 5static 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 = 1static readonly RefreshLayout_TEXT_MARGIN_LEFT: number = 7static readonly RefreshLayout_TEXT_FONT_SIZE: number = 17static readonly RefreshLayout_IMAGE_WIDTH: number = 18static readonly RefreshLayout_IMAGE_HEIGHT: number = 18// 無更多內容布局相關常量static readonly NoMoreLayoutConstant_NORMAL_PADDING: number = 8static readonly NoMoreLayoutConstant_TITLE_FONT: string = '16fp'// 刷新相關常量static readonly RefreshConstant_DELAY_PULL_DOWN_REFRESH: number = 50static readonly RefreshConstant_CLOSE_PULL_DOWN_REFRESH_TIME: number = 150static 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 數組,創建對應的 TabContentForEach(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 = 0name: 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, // 請求方法為 GETreadTimeout: 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 狀態碼是否為 200let 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 | ArrayBufferconstructor() {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 // 新聞圖片列表項 IDurl: string = '' // 新聞圖片列表項 URLtype: 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 = isVisiblethis.imageSrc = imageSrcthis.textValue = textValuethis.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=' + pageSizehttpRequestGet(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].ythat.lastMoveY = event.touches[0].ybreakcase TouchType.Move:// 如果當前處于刷新或加載狀態,則直接返回if ((that.isRefreshing === true) || (that.isLoading === true)) {return}let isDownPull = event.touches[0].y - that.lastMoveY > 0if (((isDownPull === true) || (that.isPullRefreshOperation === true)) && (that.isCanLoadMore === false)) {// 手指向下滑動,處理下拉刷新touchMovePullRefresh(that, event)} else {// 手指向上滑動,處理上拉加載更多touchMoveLoadMore(that, event)}that.lastMoveY = event.touches[0].ybreakcase TouchType.Cancel:breakcase TouchType.Up:// 處理手指抬起時的刷新或加載邏輯if ((that.isRefreshing === true) || (that.isLoading === true)) {return}if ((that.isPullRefreshOperation === true)) {// 觸發下拉刷新touchUpPullRefresh(that)} else {// 處理上拉加載更多touchUpLoadMore(that)}breakdefault:break}
}/*** 處理下拉刷新時的手指移動事件。* @param that 當前新聞數據模型。* @param event 觸摸事件對象。*/
export function touchMovePullRefresh(that: NewsModel, event: TouchEvent) {if (that.startIndex === 0) {that.isPullRefreshOperation = truelet 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 = 0that.isPullRefreshOperation = false}}
}/*** 處理手指抬起后的下拉刷新操作。* @param that 當前新聞數據模型。*/
export function touchUpPullRefresh(that: NewsModel) {if (that.isCanRefresh === true) {that.offsetY = vp2px(that.pullDownRefreshHeight)pullRefreshState(that, RefreshState.Refreshing)that.currentPage = 1setTimeout(() => {let self = thatNewsViewModel.getNewsList(that.currentPage, that.pageSize, Const.GET_NEWS_LIST).then((data: NewsData[]) => {if (data.length === that.pageSize) {self.hasMore = trueself.currentPage++} else {self.hasMore = false}self.newsData = datacloseRefresh(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 = falsethat.isRefreshing = falsethat.isVisiblePullDown = truebreakcase RefreshState.Release:that.pullDownRefreshText = $r('app.string.release_refresh_text')that.pullDownRefreshImage = $r("app.media.ic_pull_up_refresh")that.isCanRefresh = truethat.isRefreshing = falsebreakcase 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 = truethat.isRefreshing = truebreakcase RefreshState.Success:that.pullDownRefreshText = $r('app.string.refresh_success_text')that.pullDownRefreshImage = $r("app.media.ic_succeed_refresh")that.isCanRefresh = truethat.isRefreshing = truebreakcase RefreshState.Fail:that.pullDownRefreshText = $r('app.string.refresh_fail_text')that.pullDownRefreshImage = $r("app.media.ic_fail_refresh")that.isCanRefresh = truethat.isRefreshing = truebreakdefault:break}
}/*** 關閉下拉刷新動畫。* @param that 當前新聞數據模型。* @param isRefreshSuccess 是否刷新成功。*/
export function closeRefresh(that: NewsModel, isRefreshSuccess: boolean) {let self = thatsetTimeout(() => {let delay = Const.RefreshConstant_DELAY_PULL_DOWN_REFRESHif (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 = falseself.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 = truethat.isVisiblePullUpLoad = truethat.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 = falsethat.isLoading = falsethat.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'// ...
}// ...
九、視頻和代碼資源
資源請訪問:《HarmonyOS應用開發實戰指南(進階篇)》