[HarmonyOS NEXT 实战案例九] 旅游景点网格布局(下)
项目已开源,开源地址: https://gitcode.com/nutpi/HarmonyosNextCaseStudyTutorial , 欢迎fork & star
效果演示
1. 概述
在上一篇教程中,我们学习了如何使用GridRow和GridCol组件实现基本的旅游景点网格布局。本篇教程将在此基础上,深入探讨如何优化布局、添加交互功能,以及实现更多高级特性,打造一个功能完善的旅游景点应用。
本教程将涵盖以下内容:
- 响应式布局优化
- 景点卡片优化
- 景点详情页实现
- 景点筛选和排序功能
- 收藏和分享功能
- 高级动效和交互优化
2. 响应式布局优化
2.1 使用断点适配不同设备
在上一篇教程中,我们已经使用GridRow的columns属性实现了基本的响应式布局。现在,我们将进一步优化,使用自定义断点和更精细的列配置:
GridRow({
columns: { xs: 1, sm: 2, md: 2, lg: 3, xl: 4, xxl: 6 },
gutter: { x: 16, y: 16 },
breakpoints: [
{ value: 320, reference: BreakpointsReference.WindowSize }, // xs
{ value: 600, reference: BreakpointsReference.WindowSize }, // sm
{ value: 840, reference: BreakpointsReference.WindowSize }, // md
{ value: 1080, reference: BreakpointsReference.WindowSize }, // lg
{ value: 1440, reference: BreakpointsReference.WindowSize }, // xl
{ value: 1920, reference: BreakpointsReference.WindowSize } // xxl
]
}) {
// 景点卡片内容
}
这样配置后,我们可以更精确地控制不同屏幕宽度下的列数:
- 320px以下:1列
- 320px-600px:1列
- 600px-840px:2列
- 840px-1080px:2列
- 1080px-1440px:3列
- 1440px-1920px:4列
- 1920px以上:6列
2.2 使用GridCol的span属性实现特色景点
我们可以使用GridCol的span属性,为特色景点创建更大的卡片:
ForEach(this.spots, (spot: SpotType, index: number) => {
GridCol({
span: index === 0 ? { xs: 1, sm: 2, md: 2, lg: 2 } : { xs: 1, sm: 1, md: 1, lg: 1 },
offset: index === 1 && index < 3 ? { md: 0, lg: 1 } : { md: 0, lg: 0 }
}) {
this.SpotCard(spot)
}
})
这样配置后,第一个景点(索引为0)的卡片在小屏幕及以上尺寸会占据2列,其他景点卡片占据1列,形成突出特色景点的效果。同时,我们还使用offset属性在大屏幕上为第二个景点添加了偏移,使布局更加平衡。
3. 景点卡片优化
3.1 添加阴影和悬浮效果
为景点卡片添加阴影和悬浮效果,提升用户体验:
Column() {
// 景点卡片内容
}
.width('100%')
.backgroundColor(Color.White)
.borderRadius(8)
.shadow({
radius: 6,
color: '#1A000000',
offsetX: 0,
offsetY: 2
})
.stateStyles({
pressed: {
scale: { x: 0.98, y: 0.98 },
opacity: 0.9,
translate: { x: 0, y: 2 }
},
normal: {
scale: { x: 1, y: 1 },
opacity: 1,
translate: { x: 0, y: 0 }
}
})
.animation({
duration: 200,
curve: Curve.EaseOut
})
这段代码为景点卡片添加了以下效果:
- 白色背景和圆角
- 轻微的阴影效果
- 按下时的缩放和位移动画
3.2 添加景点热度指标
为景点卡片添加热度指标,显示更多信息:
// 在景点图片上添加热度指标
Stack() {
Image(spot.image)
.width('100%')
.height(160)
.borderRadius({ topLeft: 8, topRight: 8 })
.objectFit(ImageFit.Cover)
// 价格标签
Text(spot.price === 0 ? '免费' : `¥${spot.price}`)
.fontSize(12)
.fontColor(Color.White)
.backgroundColor(spot.price === 0 ? '#4CAF50' : '#FF5722')
.borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.position({ x: 8, y: 8 })
// 热度指标
if (spot.popularity > 8) {
Row() {
Image($r('app.media.ic_hot'))
.width(16)
.height(16)
.margin({ right: 4 })
Text('热门景点')
.fontSize(12)
.fontColor(Color.White)
}
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('#FF5722AA')
.borderRadius({ bottomLeft: 8, topRight: 8 })
.position({ x: 0, y: 0 })
}
// 世界遗产标识
if (spot.tags.includes('世界遗产')) {
Row() {
Image($r('app.media.ic_world_heritage'))
.width(16)
.height(16)
.margin({ right: 4 })
Text('世界遗产')
.fontSize(12)
.fontColor(Color.White)
}
.padding({ left: 8, right: 8, top: 4, bottom: 4 })
.backgroundColor('#1976D2AA')
.borderRadius(4)
.position({ x: 8, y: 40 })
}
}
这段代码在景点图片上添加了热度指标和世界遗产标识,使用户能够更快地识别热门景点和世界遗产。
3.3 添加景点推荐理由
在景点卡片中添加推荐理由,提供更多信息:
// 在景点信息中添加推荐理由
Column() {
// 景点名称和评分
// ...
// 景点位置
// ...
// 景点标签
// ...
// 推荐理由
if (spot.recommendReason) {
Row() {
Text('推荐理由:')
.fontSize(12)
.fontColor('#666666')
.fontWeight(FontWeight.Bold)
Text(spot.recommendReason)
.fontSize(12)
.fontColor('#666666')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
.width('100%')
.margin({ top: 8 })
}
}
这段代码在景点卡片中添加了推荐理由,使用户能够了解为什么这个景点值得一游。
4. 景点详情页实现
4.1 添加状态变量和点击事件
首先,添加状态变量和点击事件处理:
@State showDetail: boolean = false;
@State currentSpot: SpotType | null = null;
// 在景点卡片上添加点击事件
Column() {
// 景点卡片内容
}
.onClick(() => {
this.currentSpot = spot;
this.showDetail = true;
})
4.2 实现景点详情页
build() {
Stack() {
Column() {
// 原有的旅游景点网格布局
}
if (this.showDetail && this.currentSpot) {
this.SpotDetailPage()
}
}
.width('100%')
.height('100%')
}
@Builder
private SpotDetailPage() {
Column() {
// 顶部图片和导航栏
Stack() {
// 景点大图
Image(this.currentSpot.image)
.width('100%')
.height(280)
.objectFit(ImageFit.Cover)
// 渐变遮罩
Column()
.width('100%')
.height(280)
.backgroundImage({
source: $r('app.media.gradient_overlay'),
repeat: ImageRepeat.NoRepeat
})
// 顶部导航栏
Row() {
Image($r('app.media.ic_back_white'))
.width(24)
.height(24)
.onClick(() => {
this.showDetail = false;
})
Blank()
Row() {
Image($r('app.media.ic_share'))
.width(24)
.height(24)
.margin({ right: 16 })
Image($r('app.media.ic_favorite'))
.width(24)
.height(24)
.fillColor(this.isSpotFavorite(this.currentSpot.id) ? '#FF5722' : Color.White)
.onClick(() => {
this.toggleFavorite(this.currentSpot.id);
})
}
}
.width('100%')
.padding({ left: 16, right: 16, top: 16 })
.position({ x: 0, y: 0 })
// 景点名称和基本信息
Column() {
Text(this.currentSpot.name)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor(Color.White)
Row() {
Image($r('app.media.ic_location_white'))
.width(16)
.height(16)
.margin({ right: 4 })
Text(this.currentSpot.location)
.fontSize(14)
.fontColor(Color.White)
.opacity(0.9)
Blank()
Row() {
Image($r('app.media.ic_star'))
.width(16)
.height(16)
.margin({ right: 4 })
Text(this.currentSpot.rating.toString())
.fontSize(14)
.fontColor('#FFB300')
}
}
.width('100%')
.margin({ top: 8 })
// 标签
Row() {
ForEach(this.currentSpot.tags, (tag: string) => {
Text(tag)
.fontSize(12)
.fontColor(Color.White)
.backgroundColor('#FFFFFF33')
.borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
.margin({ right: 8, top: 8 })
})
}
.width('100%')
.flexWrap(FlexWrap.Wrap)
}
.width('100%')
.padding(16)
.position({ x: 0, y: '60%' })
}
.width('100%')
.height(280)
// 景点详情内容
Scroll() {
Column() {
// 价格和开放时间
Row() {
Column() {
Text('门票价格')
.fontSize(14)
.fontColor('#666666')
Text(this.currentSpot.price === 0 ? '免费' : `¥${this.currentSpot.price}`)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor(this.currentSpot.price === 0 ? '#4CAF50' : '#FF5722')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Divider()
.vertical(true)
.height(40)
.color('#EEEEEE')
Column() {
Text('开放时间')
.fontSize(14)
.fontColor('#666666')
Text(this.getOpeningHours(this.currentSpot.id))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
Divider()
.vertical(true)
.height(40)
.color('#EEEEEE')
Column() {
Text('建议游玩')
.fontSize(14)
.fontColor('#666666')
Text(this.getSuggestedDuration(this.currentSpot.id))
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Center)
.layoutWeight(1)
}
.width('100%')
.padding({ top: 16, bottom: 16 })
.backgroundColor(Color.White)
.borderRadius(8)
.margin({ bottom: 12 })
// 景点介绍
Column() {
Text('景点介绍')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ bottom: 12 })
Text(this.currentSpot.description)
.fontSize(14)
.fontColor('#666666')
.lineHeight(24)
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(8)
.margin({ bottom: 12 })
// 交通信息
Column() {
Text('交通信息')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ bottom: 12 })
Text(this.getTransportInfo(this.currentSpot.id))
.fontSize(14)
.fontColor('#666666')
.lineHeight(24)
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(8)
.margin({ bottom: 12 })
// 周边景点推荐
Column() {
Text('周边景点推荐')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.margin({ bottom: 12 })
// 使用GridRow和GridCol实现周边景点网格
GridRow({
columns: { xs: 2, sm: 3, md: 4, lg: 4 },
gutter: { x: 12, y: 12 }
}) {
ForEach(this.getNearbySpots(this.currentSpot.id), (spot: SpotType) => {
GridCol() {
this.NearbySpotCard(spot)
}
})
}
.width('100%')
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(8)
.margin({ bottom: 12 })
// 用户评价
Column() {
Row() {
Text('用户评价')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Blank()
Text(`${this.getReviewCount(this.currentSpot.id)}条评价 >`)
.fontSize(14)
.fontColor('#1976D2')
}
.width('100%')
.margin({ bottom: 12 })
// 用户评价列表
ForEach(this.getTopReviews(this.currentSpot.id), (review: ReviewType) => {
this.ReviewItem(review)
})
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(8)
.margin({ bottom: 24 })
}
.width('100%')
.padding({ left: 16, right: 16 })
}
.scrollBar(BarState.Off)
.scrollable(ScrollDirection.Vertical)
.width('100%')
.layoutWeight(1)
.backgroundColor('#F5F5F5')
// 底部操作栏
Row() {
Button('查看地图')
.width('48%')
.height(40)
.fontSize(16)
.fontColor('#1976D2')
.backgroundColor('#E3F2FD')
.borderRadius(20)
Button('立即预订')
.width('48%')
.height(40)
.fontSize(16)
.fontColor(Color.White)
.backgroundColor('#FF5722')
.borderRadius(20)
}
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 12 })
.backgroundColor(Color.White)
.justifyContent(FlexAlign.SpaceBetween)
}
.width('100%')
.height('100%')
}
这段代码实现了一个完整的景点详情页,包括:
- 顶部大图和导航栏,带有返回按钮、分享按钮和收藏按钮
- 景点名称、位置、评分和标签
- 价格、开放时间和建议游玩时间
- 景点介绍、交通信息
- 周边景点推荐,使用GridRow和GridCol实现
- 用户评价
- 底部操作栏,包含查看地图和立即预订按钮
4.3 周边景点卡片实现
@Builder
private NearbySpotCard(spot: SpotType) {
Column() {
Image(spot.image)
.width('100%')
.aspectRatio(1)
.borderRadius(8)
.objectFit(ImageFit.Cover)
Text(spot.name)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 8 })
Row() {
Image($r('app.media.ic_star'))
.width(12)
.height(12)
.margin({ right: 4 })
Text(spot.rating.toString())
.fontSize(12)
.fontColor('#FFB300')
Text(` · ${spot.price === 0 ? '免费' : `¥${spot.price}`}`)
.fontSize(12)
.fontColor(spot.price === 0 ? '#4CAF50' : '#FF5722')
}
.margin({ top: 4 })
}
.width('100%')
.onClick(() => {
this.currentSpot = spot;
})
}
4.4 用户评价项实现
// 评价类型定义
interface ReviewType {
id: number;
userName: string;
avatar: Resource;
rating: number;
content: string;
date: string;
}
@Builder
private ReviewItem(review: ReviewType) {
Column() {
Row() {
Image(review.avatar)
.width(40)
.height(40)
.borderRadius(20)
Column() {
Text(review.userName)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Row() {
ForEach([1, 2, 3, 4, 5], (i: number) => {
Image(i <= review.rating ? $r('app.media.ic_star_filled') : $r('app.media.ic_star_outline'))
.width(12)
.height(12)
.margin({ right: 2 })
})
Text(review.date)
.fontSize(12)
.fontColor('#999999')
.margin({ left: 8 })
}
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
.layoutWeight(1)
}
.width('100%')
Text(review.content)
.fontSize(14)
.fontColor('#666666')
.lineHeight(22)
.margin({ top: 8, bottom: 16 })
}
.width('100%')
}
4.5 辅助方法实现
// 判断景点是否已收藏
private isSpotFavorite(spotId: number): boolean {
// 模拟数据,实际应用中应该从本地存储或服务器获取
return this.favoriteSpots.includes(spotId);
}
// 切换收藏状态
private toggleFavorite(spotId: number): void {
if (this.favoriteSpots.includes(spotId)) {
this.favoriteSpots = this.favoriteSpots.filter(id => id !== spotId);
} else {
this.favoriteSpots.push(spotId);
}
}
// 获取开放时间
private getOpeningHours(spotId: number): string {
// 模拟数据,实际应用中应该从服务器获取
const openingHours = {
1: '8:00-17:00',
2: '全天开放',
3: '8:30-17:00',
4: '7:30-16:30',
5: '8:00-17:30',
6: '8:00-18:00',
7: '全天开放',
8: '8:00-18:00'
};
return openingHours[spotId] || '暂无信息';
}
// 获取建议游玩时间
private getSuggestedDuration(spotId: number): string {
// 模拟数据,实际应用中应该从服务器获取
const durations = {
1: '1-2天',
2: '半天',
3: '3-4小时',
4: '1-2天',
5: '1-2天',
6: '1-2天',
7: '1天',
8: '半天'
};
return durations[spotId] || '暂无信息';
}
// 获取交通信息
private getTransportInfo(spotId: number): string {
// 模拟数据,实际应用中应该从服务器获取
const transportInfo = {
1: '1. 公交:乘坐877路、879路等公交车到达八达岭长城站。\n2. 火车:从北京北站乘坐S2线到达八达岭站。\n3. 自驾:沿京藏高速公路行驶,在八达岭出口下高速。',
2: '1. 公交:乘坐游1路、游2路等公交车环湖游览。\n2. 地铁:乘坐地铁1号线到龙翔桥站或地铁2号线到龙翔桥站。\n3. 自驾:导航至杭州西湖景区停车场。',
3: '1. 地铁:乘坐地铁1号线或2号线到天安门东站或天安门西站。\n2. 公交:乘坐1路、2路、52路等公交车到天安门站。\n3. 自驾:导航至故宫博物院停车场。',
4: '1. 火车:乘坐火车到达黄山北站或黄山站。\n2. 汽车:从黄山市区乘坐公交车到达汤口镇,再换乘景区交通车。\n3. 自驾:导航至黄山风景区游客中心。',
5: '1. 飞机:乘坐飞机到达张家界荷花机场。\n2. 火车:乘坐火车到达张家界站。\n3. 汽车:从张家界市区乘坐公交车到达景区门口。',
6: '1. 飞机:乘坐飞机到达九黄机场。\n2. 汽车:从成都、绵阳等地乘坐长途汽车到达九寨沟。\n3. 自驾:沿G213国道行驶到达九寨沟。',
7: '1. 飞机:乘坐飞机到达丽江三义机场。\n2. 火车:乘坐火车到达丽江站。\n3. 汽车:从丽江站乘坐公交车到达古城区。',
8: '1. 轮渡:从厦门轮渡码头乘坐轮渡到达鼓浪屿。\n2. 地铁:乘坐地铁1号线到达轮渡站。\n3. 公交:乘坐公交车到达轮渡站。'
};
return transportInfo[spotId] || '暂无交通信息';
}
// 获取周边景点
private getNearbySpots(spotId: number): SpotType[] {
// 模拟数据,实际应用中应该从服务器获取
// 简单实现:返回除当前景点外的其他景点(最多4个)
return this.spots.filter(spot => spot.id !== spotId).slice(0, 4);
}
// 获取评价数量
private getReviewCount(spotId: number): number {
// 模拟数据,实际应用中应该从服务器获取
const reviewCounts = {
1: 2358,
2: 3421,
3: 5642,
4: 1987,
5: 2145,
6: 1876,
7: 2543,
8: 1432
};
return reviewCounts[spotId] || 0;
}
// 获取热门评价
private getTopReviews(spotId: number): ReviewType[] {
// 模拟数据,实际应用中应该从服务器获取
const reviews: { [key: number]: ReviewType[] } = {
1: [
{ id: 1, userName: '旅行者A', avatar: $r('app.media.avatar1'), rating: 5, content: '长城真的太壮观了,站在上面俯瞰群山,感觉非常震撼。建议穿舒适的鞋子,带足水,因为爬长城还是比较累的。', date: '2023-07-15' },
{ id: 2, userName: '旅行者B', avatar: $r('app.media.avatar2'), rating: 4, content: '八达岭长城是最受欢迎的长城段落,人比较多,但设施完善,适合带老人和孩子。秋天来的话,可以看到漂亮的红叶。', date: '2023-06-22' }
],
2: [
{ id: 3, userName: '旅行者C', avatar: $r('app.media.avatar3'), rating: 5, content: '西湖真的名不虚传,湖光山色,美不胜收。建议租自行车环湖一圈,可以欣赏到不同角度的西湖美景。', date: '2023-08-05' },
{ id: 4, userName: '旅行者D', avatar: $r('app.media.avatar4'), rating: 5, content: '断桥残雪、平湖秋月、三潭印月等景点都很美,建议安排一整天的时间慢慢游览。', date: '2023-07-28' }
],
// 其他景点的评价...
};
return reviews[spotId] || [];
}
这些辅助方法提供了景点详情页所需的各种数据,包括开放时间、建议游玩时间、交通信息、周边景点、评价数量和热门评价。在实际应用中,这些数据应该从服务器获取。
5. 景点筛选和排序功能
5.1 添加筛选选项
// 筛选选项状态变量
@State filterOptions: {
locations: string[];
tags: string[];
minRating: number;
maxPrice: number;
sortBy: string;
} = {
locations: [],
tags: [],
minRating: 0,
maxPrice: 1000,
sortBy: 'default'
};
@State showFilter: boolean = false;
// 筛选面板构建器
@Builder
private FilterPanel() {
Column() {
// 标题
Row() {
Text('筛选')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Blank()
Button('重置')
.backgroundColor('transparent')
.fontColor('#666666')
.fontSize(14)
.onClick(() => {
this.resetFilter();
})
}
.width('100%')
.padding({ top: 16, bottom: 16 })
// 地区筛选
Text('地区')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.margin({ bottom: 12 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(['北京', '杭州', '安徽', '湖南', '四川', '云南', '厦门'], (location: string) => {
Text(location)
.fontSize(14)
.fontColor(this.filterOptions.locations.includes(location) ? Color.White : '#666666')
.backgroundColor(this.filterOptions.locations.includes(location) ? '#1976D2' : '#F5F5F5')
.borderRadius(16)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8, bottom: 8 })
.onClick(() => {
if (this.filterOptions.locations.includes(location)) {
this.filterOptions.locations = this.filterOptions.locations.filter(l => l !== location);
} else {
this.filterOptions.locations.push(location);
}
})
})
}
.margin({ bottom: 16 })
// 标签筛选
Text('标签')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.margin({ bottom: 12 })
Flex({ wrap: FlexWrap.Wrap }) {
ForEach(['历史', '文化', '自然', '风景', '世界遗产', '古镇', '海岛'], (tag: string) => {
Text(tag)
.fontSize(14)
.fontColor(this.filterOptions.tags.includes(tag) ? Color.White : '#666666')
.backgroundColor(this.filterOptions.tags.includes(tag) ? '#1976D2' : '#F5F5F5')
.borderRadius(16)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8, bottom: 8 })
.onClick(() => {
if (this.filterOptions.tags.includes(tag)) {
this.filterOptions.tags = this.filterOptions.tags.filter(t => t !== tag);
} else {
this.filterOptions.tags.push(tag);
}
})
})
}
.margin({ bottom: 16 })
// 最低评分筛选
Text('最低评分')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.margin({ bottom: 12 })
Row() {
Slider({
min: 0,
max: 5,
step: 0.5,
value: this.filterOptions.minRating
})
.blockColor('#1976D2')
.trackColor('#E0E0E0')
.selectedColor('#64B5F6')
.showSteps(true)
.showTips(true)
.onChange((value: number) => {
this.filterOptions.minRating = value;
})
.layoutWeight(1)
Text(this.filterOptions.minRating.toFixed(1))
.fontSize(16)
.fontColor('#1976D2')
.margin({ left: 16 })
}
.width('100%')
.margin({ bottom: 16 })
// 最高价格筛选
Text('最高价格')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.margin({ bottom: 12 })
Row() {
Slider({
min: 0,
max: 500,
step: 50,
value: this.filterOptions.maxPrice
})
.blockColor('#1976D2')
.trackColor('#E0E0E0')
.selectedColor('#64B5F6')
.showSteps(true)
.showTips(true)
.onChange((value: number) => {
this.filterOptions.maxPrice = value;
})
.layoutWeight(1)
Text(`¥${this.filterOptions.maxPrice}`)
.fontSize(16)
.fontColor('#1976D2')
.margin({ left: 16 })
}
.width('100%')
.margin({ bottom: 16 })
// 排序方式
Text('排序方式')
.fontSize(16)
.fontWeight(FontWeight.Medium)
.margin({ bottom: 12 })
Column() {
this.SortOption('默认排序', 'default')
this.SortOption('评分从高到低', 'rating-desc')
this.SortOption('价格从低到高', 'price-asc')
this.SortOption('价格从高到低', 'price-desc')
}
.margin({ bottom: 16 })
// 底部按钮
Row() {
Button('取消')
.width('48%')
.height(40)
.backgroundColor('#F5F5F5')
.fontColor('#666666')
.borderRadius(20)
.onClick(() => {
this.showFilter = false;
})
Button('确定')
.width('48%')
.height(40)
.backgroundColor('#1976D2')
.fontColor(Color.White)
.borderRadius(20)
.onClick(() => {
this.applyFilter();
this.showFilter = false;
})
}
.width('100%')
.justifyContent(FlexAlign.SpaceBetween)
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius({ topLeft: 16, topRight: 16 })
}
// 排序选项构建器
@Builder
private SortOption(text: string, value: string) {
Row() {
Text(text)
.fontSize(14)
.fontColor('#666666')
Blank()
Radio({ value: value, group: 'sortBy' })
.checked(this.filterOptions.sortBy === value)
.onChange((isChecked: boolean) => {
if (isChecked) {
this.filterOptions.sortBy = value;
}
})
}
.width('100%')
.height(40)
.padding({ left: 8, right: 8 })
.borderRadius(4)
.backgroundColor(this.filterOptions.sortBy === value ? '#E3F2FD' : 'transparent')
.margin({ bottom: 8 })
}
// 重置筛选选项
private resetFilter(): void {
this.filterOptions = {
locations: [],
tags: [],
minRating: 0,
maxPrice: 1000,
sortBy: 'default'
};
}
// 应用筛选
private applyFilter(): void {
// 筛选逻辑在getFilteredSpots方法中实现
}
// 获取筛选后的景点列表
private getFilteredSpots(): SpotType[] {
let filtered = this.spots;
// 按地区筛选
if (this.filterOptions.locations.length > 0) {
filtered = filtered.filter(spot => this.filterOptions.locations.includes(spot.location));
}
// 按标签筛选
if (this.filterOptions.tags.length > 0) {
filtered = filtered.filter(spot => spot.tags.some(tag => this.filterOptions.tags.includes(tag)));
}
// 按评分筛选
if (this.filterOptions.minRating > 0) {
filtered = filtered.filter(spot => spot.rating >= this.filterOptions.minRating);
}
// 按价格筛选
if (this.filterOptions.maxPrice < 1000) {
filtered = filtered.filter(spot => spot.price <= this.filterOptions.maxPrice);
}
// 排序
switch (this.filterOptions.sortBy) {
case 'rating-desc':
filtered.sort((a, b) => b.rating - a.rating);
break;
case 'price-asc':
filtered.sort((a, b) => a.price - b.price);
break;
case 'price-desc':
filtered.sort((a, b) => b.price - a.price);
break;
default:
// 默认排序,保持原顺序
break;
}
return filtered;
}
这段代码实现了景点筛选和排序功能,包括:
- 地区筛选:用户可以选择一个或多个地区
- 标签筛选:用户可以选择一个或多个标签
- 最低评分筛选:用户可以设置最低评分要求
- 最高价格筛选:用户可以设置最高价格要求
- 排序方式:用户可以选择默认排序、评分从高到低、价格从低到高或价格从高到低
6. 收藏和分享功能
6.1 收藏功能实现
// 收藏景点ID列表
@State favoriteSpots: number[] = [];
// 收藏按钮构建器
@Builder
private FavoriteButton(spotId: number) {
Image(this.isSpotFavorite(spotId) ? $r('app.media.ic_favorite_filled') : $r('app.media.ic_favorite_outline'))
.width(24)
.height(24)
.fillColor(this.isSpotFavorite(spotId) ? '#FF5722' : '#666666')
.onClick(() => {
this.toggleFavorite(spotId);
if (this.isSpotFavorite(spotId)) {
this.showToast('已添加到收藏');
} else {
this.showToast('已取消收藏');
}
})
}
// 显示提示信息
private showToast(message: string): void {
// 实际应用中应该使用Toast组件
AlertDialog.show({
message: message,
autoCancel: true,
alignment: DialogAlignment.Bottom,
offset: { dx: 0, dy: -20 },
gridCount: 3,
cancel: () => {
// 取消回调
}
});
}
6.2 分享功能实现
// 分享按钮构建器
@Builder
private ShareButton(spot: SpotType) {
Image($r('app.media.ic_share'))
.width(24)
.height(24)
.onClick(() => {
this.shareSpot(spot);
})
}
// 分享景点
private shareSpot(spot: SpotType): void {
// 实际应用中应该调用系统分享API
AlertDialog.show({
title: '分享',
message: `分享景点:${spot.name}\n位置:${spot.location}\n评分:${spot.rating}`,
primaryButton: {
value: '确定',
action: () => {
console.info('用户确认分享');
}
},
secondaryButton: {
value: '取消',
action: () => {
console.info('用户取消分享');
}
}
});
}
7. 高级动效和交互优化
7.1 下拉刷新功能
@State refreshing: boolean = false;
// 在SpotGrid方法中添加下拉刷新
@Builder
private SpotGrid() {
Refresh({
refreshing: this.refreshing,
offset: 120,
friction: 100
}) {
Scroll() {
// 原有的景点网格内容
}
.scrollBar(BarState.Off)
.scrollable(ScrollDirection.Vertical)
.width('100%')
.layoutWeight(1)
}
.onRefreshing(() => {
// 模拟刷新数据
setTimeout(() => {
// 随机调整景点顺序,模拟刷新效果
this.spots = this.shuffleArray([...this.spots]);
this.refreshing = false;
this.showToast('刷新成功');
}, 2000);
})
}
// 随机打乱数组
private shuffleArray<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
7.2 景点卡片动画效果
@Builder
private SpotCard(spot: SpotType) {
Column() {
// 景点卡片内容
}
.width('100%')
.backgroundColor(Color.White)
.borderRadius(8)
.shadow({
radius: 6,
color: '#1A000000',
offsetX: 0,
offsetY: 2
})
.stateStyles({
pressed: {
scale: { x: 0.98, y: 0.98 },
opacity: 0.9,
translate: { x: 0, y: 2 }
},
normal: {
scale: { x: 1, y: 1 },
opacity: 1,
translate: { x: 0, y: 0 }
}
})
.animation({
duration: 200,
curve: Curve.EaseOut
})
.gesture(
LongPressGesture()
.onAction(() => {
this.showSpotActions(spot);
})
)
}
// 显示景点操作菜单
private showSpotActions(spot: SpotType): void {
ActionSheet.show({
title: spot.name,
message: spot.description,
autoCancel: true,
alignment: DialogAlignment.Bottom,
offset: { dx: 0, dy: -10 },
sheets: [
{
title: '查看详情',
action: () => {
this.currentSpot = spot;
this.showDetail = true;
}
},
{
title: this.isSpotFavorite(spot.id) ? '取消收藏' : '添加收藏',
action: () => {
this.toggleFavorite(spot.id);
if (this.isSpotFavorite(spot.id)) {
this.showToast('已添加到收藏');
} else {
this.showToast('已取消收藏');
}
}
},
{
title: '分享',
action: () => {
this.shareSpot(spot);
}
}
]
});
}
7.3 景点详情页过渡动画
// 在build方法中添加过渡动画
build() {
Stack() {
Column() {
// 原有的旅游景点网格布局
}
if (this.showDetail && this.currentSpot) {
this.SpotDetailPage()
.transition({
type: TransitionType.Push,
direction: TransitionDirection.Left
})
}
}
.width('100%')
.height('100%')
}
8. 完整代码
由于完整代码较长,这里只展示了部分关键代码。完整代码包含了本教程中介绍的所有功能,包括响应式布局优化、景点卡片优化、景点详情页实现、景点筛选和排序功能、收藏和分享功能、高级动效和交互优化等。
9. 总结
本教程详细讲解了如何优化旅游景点网格布局,添加交互功能,以及实现更多高级特性。通过使用HarmonyOS NEXT的GridRow和GridCol组件的高级特性,我们实现了响应式布局,使应用能够适应不同屏幕尺寸的设备。同时,我们还添加了景点卡片优化、景点详情页、景点筛选和排序功能、收藏和分享功能、高级动效和交互优化等功能,打造了一个功能完善的旅游景点应用。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。