[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组件的高级特性,我们实现了响应式布局,使应用能够适应不同屏幕尺寸的设备。同时,我们还添加了景点卡片优化、景点详情页、景点筛选和排序功能、收藏和分享功能、高级动效和交互优化等功能,打造了一个功能完善的旅游景点应用。


全栈若城
1 声望2 粉丝