172.[HarmonyOS NEXT 实战案例六:Grid] 响应式网格布局 - 基础篇

2025-06-30 22:48:51
105次阅读
0个评论

[HarmonyOS NEXT 实战案例六:Grid] 响应式网格布局 - 基础篇

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

效果演示

image.png

响应式网格布局是HarmonyOS NEXT中一种强大的布局方式,能够根据不同设备屏幕尺寸自动调整内容排列,提供一致且优质的用户体验。本文将详细介绍响应式网格布局的基础知识,包括断点系统、Grid组件配置以及实现一个新闻资讯页面的基本步骤。

1. 响应式网格布局概述

1.1 什么是响应式网格布局

响应式网格布局是一种能够根据设备屏幕尺寸自动调整内容排列的布局方式。在HarmonyOS NEXT中,通过Grid组件和断点系统的配合,可以实现在不同设备上(如手机、折叠屏、平板)呈现最佳的内容布局,无需为每种设备单独开发界面。

1.2 响应式网格布局的优势

优势 描述
一次开发,多端适配 同一套代码可以适配手机、折叠屏、平板等多种设备
提升用户体验 在不同尺寸的屏幕上都能提供最佳的内容展示方式
降低开发成本 减少为不同设备单独开发界面的工作量
提高维护效率 统一的代码结构更易于维护和更新

1.3 应用场景

响应式网格布局特别适合以下场景:

  • 新闻资讯类应用
  • 电商商品展示
  • 图片相册
  • 内容聚合平台
  • 需要在多种设备上运行的应用

2. 断点系统与屏幕适配

2.1 HarmonyOS断点系统

断点系统是响应式布局的核心,它根据设备屏幕宽度定义了不同的断点,用于在不同尺寸的设备上应用不同的布局规则。

断点名称 宽度范围(vp) 设备类型
xs [0, 320) 最小宽度设备(如手表)
sm [320, 520) 小宽度设备(如手机)
md [520, 840) 中等宽度设备(如折叠屏)
lg [840, +∞) 大宽度设备(如平板)

2.2 使用@StorageProp监听断点变化

在ResponsiveGrid.ets中,我们使用@StorageProp装饰器来监听当前断点的变化:

@StorageProp('currentBreakpoint') currentBreakpoint: string = 'md'

这样,当设备屏幕尺寸变化导致断点改变时,组件会自动更新。

2.3 根据断点调整列数

根据不同的断点,我们可以动态调整Grid的列数,以实现最佳的内容展示:

getColumnsTemplate(): string {
  // 根据断点返回不同的列模板
  if (this.currentBreakpoint === 'sm') {
    return '1fr' // 小屏设备单列显示
  } else if (this.currentBreakpoint === 'md') {
    return '1fr 1fr' // 中等屏幕双列显示
  } else {
    return '1fr 1fr 1fr' // 大屏设备三列显示
  }
}

3. 数据模型设计

3.1 NewsItem接口

为了展示新闻内容,我们定义了NewsItem接口:

interface NewsItem {
  id: number
  title: string
  summary: string
  content: string
  author: string
  publishTime: number
  readCount: number
  commentCount: number
  imageUrl: string
  category: string
  tags: string[]
  isTop: boolean
  isHot: boolean
}

3.2 分类数据

为了实现分类筛选功能,我们定义了分类数组:

categories: string[] = ['全部', '科技', '财经', '体育', '娱乐', '健康', '教育', '旅游']

4. 响应式布局实现

4.1 页面结构设计

新闻资讯页面的整体结构包括:

  1. 顶部导航栏(带搜索功能)
  2. 分类标签栏
  3. 新闻网格区域
  4. 底部导航栏

4.2 顶部导航栏实现

顶部导航栏根据是否显示搜索框有两种状态:

Row() {
  if (this.showSearch) {
    // 搜索状态的导航栏
    Row() {
      Image($r('app.media.search_icon'))
        .width(20)
        .height(20)
        .fillColor('#999999')
        .margin({ left: 12 })

      TextInput({ placeholder: '搜索新闻、关键词' })
        .fontSize(16)
        .backgroundColor('transparent')
        .border({ width: 0 })
        .layoutWeight(1)
        .margin({ left: 8, right: 12 })
        .onChange((value: string) => {
          this.searchKeyword = value
        })
    }
    .width('100%')
    .height(40)
    .backgroundColor('#F5F5F5')
    .borderRadius(20)
    .layoutWeight(1)

    Button('取消')
      .fontSize(16)
      .fontColor('#007AFF')
      .backgroundColor('transparent')
      .margin({ left: 12 })
      .onClick(() => {
        this.showSearch = false
        this.searchKeyword = ''
      })
  } else {
    // 常规状态的导航栏
    Text('新闻资讯')
      .fontSize(24)
      .fontWeight(FontWeight.Bold)
      .fontColor('#333333')
      .layoutWeight(1)

    Button() {
      Image($r('app.media.search_icon'))
        .width(24)
        .height(24)
        .fillColor('#333333')
    }
    .width(44)
    .height(44)
    .borderRadius(22)
    .backgroundColor('transparent')
    .margin({ right: 8 })
    .onClick(() => {
      this.showSearch = true
    })

    Button() {
      Image($r('app.media.big15'))
        .width(24)
        .height(24)
        .fillColor('#333333')
    }
    .width(44)
    .height(44)
    .borderRadius(22)
    .backgroundColor('transparent')
  }
}

4.3 分类标签栏实现

分类标签栏使用Scroll组件实现水平滚动:

if (!this.showSearch) {
  Scroll() {
    Row() {
      ForEach(this.categories, (category:string, index) => {
        Button(category)
          .fontSize(14)
          .fontColor(this.selectedCategory === category ? '#FFFFFF' : '#333333')
          .backgroundColor(this.selectedCategory === category ? '#007AFF' : '#F0F0F0')
          .borderRadius(16)
          .padding({ left: 16, right: 16, top: 8, bottom: 8 })
          .margin({ right: index < this.categories.length - 1 ? 12 : 0 })
          .onClick(() => {
            this.selectedCategory = category
          })
      })
    }
  }
  .scrollable(ScrollDirection.Horizontal)
  .scrollBar(BarState.Off)
  .width('100%')
  .padding({ left: 16, right: 16, bottom: 16 })
  .backgroundColor('#FFFFFF')
}

4.4 响应式网格区域实现

网格区域是本案例的核心部分,使用Grid组件实现:

Grid() {
  ForEach(this.getFilteredNews(), (item:NewsItem) => {
    GridItem() {
      // 新闻卡片内容
      Column() {
        // 图片区域
        Stack({ alignContent: Alignment.TopStart }) {
          Image(item.imageUrl)
            .width('100%')
            .height(this.currentBreakpoint === 'sm' ? 200 : 160)
            .objectFit(ImageFit.Cover)
            .borderRadius({ topLeft: 12, topRight: 12 })

          // 标签区域
          Row() {
            if (item.isTop) {
              Text('置顶')
                .fontSize(10)
                .fontColor('#FFFFFF')
                .backgroundColor('#FF6B6B')
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .borderRadius(4)
                .margin({ right: 4 })
            }

            if (item.isHot) {
              Text('热门')
                .fontSize(10)
                .fontColor('#FFFFFF')
                .backgroundColor('#FF9500')
                .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                .borderRadius(4)
                .margin({ right: 4 })
            }

            Text(item.category)
              .fontSize(10)
              .fontColor('#FFFFFF')
              .backgroundColor('rgba(0, 0, 0, 0.6)')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .borderRadius(4)
          }
          .margin({ top: 8, left: 8 })
        }

        // 内容区域
        Column() {
          // 标题
          Text(item.title)
            .fontSize(this.currentBreakpoint === 'sm' ? 18 : 16)
            .fontWeight(FontWeight.Bold)
            .fontColor('#333333')
            .maxLines(2)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
            .width('100%')
            .textAlign(TextAlign.Start)

          // 摘要(仅在小屏幕显示)
          if (this.currentBreakpoint === 'sm') {
            Text(item.summary)
              .fontSize(14)
              .fontColor('#666666')
              .maxLines(3)
              .textOverflow({ overflow: TextOverflow.Ellipsis })
              .width('100%')
              .textAlign(TextAlign.Start)
              .margin({ top: 8 })
          }

          // 标签
          if (item.tags.length > 0 && this.currentBreakpoint !== 'lg') {
            Row() {
              ForEach(item.tags.slice(0, 2), (tag:string, index) => {
                Text(`#${tag}`)
                  .fontSize(10)
                  .fontColor('#007AFF')
                  .backgroundColor('rgba(0, 122, 255, 0.1)')
                  .padding({ left: 6, right: 6, top: 2, bottom: 2 })
                  .borderRadius(6)
                  .margin({ right: index < Math.min(item.tags.length, 2) - 1 ? 4 : 0 })
              })
            }
            .width('100%')
            .margin({ top: 8 })
          }

          Blank()

          // 底部信息
          Row() {
            Column() {
              Text(item.author)
                .fontSize(12)
                .fontColor('#666666')

              Text(this.getTimeAgo(item.publishTime))
                .fontSize(10)
                .fontColor('#999999')
                .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.Start)
            .layoutWeight(1)

            Column() {
              Row() {
                Image($r('app.media.01'))
                  .width(12)
                  .height(12)
                  .fillColor('#999999')

                Text(this.formatReadCount(item.readCount))
                  .fontSize(10)
                  .fontColor('#999999')
                  .margin({ left: 2 })
              }

              Row() {
                Image($r('app.media.02'))
                  .width(12)
                  .height(12)
                  .fillColor('#999999')

                Text(item.commentCount.toString())
                  .fontSize(10)
                  .fontColor('#999999')
                  .margin({ left: 2 })
              }
              .margin({ top: 2 })
            }
            .alignItems(HorizontalAlign.End)
          }
          .width('100%')
        }
        .padding(12)
        .alignItems(HorizontalAlign.Start)
        .layoutWeight(1)
      }
      .width('100%')
      .height(this.currentBreakpoint === 'sm' ? 320 : this.currentBreakpoint === 'md' ? 280 : 240)
      .backgroundColor('#FFFFFF')
      .borderRadius(12)
      .shadow({
        radius: 8,
        color: 'rgba(0, 0, 0, 0.1)',
        offsetX: 0,
        offsetY: 2
      })
    }
    .onClick(() => {
      console.log(`查看新闻详情: ${item.title}`)
    })
  })
}
.columnsTemplate(this.getColumnsTemplate())
.rowsGap(16)
.columnsGap(12)
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16, bottom: 16 })
.backgroundColor('#F8F8F8')

4.5 Grid组件关键属性

属性 描述 示例值
columnsTemplate 设置网格的列模板 '1fr 1fr'
rowsTemplate 设置网格的行模板 '1fr 1fr'
rowsGap 行间距 16
columnsGap 列间距 12
width 网格宽度 '100%'
layoutWeight 布局权重 1

5. 数据处理与辅助功能

5.1 数据过滤

实现根据分类和搜索关键词过滤新闻数据:

getFilteredNews(): NewsItem[] {
  return this.newsItems.filter((item: NewsItem) => {
    // 分类过滤
    const categoryMatch = this.selectedCategory === '全部' || item.category === this.selectedCategory
    
    // 搜索关键词过滤
    const searchMatch = !this.searchKeyword || 
      item.title.toLowerCase().includes(this.searchKeyword.toLowerCase()) || 
      item.summary.toLowerCase().includes(this.searchKeyword.toLowerCase())
    
    return categoryMatch && searchMatch
  })
}

5.2 格式化阅读数

将大数字格式化为更易读的形式:

formatReadCount(count: number): string {
  if (count >= 10000) {
    return (count / 10000).toFixed(1) + '万'
  } else if (count >= 1000) {
    return (count / 1000).toFixed(1) + 'k'
  } else {
    return count.toString()
  }
}

5.3 计算发布时间差

计算新闻发布时间与当前时间的差值,并以友好的方式显示:

getTimeAgo(timestamp: number): string {
  const now = Date.now()
  const diff = now - timestamp
  
  // 转换为秒
  const seconds = Math.floor(diff / 1000)
  
  if (seconds < 60) {
    return '刚刚'
  } else if (seconds < 3600) {
    return Math.floor(seconds / 60) + '分钟前'
  } else if (seconds < 86400) {
    return Math.floor(seconds / 3600) + '小时前'
  } else if (seconds < 2592000) {
    return Math.floor(seconds / 86400) + '天前'
  } else {
    const date = new Date(timestamp)
    return `${date.getMonth() + 1}月${date.getDate()}日`
  }
}

6. 总结

本文详细介绍了HarmonyOS NEXT中响应式网格布局的基础知识和实现方法。通过断点系统和Grid组件的配合,我们可以轻松实现在不同设备上自动调整内容布局的功能,提供一致且优质的用户体验。在下一篇文章中,我们将深入探讨响应式网格布局的进阶技巧和优化方法。

收藏00

登录 后评论。没有帐号? 注册 一个。