十字绣网格编辑器怎么做?HarmonyOS Canvas绘制网格

如果你对十字绣感兴趣,可以去鸿蒙应用市场搜一下**「绣迹」**,下载下来体验体验。在网格上点选颜色、绘制图案,实时预览效果,体验完了再回来看这篇文章,你会更清楚这个网格编辑器是怎么用Canvas实现的。


写在前面

大家好,我是一名写了十多年Web前端的老兵。从jQuery时代一路走到React/Vue,CSS3动画、requestAnimationFrame、Web Animation API这些都算是看家本领。去年开始转战鸿蒙生态,用ArkTS开发App,这一路踩了不少坑,也积累了不少心得。

很多人觉得"前端转鸿蒙"应该很容易——都是写UI嘛,组件化、状态管理、生命周期,概念都差不多。但真正上手之后你会发现,相似的地方让你觉得亲切,不同的地方让你抓狂

比如:

  • Canvas绘图:Web的Canvas API和鸿蒙的CanvasRenderingContext2D几乎一模一样——都是那个2D上下文,都是moveTo/lineTo/arc那套。但鸿蒙Canvas在画网格时,坐标系映射、触摸坐标的获取方式需要自己处理。
  • 触摸事件:Web用onClick获取鼠标坐标,鸿蒙用onTouch直接提供Canvas内的坐标。

别担心,接下来这篇文章,我会用"绣迹"的网格编辑器,带你看看HarmonyOS的Canvas怎么画网格、怎么处理触摸交互。


这篇文章聊什么

绣迹的核心功能是网格编辑器

  1. 在Canvas上绘制十字绣网格
  2. 点击网格单元填充颜色
  3. 支持缩放和平移
  4. 实时预览效果

第一步:设计网格数据结构

// 十字绣图案数据
interface CrossStitchPattern {
  id: string;
  name: string;
  width: number;         // 网格宽度(格数)
  height: number;        // 网格高度(格数)
  grid: string[][];      // 颜色ID二维数组
  fabricType: string;    // 绣布类型
  createdAt: string;
}

// 创建空白图案
function createEmptyPattern(width: number, height: number): string[][] {
  return Array.from({ length: height }, () =>
    Array.from({ length: width }, () => 'empty')
  );
}

第二步:用Canvas绘制网格

@Entry
@Component
struct GridEditorPage {
  @State pattern: CrossStitchPattern | null = null
  @State selectedColor: string = '#dc2626'
  @State scale: number = 1
  @State offsetX: number = 0
  @State offsetY: number = 0

  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  private cellSize: number = 20

  build() {
    Column() {
      Canvas(this.context)
        .width('100%')
        .height(400)
        .backgroundColor('#fef9c3')
        .borderRadius(12)
        .onReady(() => {
          this.drawGrid();
        })
        .onTouch((event: TouchEvent) => {
          if (event.type === TouchType.Down) {
            this.handleTouch(event.touches[0].x, event.touches[0].y);
          }
        })
    }
    .padding(16)
  }

  private drawGrid() {
    if (!this.pattern) return;

    const ctx = this.context;
    const { width, height, grid } = this.pattern;
    const cellSize = this.cellSize * this.scale;

    // 清空画布
    ctx.clearRect(0, 0, width * cellSize, height * cellSize);

    // 绘制网格线
    ctx.strokeStyle = '#d1d5db';
    ctx.lineWidth = 0.5;

    // 垂直线
    for (let x = 0; x <= width; x++) {
      ctx.beginPath();
      ctx.moveTo(x * cellSize + this.offsetX, this.offsetY);
      ctx.lineTo(x * cellSize + this.offsetX, height * cellSize + this.offsetY);
      ctx.stroke();
    }

    // 水平线
    for (let y = 0; y <= height; y++) {
      ctx.beginPath();
      ctx.moveTo(this.offsetX, y * cellSize + this.offsetY);
      ctx.lineTo(width * cellSize + this.offsetX, y * cellSize + this.offsetY);
      ctx.stroke();
    }

    // 填充颜色
    for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
        const color = grid[y][x];
        if (color && color !== 'empty') {
          ctx.fillStyle = color;
          ctx.fillRect(
            x * cellSize + this.offsetX + 1,
            y * cellSize + this.offsetY + 1,
            cellSize - 2,
            cellSize - 2
          );

          // 画十字标记
          ctx.strokeStyle = 'rgba(255,255,255,0.5)';
          ctx.lineWidth = 1;
          ctx.beginPath();
          ctx.moveTo(x * cellSize + this.offsetX + 3, y * cellSize + this.offsetY + 3);
          ctx.lineTo(x * cellSize + this.offsetX + cellSize - 3, y * cellSize + this.offsetY + cellSize - 3);
          ctx.moveTo(x * cellSize + this.offsetX + cellSize - 3, y * cellSize + this.offsetY + 3);
          ctx.lineTo(x * cellSize + this.offsetX + 3, y * cellSize + this.offsetY + cellSize - 3);
          ctx.stroke();
        }
      }
    }
  }

  private handleTouch(touchX: number, touchY: number) {
    if (!this.pattern) return;

    const cellSize = this.cellSize * this.scale;
    const gridX = Math.floor((touchX - this.offsetX) / cellSize);
    const gridY = Math.floor((touchY - this.offsetY) / cellSize);

    // 检查是否在网格范围内
    if (gridX >= 0 && gridX < this.pattern.width &&
        gridY >= 0 && gridY < this.pattern.height) {
      // 更新网格颜色
      this.pattern.grid[gridY][gridX] = this.selectedColor;
      this.drawGrid();
    }
  }
}

第三步:实现缩放功能

// 缩放控制
@Component
struct ZoomControls {
  @Prop scale: number = 1
  onZoomIn: () => void = () => {}
  onZoomOut: () => void = () => {}
  onReset: () => void = () => {}

  build() {
    Row() {
      Button('-')
        .width(40)
        .height(40)
        .onClick(this.onZoomOut)

      Text(`${Math.round(this.scale * 100)}%`)
        .fontSize(14)
        .width(60)
        .textAlign(TextAlign.Center)

      Button('+')
        .width(40)
        .height(40)
        .onClick(this.onZoomIn)

      Button('重置')
        .width(60)
        .height(40)
        .margin({ left: 8 })
        .onClick(this.onReset)
    }
    .justifyContent(FlexAlign.Center)
  }
}

// 在页面中使用
build() {
  Column() {
    ZoomControls({
      scale: this.scale,
      onZoomIn: () => {
        this.scale = Math.min(3, this.scale + 0.2);
        this.drawGrid();
      },
      onZoomOut: () => {
        this.scale = Math.max(0.5, this.scale - 0.2);
        this.drawGrid();
      },
      onReset: () => {
        this.scale = 1;
        this.offsetX = 0;
        this.offsetY = 0;
        this.drawGrid();
      }
    })

    Canvas(this.context)
      .width('100%')
      .height(400)
      .onReady(() => this.drawGrid())
      .onTouch((event: TouchEvent) => {
        if (event.type === TouchType.Down) {
          this.handleTouch(event.touches[0].x, event.touches[0].y);
        }
      })
  }
}

第四步:颜色选择器

// DMC绣线颜色
const DMC_COLORS = [
  { id: 'ecru', name: 'Ecru', hex: '#f5f0e0' },
  { id: 'blanc', name: 'Blanc', hex: '#ffffff' },
  { id: 'dmc_310', name: 'Black', hex: '#1f2937' },
  { id: 'dmc_321', name: 'Christmas Red', hex: '#dc2626' },
  { id: 'dmc_666', name: 'Red Bright', hex: '#ef4444' },
  { id: 'dmc_336', name: 'Blue', hex: '#1e40af' },
  { id: 'dmc_700', name: 'Green', hex: '#16a34a' },
  { id: 'dmc_972', name: 'Yellow', hex: '#facc15' },
  { id: 'dmc_550', name: 'Violet', hex: '#7e22ce' },
  { id: 'dmc_977', name: 'Brown', hex: '#d97706' },
];

@Component
struct ColorPalette {
  @Prop selectedColor: string = ''
  onColorSelect: (color: string) => void = () => {}

  build() {
    Column() {
      Text('选择颜色')
        .fontSize(14)
        .fontWeight(FontWeight.Medium)
        .margin({ bottom: 8 })

      Flex({ wrap: FlexWrap.Wrap }) {
        ForEach(DMC_COLORS, (color) => {
          Column() {
            Circle({ width: 32, height: 32 })
              .fill(color.hex)
              .stroke(this.selectedColor === color.hex ? '#000000' : 'transparent')
              .strokeWidth(2)

            Text(color.name)
              .fontSize(10)
              .fontColor('#6b7280')
          }
          .margin({ right: 8, bottom: 8 })
          .onClick(() => this.onColorSelect(color.hex))
        })
      }
    }
  }
}

第五步:保存和加载图案

// 保存图案
async function savePattern(context: Context, pattern: CrossStitchPattern): Promise<boolean> {
  const patterns = await getItem<CrossStitchPattern[]>(context, 'patterns', []);
  const index = patterns.findIndex(p => p.id === pattern.id);

  if (index > -1) {
    patterns[index] = pattern;
  } else {
    patterns.push(pattern);
  }

  return await setItem(context, 'patterns', patterns);
}

// 加载图案
async function loadPattern(context: Context, id: string): Promise<CrossStitchPattern | null> {
  const patterns = await getItem<CrossStitchPattern[]>(context, 'patterns', []);
  return patterns.find(p => p.id === id) || null;
}

// 导出图案为图片
async function exportPatternAsImage(context: CanvasRenderingContext2D, pattern: CrossStitchPattern): Promise<void> {
  // Canvas可以导出为图片
  // 具体实现取决于鸿蒙的Canvas导出API
}

第六步:常见问题

6.1 大图案性能问题

问题:图案很大时(如100x100),绘制变慢。

解决:只绘制可见区域,或者使用离屏Canvas。

// 只绘制可见区域
private drawVisibleArea() {
  const startCol = Math.max(0, Math.floor(-this.offsetX / (this.cellSize * this.scale)));
  const endCol = Math.min(this.pattern.width, startCol + Math.ceil(400 / (this.cellSize * this.scale)) + 1);
  const startRow = Math.max(0, Math.floor(-this.offsetY / (this.cellSize * this.scale)));
  const endRow = Math.min(this.pattern.height, startRow + Math.ceil(400 / (this.cellSize * this.scale)) + 1);

  // 只绘制这个范围内的格子
  for (let y = startRow; y < endRow; y++) {
    for (let x = startCol; x < endCol; x++) {
      // 绘制逻辑...
    }
  }
}

6.2 触摸精度问题

问题:手指太粗,点不到精确的格子。

解决:增加触摸区域的判断范围,或者提供放大镜功能。


总结

这篇文章围绕"绣迹"的网格编辑器,讲解了:

Canvas网格绘制

  • 绘制垂直和水平网格线
  • 填充颜色和十字标记
  • 缩放和平移

触摸交互

  • 触摸坐标转换为网格坐标
  • 点击填充颜色
  • 边界检查

性能优化

  • 只绘制可见区域
  • 离屏Canvas缓存

如果你对"绣迹"感兴趣,欢迎去鸿蒙应用市场搜索下载体验。

Logo

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

更多推荐