
引言
在上一篇文章中,我们搭建了首页的骨架(Tabs容器和自定义TabBar)。这篇文章将让首页"活"起来,实现四个核心功能模块:
- 当前节气卡片:根据日期自动显示对应节气
- 精选文章:横向滚动展示热门文章
- 热门分类:网格布局的功能入口
- 时间轴:全年节气的可视化展示
通过本文,你将掌握在鸿蒙中:
- 如何根据日期动态加载数据
- 如何实现流畅的横向滚动效果
- Grid网格布局的最佳实践
- 自动滚动的动画实现技巧
学习目标
完成本文后,你将能够:
- ✅ 实现基于日期的动态内容展示
- ✅ 创建横向滚动的文章列表
- ✅ 使用Grid组件构建网格布局
- ✅ 实现平滑的自动滚动动画
- ✅ 处理图片路径和资源引用
需求分析
功能模块设计
| 模块 | 功能描述 | 技术要点 |
|---|---|---|
| 当前节气卡片 | 显示今日对应节气,点击跳转详情 | 日期计算、条件渲染、路由跳转 |
| 精选文章 | 横向滚动展示3-5篇热门文章 | Scroll横向滚动、固定宽度、ForEach |
| 热门分类 | 4列网格展示功能入口 | Grid布局、columnsTemplate |
| 时间轴 | 全年24节气横向展示,自动滚动 | Scroller控制、animateTo动画 |
核心实现
步骤1: 实现当前节气卡片
功能说明
根据当前日期,自动匹配对应的节气,并展示:
- 节气名称(如"立春")
- 阳历日期(如"2024-02-04")
- 简短描述
- 精美背景图
完整代码
typescript
// components/CurrentHolidayCard.ets
import router from '@ohos.router';
import { holidays } from '../mock/HolidayMockData';
import { getCurrentDate } from '../utils/DateUtils';
import type { Holiday } from '../models/HolidayModel';
@Component
export struct CurrentHolidayCard {
// 当前节气数据
@State currentHoliday: Holiday | null = null;
/**
* 组件初始化
*/
aboutToAppear() {
this.loadCurrentHoliday();
}
/**
* 加载当前节气
* 根据月份和日期查找最接近的节气
*/
loadCurrentHoliday(): void {
const { month, day } = getCurrentDate();
console.info('[CurrentHolidayCard] 当前日期: ' + month + '月' + day + '日');
// 查找最接近的节气(前后15天内)
const holiday = holidays.find((h: Holiday) => {
const [hMonth, hDay] = h.solarDate.split('-').slice(1, 3).map(Number);
return hMonth === month && Math.abs(hDay - day) <= 15;
});
// 如果没找到,默认显示第一个节气
this.currentHoliday = holiday || holidays[0];
console.info('[CurrentHolidayCard] 当前节气: ' + this.currentHoliday.name);
}
/**
* 构建UI
*/
build() {
if (!this.currentHoliday) {
// 加载中状态
Column() {
LoadingProgress()
.width(40)
.height(40)
Text('加载中...')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 8 })
}
.width('100%')
.height(200)
.justifyContent(FlexAlign.Center)
} else {
// 显示节气卡片
this.buildCard()
}
}
/**
* 构建节气卡片
*/
@Builder
buildCard(): void {
Stack({ alignContent: Alignment.BottomStart }) {
// ===== 背景图 =====
Image('rawfile://bg/holidays/' + this.currentHoliday!.id + '.png')
.width('100%')
.height(200)
.borderRadius(16)
.objectFit(ImageFit.Cover)
// ===== 渐变遮罩 =====
Row()
.width('100%')
.height(200)
.linearGradient({
angle: 180,
colors: [
['#00000000', 0.3], // 顶部透明
['#000000BB', 1] // 底部半透明黑
]
})
.borderRadius(16)
// ===== 内容区域 =====
Column({ space: 8 }) {
// 节气名称
Text(this.currentHoliday!.name)
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor('#FFFFFF')
// 阳历日期
Text(this.currentHoliday!.solarDate)
.fontSize(14)
.fontColor('#FFFFFF')
.opacity(0.9)
// 描述文字
Text(this.currentHoliday!.description)
.fontSize(13)
.fontColor('#FFFFFF')
.opacity(0.8)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.padding(16)
.width('100%')
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height(200)
.onClick(() => {
// 点击跳转到详情页
try {
router.pushUrl({
url: 'pages/Detail',
params: {
holidayId: this.currentHoliday!.id,
title: this.currentHoliday!.name
}
});
} catch (error) {
console.error('路由跳转失败: ' + JSON.stringify(error));
}
})
}
}
代码解析
1. 日期匹配逻辑
typescript
const holiday = holidays.find((h: Holiday) => {
const [hMonth, hDay] = h.solarDate.split('-').slice(1, 3).map(Number);
return hMonth === month && Math.abs(hDay - day) <= 15;
});
原理:
- 解析节气的阳历日期(如"2024-02-04")
- 提取月份和日期
- 查找当前月份且日期相差不超过15天的节气
2. 渐变遮罩效果
typescript
.linearGradient({
angle: 180, // 从上到下
colors: [
['#00000000', 0.3], // 30%位置: 完全透明
['#000000BB', 1] // 100%位置: 半透明黑
]
})
效果:
- 顶部透明,不遮挡图片
- 底部半透明黑,增强文字可读性
- 平滑过渡,视觉效果自然
步骤2: 实现精选文章横向滚动
文章卡片组件
typescript
// components/ArticleCard.ets
import router from '@ohos.router';
import type { Article } from '../models/ArticleModel';
@Component
export struct ArticleCard {
@Prop article: Article;
build() {
Column({ space: 8 }) {
// ===== 封面图 =====
Image(this.getImageSource())
.width(160)
.height(100)
.borderRadius(12)
.objectFit(ImageFit.Cover)
// ===== 标题 =====
Text(this.article.title)
.fontSize(14)
.fontColor('#333333')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.width(160)
// ===== 标签 =====
if (this.article.tags && this.article.tags.length > 0) {
Text(this.article.tags[0])
.fontSize(11)
.fontColor('#4A9B6D')
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.backgroundColor('#E8F5E9')
.borderRadius(4)
}
}
.width(160)
.padding(8)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.shadow({
radius: 4,
color: '#0D000000',
offsetX: 0,
offsetY: 2
})
.onClick(() => {
try {
router.pushUrl({
url: 'pages/ArticleDetail',
params: { articleId: this.article.id }
});
} catch (error) {
console.error('路由跳转失败: ' + JSON.stringify(error));
}
})
}
/**
* 获取图片源
* 支持rawfile和base/media两种路径
*/
getImageSource(): string | Resource {
if (this.article.coverImage.startsWith('rawfile://')) {
return this.article.coverImage; // rawfile路径
} else {
return $r('app.media.' + this.article.coverImage); // base/media资源
}
}
}
横向滚动列表
typescript
@Builder
buildFeaturedArticles(): void {
Column({ space: 12 }) {
// ===== 标题栏 =====
Row() {
Text('精选文章')
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Blank()
Text('查看更多')
.fontSize(13)
.fontColor('#4A9B6D')
.onClick(() => {
try {
router.pushUrl({ url: 'pages/Encyclopedia' });
} catch (error) {
console.error('路由跳转失败: ' + JSON.stringify(error));
}
})
}
.width('100%')
// ===== 横向滚动列表 =====
Scroll() {
Row({ space: 12 }) {
ForEach(this.featuredArticles, (article: Article) => {
ArticleCard({ article: article })
}, (article: Article) => article.id)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
}
.padding({ left: 16, right: 16 })
}
关键技术点
1. 固定宽度
typescript
ArticleCard({ article: article })
.width(160) // 必须固定宽度
原因:
- 横向滚动需要知道每个子项的宽度
- 不固定宽度会导致布局混乱
2. 隐藏滚动条
typescript
.scrollBar(BarState.Off)
效果:
- 保持滚动功能
- 视觉上更简洁
- 适合移动端交互
步骤3: 实现热门分类网格
分类卡片组件
typescript
// components/CategoryCard.ets
import router from '@ohos.router';
interface CategoryItem {
id: string;
title: string;
icon: Resource;
route?: string;
}
@Component
export struct CategoryCard {
@Prop category: CategoryItem;
build() {
Column({ space: 8 }) {
// 图标
Image(this.category.icon)
.width(40)
.height(40)
// 标题
Text(this.category.title)
.fontSize(13)
.fontColor('#333333')
}
.width('100%')
.height(80)
.justifyContent(FlexAlign.Center)
.backgroundColor('#FFFFFF')
.borderRadius(12)
.shadow({
radius: 2,
color: '#0D000000',
offsetX: 0,
offsetY: 1
})
.onClick(() => {
if (this.category.route) {
try {
router.pushUrl({ url: this.category.route });
} catch (error) {
console.error('路由跳转失败: ' + JSON.stringify(error));
}
}
})
}
}
网格布局
typescript
@Builder
buildCategories(): void {
Column({ space: 12 }) {
Text('热门分类')
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Grid() {
ForEach(this.categories, (category: CategoryItem) => {
GridItem() {
CategoryCard({ category: category })
}
}, (category: CategoryItem) => category.id)
}
.columnsTemplate('1fr 1fr 1fr 1fr')
.rowsGap(12)
.columnsGap(12)
}
.padding({ left: 16, right: 16 })
}
Grid布局详解
columnsTemplate语法:
typescript
.columnsTemplate('1fr 1fr 1fr 1fr') // 4列,每列等宽
.columnsTemplate('1fr 2fr 1fr') // 3列,中间列是两边的2倍
.columnsTemplate('100px 1fr 100px') // 混合单位
fr单位:
- flexible的缩写
- 表示弹性空间
1fr 1fr= 两列等分
步骤4: 实现时间轴自动滚动
时间轴组件
typescript
// components/Timeline.ets
import router from '@ohos.router';
import { holidays } from '../mock/HolidayMockData';
import { getCurrentDate } from '../utils/DateUtils';
import type { Holiday } from '../models/HolidayModel';
@Component
export struct Timeline {
// 滚动控制器
private scroller: Scroller = new Scroller();
// 当前选中的节气ID
@State selectedId: string = '';
/**
* 组件初始化
*/
aboutToAppear() {
// 获取当前节气ID
const currentDate = getCurrentDate();
const currentHoliday = holidays.find((h: Holiday) => {
const [month, day] = h.solarDate.split('-').slice(1, 3).map(Number);
return month === currentDate.month && Math.abs(day - currentDate.day) <= 15;
});
if (currentHoliday) {
this.selectedId = currentHoliday.id;
}
// 启动自动滚动
this.startAutoScroll();
}
/**
* 自动滚动
* 每3秒滚动一个节气位置
*/
startAutoScroll(): void {
setInterval(() => {
animateTo({
duration: 1000,
curve: Curve.EaseInOut
}, () => {
this.scroller.scrollBy(80, 0);
});
}, 3000);
}
/**
* 构建UI
*/
build() {
Column({ space: 12 }) {
Text('节气时间轴')
.fontSize(18)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
// 横向滚动的时间轴
Scroll(this.scroller) {
Row({ space: 16 }) {
ForEach(holidays, (holiday: Holiday) => {
this.buildTimelineItem(holiday)
}, (holiday: Holiday) => holiday.id)
}
.padding({ left: 16, right: 16 })
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
}
}
/**
* 构建单个时间节点
*/
@Builder
buildTimelineItem(holiday: Holiday): void {
const isSelected = holiday.id === this.selectedId;
Column({ space: 6 }) {
// 圆点
Circle()
.width(12)
.height(12)
.fill(isSelected ? '#4A9B6D' : '#CCCCCC')
// 节气名称
Text(holiday.name)
.fontSize(12)
.fontColor(isSelected ? '#4A9B6D' : '#666666')
.fontWeight(isSelected ? FontWeight.Bold : FontWeight.Normal)
// 日期
Text(holiday.solarDate.slice(5))
.fontSize(11)
.fontColor('#999999')
}
.width(60)
.onClick(() => {
this.selectedId = holiday.id;
// 点击跳转到详情页
try {
router.pushUrl({
url: 'pages/Detail',
params: { holidayId: holiday.id }
});
} catch (error) {
console.error('路由跳转失败: ' + JSON.stringify(error));
}
})
}
}
动画实现详解
1. animateTo平滑动画
typescript
animateTo({
duration: 1000, // 动画时长
curve: Curve.EaseInOut // 缓动曲线
}, () => {
this.scroller.scrollBy(80, 0);
});
优点:
- 比setInterval更流畅
- 支持缓动效果
- 性能更好
2. Scroller控制器
typescript
private scroller: Scroller = new Scroller();
Scroll(this.scroller) {
// 内容
}
// 控制滚动
this.scroller.scrollBy(80, 0); // 相对滚动
this.scroller.scrollTo(100, 0); // 绝对滚动
常见问题与解决方案
问题1: 图片路径判断错误
现象 :
rawfile图片显示不出来,控制台报错"Resource not found"。
解决方案:
typescript
getImageSource(): string | Resource {
if (this.imagePath.startsWith('rawfile://')) {
return this.imagePath; // rawfile用字符串
} else {
return $r('app.media.' + this.imagePath); // base/media用$r()
}
}
规则:
rawfile://开头的路径: 直接使用字符串- 其他路径: 使用
$r('app.media.xxx')引用
问题2: ForEach缺少key导致警告
现象 :
控制台警告:"ForEach needs key generator function"。
解决方案:
typescript
ForEach(items, (item) => {
Text(item.name)
}, (item) => item.id) // 第三个参数是key生成函数
为什么需要key:
- 帮助框架识别列表项
- 优化渲染性能
- 避免不必要的重建
问题3: 自动滚动卡顿
现象 :
使用setInterval实现自动滚动,页面明显卡顿。
解决方案:
typescript
setInterval(() => {
animateTo({
duration: 1000,
curve: Curve.EaseInOut
}, () => {
this.scroller.scrollBy(80, 0);
});
}, 3000);
优化建议:
- 使用animateTo添加动画
- 调整滚动距离和频率
本章小结
核心知识点
本文详细实现了首页的四个核心功能模块:
1. 当前节气卡片
- 根据日期动态匹配节气
- 渐变遮罩增强可读性
- 点击跳转详情页
2. 精选文章横向滚动
- Scroll组件横向滚动
- 固定宽度保证布局
- ForEach提供唯一key
3. 热门分类网格
- Grid布局实现4列网格
- columnsTemplate定义列宽
- rowsGap/columnsGap设置间距
4. 时间轴自动滚动
- Scroller控制滚动位置
- animateTo实现平滑动画
- 定时自动滚动效果
最佳实践总结
✅ 图片路径处理
typescript
if (path.startsWith('rawfile://')) {
return path;
} else {
return $r('app.media.' + path);
}
✅ ForEach使用
typescript
ForEach(items, (item) => {
Card({ item })
}, (item) => item.id)
✅ 横向滚动
typescript
Scroll() {
Row({ space: 12 }) {
ForEach(items, (item) => {
Card().width(160)
})
}
}
.scrollable(ScrollDirection.Horizontal)
下一步预告
首页的核心功能已经完成!在下一篇文章中,我们将:
- 深入讲解自定义TabBar的实现细节
- 实现Tab切换的状态管理
- 应用主题色到导航栏
- 适配不同屏幕尺寸
相关链接
- 项目源码 : Atomgit仓库