70. [HarmonyOS NEXT 实战案例九] 旅游景点网格布局(下)

简介: 在上一篇教程中,我们学习了如何使用GridRow和GridCol组件实现基本的旅游景点网格布局。本篇教程将在此基础上,深入探讨如何优化布局、添加交互功能,以及实现更多高级特性,打造一个功能完善的旅游景点应用。

[HarmonyOS NEXT 实战案例九] 旅游景点网格布局(下)

项目已开源,开源地址: https://gitcode.com/nutpi/HarmonyosNextCaseStudyTutorial , 欢迎fork & star

效果演示

img_4276b4e4.png

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

相关文章
|
9天前
|
容器
69.[HarmonyOS NEXT 实战案例九] 旅游景点网格布局(上)
本教程将详细讲解如何使用HarmonyOS NEXT中的GridRow和GridCol组件实现旅游景点网格布局。通过网格布局,我们可以以美观、规整的方式展示各种旅游景点信息,为用户提供良好的浏览体验。
23 1
|
17天前
|
开发框架 前端开发 JavaScript
【HarmonyOS Next之旅】基于ArkTS开发(二) -> UI开发一
本文介绍了方舟开发框架(ArkUI)及其两种开发范式:基于ArkTS的声明式开发范式和类Web开发范式。ArkUI是用于构建HarmonyOS应用界面的UI框架,提供极简UI语法和基础设施。声明式开发范式使用ArkTS语言,以组件、动画和状态管理为核心,适合复杂团队协作;类Web开发范式采用HML、CSS、JavaScript三段式开发,适用于简单界面应用,贴近Web开发者习惯。文中还概述了两者的架构和基础能力,帮助开发者选择合适的范式进行高效开发。
68 15
|
17天前
|
编解码 前端开发 Java
【HarmonyOS Next之旅】基于ArkTS开发(二) -> UI开发三
本文介绍了基于声明式UI范式的图形绘制与动画效果实现方法,涵盖绘制图形、添加动画效果及常见组件说明三部分内容。在绘制图形部分,详细讲解了如何通过Circle组件为食物成分表添加圆形标签,以及使用Path组件结合SVG命令绘制自定义图形(如应用Logo)。动画效果部分则展示了如何利用animateTo实现闪屏动画,包括渐出、放大效果,并设置页面跳转;同时介绍了页面间共享元素转场动画的实现方式。最后,文章列举了声明式开发范式中的各类组件及其功能,帮助开发者快速上手构建复杂交互页面。
59 11
|
18天前
|
开发工具
鸿蒙开发:DevEcoStudio中的代码生成
其实大家可以发现,一篇文章下来,都是基于右键后的Generate选项,所以,还是非常的简单的,当然了,还是希望大家,以上的功能,能够应用在实际的开发中,而不是停留在纸面上。
鸿蒙开发:DevEcoStudio中的代码生成
|
14天前
|
UED 容器
5.HarmonyOS Next开发宝典:掌握Flex布局的艺术
Flex布局(弹性布局)是HarmonyOS Next中最强大的布局方式之一,它提供了一种更加高效、灵活的方式来对容器中的子元素进行排列、对齐和分配空间。无论是简单的居中显示,还是复杂的自适应界面,Flex布局都能轻松应对。
37 0
|
19天前
|
存储 JSON 搜索推荐
鸿蒙5开发宝藏案例分享---自由流转的拖拽多屏联动
本文分享了鸿蒙开发中的五大实用案例,包括页面跳转、列表渲染、网络请求封装、数据持久化和系统能力调用。通过具体代码示例与避坑指南,手把手教你掌握常用功能,助你高效开发。无论是初学者还是进阶开发者,都能从中受益!
|
19天前
|
传感器 人工智能 JSON
鸿蒙5开发宝藏案例分享---应用接续提升内容发布体验
本文分享了鸿蒙应用接续功能的实战经验,帮助开发者实现跨设备流转。文章介绍了该功能的核心要点、开发条件及多个实战案例,如图文草稿跨设备接续、协同文档实时接续和社交通讯录接续,并提供了避坑指南与调试秘籍。通过动态压缩策略优化传输速度,结合AI能力提升体验。适合想了解鸿蒙跨设备开发的开发者参考学习。
|
19天前
|
传感器 数据管理 定位技术
鸿蒙5开发宝藏案例分享---一多开发实例(游戏)
这篇文章为开发者揭示了鸿蒙系统中的隐藏宝藏——官方提供的高质量开发案例。通过这些案例,可以大幅提升开发效率,轻松实现分布式游戏、跨端协同等功能。文中详细介绍了四个实战案例:用手机作为电视游戏手柄的分布式游戏手柄、支持多设备数据同步的跨端接力抓宠功能、针对中低端设备优化3D粒子特效的方法,以及利用卡片服务创造各种实用功能的技巧。最后还提供了避坑指南,帮助开发者更高效地利用文档资源。
|
18天前
|
JavaScript 小程序 API
UniApp X:鸿蒙原生开发的机会与DCloud的崛起之路·优雅草卓伊凡
UniApp X:鸿蒙原生开发的机会与DCloud的崛起之路·优雅草卓伊凡
77 12
UniApp X:鸿蒙原生开发的机会与DCloud的崛起之路·优雅草卓伊凡
|
14天前
|
JSON IDE Java
鸿蒙开发:json转对象插件回来了
首先,我重新编译了插件,进行了上传,大家可以下载最新的安装包进行体验了,还是和以前一样,提供了在线版和IDE插件版,两个选择,最新的版本,除了升级了版本,兼容了最新的DevEco Studio ,还做了一层优化,就是针对嵌套对象和属性的生成,使用方式呢,一年前的文章中有过详细的概述,这里呢也简单介绍一下。
鸿蒙开发:json转对象插件回来了
OSZAR »