174.[HarmonyOS NEXT 实战案例六:Grid] 响应式网格布局 - 高级篇

2025-06-30 22:57:35
104次阅读
0个评论

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

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

效果演示

image.png

在前两篇文章中,我们介绍了HarmonyOS NEXT响应式网格布局的基础知识和进阶技巧。本篇将深入探讨响应式网格布局的高级应用,包括复杂布局案例、高级组件封装、性能优化策略以及实际项目中的最佳实践,帮助开发者掌握响应式网格布局的精髓。

1. 复杂布局案例

1.1 新闻门户首页布局

新闻门户首页通常包含多种内容区块,如头条新闻、专题报道、分类新闻等。下面是一个复杂新闻首页的布局实现:

@Component
struct NewsHomePage {
  @StorageProp('currentBreakpoint') currentBreakpoint: string = 'md'
  
  build() {
    Column() {
      // 顶部导航栏
      this.NavigationBar()
      
      // 内容区域
      Grid() {
        // 头条新闻区域
        GridItem() {
          this.HeadlineNews()
        }.area('headline')
        
        // 专题报道区域
        GridItem() {
          this.FeaturedNews()
        }.area('featured')
        
        // 最新新闻区域
        GridItem() {
          this.LatestNews()
        }.area('latest')
        
        // 热门新闻区域
        GridItem() {
          this.HotNews()
        }.area('hot')
        
        // 分类新闻区域
        GridItem() {
          this.CategoryNews()
        }.area('category')
      }
      .width('100%')
      .layoutWeight(1)
      .columnsTemplate(this.getColumnsTemplate())
      .rowsTemplate(this.getRowsTemplate())
      .areas(this.getGridAreas())
      .backgroundColor('#F8F8F8')
    }
    .width('100%')
    .height('100%')
  }
  
  // 根据断点获取列模板
  getColumnsTemplate(): string {
    switch (this.currentBreakpoint) {
      case 'sm':
        return '1fr' // 小屏单列
      case 'md':
        return '2fr 1fr' // 中屏两列,左侧占比大
      case 'lg':
        return '2fr 1fr 1fr' // 大屏三列
      default:
        return '1fr'
    }
  }
  
  // 根据断点获取行模板
  getRowsTemplate(): string {
    switch (this.currentBreakpoint) {
      case 'sm':
        return 'auto auto auto auto auto' // 小屏五行
      case 'md':
        return 'auto auto auto' // 中屏三行
      case 'lg':
        return 'auto auto' // 大屏两行
      default:
        return 'auto'
    }
  }
  
  // 根据断点获取网格区域定义
  getGridAreas() {
    switch (this.currentBreakpoint) {
      case 'sm':
        return [
          ['headline'],
          ['featured'],
          ['latest'],
          ['hot'],
          ['category']
        ]
      case 'md':
        return [
          ['headline', 'featured'],
          ['latest', 'hot'],
          ['category', 'category']
        ]
      case 'lg':
        return [
          ['headline', 'featured', 'hot'],
          ['latest', 'category', 'category']
        ]
      default:
        return [['headline']]
    }
  }
  
  // 组件定义...
  @Builder NavigationBar() {
    // 导航栏实现
  }
  
  @Builder HeadlineNews() {
    // 头条新闻实现
  }
  
  @Builder FeaturedNews() {
    // 专题报道实现
  }
  
  @Builder LatestNews() {
    // 最新新闻实现
  }
  
  @Builder HotNews() {
    // 热门新闻实现
  }
  
  @Builder CategoryNews() {
    // 分类新闻实现
  }
}

1.2 电商产品展示布局

电商应用中的产品展示页面需要根据不同设备显示不同数量的产品:

@Component
struct ProductGrid {
  @StorageProp('currentBreakpoint') currentBreakpoint: string = 'md'
  @State products: Product[] = []
  
  build() {
    Grid() {
      ForEach(this.products, (product: Product) => {
        GridItem() {
          Column() {
            // 产品图片
            Image(product.imageUrl)
              .width('100%')
              .aspectRatio(1)
              .objectFit(ImageFit.Cover)
              .borderRadius(8)
            
            // 产品信息
            Column() {
              // 产品名称
              Text(product.name)
                .fontSize(16)
                .fontWeight(FontWeight.Medium)
                .maxLines(2)
                .textOverflow({ overflow: TextOverflow.Ellipsis })
              
              // 价格信息
              Row() {
                Text(`¥${product.price.toFixed(2)}`)
                  .fontSize(18)
                  .fontWeight(FontWeight.Bold)
                  .fontColor('#FF6B6B')
                
                if (product.originalPrice > product.price) {
                  Text(`¥${product.originalPrice.toFixed(2)}`)
                    .fontSize(14)
                    .fontColor('#999999')
                    .decoration({ type: TextDecorationType.LineThrough })
                    .margin({ left: 8 })
                }
              }
              .width('100%')
              .margin({ top: 8 })
              
              // 销量和评分
              if (this.currentBreakpoint !== 'sm') {
                Row() {
                  Text(`销量 ${product.sales}`)
                    .fontSize(12)
                    .fontColor('#999999')
                  
                  Text(`评分 ${product.rating.toFixed(1)}`)
                    .fontSize(12)
                    .fontColor('#999999')
                }
                .width('100%')
                .justifyContent(FlexAlign.SpaceBetween)
                .margin({ top: 8 })
              }
            }
            .width('100%')
            .alignItems(HorizontalAlign.Start)
            .padding({ top: 8 })
          }
          .width('100%')
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .padding(12)
        }
      })
    }
    .width('100%')
    .columnsTemplate(this.getColumnsTemplate())
    .rowsGap(16)
    .columnsGap(16)
    .padding(16)
  }
  
  // 根据断点获取列模板
  getColumnsTemplate(): string {
    switch (this.currentBreakpoint) {
      case 'sm':
        return '1fr 1fr' // 小屏两列
      case 'md':
        return '1fr 1fr 1fr' // 中屏三列
      case 'lg':
        return '1fr 1fr 1fr 1fr' // 大屏四列
      default:
        return '1fr 1fr'
    }
  }
}

2. 高级组件封装

2.1 响应式卡片组件

封装一个通用的响应式卡片组件,可在不同布局中复用:

@Component
struct ResponsiveCard {
  @StorageProp('currentBreakpoint') currentBreakpoint: string = 'md'
  @Prop title: string = ''
  @Prop subtitle: string = ''
  @Prop content: string = ''
  @Prop imageUrl: string = ''
  @Prop tags: string[] = []
  @Prop footerText: string = ''
  @Prop aspectRatio: number = 16/9
  
  build() {
    Column() {
      if (this.currentBreakpoint === 'sm') {
        // 小屏布局:图片在上,内容在下
        this.CardImage()
        this.CardContent()
      } else {
        // 中大屏布局:根据aspectRatio决定是横向还是纵向布局
        if (this.aspectRatio >= 1) {
          // 横向布局:图片在上,内容在下
          this.CardImage()
          this.CardContent()
        } else {
          // 纵向布局:图片在左,内容在右
          Row() {
            this.CardImage()
              .layoutWeight(1)
            
            this.CardContent()
              .layoutWeight(2)
          }
          .width('100%')
        }
      }
    }
    .width('100%')
    .backgroundColor('#FFFFFF')
    .borderRadius(12)
    .shadow({
      radius: 8,
      color: 'rgba(0, 0, 0, 0.1)',
      offsetX: 0,
      offsetY: 2
    })
  }
  
  @Builder CardImage() {
    Image(this.imageUrl)
      .width('100%')
      .aspectRatio(this.aspectRatio)
      .objectFit(ImageFit.Cover)
      .borderRadius({ topLeft: 12, topRight: 12 })
  }
  
  @Builder CardContent() {
    Column() {
      // 标题
      Text(this.title)
        .fontSize(this.getTitleFontSize())
        .fontWeight(FontWeight.Bold)
        .maxLines(2)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
        .width('100%')
      
      // 副标题
      if (this.subtitle) {
        Text(this.subtitle)
          .fontSize(14)
          .fontColor('#666666')
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .width('100%')
          .margin({ top: 4 })
      }
      
      // 内容
      if (this.content) {
        Text(this.content)
          .fontSize(14)
          .fontColor('#333333')
          .maxLines(this.getContentMaxLines())
          .textOverflow({ overflow: TextOverflow.Ellipsis })
          .width('100%')
          .margin({ top: 8 })
      }
      
      // 标签
      if (this.tags.length > 0) {
        Row() {
          ForEach(this.tags.slice(0, this.getMaxTags()), (tag: string, index) => {
            Text(`#${tag}`)
              .fontSize(12)
              .fontColor('#007AFF')
              .backgroundColor('rgba(0, 122, 255, 0.1)')
              .padding({ left: 8, right: 8, top: 4, bottom: 4 })
              .borderRadius(12)
              .margin({ right: index < this.getMaxTags() - 1 ? 8 : 0 })
          })
        }
        .width('100%')
        .margin({ top: 12 })
      }
      
      // 底部文本
      if (this.footerText) {
        Text(this.footerText)
          .fontSize(12)
          .fontColor('#999999')
          .width('100%')
          .margin({ top: 12 })
      }
    }
    .width('100%')
    .alignItems(HorizontalAlign.Start)
    .padding(16)
  }
  
  // 根据断点获取标题字体大小
  getTitleFontSize(): number {
    switch (this.currentBreakpoint) {
      case 'sm': return 16
      case 'md': return 18
      case 'lg': return 20
      default: return 16
    }
  }
  
  // 根据断点获取内容最大行数
  getContentMaxLines(): number {
    switch (this.currentBreakpoint) {
      case 'sm': return 3
      case 'md': return 4
      case 'lg': return 5
      default: return 3
    }
  }
  
  // 根据断点获取最大标签数量
  getMaxTags(): number {
    switch (this.currentBreakpoint) {
      case 'sm': return 2
      case 'md': return 3
      case 'lg': return 5
      default: return 2
    }
  }
}

2.2 响应式网格容器组件

封装一个通用的响应式网格容器组件,简化网格布局的使用:

@Component
struct ResponsiveGridContainer {
  @StorageProp('currentBreakpoint') currentBreakpoint: string = 'md'
  @BuilderParam content: () => void
  @Prop columnsMap: Record<string, string> = { sm: '1fr', md: '1fr 1fr', lg: '1fr 1fr 1fr' }
  @Prop gapMap: Record<string, number> = { sm: 12, md: 16, lg: 20 }
  @Prop paddingMap: Record<string, number> = { sm: 12, md: 16, lg: 20 }
  
  build() {
    Grid() {
      this.content()
    }
    .width('100%')
    .columnsTemplate(this.getColumnsTemplate())
    .rowsGap(this.getGap())
    .columnsGap(this.getGap())
    .padding(this.getPadding())
  }
  
  // 根据断点获取列模板
  getColumnsTemplate(): string {
    return this.columnsMap[this.currentBreakpoint] || this.columnsMap.md || '1fr 1fr'
  }
  
  // 根据断点获取间距
  getGap(): number {
    return this.gapMap[this.currentBreakpoint] || this.gapMap.md || 16
  }
  
  // 根据断点获取内边距
  getPadding(): number {
    return this.paddingMap[this.currentBreakpoint] || this.paddingMap.md || 16
  }
}

使用示例:

ResponsiveGridContainer({
  columnsMap: { sm: '1fr', md: '1fr 1fr', lg: '1fr 1fr 1fr 1fr' },
  gapMap: { sm: 12, md: 16, lg: 20 },
  paddingMap: { sm: 12, md: 16, lg: 24 }
}) {
  ForEach(this.products, (product: Product) => {
    GridItem() {
      // 产品卡片内容
    }
  })
}

3. 高级性能优化

3.1 网格项复用策略

实现网格项复用,提高大量数据时的性能:

// 定义可复用的网格项ID生成器
function getItemId(item: NewsItem): string {
  return `news-${item.id}`
}

// 在Grid中使用
Grid() {
  LazyForEach(new NewsDataSource(this.getFilteredNews()), (item: NewsItem) => {
    GridItem() {
      // 网格项内容
    }
    .id(getItemId(item)) // 设置唯一ID用于复用
  })
}

3.2 图片加载优化

优化网格中的图片加载,提高用户体验:

@Component
struct OptimizedImage {
  @Prop src: string = ''
  @Prop width: string | number = '100%'
  @Prop height: string | number = '100%'
  @Prop borderRadius: number | object = 0
  @State isLoading: boolean = true
  @State loadFailed: boolean = false
  
  build() {
    Stack({ alignContent: Alignment.Center }) {
      // 加载中占位图
      if (this.isLoading && !this.loadFailed) {
        Column() {
          LoadingProgress()
            .width(24)
            .height(24)
            .color('#CCCCCC')
        }
        .width('100%')
        .height('100%')
        .backgroundColor('#F5F5F5')
        .borderRadius(this.borderRadius)
      }
      
      // 加载失败占位图
      if (this.loadFailed) {
        Column() {
          Image($r('app.media.image_error'))
            .width(32)
            .height(32)
            .fillColor('#CCCCCC')
          
          Text('加载失败')
            .fontSize(12)
            .fontColor('#999999')
            .margin({ top: 8 })
        }
        .width('100%')
        .height('100%')
        .backgroundColor('#F5F5F5')
        .borderRadius(this.borderRadius)
      }
      
      // 实际图片
      Image(this.src)
        .width('100%')
        .height('100%')
        .objectFit(ImageFit.Cover)
        .borderRadius(this.borderRadius)
        .opacity(this.isLoading || this.loadFailed ? 0 : 1)
        .onComplete((event: { width: number, height: number, componentWidth: number, componentHeight: number }) => {
          if (event.width > 0 && event.height > 0) {
            this.isLoading = false
          } else {
            this.loadFailed = true
            this.isLoading = false
          }
        })
        .onError(() => {
          this.loadFailed = true
          this.isLoading = false
        })
    }
    .width(this.width)
    .height(this.height)
  }
}

3.3 渲染优化

使用条件渲染和延迟加载优化网格渲染性能:

@Component
struct OptimizedGrid {
  @StorageProp('currentBreakpoint') currentBreakpoint: string = 'md'
  @State items: any[] = []
  @State visibleItems: any[] = []
  @State isInitialRender: boolean = true
  
  aboutToAppear() {
    // 初始只加载部分数据
    this.visibleItems = this.items.slice(0, this.getInitialLoadCount())
    
    // 延迟加载剩余数据
    setTimeout(() => {
      this.isInitialRender = false
      this.visibleItems = this.items
    }, 300)
  }
  
  build() {
    Grid() {
      ForEach(this.visibleItems, (item: any) => {
        GridItem() {
          // 网格项内容
        }
      })
    }
    .columnsTemplate(this.getColumnsTemplate())
    .rowsGap(16)
    .columnsGap(16)
    .width('100%')
  }
  
  // 根据断点获取初始加载数量
  getInitialLoadCount(): number {
    switch (this.currentBreakpoint) {
      case 'sm': return 4
      case 'md': return 6
      case 'lg': return 9
      default: return 4
    }
  }
  
  // 根据断点获取列模板
  getColumnsTemplate(): string {
    switch (this.currentBreakpoint) {
      case 'sm': return '1fr 1fr'
      case 'md': return '1fr 1fr 1fr'
      case 'lg': return '1fr 1fr 1fr 1fr'
      default: return '1fr 1fr'
    }
  }
}

4. 实际案例:新闻资讯应用

4.1 新闻资讯应用架构

基于ResponsiveGrid.ets实现的新闻资讯应用架构:

// 应用主结构
@Entry
@Component
struct NewsApp {
  @StorageProp('currentBreakpoint') currentBreakpoint: string = 'md'
  @State currentTab: string = 'home'
  
  build() {
    Column() {
      // 根据当前标签显示不同页面
      if (this.currentTab === 'home') {
        NewsHomePage()
      } else if (this.currentTab === 'video') {
        VideoPage()
      } else if (this.currentTab === 'live') {
        LivePage()
      } else if (this.currentTab === 'profile') {
        ProfilePage()
      }
      
      // 底部导航栏
      this.BottomNavigationBar()
    }
    .width('100%')
    .height('100%')
  }
  
  @Builder BottomNavigationBar() {
    Row() {
      // 新闻标签
      Column() {
        Image($r('app.media.03'))
          .width(24)
          .height(24)
          .fillColor(this.currentTab === 'home' ? '#007AFF' : '#8E8E93')
        
        Text('新闻')
          .fontSize(10)
          .fontColor(this.currentTab === 'home' ? '#007AFF' : '#8E8E93')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .onClick(() => this.currentTab = 'home')
      
      // 视频标签
      Column() {
        Image($r('app.media.04'))
          .width(24)
          .height(24)
          .fillColor(this.currentTab === 'video' ? '#007AFF' : '#8E8E93')
        
        Text('视频')
          .fontSize(10)
          .fontColor(this.currentTab === 'video' ? '#007AFF' : '#8E8E93')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .onClick(() => this.currentTab = 'video')
      
      // 直播标签
      Column() {
        Image($r('app.media.big30'))
          .width(24)
          .height(24)
          .fillColor(this.currentTab === 'live' ? '#007AFF' : '#8E8E93')
        
        Text('直播')
          .fontSize(10)
          .fontColor(this.currentTab === 'live' ? '#007AFF' : '#8E8E93')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .onClick(() => this.currentTab = 'live')
      
      // 我的标签
      Column() {
        Image($r('app.media.profile_icon'))
          .width(24)
          .height(24)
          .fillColor(this.currentTab === 'profile' ? '#007AFF' : '#8E8E93')
        
        Text('我的')
          .fontSize(10)
          .fontColor(this.currentTab === 'profile' ? '#007AFF' : '#8E8E93')
          .margin({ top: 2 })
      }
      .layoutWeight(1)
      .onClick(() => this.currentTab = 'profile')
    }
    .width('100%')
    .height(60)
    .backgroundColor('#FFFFFF')
    .borderColor('#E5E5EA')
    .borderWidth({ top: 1 })
  }
}

4.2 新闻详情页面

新闻详情页面的响应式布局实现:

@Component
struct NewsDetailPage {
  @StorageProp('currentBreakpoint') currentBreakpoint: string = 'md'
  @Prop newsId: number = 0
  @State newsDetail: NewsDetail = null
  @State relatedNews: NewsItem[] = []
  
  aboutToAppear() {
    // 模拟加载新闻详情和相关新闻
    this.loadNewsDetail()
    this.loadRelatedNews()
  }
  
  build() {
    if (this.newsDetail) {
      // 小屏和中屏使用垂直布局
      if (this.currentBreakpoint === 'sm' || this.currentBreakpoint === 'md') {
        Column() {
          // 顶部导航栏
          this.NavigationBar()
          
          // 新闻内容
          Scroll() {
            Column() {
              // 新闻标题和信息
              this.NewsHeader()
              
              // 新闻内容
              this.NewsContent()
              
              // 相关新闻
              this.RelatedNews()
            }
            .width('100%')
            .padding(16)
          }
          .width('100%')
          .layoutWeight(1)
        }
        .width('100%')
        .height('100%')
      } else {
        // 大屏使用水平布局
        Column() {
          // 顶部导航栏
          this.NavigationBar()
          
          // 内容区域
          Row() {
            // 新闻内容
            Scroll() {
              Column() {
                // 新闻标题和信息
                this.NewsHeader()
                
                // 新闻内容
                this.NewsContent()
              }
              .width('100%')
              .padding(24)
            }
            .width('70%')
            .height('100%')
            
            // 相关新闻
            Scroll() {
              Column() {
                this.RelatedNews()
              }
              .width('100%')
              .padding(16)
            }
            .width('30%')
            .height('100%')
            .backgroundColor('#F5F5F5')
          }
          .width('100%')
          .layoutWeight(1)
        }
        .width('100%')
        .height('100%')
      }
    } else {
      // 加载中状态
      Column() {
        LoadingProgress()
          .width(48)
          .height(48)
        
        Text('加载中...')
          .fontSize(16)
          .fontColor('#999999')
          .margin({ top: 16 })
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
    }
  }
  
  // 组件定义...
  @Builder NavigationBar() {
    // 导航栏实现
  }
  
  @Builder NewsHeader() {
    // 新闻标题和信息实现
  }
  
  @Builder NewsContent() {
    // 新闻内容实现
  }
  
  @Builder RelatedNews() {
    // 相关新闻实现
    Text('相关推荐')
      .fontSize(20)
      .fontWeight(FontWeight.Bold)
      .width('100%')
      .margin({ top: 24, bottom: 16 })
    
    Grid() {
      ForEach(this.relatedNews, (item: NewsItem) => {
        GridItem() {
          // 相关新闻卡片
        }
      })
    }
    .columnsTemplate(this.getRelatedNewsColumns())
    .rowsGap(16)
    .columnsGap(16)
    .width('100%')
  }
  
  // 根据断点获取相关新闻列数
  getRelatedNewsColumns(): string {
    switch (this.currentBreakpoint) {
      case 'sm': return '1fr'
      case 'md': return '1fr 1fr'
      case 'lg': return '1fr'
      default: return '1fr'
    }
  }
  
  // 加载新闻详情
  loadNewsDetail() {
    // 模拟网络请求
  }
  
  // 加载相关新闻
  loadRelatedNews() {
    // 模拟网络请求
  }
}

5. 最佳实践与注意事项

5.1 响应式设计原则

原则 描述
内容优先 确定哪些内容在不同屏幕尺寸下必须显示,哪些可以隐藏
流式布局 使用相对单位和弹性布局,避免固定尺寸
断点设计 合理设置断点,确保在断点切换时布局变化自然
渐进增强 从小屏幕开始设计,逐步增强大屏幕的体验
一致性 保持不同屏幕尺寸下的视觉和交互一致性

5.2 性能优化建议

  1. 懒加载与分页:对于大量数据,使用懒加载和分页技术
  2. 图片优化:根据屏幕尺寸加载不同分辨率的图片
  3. 条件渲染:只渲染当前可见的内容
  4. 复用组件:使用ID标识可复用的组件
  5. 减少状态变化:避免频繁的状态变化导致不必要的重渲染

5.3 调试技巧

  1. 使用预览器:利用HarmonyOS开发工具的预览器测试不同屏幕尺寸
  2. 断点日志:在断点变化时添加日志,帮助调试
  3. 边界测试:测试断点边界值附近的布局变化
  4. 设备旋转测试:测试设备旋转时的布局变化
  5. 真机测试:在实际设备上测试,确保体验一致

6. 总结

本文深入探讨了HarmonyOS NEXT响应式网格布局的高级应用,包括复杂布局案例、高级组件封装、性能优化策略以及实际项目中的最佳实践。通过掌握这些高级技巧,开发者可以构建出适应各种设备的高质量应用,实现真正的"一次开发,多端部署"。

收藏00

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