前言

这个案例看着是一个小页面,里面其实塞了不少常见业务写法:列表、筛选、按钮事件、条件样式,基本都能练到。
它的主线是 图片九宫格切割,细节落在 正方形裁剪预览与发布效果。这些细节刚好能把页面状态、用户操作和组件刷新串起来。

A hand-drawn sketch-notes infographic illustrating

先把业务看明白

NineGridImageSplitPage 不是一个只展示静态内容的页面。它会根据用户点击、输入、切换或计时来改变界面,所以阅读代码时可以先抓三条线:

观察点 在这个案例里的表现
页面主题 图片九宫格切割
主要文案 原图, 清新, 复古, 黑白, 暖调, 冷调
常用组件 Button, Column, Divider, ForEach, Grid, GridItem, Row, Scroll, Slider, Stack, Text
适合练习 状态驱动 UI、条件样式、列表渲染、事件回调

状态和 UI 的关系

ArkUI 的页面刷新依赖状态变化。下面这些 @State 字段就是页面的“开关”和“数据源”,不用手动找 DOM,也不用自己通知某个控件刷新。

状态字段 类型 我会怎么理解它
selectedCount number 当前选中项,决定高亮和内容切换
filterIndex number 当前选中项,决定高亮和内容切换
previewMode boolean 记录页面当前选择、输入或展示状态
spacing number 记录页面当前选择、输入或展示状态

一个经验:先把 @State 看完,再看 build(),页面逻辑会清楚很多。否则很容易被一长串布局代码带偏。

核心逻辑拆读

片段 1:get cols(): number {

这段代码承担的是页面里的“判断”或“动作”,把它从布局里抽出来后,build() 会清爽很多。后面要加新条件,也不会到处翻 UI 代码。

A sketch-notes style comparison chart showing the

  get cols(): number {
    if (this.selectedCount <= 1) return 1
    if (this.selectedCount <= 4) return 2
    return 3
  }
片段 2:get currentFilter(): FilterPreset_wc1v {

这段代码承担的是页面里的“判断”或“动作”,把它从布局里抽出来后,build() 会清爽很多。后面要加新条件,也不会到处翻 UI 代码。

  get currentFilter(): FilterPreset_wc1v {
    return this.filterPresets[this.filterIndex]
  }

使用方式

把下面代码放进 ArkTS 页面文件中即可运行。用于正式项目时,我会继续把数据模型、卡片 Builder 和页面事件拆出去,页面文件只负责组合。

A hand-drawn flowchart detailing the initializatio

建议练习顺序:先改状态字段 -> 再改交互事件 -> 最后调整 UI 样式
检查重点:点击是否刷新、列表 key 是否稳定、条件样式是否集中管理
这份代码可以继续扩展的方向
  • 把模拟数据替换成接口数据
  • 抽出复用卡片组件
  • 增加空状态和异常状态
  • 补充深色模式或主题色适配
  • 对输入类场景增加校验提示

完整代码

// NineGridImageSplitPage - 图片九宫格切割 - 正方形裁剪预览与发布效果

interface GridCell {
  row: number
  col: number
  bgX: number
  bgY: number
}

interface FilterPreset_wc1v {
  name: string
  brightness: number
  contrast: number
  saturate: number
  grayscale: number
}

@Entry
@Component
struct NineGridImageSplitPage {
  @State selectedCount: number = 9
  @State filterIndex: number = 0
  @State previewMode: boolean = false
  @State spacing: number = 4

  private filterPresets: FilterPreset_wc1v[] = [
    { name: '原图', brightness: 1.0, contrast: 1.0, saturate: 1.0, grayscale: 0 },
    { name: '清新', brightness: 1.1, contrast: 0.9, saturate: 1.3, grayscale: 0 },
    { name: '复古', brightness: 0.9, contrast: 1.1, saturate: 0.7, grayscale: 0.2 },
    { name: '黑白', brightness: 1.0, contrast: 1.2, saturate: 0, grayscale: 1.0 },
    { name: '暖调', brightness: 1.05, contrast: 1.0, saturate: 1.2, grayscale: 0 },
    { name: '冷调', brightness: 0.95, contrast: 1.1, saturate: 0.9, grayscale: 0 },
  ]

  private colorPalette: string[] = [
    '#FF6B9D', '#C44569', '#F8B500', '#6C5CE7',
    '#00B894', '#0984E3', '#E17055', '#74B9FF',
    '#A29BFE',
  ]

  get cols(): number {
    if (this.selectedCount <= 1) return 1
    if (this.selectedCount <= 4) return 2
    return 3
  }

  get currentFilter(): FilterPreset_wc1v {
    return this.filterPresets[this.filterIndex]
  }

  getCells(): GridCell[] {
    const cells: GridCell[] = []
    for (let r = 0; r < this.cols; r++) {
      for (let c = 0; c < this.cols; c++) {
        if (cells.length < this.selectedCount) {
          cells.push({ row: r, col: c, bgX: c, bgY: r })
        }
      }
    }
    return cells
  }

  build() {
    Column({ space: 0 }) {
      // 顶部导航
      Row() {
        Text('✕').fontSize(20).fontColor('#1a1a1a')
        Blank()
        Text('九宫格切割')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1a1a1a')
        Blank()
        Text('发布')
          .fontSize(16)
          .fontColor('#1890ff')
          .fontWeight(FontWeight.Bold)
      }
      .width('100%')
      .padding({ left: 16, right: 16, top: 14, bottom: 14 })
      .backgroundColor('#ffffff')

      Scroll() {
        Column({ space: 20 }) {
          // 源图预览(用颜色渐变模拟)
          Column() {
            Text('源图预览')
              .fontSize(13)
              .fontColor('#999999')
              .padding({ bottom: 10 })
              .width('100%')

            Stack() {
              Column()
                .width('100%')
                .height(280)
                .linearGradient({
                  angle: 135,
                  colors: [['#FF6B9D', 0], ['#C44569', 0.3], ['#F8B500', 0.6], ['#6C5CE7', 1.0]]
                })
                .borderRadius(12)
                .brightness(this.currentFilter.brightness)
                .contrast(this.currentFilter.contrast)
                .saturate(this.currentFilter.saturate)
                .grayscale(this.currentFilter.grayscale)

              // 九宫格切割线
              if (this.selectedCount === 9) {
                Column() {
                  Divider().strokeWidth(2).color('#ffffff80').margin({ top: 93 })
                  Divider().strokeWidth(2).color('#ffffff80').margin({ top: 93 })
                }
                .width('100%')
                .height(280)
                .position({ x: 0, y: 0 })

                Row() {
                  Divider().width(2).height(280).backgroundColor('#ffffff80').margin({ left: '33%' })
                  Divider().width(2).height(280).backgroundColor('#ffffff80').margin({ left: '33%' })
                }
                .width('100%')
                .height(280)
                .position({ x: 0, y: 0 })
              }
            }
            .width('100%')
          }
          .padding({ left: 16, right: 16 })

          // 滤镜选择
          Column({ space: 10 }) {
            Text('滤镜效果')
              .fontSize(14)
              .fontWeight(FontWeight.Medium)
              .fontColor('#1a1a1a')
              .padding({ left: 16 })
              .width('100%')

            Scroll() {
              Row({ space: 12 }) {
                ForEach(this.filterPresets, (f: FilterPreset_wc1v, idx: number) => {
                  Column({ space: 6 }) {
                    Column()
                      .width(60)
                      .height(60)
                      .linearGradient({
                        angle: 135,
                        colors: [['#FF6B9D', 0], ['#6C5CE7', 1.0]]
                      })
                      .borderRadius(8)
                      .brightness(f.brightness)
                      .contrast(f.contrast)
                      .saturate(f.saturate)
                      .grayscale(f.grayscale)
                      .border({ width: 2, color: this.filterIndex === idx ? '#1890ff' : 'transparent' })

                    Text(f.name)
                      .fontSize(11)
                      .fontColor(this.filterIndex === idx ? '#1890ff' : '#666666')
                  }
                  .alignItems(HorizontalAlign.Center)
                  .onClick(() => { this.filterIndex = idx })
                })
              }
              .padding({ left: 16, right: 16 })
            }
            .scrollable(ScrollDirection.Horizontal)
            .scrollBar(BarState.Off)
          }

          // 切割数量选择
          Column({ space: 12 }) {
            Text('切割数量')
              .fontSize(14)
              .fontWeight(FontWeight.Medium)
              .fontColor('#1a1a1a')
              .padding({ left: 16 })
              .width('100%')

            Row({ space: 12 }) {
              ForEach([1, 2, 3, 4, 6, 9], (n: number) => {
                Column({ space: 4 }) {
                  // 预览小网格
                  Column() {
                    Grid() {
                      ForEach(Array.from({ length: n }), (_: Object, i: number) => {
                        GridItem() {
                          Column()
                            .width('100%')
                            .height('100%')
                            .backgroundColor(this.colorPalette[i % this.colorPalette.length])
                        }
                      })
                    }
                    .columnsTemplate(n <= 1 ? '1fr' : n <= 4 ? '1fr 1fr' : '1fr 1fr 1fr')
                    .rowsGap(2)
                    .columnsGap(2)
                    .width(36)
                    .height(36)
                  }
                  .width(44)
                  .height(44)
                  .backgroundColor(this.selectedCount === n ? '#e8f4ff' : '#f5f5f5')
                  .borderRadius(8)
                  .border({ width: 2, color: this.selectedCount === n ? '#1890ff' : 'transparent' })
                  .justifyContent(FlexAlign.Center)
                  .alignItems(HorizontalAlign.Center)

                  Text(`${n}`)
                    .fontSize(11)
                    .fontColor(this.selectedCount === n ? '#1890ff' : '#666666')
                }
                .alignItems(HorizontalAlign.Center)
                .onClick(() => { this.selectedCount = n })
              })
            }
            .justifyContent(FlexAlign.SpaceEvenly)
            .width('100%')
          }

          // 间距设置
          Column({ space: 8 }) {
            Row() {
              Text('切割间距')
                .fontSize(14)
                .fontWeight(FontWeight.Medium)
                .fontColor('#1a1a1a')
              Blank()
              Text(`${this.spacing}px`)
                .fontSize(13)
                .fontColor('#1890ff')
            }
            .padding({ left: 16, right: 16 })
            .width('100%')

            Slider({ value: this.spacing, min: 0, max: 20, step: 1 })
              .width('90%')
              .trackColor('#e8e8e8')
              .selectedColor('#1890ff')
              .onChange((v: number) => { this.spacing = Math.round(v) })
          }

          // 切割预览
          Column({ space: 12 }) {
            Text('切割预览')
              .fontSize(14)
              .fontWeight(FontWeight.Medium)
              .fontColor('#1a1a1a')
              .padding({ left: 16 })
              .width('100%')

            Grid() {
              ForEach(this.getCells(), (cell: GridCell, idx: number) => {
                GridItem() {
                  Column()
                    .width('100%')
                    .height('100%')
                    .linearGradient({
                      angle: 135,
                      colors: [['#FF6B9D', 0], ['#C44569', 0.3], ['#F8B500', 0.6], ['#6C5CE7', 1.0]]
                    })
                    .brightness(this.currentFilter.brightness)
                    .contrast(this.currentFilter.contrast)
                    .saturate(this.currentFilter.saturate)
                    .grayscale(this.currentFilter.grayscale)
                }
              })
            }
            .columnsTemplate(this.cols === 1 ? '1fr' : this.cols === 2 ? '1fr 1fr' : '1fr 1fr 1fr')
            .rowsGap(this.spacing)
            .columnsGap(this.spacing)
            .width('100%')
            .height(this.cols === 1 ? 300 : this.cols === 2 ? 200 : 300)
            .padding({ left: 16, right: 16 })
          }

          // 发布按钮
          Button('保存并发布')
            .width('90%')
            .height(50)
            .fontSize(16)
            .backgroundColor('#1890ff')
            .borderRadius(25)
            .fontWeight(FontWeight.Medium)
            .margin({ top: 8 })

          Column().height(32)
        }
        .padding({ top: 16 })
      }
      .layoutWeight(1)
      .backgroundColor('#f5f7fa')
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f5f7fa')
  }
}

一个小复盘

这个案例可以当成一个小模板:先把页面会变的东西放进状态,再用函数或 Builder 处理重复逻辑,最后让布局只关心展示。写 HarmonyOS7 页面时,这个顺序通常比一上来堆 UI 更稳。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐