欢迎加入开源鸿蒙 PC社区

https://harmonypc.csdn.net/

效果截图

在这里插入图片描述


第8篇:单词切换与底部导航

系列教程导航

篇号 标题 状态
01 环境搭建与项目创建 ✅ 已完成
02 数据模型与单词仓库 ✅ 已完成
03 主入口页面与导航结构 ✅ 已完成
04 极速划词页面实现 ✅ 已完成
05 手写画布实现 ✅ 已完成
06 百度OCR手写识别接入 ✅ 已完成
07 答案比对与反馈UI ✅ 已完成
08 单词切换与底部导航 📖 本篇
09 词根分解与水印展示 ⏳ 下一篇
10 项目总结与优化方向

源码仓库https://gitcode.com/qq_33247427/englishProject


一、单词状态管理

1.1 核心状态变量

默写功能需要管理以下状态:当前是第几个单词、当前单词的详细信息、以及完整的单词列表。状态管理是 ArkUI 声明式框架的核心概念,正确区分哪些数据需要响应式、哪些不需要,直接影响应用的性能和代码质量。在鸿蒙开发中,过度使用 @State 会导致不必要的界面重绘,而遗漏必要的 @State 则会导致界面不更新。

@Entry
@Component
struct DictationContent {
  /** 当前单词在列表中的索引(从 0 开始) */
  @State currentIndex: number = 0;

  /** 当前正在默写的单词对象 */
  @State currentWord: VocabularyWord | null = null;

  /** 当前日期分组下的所有单词 */
  private dictWords: VocabularyWord[] = [];

  /** 数据仓库实例 */
  private repository: SpeedWordRepository = new SpeedWordRepository();

  /** 当前选中的日期 */
  private selectedDate: string = '3/12';
}
1.2 状态设计说明
变量 装饰器 说明
currentIndex @State 需要响应式,因为进度显示依赖它
currentWord @State 需要响应式,因为音标、词义、水印都依赖它
dictWords private 不需要响应式,列表加载后不会变化
repository private 不需要响应式,仓库实例是固定的
selectedDate private 不需要响应式,当前页面不切换日期

为什么 dictWords 不用 @State

dictWordsaboutToAppear 中加载一次后就不再变化。我们通过 currentIndex 来控制当前显示哪个单词,而不是修改 dictWords 数组本身。这样避免了不必要的 UI 刷新。

1.3 aboutToAppear 初始化
aboutToAppear(): void {
  // 从仓库加载当前日期的单词列表
  this.dictWords = this.repository.getWordsByDate(this.selectedDate);

  // 设置第一个单词为当前单词
  if (this.dictWords.length > 0) {
    this.currentIndex = 0;
    this.currentWord = this.dictWords[0];
  }
}

aboutToAppear 是 ArkUI 组件的生命周期方法,在组件创建后、首次渲染前调用。适合做数据初始化。


二、prevWord / nextWord 切换方法

2.1 实现代码

单词切换是默写功能的核心交互之一。用户完成一个单词的默写后,需要快速切换到下一个单词继续练习。切换逻辑看似简单,但需要注意边界条件的处理——当用户已经在第一个或最后一个单词时,对应的切换操作应该被禁止,避免数组越界错误。同时,每次切换都需要重置画布和反馈状态,给用户一个"全新开始"的感觉。

/**
 * 切换到上一个单词
 * 边界检查:已经是第一个时不执行
 */
private prevWord(): void {
  if (this.currentIndex <= 0) {
    return;  // 已经是第一个,不能再往前
  }

  this.currentIndex--;
  this.currentWord = this.dictWords[this.currentIndex];
  this.clearCanvas();  // 切换时清空画布和反馈状态
}

/**
 * 切换到下一个单词
 * 边界检查:已经是最后一个时不执行
 */
private nextWord(): void {
  if (this.currentIndex >= this.dictWords.length - 1) {
    return;  // 已经是最后一个,不能再往后
  }

  this.currentIndex++;
  this.currentWord = this.dictWords[this.currentIndex];
  this.clearCanvas();  // 切换时清空画布和反馈状态
}
2.2 边界检查逻辑
场景 currentIndex 条件 结果
第一个单词,点击"上一个" 0 <= 0 不执行
中间单词,点击"上一个" 3 > 0 切换到 index 2
最后一个单词,点击"下一个" 19 (共20个) >= length - 1 不执行
中间单词,点击"下一个" 3 < length - 1 切换到 index 4
2.3 为什么切换时要 clearCanvas?

切换单词后,画布上还残留着上一个单词的笔迹,反馈浮层还显示着上一次的识别结果。如果不清空,用户会困惑"这是哪个单词的反馈"。

clearCanvas() 会重置:

  • Canvas 画布内容(清除笔迹)
  • feedbackText(清除反馈文本)
  • showAnswer(隐藏正确答案)
  • recognizedText(清除识别结果)
  • strokes 数组(清除笔画数据)

三、底部导航按钮

3.1 设计规范

底部导航按钮遵循以下设计规范:

属性 正常状态 禁用状态
背景色 #FFFFFF(白色) #FFFFFF(白色)
边框色 #E5E7EB(浅灰) #F3F4F6(极浅灰)
文字色 #374151(深灰) #D1D5DB(浅灰)
边框宽度 1px 1px
圆角 8px 8px
高度 40px 40px
可点击
3.2 完整实现
// 底部导航区域
Row({ space: 12 }) {
  // Blank 占位,将按钮推到右侧
  Blank()

  // 上一个按钮
  Button('〈 上一个')
    .fontSize(14)
    .fontColor(this.currentIndex > 0 ? '#374151' : '#D1D5DB')
    .backgroundColor('#FFFFFF')
    .borderRadius(8)
    .height(40)
    .padding({ left: 20, right: 20 })
    .type(ButtonType.Normal)
    .border({
      width: 1,
      color: this.currentIndex > 0 ? '#E5E7EB' : '#F3F4F6'
    })
    .enabled(this.currentIndex > 0)
    .onClick(() => {
      this.prevWord();
    })

  // 下一个按钮
  Button('下一个 〉')
    .fontSize(14)
    .fontColor(this.currentIndex < this.dictWords.length - 1 ? '#374151' : '#D1D5DB')
    .backgroundColor('#FFFFFF')
    .borderRadius(8)
    .height(40)
    .padding({ left: 20, right: 20 })
    .type(ButtonType.Normal)
    .border({
      width: 1,
      color: this.currentIndex < this.dictWords.length - 1 ? '#E5E7EB' : '#F3F4F6'
    })
    .enabled(this.currentIndex < this.dictWords.length - 1)
    .onClick(() => {
      this.nextWord();
    })
}
.width('100%')
.padding({ top: 10, bottom: 12 })
3.3 设计要点解析

为什么用 Blank() 实现右对齐?

Row 布局中,Blank() 会占据所有剩余空间,将后面的元素推到右侧。这比使用 justifyContent(FlexAlign.End) 更灵活,因为如果将来要在左侧添加其他元素(比如"返回"按钮),只需在 Blank() 前面插入即可。

┌──────────────────────────────────────────────┐
│ [Blank 占满剩余空间]  [〈 上一个] [下一个 〉]  │
└──────────────────────────────────────────────┘

为什么没有"取消"按钮?

默写场景下,用户的操作流程是线性的:写 → 识别 → 看结果 → 下一个。不需要"取消"操作。如果用户想退出,可以通过系统返回手势或页面顶部的返回按钮。

为什么禁用时边框也变浅?

如果只改文字颜色不改边框,禁用按钮看起来像是"可以点击但文字很浅",容易误导用户。边框同步变浅后,整个按钮的视觉权重降低,用户能直观感知"这个按钮当前不可用"。

3.4 ButtonType.Normal 的作用

ArkUI 的 Button 默认是胶囊形状(ButtonType.Capsule),两端是半圆。设置 ButtonType.Normal 后变为普通矩形,配合 borderRadius(8) 实现圆角矩形效果。

// 默认胶囊形状(两端半圆)
Button('文字')  // ButtonType.Capsule

// 普通矩形 + 自定义圆角
Button('文字')
  .type(ButtonType.Normal)
  .borderRadius(8)

四、进度显示

4.1 标题栏布局

标题栏包含两个元素:左侧的功能标题和右侧的进度文本。

// 标题栏
Row() {
  // 功能标题
  Text('默写英文')
    .fontSize(18)
    .fontWeight(FontWeight.Bold)
    .fontColor('#1A1A1A')

  // 弹性空间
  Blank()

  // 进度显示
  Text(`${this.currentIndex + 1} / ${this.dictWords.length}`)
    .fontSize(14)
    .fontColor('#6B7280')
}
.width('100%')
.margin({ top: 16 })
.alignItems(VerticalAlign.Center)
4.2 进度文本格式
// currentIndex 从 0 开始,显示时 +1 转为人类可读的序号
`${this.currentIndex + 1} / ${this.dictWords.length}`

// 示例输出:
// "1 / 20"  — 第一个单词
// "5 / 20"  — 第五个单词
// "20 / 20" — 最后一个单词
4.3 视觉效果
┌──────────────────────────────────────┐
│ 默写英文                    1 / 20   │
│ (粗体 18px 黑色)      (14px 灰色)    │
└──────────────────────────────────────┘

标题用粗体大字表明当前功能,进度用小字灰色作为辅助信息,不抢视觉焦点。


五、音标词义提示区

5.1 设计目的

默写时,用户需要知道"要写哪个单词"。我们通过显示音标和中文释义来提示,而不直接显示英文单词(否则就不是"默写"了)。

5.2 实现代码
// 音标词义提示区
Column({ space: 6 }) {
  // 音标(灰色,辅助信息)
  Text(this.currentWord !== null ? this.currentWord.phonetic : '')
    .fontSize(16)
    .fontColor('#9CA3AF')
    .alignSelf(ItemAlign.Start)

  // 中文释义(深色,主要提示)
  Text(this.currentWord !== null ? this.currentWord.meaning : '')
    .fontSize(16)
    .fontColor('#374151')
    .fontWeight(FontWeight.Medium)
    .alignSelf(ItemAlign.Center)
}
.width('100%')
.padding({ top: 16, bottom: 12 })
5.3 为什么不用可选链 ?.

在 ArkTS 的 UI 描述(build() 方法内)中,不支持可选链操作符 ?.。必须用 if 判断或三元表达式:

// ❌ 错误:ArkTS UI 描述中不支持可选链
Text(this.currentWord?.phonetic ?? '')

// ✅ 正确:使用三元表达式
Text(this.currentWord !== null ? this.currentWord.phonetic : '')

// ✅ 也正确:使用 if 条件渲染
if (this.currentWord !== null) {
  Text(this.currentWord.phonetic)
    .fontSize(16)
    .fontColor('#9CA3AF')
}
5.4 颜色层次
元素 颜色 字重 作用
音标 #9CA3AF(浅灰) Regular 辅助参考
释义 #374151(深灰) Medium 主要提示

释义是用户判断"要写哪个单词"的核心信息,所以用更深的颜色和更粗的字重突出显示。音标是辅助信息,用浅色弱化。


六、页面间距统一

6.1 间距策略

整个默写页面使用统一的左右间距 30px,通过父容器的 padding 实现:

Column({ space: 0 }) {
  // 标题栏
  // 音标词义提示区
  // 手写画布(Stack)
  // 底部导航
}
.layoutWeight(1)
.height('100%')
.backgroundColor('#FAFAF7')
.padding({ left: 30, right: 30 })  // 统一左右间距
6.2 为什么用父容器 padding 而不是子元素 margin?
方案 优点 缺点
父容器 padding 一处设置,所有子元素自动对齐 个别子元素需要全宽时要特殊处理
子元素 margin 每个元素可以独立控制 容易遗漏,间距不一致

在我们的场景中,所有子元素都需要相同的左右间距,用父容器 padding 是最简洁的方案。

6.3 标题栏的额外 margin

标题栏需要和顶部有一定距离,使用 margin({ top: 16 }) 实现:

Row() {
  Text('默写英文')
    .fontSize(18)
    .fontWeight(FontWeight.Bold)
  Blank()
  Text(`${this.currentIndex + 1} / ${this.dictWords.length}`)
    .fontSize(14)
    .fontColor('#6B7280')
}
.width('100%')
.margin({ top: 16 })  // 距离顶部 16px
.alignItems(VerticalAlign.Center)
6.4 间距数值规范

本项目使用 4px 为基础单位的间距系统:

间距值 用途
4px 紧凑间距(同一组内元素)
6px 小间距(标签与内容)
8px 常规间距(相关元素之间)
12px 中等间距(区块内部)
16px 大间距(区块之间)
30px 页面边距(左右 padding)

七、DictationContent 完整布局结构

7.1 整体结构图
Column (padding: left 30, right 30)
│
├── Row (标题栏)
│   ├── Text "默写英文" (fontSize 18, Bold)
│   ├── Blank()
│   └── Text "1 / 20" (fontSize 14, gray)
│
├── Column (音标词义提示区)
│   ├── Text 音标 (fontSize 16, #9CA3AF)
│   └── Text 释义 (fontSize 16, #374151, Medium)
│
├── Stack (手写画布区域, layoutWeight 1)
│   ├── Column 白色底层
│   ├── Column 水印层 (条件渲染)
│   ├── Canvas 手写层
│   ├── Column 反馈浮层 (左上角, 条件渲染)
│   └── Row 工具栏 (右上角)
│
└── Row (底部导航)
    ├── Blank()
    ├── Button "〈 上一个"
    └── Button "下一个 〉"
7.2 完整代码骨架
import { VocabularyWord } from '../models/VocabularyWord';
import { SpeedWordRepository } from '../data/SpeedWordRepository';

@Entry
@Component
struct DictationContent {
  @State currentIndex: number = 0;
  @State currentWord: VocabularyWord | null = null;
  @State feedbackText: string = '';
  @State feedbackColor: string = '#6B7280';
  @State showAnswer: boolean = false;
  @State showWatermark: boolean = true;
  @State isRecognizing: boolean = false;

  private dictWords: VocabularyWord[] = [];
  private repository: SpeedWordRepository = new SpeedWordRepository();
  private selectedDate: string = '3/12';
  private canvasContext: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(new RenderingContextSettings(true));

  aboutToAppear(): void {
    this.dictWords = this.repository.getWordsByDate(this.selectedDate);
    if (this.dictWords.length > 0) {
      this.currentWord = this.dictWords[0];
    }
  }

  build() {
    Column({ space: 0 }) {
      // ① 标题栏
      Row() {
        Text('默写英文')
          .fontSize(18)
          .fontWeight(FontWeight.Bold)
          .fontColor('#1A1A1A')
        Blank()
        Text(`${this.currentIndex + 1} / ${this.dictWords.length}`)
          .fontSize(14)
          .fontColor('#6B7280')
      }
      .width('100%')
      .margin({ top: 16 })
      .alignItems(VerticalAlign.Center)

      // ② 音标词义提示区
      Column({ space: 6 }) {
        Text(this.currentWord !== null ? this.currentWord.phonetic : '')
          .fontSize(16)
          .fontColor('#9CA3AF')
          .alignSelf(ItemAlign.Start)
        Text(this.currentWord !== null ? this.currentWord.meaning : '')
          .fontSize(16)
          .fontColor('#374151')
          .fontWeight(FontWeight.Medium)
          .alignSelf(ItemAlign.Center)
      }
      .width('100%')
      .padding({ top: 16, bottom: 12 })

      // ③ 手写画布区域(占据剩余空间)
      Stack({ alignContent: Alignment.TopStart }) {
        // 白色底层、水印层、Canvas、反馈浮层、工具栏
        // ... 详见第 5、7 篇
      }
      .width('100%')
      .layoutWeight(1)
      .borderRadius(12)
      .clip(true)

      // ④ 底部导航
      Row({ space: 12 }) {
        Blank()
        Button('〈 上一个')
          .fontSize(14)
          .fontColor(this.currentIndex > 0 ? '#374151' : '#D1D5DB')
          .backgroundColor('#FFFFFF')
          .borderRadius(8)
          .height(40)
          .padding({ left: 20, right: 20 })
          .type(ButtonType.Normal)
          .border({ width: 1, color: this.currentIndex > 0 ? '#E5E7EB' : '#F3F4F6' })
          .enabled(this.currentIndex > 0)
          .onClick(() => { this.prevWord(); })

        Button('下一个 〉')
          .fontSize(14)
          .fontColor(this.currentIndex < this.dictWords.length - 1 ? '#374151' : '#D1D5DB')
          .backgroundColor('#FFFFFF')
          .borderRadius(8)
          .height(40)
          .padding({ left: 20, right: 20 })
          .type(ButtonType.Normal)
          .border({ width: 1, color: this.currentIndex < this.dictWords.length - 1 ? '#E5E7EB' : '#F3F4F6' })
          .enabled(this.currentIndex < this.dictWords.length - 1)
          .onClick(() => { this.nextWord(); })
      }
      .width('100%')
      .padding({ top: 10, bottom: 12 })
    }
    .layoutWeight(1)
    .height('100%')
    .backgroundColor('#FAFAF7')
    .padding({ left: 30, right: 30 })
  }

  // ... prevWord, nextWord, clearCanvas, checkAnswer 等方法
}
7.3 layoutWeight 的作用

在鸿蒙 ArkUI 的线性布局中,layoutWeight 是一个非常重要的属性。它的作用类似于前端开发中 CSS Flexbox 的 flex: 1,能够让组件自动填充父容器中除了固定尺寸元素之外的所有剩余空间。在我们的默写页面中,标题栏、提示区和底部导航都有固定的高度,而手写画布需要占据中间所有的剩余空间。使用 layoutWeight(1) 就能完美实现这个需求,无论设备屏幕多大,画布都能自适应填充。

// Stack(画布区域)设置 layoutWeight(1)
Stack({ alignContent: Alignment.TopStart }) { ... }
  .layoutWeight(1)  // 占据 Column 中除标题、提示、导航外的所有剩余空间

layoutWeight 类似于 CSS Flexbox 的 flex: 1,让画布区域自动填充剩余高度。这样无论设备屏幕多大,画布都能自适应。


八、本篇小结

通过本篇教程,我们完成了单词切换与底部导航的完整实现:

  • ✅ 设计单词状态管理方案(currentIndex + currentWord + dictWords)
  • ✅ 实现 prevWord / nextWord 切换方法(含边界检查)
  • ✅ 底部导航按钮样式(白底灰边 + 禁用态视觉反馈)
  • ✅ Blank() 实现右对齐布局
  • ✅ 进度显示(标题栏 “1 / 20” 格式)
  • ✅ 音标词义提示区(颜色层次区分主次信息)
  • ✅ 页面间距统一管理(父容器 padding 30px)
  • ✅ DictationContent 完整布局结构

本篇新增/修改的文件

electron/src/main/ets/
└── pages/
    └── SpeedVocabPage.ets    ← 修改:添加切换逻辑、底部导航、提示区

九、开发过程中的常见问题

9.1 切换单词后画布残留笔迹

现象:点击"下一个"后,画布上还显示着上一个单词的笔迹。

原因nextWord() 方法中没有调用 clearCanvas()

解决:确保每次切换单词时都清空画布。这是一个容易遗漏的点,建议在 prevWordnextWord 方法的最后一行统一调用 clearCanvas()

9.2 按钮禁用状态不生效

现象:第一个单词时,"上一个"按钮仍然可以点击。

排查:检查 enabled 属性的条件表达式是否正确。常见错误是用 >= 代替了 >,或者 dictWords.length 写成了 dictWords.length - 1

// ❌ 错误:第一个单词时 currentIndex === 0,0 >= 0 为 true
.enabled(this.currentIndex >= 0)

// ✅ 正确:第一个单词时 currentIndex === 0,0 > 0 为 false
.enabled(this.currentIndex > 0)
9.3 进度显示不更新

现象:切换单词后,进度文本仍然显示 “1 / 20”。

原因currentIndex 没有被 @State 装饰,或者赋值方式不正确。

检查:确认 @State currentIndex: number = 0; 声明正确,且在 prevWord/nextWord 中使用 this.currentIndex++ 而不是局部变量。

9.4 音标显示为空

现象:提示区的音标位置是空白的。

可能原因

  1. currentWordnull(初始化时机问题)
  2. 数据源中 phonetic 字段为空字符串
  3. 三元表达式写错(条件判断反了)

调试方法:在 aboutToAppear 中打印 this.currentWord,确认数据加载正确。

9.5 页面间距在不同设备上表现不一致

现象:在平板上间距合适,在鸿蒙 PC 上间距过大或过小。

建议:30px 的左右间距在 10-13 英寸设备上表现良好。如果需要适配更多尺寸,可以使用百分比或者根据屏幕宽度动态计算:

// 动态间距(屏幕宽度的 3%,最小 20px,最大 40px)
private getHorizontalPadding(): number {
  const screenWidth = px2vp(display.getDefaultDisplaySync().width);
  return Math.min(40, Math.max(20, screenWidth * 0.03));
}

下一篇预告

第 9 篇:词根分解与水印展示 — 我们将实现词根词缀分解的 Chip 组件展示,以及在手写水印中显示完整的分解信息,帮助用户理解单词构词法。

Logo

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

更多推荐