HarmonyOS5 儿童画板app:手绘写字(附代码)

2025-06-28 20:48:35
108次阅读
0个评论

screenshots (1).gif

鸿蒙汉字书写应用开发指南

一、核心架构设计

1. 横屏初始化实现

aboutToAppear(): void {
  window.getLastWindow(getContext()).then((windowClass) => {
    windowClass.setPreferredOrientation(window.Orientation.LANDSCAPE)
  })
}

通过window模块强制设置为横屏模式,优化汉字书写体验。

2. 组件化架构

  • CalligraphyPractice:主入口组件,管理整体布局和状态
  • BottomText:底部汉字选择组件
  • FinishPage:书写完成展示页

二、Canvas绘图核心技术

1. 毛笔效果算法

onTouch((event) => {
  const distance = Math.sqrt(
    (touch.x - canvasWidth/2) ** 2 + 
    (touch.y - canvasHeight/2) ** 2
  );
  const brushWidth = selectedWidth * (1 - distance / Math.max(canvasWidth, canvasHeight) * 0.3);
  context.lineWidth = brushWidth > 5 ? brushWidth : 5;
});

通过计算触摸点与画布中心的距离动态调整笔刷粗细,模拟真实毛笔效果。

2. 米字格绘制

getPoints = (r: number, l: number) => {
  return [
    [r - Math.sqrt(r*r/2), r - Math.sqrt(r*r/2)],
    [l - points[0][0], points[0][1]],
    // 其他顶点...
  ];
}

基于几何计算精确绘制米字格辅助线,帮助掌握汉字结构。

三、关键功能实现

1. 文件存储管理

savePicture(img: string) {
  const imgPath = getContext().tempDir + '/' + Date.now() + '.jpeg';
  const file = fileIo.openSync(imgPath, fileIo.OpenMode.CREATE);
  const imgBuffer = buffer.from(img.split(';base64,').pop(), 'base64');
  fileIo.writeSync(file.fd, imgBuffer.buffer);
  fileIo.closeSync(file);
}

安全保存书写作品到应用沙箱目录。

2. 状态管理

@State modeIndex: number = 0; // 0:毛笔 1:橡皮擦
@State wordOpacity: number[] = [1, 0, 0, 0, 0]; 

响应式状态自动同步UI变化。

四、交互流程优化

1. 汉字切换逻辑

onClick(() => {
  if (this.n === 4) {
    pageInfos.pushPathByName('Finish', imgUrl);
  } else {
    this.n++;
    wordOpacity[this.n] = 1;
    wordOpacity[this.n-1] = 0;
  }
});

2. 清除功能

context.clearRect(0, 0, canvasWidth, canvasHeight);
drawGuideLines(context, 20);

五、UI适配方案

1. 横屏布局

Canvas(context)
  .aspectRatio(1)  // 正方形画布
  .height('90%')   // 充分利用高度

2. 底部导航

Image($r('app.media.last'))
  .width('8%')  // 比例适配
  .height('100%')

六、完整代码结构

点击查看核心实现
import { fileIo } from '@kit.CoreFileKit';
import { buffer } from '@kit.ArkTS';
import { window } from '@kit.ArkUI';

@Entry
@Component
struct CalligraphyPractice {
  @Provide('pageInfos') pageInfos: NavPathStack = new NavPathStack();
  @State message: string = '汉字书写练习';
  @State modeValue: string = '毛笔'; // 当前工具:毛笔 or 橡皮擦
  @State modeIndex: number = 0; // 工具下标
  @State selectedWidth: number = 25; // 笔刷粗细
  @State selectedColor: string = '#8B0000'; // 毛笔颜色(暗红)
  @State imgUrl: Array<string> = []; // 保存图像URL
  @State wordOpacity: Array<number> = [1, 0, 0, 0, 0]; // 汉字选择透明度
  @State clearOpacity: number = 0.5;
  @State n: number = 0; // 当前汉字索引
  @State nextImg: string = 'app.media.next';
  @State imgHeight: Array<string> = ['70%', '100%'];
  @State showGuide: boolean = true; // 是否显示笔画引导
  @State imgOpacity: number = 0.5;

  // 画布参数
  private canvasWidth: number = 0;
  private canvasHeight: number = 0;
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D();

  // 练习的汉字列表
  private words: Array<string> = ['永', '天', '地', '人', '和'];

// 页面初始化(横屏显示)
  aboutToAppear(): void {
    // 设置当前app以横屏方式显示
    window.getLastWindow(getContext()).then((windowClass) => {
      windowClass.setPreferredOrientation(window.Orientation.LANDSCAPE) // 设置为横屏
    })
  }

  // 获取米字格顶点坐标
  getPoints = (r: number, l: number) => {
    let points: number[][] = [[], [], [], []];
    points[0] = [r - Math.sqrt(r * r / 2), r - Math.sqrt(r * r / 2)];
    points[1] = [l - points[0][0], points[0][1]];
    points[2] = [points[1][1], points[1][0]];
    points[3] = [l - points[0][0], l - points[0][1]];
    return points;
  }

  // 构建路由表
  @Builder
  PagesMap(name: string) {
    if (name === 'Finish') {
      FinishPage()
    }
  }

  // 绘制米字格辅助线
  drawGuideLines = (ctx: CanvasRenderingContext2D, r: number) => {
    const width = ctx.width;
    const height = ctx.height;
    let points = this.getPoints(r, width);

    // 对角线1
    let n = 100;
    let step = width / n;
    let start = points[0];
    ctx.beginPath();
    ctx.moveTo(start[0], start[1]);
    ctx.lineTo(width, height);
    ctx.strokeStyle = '#D2B48C';
    ctx.lineWidth = 1;
    ctx.stroke();

    // 对角线2
    start = points[1];
    ctx.beginPath();
    ctx.moveTo(start[0], start[1]);
    ctx.lineTo(0, height);
    ctx.stroke();

    // 竖中线
    ctx.beginPath();
    ctx.moveTo(width / 2, 0);
    ctx.lineTo(width / 2, height);
    ctx.stroke();

    // 横中线
    ctx.beginPath();
    ctx.moveTo(0, height / 2);
    ctx.lineTo(width, height / 2);
    ctx.stroke();
  }

  // 保存图片到沙箱
  savePicture(img: string, n: number) {
    const imgPath = getContext().tempDir + '/' + Date.now() + '.jpeg';
    const file = fileIo.openSync(imgPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE);
    const base64Image = img.split(';base64,').pop();
    const imgBuffer = buffer.from(base64Image, 'base64');
    fileIo.writeSync(file.fd, imgBuffer.buffer);
    fileIo.closeSync(file);
    this.imgUrl[n] = 'file://' + imgPath;
  }

  build() {
    Navigation(this.pageInfos) {
      Column() {
        // 顶部标题和熊猫图标
        Row() {
          Image($r('app.media.panda'))
            .margin({ top: 20 })
            .aspectRatio(1)
            .height('80%')
            .margin({ top: 10, left: 30 });

          // 书写区域
          Stack() {
            // 汉字提示
            Text(this.words[this.n])
              .fontSize(200)
              .fontFamily('STKaiti') // 楷体字体
              .opacity(this.showGuide ? 0.1 : 0);

            // 画布
            Canvas(this.context)
              .aspectRatio(1)
              .height('90%')
              .backgroundColor('#FFF8DC') // 米黄色背景
              .borderRadius(15)
              .opacity(0.9)
              .onReady(() => {
                this.drawGuideLines(this.context, 20);
              })
              .onAreaChange((oldVal, newVal) => {
                this.canvasWidth = newVal.width as number;
                this.canvasHeight = newVal.height as number;
              })
              .onTouch((event) => {
                const touch: TouchObject = event.touches[0];
                switch (event.type) {
                  case TouchType.Down:
                    this.context.beginPath();
                    this.context.moveTo(touch.x, touch.y);
                    this.clearOpacity = 1;
                    break;
                  case TouchType.Move:
                    // 毛笔效果:移动时线条粗细变化
                    const distance = Math.sqrt(
                      (touch.x - this.canvasWidth/2) * (touch.x - this.canvasWidth/2) +
                        (touch.y - this.canvasHeight/2) * (touch.y - this.canvasHeight/2)
                    );
                    const brushWidth = this.selectedWidth * (1 - distance / Math.max(this.canvasWidth, this.canvasHeight) * 0.3);
                    this.context.lineWidth = brushWidth > 5 ? brushWidth : 5;
                    this.context.strokeStyle = this.modeIndex === 0 ? this.selectedColor : 'white';
                    this.context.lineTo(touch.x, touch.y);
                    this.context.stroke();
                    break;
                  case TouchType.Up:
                    this.context.closePath();
                    break;
                }
              });
          }
          .margin({ left: 20, top: 5 });

          // 清除按钮
          Column() {
            Button('清除')
              .opacity(this.clearOpacity)
              .type(ButtonType.ROUNDED_RECTANGLE)
              .fontColor('#8B0000')
              .fontSize(28)
              .backgroundColor('#FFEBCD')
              .width('15%')
              .height('18%')
              .margin({ top: 20, left: 10 })
              .onClick(() => {
                this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
                this.drawGuideLines(this.context, 20);
                this.clearOpacity = 0.5;
              });
          }
          .height('100%');
        }
        .width('100%')
        .height('80%');

        // 底部导航区
        Row() {
          // 上一个按钮
          Image($r('app.media.last'))
            .onClick(() => {
              if (this.n > 0) {
                this.n--;
                this.wordOpacity[this.n] = 1;
                this.wordOpacity[this.n+1] = 0;
                this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
                this.drawGuideLines(this.context, 20);
              }
              if (this.n < 4) {
                this.nextImg = 'app.media.next';
              }
              if (this.n === 0) {
                // 当切换到第一个汉字时,设置底部导航栏图片透明度为0.5
                this.imgOpacity = 0.5;
              }
            })
            .opacity(this.imgOpacity)
            .backgroundColor('#FFEBCD')
            .margin({ left: 20 })
            .height('100%')
            .borderRadius(5)
            .borderWidth(8)
            .borderColor('#FFEBCD');

          // 汉字选择区
          Row({ space: 10 }) {
            Stack() {
              BottomText({ imgH: this.imgHeight[0], wds: this.words[0] })
              BottomText({ imgH: this.imgHeight[1], wds: this.words[0], wdSize: 45 })
                .opacity(this.wordOpacity[0])
            }

            Stack() {
              BottomText({ imgH: this.imgHeight[0], wds: this.words[1] })
              BottomText({ imgH: this.imgHeight[1], wds: this.words[1], wdSize: 45 })
                .opacity(this.wordOpacity[1])
            }

            Stack() {
              BottomText({ imgH: this.imgHeight[0], wds: this.words[2] })
              BottomText({ imgH: this.imgHeight[1], wds: this.words[2], wdSize: 45 })
                .opacity(this.wordOpacity[2])
            }

            Stack() {
              BottomText({ imgH: this.imgHeight[0], wds: this.words[3] })
              BottomText({ imgH: this.imgHeight[1], wds: this.words[3], wdSize: 45 })
                .opacity(this.wordOpacity[3])
            }

            Stack() {
              BottomText({ imgH: this.imgHeight[0], wds: this.words[4] })
              BottomText({ imgH: this.imgHeight[1], wds: this.words[4], wdSize: 45 })
                .opacity(this.wordOpacity[4])
            }
          }
          .margin({ left: 10 });

          // 下一个/完成按钮
          Image($r(this.nextImg))
            .borderWidth(8)
            .borderColor('#FFEBCD')
            .backgroundColor('#FFEBCD')
            .height('100%')
            .borderRadius(5)
            .onClick(() => {
              // 保存当前书写内容
              this.imgUrl[this.n] = this.context.toDataURL();
              this.savePicture(this.imgUrl[this.n], this.n);

              // 清空画布并准备下一个汉字
              this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
              this.drawGuideLines(this.context, 20);

              if (this.n === 4) {
                // 所有汉字完成,跳转到结果页
                this.pageInfos.pushPathByName('Finish', this.imgUrl);
              } else {
                // 切换到下一个汉字
                this.nextImg = this.n + 1 === 4 ? 'app.media.finish' : 'app.media.next';
                this.n++;
                this.wordOpacity[this.n] = 1;
                this.wordOpacity[this.n-1] = 0;
                if (this.n > 0) {
                  this.imgOpacity = 1;
                }
              }
              console.log('n: ' + this.n);
            })
            .margin({ left: 250 });
        }
        .width('100%')
        .height('18%')
        .backgroundColor('#FFEBCD');
      }
    }
    .width('100%')
    .mode(NavigationMode.Stack)
    .navDestination(this.PagesMap)
    .hideTitleBar(true)
    .hideToolBar(true)
    .backgroundColor('#8B4513'); // 棕色背景
  }
}

// 底部汉字选择组件
@Component
export struct BottomText {
  public imgH: string = '';
  public wds: string = '';
  public wdSize: number = 30;

  build() {
    Stack() {
      Text(this.wds)
        .height(this.imgH)
        .fontSize(this.wdSize)
        .fontFamily('STKaiti');
      Image($r('app.media.mi'))
        .height(this.imgH)
        .opacity(0.3);
    }
    .aspectRatio(1)
    .borderRadius(5)
    .backgroundColor('#FFEBCD');
  }
}

// 完成页组件
@Component
export struct FinishPage {
  @Consume('pageInfos') pageInfos: NavPathStack;
  private imgUrl: Array<string> = [];

  @Builder
  ImageItem(url: string) {
    Image(url)
      .backgroundColor('#FFF8DC')
      .borderRadius(10)
      .height('30%')
      .width('18%');
  }

  build() {
    NavDestination() {
      Column() {
        Image($r("app.media.panda"))
          .height('25%')
          .aspectRatio(1)
          .margin({ top: 10 });

        Text('你的书法作品:')
          .fontSize(30)
          .fontWeight(FontWeight.Bold)
          .fontFamily('STKaiti')
          .margin({ top: 10, bottom: 15 });

        Row({ space: 10 }) {
          this.ImageItem(this.imgUrl[0])
          this.ImageItem(this.imgUrl[1])
          this.ImageItem(this.imgUrl[2])
          this.ImageItem(this.imgUrl[3])
          this.ImageItem(this.imgUrl[4])
        }
        .margin({ top: 5 });

        Button('重新练习')
          .type(ButtonType.ROUNDED_RECTANGLE)
          .fontColor('#8B0000')
          .fontSize(30)
          .backgroundColor('#FFF8DC')
          .height('15%')
          .width('30%')
          .margin({ top: 30 })
          .onClick(() => {
            // 清空数据并返回首页
            this.imgUrl = [];
            this.pageInfos.pop()
          });
      }
      .height('100%')
      .width('100%')
      .backgroundColor('#8B4513');
    }
    .onReady((context: NavDestinationContext) => {
      this.pageInfos = context.pathStack;
      this.imgUrl = context.pathInfo.param as Array<string>;
    })
    .hideTitleBar(true)
    .hideToolBar(true);
  }
}

七、设计要点总结

  1. 物理模拟:通过数学算法还原真实书写体验
  2. 辅助功能:米字格帮助掌握汉字结构
  3. 状态驱动:响应式数据自动更新UI
  4. 安全存储:沙箱机制保护用户数据
  5. 横屏优化:专为书写场景设计的布局
收藏00

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