前言

A clean technical diagram illustrating the state m

这类页面我一般会拿来练业务感。它不是纯展示页,用户点一下之后,界面应该马上给反馈,代码组织也不能散。

这个案例的主线是 代码片段管理,后面的 收藏、语言分类、复制功能 则把页面做得更像真实业务页。读代码时,建议把交互、状态和列表这三条线一起看。

这个页面适合练什么

CodeSnippetManagerPage 对应的是 代码片段管理 - 收藏、语言分类、复制功能 这个业务场景。别把它只当成一个 UI 练习页,它真正有价值的地方在于:同一个页面里同时出现了状态切换、列表渲染、条件展示和用户反馈。

观察点 在这个案例里的表现
页面主题 代码片段管理
主要文案 ArkTS 防抖函数, 防止按钮短时间内重复点击的通用工具, 工具, 防抖, 性能, Flex 居中布局
常用组件 Column, Flex, ForEach, Image, Row, Scroll, Stack, Text, TextInput
适合练习 状态驱动 UI、局部刷新、事件回调、页面结构拆分

使用方法

把完整代码放进 ArkTS 页面文件后,就可以直接在预览器里运行。实际使用时,建议先手动点一遍页面里的主要交互,看状态有没有跟着变化;然后再去对照代码,理解每个 @State 字段到底控制了哪一块区域。

如果你想把这个案例改成自己的版本,我建议先做三件事:换掉模拟数据、调整筛选规则、再把重复结构抽成 Builder 或子组件。顺序别反,先改结构往往最容易把自己绕进去。

状态设计别忽略

ArkUI 页面写顺手之后,你会发现很多问题其实都不是样式问题,而是状态没放对位置。这个案例里能直接看到哪些数据是输入、哪些是选择、哪些是列表源,读起来会比较顺。

状态字段 类型 说明
snippets CodeSnippet[] 记录当前展示状态或页面选择
searchText string 用户输入值,会直接参与计算、筛选或提交
selectedLang string 当前选中项,决定内容切换、高亮或过滤结果
showFavoriteOnly boolean 布尔状态,控制弹层、模式或显示隐藏
expandedId number 记录当前展示状态或页面选择

我自己看这类示例时,会先把 @State 和事件函数圈出来,再去看布局。这样不会被大段 ColumnRow 搞乱节奏。

关键代码单独看

片段 1:getLangs(): string[] {

A hand-drawn flowchart in ink-notes style detailin

这段代码我建议单独看。它通常负责派生数据、交互动作或者局部渲染逻辑,把这部分抽出去之后,页面主结构会干净很多,后续要改规则也更省事。

  getLangs(): string[] {
    const langs = ['全部']
    this.snippets.forEach((s: CodeSnippet) => {
      if (!langs.includes(s.language)) langs.push(s.language)
    })
    return langs
  }
片段 2:getFiltered(): CodeSnippet[] {

这段代码我建议单独看。它通常负责派生数据、交互动作或者局部渲染逻辑,把这部分抽出去之后,页面主结构会干净很多,后续要改规则也更省事。

  getFiltered(): CodeSnippet[] {
    return this.snippets.filter((s: CodeSnippet) => {
      const matchSearch = s.title.includes(this.searchText) || s.description.includes(this.searchText)
      const matchLang = this.selectedLang === '全部' || s.language === this.selectedLang
      const matchFav = !this.showFavoriteOnly || s.isFavorite
      return matchSearch && matchLang && matchFav
    })
  }

An ink-sketch style infographic showing the deriva

必读小结

适合拿来练手的能力:页面拆分、状态联动、条件渲染、交互反馈。

推荐阅读顺序:先看前言 -> 再跑页面 -> 再看状态表 -> 最后啃完整代码
如果只想快速上手,优先改模拟数据和交互函数,收益最高
我会重点检查的几个地方
  • 点击或输入之后,界面有没有立即更新
  • 列表渲染是否依赖了稳定的数据结构
  • 筛选、统计、派生数据是不是单独放进函数里
  • 是否还有重复布局可以继续抽出去
  • 文案、颜色、间距是不是同一套风格

完整代码

// CodeSnippetManagerPage - 代码片段管理:收藏、语言分类、复制功能

interface CodeSnippet {
  id: number
  title: string
  description: string
  language: string
  code: string
  tags: string[]
  isFavorite: boolean
  createTime: string
  viewCount: number
}

@Entry
@Component
struct CodeSnippetManagerPage {
  @State snippets: CodeSnippet[] = [
    {
      id: 1, title: 'ArkTS 防抖函数', description: '防止按钮短时间内重复点击的通用工具',
      language: 'ArkTS',
      code: 'function debounce(fn: Function, delay: number) {\n  let timer: number = -1\n  return (...args: Object[]) => {\n    clearTimeout(timer)\n    timer = setTimeout(() => fn(...args), delay)\n  }\n}',
      tags: ['工具', '防抖', '性能'], isFavorite: true, createTime: '2024-01-15', viewCount: 128
    },
    {
      id: 2, title: 'Flex 居中布局', description: '水平垂直完全居中的 Flex 模板',
      language: 'ArkTS',
      code: 'Flex({\n  direction: FlexDirection.Row,\n  justifyContent: FlexAlign.Center,\n  alignItems: ItemAlign.Center\n}) {\n  Text("居中内容")\n}',
      tags: ['布局', 'Flex', 'UI'], isFavorite: false, createTime: '2024-02-03', viewCount: 89
    },
    {
      id: 3, title: 'LazyForEach 数据源', description: '大列表懒加载标准 IDataSource 实现',
      language: 'ArkTS',
      code: 'class BasicDataSource implements IDataSource {\n  private dataArray: string[] = []\n  totalCount(): number { return this.dataArray.length }\n  getData(index: number): string { return this.dataArray[index] }\n  registerDataChangeListener() {}\n  unregisterDataChangeListener() {}\n}',
      tags: ['性能', '列表', '懒加载'], isFavorite: true, createTime: '2024-02-20', viewCount: 210
    },
    {
      id: 4, title: 'animateTo 淡入', description: '通用组件淡入出现动画',
      language: 'ArkTS',
      code: 'animateTo(\n  { duration: 300, curve: Curve.EaseOut },\n  () => { this.opacity = 1 }\n)',
      tags: ['动画', 'UI', '效果'], isFavorite: false, createTime: '2024-03-01', viewCount: 67
    },
    {
      id: 5, title: 'HTTP 请求封装', description: '基于 @ohos.net.http 的 GET/POST 封装',
      language: 'ArkTS',
      code: 'import http from "@ohos.net.http"\nconst req = http.createHttp()\nreq.request(url, {\n  method: http.RequestMethod.GET,\n  header: { "Content-Type": "application/json" }\n}, (err, data) => {\n  if (!err) { console.log(data.result) }\n})',
      tags: ['网络', 'HTTP', '请求'], isFavorite: true, createTime: '2024-03-10', viewCount: 305
    },
    {
      id: 6, title: 'Stack 浮层徽章', description: '右上角红点/数字徽章标准写法',
      language: 'ArkTS',
      code: 'Stack({ alignContent: Alignment.TopEnd }) {\n  Image($r("app.media.icon")).width(40)\n  Text("3")\n    .fontSize(10).fontColor(Color.White)\n    .width(16).height(16)\n    .backgroundColor(Color.Red)\n    .borderRadius(8)\n    .textAlign(TextAlign.Center)\n}',
      tags: ['UI', '徽章', 'Stack'], isFavorite: false, createTime: '2024-03-18', viewCount: 156
    }
  ]
  @State searchText: string = ''
  @State selectedLang: string = '全部'
  @State showFavoriteOnly: boolean = false
  @State expandedId: number = -1

  getLangs(): string[] {
    const langs = ['全部']
    this.snippets.forEach((s: CodeSnippet) => {
      if (!langs.includes(s.language)) langs.push(s.language)
    })
    return langs
  }

  getFiltered(): CodeSnippet[] {
    return this.snippets.filter((s: CodeSnippet) => {
      const matchSearch = s.title.includes(this.searchText) || s.description.includes(this.searchText)
      const matchLang = this.selectedLang === '全部' || s.language === this.selectedLang
      const matchFav = !this.showFavoriteOnly || s.isFavorite
      return matchSearch && matchLang && matchFav
    })
  }

  toggleFavorite(id: number) {
    this.snippets = this.snippets.map((s) => { if (s.id === id) { s.isFavorite = !s.isFavorite } return s })
  }

  build() {
    Column() {
      // 顶部
      Row() {
        Text('代码片段').fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1A1A1A')
        Blank()
        Text(`${this.snippets.filter((s: CodeSnippet) => s.isFavorite).length} 收藏`)
          .fontSize(13).fontColor('#FAAD14')
      }.width('100%').padding({ left: 16, right: 16, top: 16, bottom: 8 })

      // 搜索框
      Row() {
        Text('🔍').fontSize(16).margin({ right: 8 })
        TextInput({ placeholder: '搜索标题、描述...', text: this.searchText })
          .layoutWeight(1).fontSize(14).backgroundColor('transparent')
          .onChange((val: string) => { this.searchText = val })
      }
      .width('100%')
      .padding({ left: 12, right: 12, top: 10, bottom: 10 })
      .backgroundColor(Color.White)
      .borderRadius(10)
      .margin({ left: 16, right: 16, bottom: 10 })

      // 语言筛选
      Row() {
        Scroll() {
          Row({ space: 8 }) {
            ForEach(this.getLangs(), (lang: string) => {
              Text(lang)
                .fontSize(13)
                .fontColor(this.selectedLang === lang ? Color.White : '#595959')
                .padding({ left: 12, right: 12, top: 6, bottom: 6 })
                .backgroundColor(this.selectedLang === lang ? '#1890FF' : '#F0F0F0')
                .borderRadius(16)
                .onClick(() => { this.selectedLang = lang })
            })
          }.padding({ left: 16, right: 16 })
        }.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off)
        .layoutWeight(1)

        Text(this.showFavoriteOnly ? '⭐' : '☆')
          .fontSize(20).margin({ left: 8, right: 16 })
          .onClick(() => { this.showFavoriteOnly = !this.showFavoriteOnly })
      }.width('100%').margin({ bottom: 10 })

      // 片段列表
      Scroll() {
        Column({ space: 10 }) {
          ForEach(this.getFiltered(), (snippet: CodeSnippet) => {
            Column() {
              Row() {
                Column() {
                  Row({ space: 6 }) {
                    Text(snippet.language)
                      .fontSize(11).fontColor('#1890FF')
                      .padding({ left: 8, right: 8, top: 2, bottom: 2 })
                      .backgroundColor('#E6F7FF').borderRadius(10)
                    Text(snippet.title).fontSize(15).fontWeight(FontWeight.Medium).fontColor('#1A1A1A')
                  }
                  Text(snippet.description).fontSize(12).fontColor('#8C8C8C').margin({ top: 4 })
                }.alignItems(HorizontalAlign.Start).layoutWeight(1)

                Text(snippet.isFavorite ? '⭐' : '☆').fontSize(20)
                  .onClick(() => this.toggleFavorite(snippet.id))
              }.width('100%')

              // 代码预览
              if (this.expandedId === snippet.id) {
                Text(snippet.code)
                  .fontSize(12).fontColor('#D4D4D4')
                  .fontFamily('monospace')
                  .backgroundColor('#1E1E1E')
                  .padding(12).borderRadius(8)
                  .width('100%').margin({ top: 10 })
                  .copyOption(CopyOptions.LocalDevice)
              }

              Row() {
                ForEach(snippet.tags, (tag: string) => {
                  Text(`#${tag}`)
                    .fontSize(11).fontColor('#8C8C8C')
                    .margin({ right: 8 })
                })
                Blank()
                Text(`${snippet.viewCount} 次查看`).fontSize(11).fontColor('#BFBFBF')
                Text(this.expandedId === snippet.id ? '收起' : '展开')
                  .fontSize(12).fontColor('#1890FF').margin({ left: 12 })
                  .onClick(() => {
                    this.expandedId = this.expandedId === snippet.id ? -1 : snippet.id
                  })
              }.width('100%').margin({ top: 10 })
            }
            .width('100%')
            .padding(14)
            .backgroundColor(Color.White)
            .borderRadius(12)
            .shadow({ radius: 4, color: '#0D000000', offsetX: 0, offsetY: 2 })
          })
        }.padding({ left: 16, right: 16, bottom: 24 })
      }.layoutWeight(1)
    }
    .width('100%').height('100%').backgroundColor('#F5F5F5')
  }
}

写在最后

这类案例最值得学的,其实不是某一个控件怎么写,而是页面组织方式。把状态放对、把交互函数收好、把布局压简单,后面你接正式项目时会轻松很多。

Logo

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

更多推荐