介绍
安卓和 IOS 应用市场中热榜 APP 层出不穷,但鸿蒙市场乏善可陈,因此我开发了一个鸿蒙版热榜 APP 来填补市场的空白,同时也提升了鸿蒙开发技能树的熟练度和深度。
主界面的截图如下。
功能
- Tab 栏选择不同媒体(掘金热榜、知乎热榜、36kr等),加载热榜,支持下拉刷新。
- 点击新闻卡片跳转至 Webview 页面
- Webview 页面加载具体移动端新闻页面
代码讲解
1.入口页
首先来看我们的入口页,我把组件全部分装在TabBar子组件中,因此首页全部包裹在一个Column根组件中,非常的直观易懂。
import TabBar from '../view/TabBar';
import { CommonConstant as Const } from '../common/constant/CommonConstant';
/**
* The Index is the entry point of the application.
*/
@Entry
@Component
struct Index {
build() {
Column() {
TabBar()
}
.width(Const.FULL_WIDTH)
.backgroundColor($r('app.color.listColor'))
.justifyContent(FlexAlign.Center)
}
}
2.TabBar组件
@Component
export default struct TabBar {
@State tabBarArray: NewsTypeBean[] = [{name:'juejin',id:0},{name:'thepaper',id:1},{name:'zhihu',id:2}];
@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'))
}
}
build() {
Tabs() {
ForEach(this.tabBarArray, (tabsItem: NewsTypeBean) => {
TabContent() {
Column() {
NewsList({ currentIndex: $currentIndex })
}
}
.tabBar(this.TabBuilder(tabsItem.id))
}, (item: NewsTypeBean) => 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)
}
}
我们来仔细看下TabBar组件,我们的Tab栏都可以在 tabBarArray 中进行配置,当然这里一般是从后端请求过来的,但我对接的后端没有这个API,因此直接在代码里配置了。currentIndex
:表示当前选中的标签索引,默认为0(即第一项,掘金)。 currentPage
:表示当前的页码,初始值为1。
TabBuilder
用到了 Builder 装饰器,可以批量生成 Tab 子组件,用于构建每个标签的样式和内容。它接收一个index
(索引)参数,然后使用这个索引来访问tabBarArray
数组中的元素,显示对应的新闻类型名称。此外,根据是否是当前选中的标签,设置不同的字体大小和字体粗细。最后,设置一些样式,如高度、内边距、字体大小、字体粗细和颜色。
ForEach
循环遍历tabBarArray
数组,为每种新闻类型创建一个TabContent
,这里面包含一个Column
,用来显示新闻列表NewsList组件。每个TabContent
使用TabBuilder
方法设置其对应的标签。设置标签栏的高度、模式、宽度,并定义当标签改变时的行为,即更新currentIndex
和重置currentPage
。
下面我们深入探究下 NewsList 组件。
3.NewsList 组件
这个组件代码量很大,实际上很简单,它定义了一个用于展示新闻列表的组件,主要功能包括:
-
加载后端数据:通过
changeCategory
方法,当用户选择不同的新闻类别时,它会重新获取对应类别的新闻数据。这涉及到网络请求(模拟为NewsViewModel.getNewsList
),成功时更新模型中的新闻数据和状态,失败时显示错误信息。 -
动态布局:根据
newsModel.pageState
的值(成功、加载中、失败)构建不同的UI布局。成功时显示新闻列表,加载中时显示加载动画,加载失败时显示错误提示。 -
定制化的刷新和加载布局:通过自定义的
CustomRefreshLoadLayoutClass
来实现下拉刷新和上拉加载更多的效果,增强了用户体验。
@State newsModel: NewsModel = new NewsModel();
@Watch('changeCategory') @Link currentIndex: number;
changeCategory() {
this.newsModel.currentPage = 1;
NewsViewModel.getNewsList(Const.NEWS_TYPE_Array[this.currentIndex])
.then((data: NewsData[]) => {
console.log(JSON.stringify(data))
this.newsModel.pageState = PageState.Success;
if (data) {
// 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() {
// Request news data.
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) {
if (this.newsModel.pageState === PageState.Success) {
listTouchEvent(this.newsModel, event,this.currentIndex);
}
}
})
}
@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($r('app.float.news_list_height'))
.backgroundColor($r('app.color.white'))
.margin({ top: $r('app.float.news_list_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
})
// Remove the rebound effect.
.edgeEffect(EdgeEffect.None)
.offset({ x: 0, y: `${this.newsModel.offsetY}${CommonConstant.LIST_OFFSET_UNIT}` })
.onScrollIndex((start: number, end: number) => {
// Listen to the first index of the current list.
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 })
}
}
对接后端的函数如下所示,根据 path ,也就是相应的媒体分类(36kr、zhihu、juejin等)请求热榜数据。
getNewsList( path: string): Promise<NewsData[]> {
return new Promise(async (resolve: Function, reject: Function) => {
console.log(path)
let url = "https://api-hot.efefee.cn/" + path;
httpRequestGet(url).then((data: ResponseResult) => {
console.log(data.msg as string)
if (data.code === 200) {
console.log(data.msg as string)
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'));
});
});
}
以 api-hot.efefee.cn/juejin 为例,返回的数据格式如下所示:
我们的返回结果主要有 code、title、data 等属性,实际只用到了 code 和 data,而业务逻辑只用到了 data 属性,因此我们只 resolve 了 data 属性即 resolve(data.data)
,然后把每个 item 传输给 NewsItem 组件。
我们的业务逻辑是放在 NewsItem 组件中的,显示新闻标题,封面以及对应的 webview 链接。我们来看看 NewsItem 组件是如何实现的。
4. NewsItem 组件
import { CommonConstant, CommonConstant as Const } from '../common/constant/CommonConstant';
import { NewsData, NewsFile } from '../viewmodel/NewsViewModel';
import router from '@ohos.router';
/**
* The news list item component.
*/
@Component
export default struct NewsItem {
private newsData: NewsData = new NewsData();
build() {
Column() {
Row() {
Image($r('app.media.news'))
.width(Const.NewsTitle_IMAGE_WIDTH)
.height($r('app.float.news_title_image_height'))
.objectFit(ImageFit.Fill)
Text(this.newsData.title)
.fontSize(Const.NewsTitle_TEXT_FONT_SIZE)
.fontColor($r('app.color.fontColor_text'))
.width(Const.NewsTitle_TEXT_WIDTH)
.maxLines(1)
.margin({ left: Const.NewsTitle_TEXT_MARGIN_LEFT })
.textOverflow({ overflow: TextOverflow.Ellipsis })
.fontWeight(Const.NewsTitle_TEXT_FONT_WEIGHT)
}
.alignItems(VerticalAlign.Center)
.height($r('app.float.news_title_row_height'))
.margin({
top: $r('app.float.news_title_row_margin_top'),
left: Const.NewsTitle_IMAGE_MARGIN_LEFT
})
Text(this.newsData.title)
.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 })
Image(this.newsData.cover)
.objectFit(ImageFit.Cover)
.borderRadius(Const.NewsGrid_IMAGE_BORDER_RADIUS)
}
.alignItems(HorizontalAlign.Start)
.onClick(() => {
router.pushUrl({
url: 'pages/WebView', // 目标url
params: {
url: this.newsData.mobileUrl
}
}, router.RouterMode.Standard, (err) => {
if (err) {
console.error(`Invoke pushUrl failed, code is ${err.code}, message is ${err.message}`);
return;
}
console.info('Invoke pushUrl succeeded.');
});
}
)
}
}
发现没有,核心逻辑就是下面这几行,我们绑定点击事件 onClick,传入 mobileUrl参数,跳转到 webview 页面,加载出移动端具体新闻页面。
router.pushUrl({
url: 'pages/WebView', // 目标url
params: {
url: this.newsData.mobileUrl
}
}, router.RouterMode.Standard, (err) => {
if (err) {
console.error(`Invoke pushUrl failed, code is ${err.code}, message is ${err.message}`);
return;
}
console.info('Invoke pushUrl succeeded.');
});
}
5.webview 组件
Web组件用于在应用程序中显示Web页面内容,为开发者提供页面加载、页面交互、页面调试等能力。
- 页面加载:Web组件提供基础的前端页面加载的能力,包括加载网络页面、本地页面、Html格式文本数据。
- 页面交互:Web组件提供丰富的页面交互的方式,包括:设置前端页面深色模式,新窗口中加载页面,位置权限管理,Cookie管理,应用侧使用前端页面JavaScript等能力。
- 页面调试:Web组件支持使用Devtools工具调试前端页面。
注意官方文档中的示例存在错误。
注意,不是!不是!不是! import web_view from '@ohos.web.webview';
导入webview,正确的导入为 import webview from '@ohos.web.webview';
,这个错误我调试了很久,IDE连报错都没有,真的伤脑筋。
import webview from '@ohos.web.webview';
import router from '@ohos.router';
class Params {
url: string
}
@Entry
@Component
struct WebComponent {
webController: webview.WebviewController = new webview.WebviewController();
@State params: object = router.getParams();
build() {
Column() {
// 组件创建时,加载url
Web({ src: this.params['url'], controller: this.webController })
}
}
}
逻辑十分简单,加载显示页面。
总结
- 采用了 TabBar 和 NewsList 组件实现了新闻列表的展示,并支持下拉刷新和上拉加载更多。
- 在 NewsItem 组件中实现了新闻标题、封面图和跳转 Webview 的逻辑。
- 在 Webview 组件中正确导入了
@ohos.web.webview
模块,加载并显示新闻详情页面。
整体来看,这是一个功能完整、交互体验良好的鸿蒙热榜 APP 应用程序,不足之处,还请大家批评指正。