咱们来做一个鸿蒙待办清单 App —— 从零开始,手把手教你

说实话,待办清单(Todo List)这个东西,基本上是所有开发框架入门的"第一课"。你在学网页开发的时候写过,学 Android 的时候写过,现在轮到鸿蒙了,还是它。但别小看这个案例,虽然逻辑不复杂,但它能帮你搞明白 ArkTS 声明式 UI 里几个最核心的东西:@State 状态管理、组件嵌套、列表渲染、事件处理。

这篇文章就一步一步来,带你把这个待办清单做出来。放心,每一步我都会解释清楚,不会跳过任何代码。


先看看最终效果

做之前先有个概念,我们最终要实现的效果是这样的:

页面上方有个"待办"标题,下面是一个列表,列表里有几条待办事项,比如"ArkTS基础语法"、“ArkTS案例讲解"之类的。每条事项前面有一个图标,后面也有一个图标。当你点击某一条的时候,图标会变,文字会加删除线,整行背景颜色也会变,表示"这件事已经做完了”。再点一下,又恢复原样。

就是这么一个简单的交互,但里面用到了好几个重要的 ArkTS 知识点。


用到哪些组件?

先别急着写代码,我们先认识一下这次要用的几个基础组件:

  • Text 组件:就是显示文字的,跟 HTML 里的 span 差不多
  • Column 组件:垂直布局容器,里面放的东西会从上到下排列
  • Row 组件:水平布局容器,里面放的东西会从左到右排列
  • Image 组件:显示图片的,这里我们用它来显示图标
  • List / ListItem 组件:列表容器,跟 HTML 里的 ul/li 是一个意思
  • ForEach:循环渲染,用来把数组里的数据变成一组 UI 组件

这里面最关键的是 @State 装饰器——它是 ArkTS 状态管理的核心。被 @State 修饰的变量一旦发生变化,UI 会自动刷新。这个概念如果你之前接触过 Vue 的响应式数据,或者 React 的 useState,应该会觉得很熟悉。


开干!先搭主框架

打开 DevEco Studio,创建一个新项目(Empty Ability 就行),然后找到 pages/Index.ets 这个文件,把里面的内容全部删掉,我们从零开始写。

首先,写最外层的入口组件:

@Entry
@Component
struct Index {
  // 待办事项文本内容,用一个 string 数组来存
  private event: string[] =
    ['ArkTS基础语法', 'ArkTS案例讲解', 'ArkTS案例练习', 'ArkTS UI语法', 'ArkTS界面开发', 'ArkTS逻辑开发']

  build() {
    // 整个页面用一个 Column 来做垂直布局
    Column() {
      // 标题
      Text('待办')
        .font({ size: 30, weight: FontWeight.Bold })
        .width('90%').textAlign(TextAlign.Start)

      // 列表
      List({ space: 10 }) {
        // 用 ForEach 循环创建列表项
        ForEach(this.event, (item: string) => {
          ListItem() {
            // 每一项调用自定义的 EventText 组件
            EventText({ content: item })
          }
        })
      }
      .width('100%').height('100%')
      .alignListItem(ListItemAlign.Center)
    }
    .height('100%')
    .width('100%')
  }
}

来一行一行看:

@Entry 这个装饰器告诉系统,这是一个页面的入口组件。一个页面只有一个 @Entry,就像一个 HTML 文件只有一个 body。

@Component 表示这是一个自定义组件。在 ArkTS 里,你想封装一个可复用的 UI 组件,就加上这个装饰器。

private event: string[] = [...] 这里我们定义了一个私有变量,存了 6 条待办事项的文本。注意,这里没有用 @State,因为这个数组本身不会变化(我们不会动态添加或删除待办事项,只是切换已完成/未完成的状态)。真正会变化的状态,我们放在子组件里。

build() 是每个组件必须有的方法,用来描述这个组件长什么样。你把 UI 结构写在里面, ArkTS 的渲染引擎就会把它画到屏幕上。

Column() 是整个页面的外层容器。Column 会让里面的子组件从上到下排列,所以标题在上面,列表在下面。

Text('待办') 就是一个文本标签,显示"待办"两个字。后面的 .font() 设置字号和粗体,.width('90%') 让它占页面宽度的 90%,.textAlign(TextAlign.Start) 让文字左对齐。这样标题不会紧贴屏幕左边缘,留了一点呼吸空间。

List({ space: 10 }) 是列表容器,space: 10 表示每个列表项之间有 10vp 的间距。vp 是鸿蒙的虚拟像素单位,跟 dp 差不多意思。

ForEach(this.event, (item: string) => {...}) 这就是循环渲染。它会遍历 this.event 数组里的每一项,对每一项执行里面的回调函数,生成一个 ListItem。这跟 JavaScript 的 array.map() 是一个道理。

EventText({ content: item }) 这里我们调用了自定义的 EventText 组件,把当前循环的文本内容通过 content 参数传进去。这个组件我们接下来写。

.alignListItem(ListItemAlign.Center) 让每个列表项在列表中居中显示。

好,主框架就是这样,不复杂对吧?最核心的逻辑其实在那个子组件 EventText 里。


重点来了:写子组件 EventText

这个子组件负责渲染每一个待办事项,并且处理点击切换状态的所有逻辑。

@Component
struct EventText {
  // 状态变量,控制当前事项是否已完成
  @State radio: boolean = false
  // 从父组件传进来的文本内容
  private content?: string

  build() {
    Row() {
      // 左侧图标:根据状态显示不同的图片
      if (this.radio) {
        Image($r('app.media.ic_filled'))
          .width(25)
      } else {
        Image($r('app.media.ic_security'))
          .width(25)
      }

      // 中间文本:根据状态改变字号、删除线、透明度
      Text(this.content)
        .fontSize(this.radio ? 22 : 23)
        .decoration({
          type: this.radio ? TextDecorationType.LineThrough : TextDecorationType.None
        })
        .opacity(this.radio ? 0.5 : 1)

      // 右侧图标:同样根据状态切换
      Image($r(this.radio ? 'app.media.ic_filled' : 'app.media.ic_security'))
        .width(this.radio ? 24 : 25)
        .height(this.radio ? 24 : 25)
    }
    .onClick(() => {
      this.radio = !this.radio
    })
    .width('90%')
    .height(45)
    .padding(5)
    .borderRadius(10)
    .justifyContent(FlexAlign.SpaceBetween)
    .backgroundColor(this.radio ? '#dddddd' : '#eeeeee')
  }
}

这段代码值得好好说说,因为它包含了 ArkTS 里好几个关键概念。

@State 是灵魂

@State radio: boolean = false 这一行是整个案例的核心。

radio 是一个布尔值,初始是 false,表示这条待办事项还没完成。关键是 @State 这个装饰器——它告诉 ArkTS 框架:“这个变量是一个状态变量,你帮我盯着它,它一变,你就把用到它的 UI 全部重新渲染。”

所以当我们后面在 onClick 里写 this.radio = !this.radio 的时候,框架检测到 radio 变了,就会自动重新执行 build() 方法,把新的状态反映到界面上。图标换了、文字加了删除线、背景色变了——这些都是因为 @State 触发的自动刷新。

如果你去掉 @State,直接写 radio: boolean = false,那点击之后数据变了但界面不会更新,你就会看到"点了没反应"的 bug。

用 if 语句切换图标

if (this.radio) {
  Image($r('app.media.ic_filled'))
    .width(25)
} else {
  Image($r('app.media.ic_security'))
    .width(25)
}

这里用了 ArkTS 的条件渲染。当 radiotrue 时显示已完成的图标(ic_filled),为 false 时显示未完成的图标(ic_security)。

$r('app.media.ic_filled') 这种写法是 ArkTS 的资源引用方式。app.media 表示应用的 media 资源目录,ic_filled 是图标文件名。你需要把对应的图标文件放到项目的 resources/base/media/ 目录下。

这里提醒一下,图标文件你需要自己准备两个:

  • ic_filled.png(或 .svg)—— 已完成状态的图标,比如一个打勾的圆圈
  • ic_security.png(或 .svg)—— 未完成状态的图标,比如一个空心的圆圈

文本的三种状态变化

Text(this.content)
  .fontSize(this.radio ? 22 : 23)
  .decoration({
    type: this.radio ? TextDecorationType.LineThrough : TextDecorationType.None
  })
  .opacity(this.radio ? 0.5 : 1)

这段代码让文本在"已完成"和"未完成"两种状态下看起来不一样:

  • 字号:完成后从 23 变成 22,稍微小一点。这个变化很微妙,不是必须的,但给人一种"这个事项已经不那么重要了"的感觉。
  • 删除线:这是最明显的视觉变化。TextDecorationType.LineThrough 就是给文字加一条横线穿过中间,跟 CSS 的 text-decoration: line-through 一模一样。
  • 透明度:完成后变成 0.5,也就是半透明。这样跟旁边的未完成事项一对比,视觉上就能很清楚地看出哪些做完了,哪些还没做。

这三个效果组合在一起,用户体验就很好了——你一眼就能看出哪些事项已经处理过了。

右侧图标用了一个小技巧

Image($r(this.radio ? 'app.media.ic_filled' : 'app.media.ic_security'))
  .width(this.radio ? 24 : 25)
  .height(this.radio ? 24 : 25)

注意看这里跟左侧图标的写法不一样。左侧用了 if...else 来切换整个 Image 组件,右侧则是在同一个 Image 里动态切换资源路径。两种写法都能实现效果,这里主要是展示不同的写法。

右侧图标在完成状态下宽高都是 24,未完成时是 25。这个 1vp 的差别可能肉眼看不太出来,但它说明了一个事情:你可以用三元表达式根据状态来动态调整任何样式属性。

Row 的布局设置

Row() {
  // ... 左图标、文本、右图标
}
.onClick(() => {
  this.radio = !this.radio
})
.width('90%')
.height(45)
.padding(5)
.borderRadius(10)
.justifyContent(FlexAlign.SpaceBetween)
.backgroundColor(this.radio ? '#dddddd' : '#eeeeee')

Row() 是一个水平布局容器,里面的三个元素(左图标、文本、右图标)会从左到右排列。

.onClick() 绑定了点击事件。点击整行任何位置都会触发,把 radio 取反——原来是 false 就变成 true,原来是 true 就变成 false。这就是切换的核心逻辑,非常简洁。

.width('90%') 让每一行占页面 90% 的宽度,两边留白。

.height(45) 固定每行高度为 45vp,这样所有行的高度一致,看起来整齐。

.padding(5) 内边距 5vp,让内容不会紧贴行边缘。

.borderRadius(10) 圆角 10vp,让行看起来不那么死板,有点卡片的感觉。

.justifyContent(FlexAlign.SpaceBetween) 这个很关键。它让 Row 里的三个子元素(左图标、文本、右图标)分别贴到行的最左边、中间、最右边。如果没有这个设置,三个元素会挤在一起。

.backgroundColor(this.radio ? '#dddddd' : '#eeeeee') 背景色也根据状态变化。未完成时是 #eeeeee(浅灰),完成时是 #dddddd(稍深一点的灰)。这个颜色差异虽然不大,但跟文本的透明度变化配合起来,完成和未完成的区别就很明显了。


完整代码

把上面的两个组件拼在一起,完整的 Index.ets 长这样:

@Entry
@Component
struct Index {
  // 待办事项文本内容 string 数组
  private event: string[] =
    ['ArkTS基础语法', 'ArkTS案例讲解', 'ArkTS案例练习', 'ArkTS UI语法', 'ArkTS界面开发', 'ArkTS逻辑开发']

  build() {
    // 乘法表的布局展示
    Column() {
      // 标题
      Text('待办')
        .font({ size: 30, weight: FontWeight.Bold })
        .width('90%').textAlign(TextAlign.Start)

      // 列表
      List({ space: 10 }) {
        // 利用循环创建列表项
        ForEach(this.event, (item: string) => {
          ListItem() {
            // 调用 string 数组为列表项填充文本内容
            EventText({ content: item })
          }
        })
      }
      .width('100%').height('100%')
      .alignListItem(ListItemAlign.Center)
    }
    .height('100%')
    .width('100%')
  }
}

@Component
struct EventText {
  // 状态变量,改变显式状态
  @State radio: boolean = false
  private content?: string

  build() {
    Row() {
      // 用 if 语句配合状态变量实现点击切换图标
      if (this.radio) {
        Image($r('app.media.ic_filled'))
          .width(25)
      } else {
        Image($r('app.media.ic_security'))
          .width(25)
      }

      // 用变量填充文本内容,用状态变量设置点击事件中各属性的变化,从而更新视图状态
      Text(this.content)
        .fontSize(this.radio ? 22 : 23)
        .decoration({
          type: this.radio ? TextDecorationType.LineThrough : TextDecorationType.None
        })
        .opacity(this.radio ? 0.5 : 1)

      Image($r(this.radio ? 'app.media.ic_filled' : 'app.media.ic_security'))
        .width(this.radio ? 24 : 25)
        .height(this.radio ? 24 : 25)
    }
    .onClick(() => {
      this.radio = !this.radio
    })
    .width('90%')
    .height(45)
    .padding(5)
    .borderRadius(10)
    .justifyContent(FlexAlign.SpaceBetween)
    .backgroundColor(this.radio ? '#dddddd' : '#eeeeee')
  }
}

几个你可能踩的坑

1. 图标文件找不到

如果你运行后看到图标位置是空白的或者报错,大概率是 ic_filledic_security 这两个图标文件没有放到正确的位置。它们应该放在 resources/base/media/ 目录下。图标格式可以是 png、jpg、svg,都可以。

2. @State 忘了加

如果你发现点击之后界面完全没反应,但逻辑上 radio 确实变了,那检查一下是不是 @State 装饰器漏了。没有 @State,ArkTS 不知道要监听这个变量的变化,UI 就不会刷新。

3.ForEach 的 key 问题

如果你以后在 ForEach 里遇到了列表渲染异常(比如删除一项后显示错乱),那是因为 ForEach 需要一个唯一的 key 来追踪每个列表项。我们这个案例因为数组是固定的不会变化,所以没加 key 也能正常运行。但在实际项目中,建议给 ForEach 加上第三个参数作为 key:

ForEach(this.event, (item: string) => {
  ListItem() {
    EventText({ content: item })
  }
}, (item: string) => item)

第三个参数是一个 key 生成函数,返回一个唯一值来标识每一项。

4. 私有变量 vs 状态变量

Index 组件里的 event 数组用的是 private,不是 @State。因为我们的待办事项列表是固定的,不会动态增删。如果你以后想实现"添加新待办"的功能,那就要把 event 改成 @State 修饰的数组,这样往数组里 push 新元素的时候 UI 才会更新。


总结一下这个案例里学到的东西

概念 用在哪 说明了什么
@Entry Index 组件 标记页面入口
@Component Index、EventText 定义自定义 UI 组件
@State EventText.radio 状态变化自动刷新 UI
Column / Row 布局 垂直和水平排列子组件
List / ListItem 列表 渲染列表结构
ForEach 循环渲染 把数组数据变成 UI 组件
Image 图标 显示本地图片资源
.onClick() 事件 处理用户点击交互
if...else 条件渲染 根据状态显示不同 UI
.decoration() 文本样式 添加删除线效果

一个小小的待办清单,其实把 ArkTS 声明式 UI 的核心用法都过了一遍。如果你能把这段代码完全搞明白,后面学更复杂的案例就不会那么吃力了。

Logo

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

更多推荐