第 11 章 图形绘制

169 阅读22分钟

该章节围绕图形绘制展开,主要介绍了在应用开发中绘制图形的相关知识,涵盖绘制几何图形和使用画布绘制自定义图形两大部分,并给出了具体案例。

在绘制几何图形方面,详细讲解了创建绘制组件的两种方式,形状视口viewport的使用方法,以及通过多种属性对组件样式进行自定义的技巧,还提供了绘制封闭路径、圆和圆环的场景示例。使用画布绘制自定义图形时,介绍了直接绘制、离屏绘制和加载动画三种方式,阐述了画布组件的初始化、绘制方式、常用方法,并给出规则基础形状和不规则图形绘制的场景示例。

案例实战部分,基于Canvas实现抽奖转盘和画布两个案例。抽奖转盘案例包含绘制主界面、实现抽奖功能、显示抽奖结果等步骤,涉及Stack组件、Canvas组件、显式动画等知识点。基于Canvas实现画布的案例,具备绘画、撤销重做、橡皮擦、清空、画笔属性设置、缩放等功能,涉及Canvas、手势事件、折叠屏适配等知识点。

11.1 绘制几何图形 (Shape)

绘制组件用于在页面绘制图形,Shape组件是绘制组件的父组件,父组件中会描述所有绘制组件均支持的通用属性。

11.1.1 创建绘制组件

绘制组件可以由以下两种形式创建:

  • 绘制组件使用Shape作为父组件,实现类似SVG的效果。接口调用为以下形式:
Shape(value?: PixelMap)

该接口用于创建带有父组件的绘制组件,其中value用于设置绘制目标,可将图形绘制在指定的PixelMap对象中,若未设置,则在当前绘制目标中进行绘制。

Shape() {
  Rect().width(300).height(50)
}
  • 绘制组件单独使用,用于在页面上绘制指定的图形。有7种绘制类型,分别为Circle(圆形)、Ellipse(椭圆形)、Line(直线)、Polyline(折线)、Polygon(多边形)、Path(路径)、Rect(矩形)。以Circle的接口调用为例:
Circle(value?: { width?: string | number, height?: string | number })

该接口用于在页面绘制圆形,其中width用于设置圆形的宽度,height用于设置圆形的高度,圆形直径由宽高最小值确定。

Circle({ width: 150, height: 150 })

11.1.2 形状视口viewport

viewPort(value: { 
  x?: number | string, 
  y?: number | string, 
  width?: number | string, 
  height?: number | string 
})

形状视口viewport指定用户空间中的一个矩形,该矩形映射到为关联的SVG元素建立的视区边界。viewport属性的值包含x、y、width和height四个可选参数,x和y表示视区的左上角坐标,width和height表示其尺寸。

以下3个示例讲解viewport具体用法:

  • 通过形状视口对图形进行放大与缩小。
class tmp {
  x: number = 0
  y: number = 0
  width: number = 75
  height: number = 75
}

let vp: tmp = new tmp()

class tmp1 {
  x: number = 0
  y: number = 0
  width: number = 300
  height: number = 300
}

let vp1: tmp1 = new tmp1()

@Entry
@ComponentV2
struct UsageViewport01 {
  build() {
    Column() {
      // 画一个宽高都为75的圆
      Text('原始尺寸Circle组件')
      Circle({ width: 75, height: 75 }).fill('#E87361')

      Row({ space: 10 }) {
        Column() {
          // 创建一个宽高都为150的shape组件,背景色为黄色,一个宽高都为75的viewport。
          // 用一个蓝色的矩形来填充viewport,在viewport中绘制一个直径为75的圆。
          // 绘制结束,viewport会根据组件宽高放大两倍
          Text('shape内放大的Circle组件')
          Shape() {
            Rect().width('100%').height('100%').fill('#0097D4')
            Circle({ width: 75, height: 75 }).fill('#E87361')
          }
          .viewPort(vp)
          .width(150)
          .height(150)
          .backgroundColor('#F5DC62')
        }

        Column() {
          // 创建一个宽高都为150的shape组件,背景色为黄色,一个宽高都为300的viewport。
          // 用一个绿色的矩形来填充viewport,在viewport中绘制一个直径为75的圆。
          // 绘制结束,viewport会根据组件宽高缩小两倍。
          Text('Shape内缩小的Circle组件')
          Shape() {
            Rect().width('100%').height('100%').fill('#BDDB69')
            Circle({ width: 75, height: 75 }).fill('#E87361')
          }
          .viewPort(vp1)
          .width(150)
          .height(150)
          .backgroundColor('#F5DC62')
        }
      }
    }
  }
}

  • 创建一个宽高都为300的shape组件,背景色为黄色,一个宽高都为300的viewport。用一个蓝色的矩形来填充viewport,在viewport中绘制一个半径为75的圆。
class tmp {
  x: number = 0
  y: number = 0
  width: number = 300
  height: number = 300
}

let vp: tmp = new tmp()

@Entry
@ComponentV2
struct UsageViewport02 {
  build() {
    Shape() {
      Rect().width("100%").height("100%").fill("#0097D4")
      Circle({ width: 150, height: 150 }).fill("#E87361")
    }
    .viewPort(vp)
    .width(300)
    .height(300)
    .backgroundColor("#F5DC62")
    .margin({ left: 10 })
  }
}

  • 创建一个宽高都为300的shape组件,背景色为黄色,创建一个宽高都为300的viewport。用一个蓝色的矩形来填充viewport,在viewport中绘制一个半径为75的圆,将viewport向右方和下方各平移150。
class tmp {
  x: number = -150
  y: number = -150
  width: number = 300
  height: number = 300
}

let vp: tmp = new tmp()

@Entry
@ComponentV2
struct UsageViewport03 {
  build() {
    Shape() {
      Rect().width("100%").height("100%").fill("#0097D4")
      Circle({ width: 150, height: 150 }).fill("#E87361")
    }
    .viewPort(vp)
    .width(300)
    .height(300)
    .backgroundColor("#F5DC62")
  }
}

11.1.3 自定义样式

绘制组件支持通过各种属性对组件样式进行更改。

  • 通过fill可以设置组件填充区域颜色。
@Entry
@ComponentV2
struct UsageStyle {
  build() {
    Column({ space: 10 }) {
      Path()
        .width(100)
        .height(100)
        .commands('M150 0 L300 300 L0 300 Z')
        .fill("#E87361")
        .strokeWidth(0)
    }
  }
}

  • 通过stroke可以设置组件边框颜色。
@Entry
@ComponentV2
struct UsageStyle {
  build() {
    Column({ space: 10 }) {
      Path()
        .width(100)
        .height(100)
        .fillOpacity(0)
        .commands('M150 0 L300 300 L0 300 Z')
        .stroke(Color.Red)
    }
  }
}

  • 通过strokeOpacity可以设置边框透明度。
@Entry
@ComponentV2
struct UsageStyle {
  build() {
    Column({ space: 10 }) {
      Path()
        .width(100)
        .height(100)
        .fillOpacity(0)
        .commands('M150 0 L300 300 L0 300 Z')
        .stroke(Color.Red)
        .strokeWidth(10)
        .strokeOpacity(0.2)
    }
  }
}

  • 通过strokeLineJoin可以设置线条拐角绘制样式。拐角绘制样式分为Bevel(使用斜角连接路径段)、Miter(使用尖角连接路径段)、Round(使用圆角连接路径段)。
@Entry
@ComponentV2
struct UsageStyle {
  build() {
    Column({ space: 10 }) {
      Polyline()
        .width(100)
        .height(100)
        .fillOpacity(0)
        .stroke(Color.Red)
        .strokeWidth(8)
        .points([[20, 0], [0, 100], [100, 90]])
         // 设置折线拐角处为圆弧
        .strokeLineJoin(LineJoinStyle.Round)
    }
  }
}

  • 通过strokeMiterLimit设置斜接长度与边框宽度比值的极限值。

斜接长度表示外边框外边交点到内边交点的距离,边框宽度即strokeWidth属性的值。strokeMiterLimit取值需大于等于1,且在strokeLineJoin属性取值LineJoinStyle.Miter时生效。

@Entry
@ComponentV2
struct UsageStyle {
  build() {
    Column({ space: 10 }) {
      Row() {
        Polyline()
          .width(100)
          .height(100)
          .fillOpacity(0)
          .stroke(Color.Red)
          .strokeWidth(10)
          .points([[20, 0], [20, 100], [100, 100]])
          // 设置折线拐角处为尖角
          .strokeLineJoin(LineJoinStyle.Miter)
          // 设置斜接长度与线宽的比值
          .strokeMiterLimit(1/Math.sin(45))
        Polyline()
          .width(100)
          .height(100)
          .fillOpacity(0)
          .stroke(Color.Red)
          .strokeWidth(10)
          .points([[20, 0], [20, 100], [100, 100]])
          .strokeLineJoin(LineJoinStyle.Miter)
          .strokeMiterLimit(1.42)
      }
    }
  }
}

  • 通过antiAlias设置是否开启抗锯齿,默认值为true(开启抗锯齿)。
@Entry
@ComponentV2
struct UsageStyle {
  build() {
    Column({ space: 10 }) {
      //开启抗锯齿
      Circle()
        .width(150)
        .height(200)
        .fillOpacity(0)
        .strokeWidth(5)
        .stroke(Color.Black)
    }
  }
}

@Entry
@ComponentV2
struct UsageStyle {
  build() {
    Column({ space: 10 }) {
      //关闭抗锯齿
      Circle()
        .width(150)
        .height(200)
        .fillOpacity(0)
        .strokeWidth(5)
        .stroke(Color.Black)
        .antiAlias(false)
    }
  }
}

11.1.4 场景示例

11.1.4.1 绘制封闭路径

在Shape的(-80, -5)点绘制一个封闭路径,填充颜色0x317AF7,线条宽度3,边框颜色红色,拐角样式锐角(默认值)。

@Entry
@ComponentV2
struct ShapeExample {
  build() {
    Column({ space: 10 }) {
      Shape() {
        Path().width(200).height(60).commands('M0 0 L400 0 L400 150 Z')
      }
      .viewPort({
        x: -80,
        y: -5,
        width: 500,
        height: 300
      })
      .fill(0x317AF7)
      .stroke(Color.Red)
      .strokeWidth(3)
      .strokeLineJoin(LineJoinStyle.Miter)
      .strokeMiterLimit(5)
    }.width('100%').margin({ top: 15 })
  }
}

11.1.4.2 绘制圆和圆环

绘制一个直径为150的圆,和一个直径为150、线条为红色虚线的圆环(宽高设置不一致时以短边为直径)。

@Entry
@ComponentV2
struct CircleExample {
  build() {
    Column({ space: 10 }) {
      //绘制一个直径为150的圆
      Circle({ width: 150, height: 150 })
      //绘制一个直径为150、线条为红色虚线的圆环
      Circle()
        .width(150)
        .height(200)
        .fillOpacity(0)
        .strokeWidth(3)
        .stroke(Color.Red)
        .strokeDashArray([1, 2])
    }.width('100%')
  }
}

11.2 使用画布绘制自定义图形 (Canvas)

Canvas提供画布组件,用于自定义绘制图形,绘制对象可以是基础形状、文本、图片等。

11.2.1 使用画布组件绘制自定义图形

可以由以下三种形式在画布绘制自定义图形。

11.2.1.1 直接绘制

使用CanvasRenderingContext2D对象在Canvas画布上绘制。

@Entry
@ComponentV2
struct CanvasExample1 {
  // 用来配置CanvasRenderingContext2D对象的参数,
  // 包括是否开启抗锯齿,true表明开启抗锯齿。
  private settings: RenderingContextSettings =
    new RenderingContextSettings(true)

  // 用来创建CanvasRenderingContext2D对象,
  // 通过在canvas中调用CanvasRenderingContext2D对象来绘制。
  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(this.settings)

  build() {
    Flex({
      direction: FlexDirection.Column,
      alignItems: ItemAlign.Center,
      justifyContent: FlexAlign.Center
    }) {
      //在canvas中调用CanvasRenderingContext2D对象。
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .backgroundColor('#F5DC62')
        .onReady(() => {
          //可以在这里绘制内容。
          this.context.strokeRect(50, 50, 200, 150)
        })
    }
    .width('100%')
    .height('100%')
  }
}

11.2.1.2 离屏绘制

离屏绘制是指将需要绘制的内容先绘制在缓存区,再将其转换成图片,一次性绘制到Canvas上,加快了绘制速度。过程为:

  1. 通过transferToImageBitmap方法将离屏画布最近渲染的图像创建为一个ImageBitmap对象。
  2. 通过CanvasRenderingContext2D对象的transferFromImageBitmap方法显示给定的ImageBitmap对象。
@Entry
@ComponentV2
struct CanvasExample2 {
  // 用来配置CanvasRenderingContext2D对象
  // 和OffscreenCanvasRenderingContext2D对象的参数,
  // 包括是否开启抗锯齿。true表明开启抗锯齿
  private settings: RenderingContextSettings =
    new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(this.settings)

  // 用来创建OffscreenCanvas对象,width为离屏画布的宽度,height为离屏画布的高度。
  // 通过在canvas中调用OffscreenCanvasRenderingContext2D对象来绘制。
  private offCanvas: OffscreenCanvas = new OffscreenCanvas(600, 600)

  build() {
    Flex({
      direction: FlexDirection.Column,
      alignItems: ItemAlign.Center,
      justifyContent: FlexAlign.Center
    }) {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .backgroundColor('#F5DC62')
        .onReady(() => {
          let offContext = this.offCanvas.getContext("2d", this.settings)
          //可以在这里绘制内容
          offContext.strokeRect(50, 50, 200, 150)
          //将离屏绘值渲染的图像在普通画布上显示
          let image = this.offCanvas.transferToImageBitmap()
          this.context.transferFromImageBitmap(image)
        })
    }
    .width('100%')
    .height('100%')
  }
}

11.2.1.3 加载动画

可以应用第三方模块Lottie,在Canvas上加载Lottie动画。lottie是一个适用于OpenHarmony的动画库,它可以解析Adobe After Effects软件通过Bodymovin插件导出的json格式的动画,并在移动设备上进行本地渲染。

  1. 下载安装

在DevEco Studio的终端命令行里,运行如下命令:

ohpm install @ohos/lottie

  1. 使用示例

本案例采用Lottie动画库的Canvas渲染模式,实现了一个卡通小动物开口说话的生动动画。

import lottie, { AnimationItem } from '@ohos/lottie'

@Entry
@ComponentV2
struct Index {
  // 构建上下文
  private renderingSettings: RenderingContextSettings =
    new RenderingContextSettings(true)
  private canvasRenderingContext: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(this.renderingSettings)
  private animateItem: AnimationItem | null = null
  private animateName: string = "animation" // 动画名称

  // 页面销毁时释放动画资源
  aboutToDisappear(): void {
    console.info('aboutToDisappear')
    lottie.destroy()
  }

  build() {
    Row() {
      // 关联画布
      Canvas(this.canvasRenderingContext)
        .width(200)
        .height(200)
        .backgroundColor(Color.Gray)
        .onReady(() => {
          // 加载动画
          if (this.animateItem != null) {
            // 可在此生命回调周期中加载动画,可以保证动画尺寸正确
            this.animateItem.resize()
          } else {
            // 抗锯齿的设置
            this.canvasRenderingContext.imageSmoothingEnabled = true
            this.canvasRenderingContext.imageSmoothingQuality = 'medium'
            this.loadAnimation()
          }
        })
    }
  }

  loadAnimation() {
    this.animateItem = lottie.loadAnimation({
      container: this.canvasRenderingContext,
      renderer: 'canvas', // canvas 渲染模式
      loop: true,
      autoplay: false,
      name: this.animateName,
      contentMode: 'Contain',

      // 路径加载动画只支持entry/src/main/ets 文件夹下的相对路径
      path: "common/lottie/animation.json",
    })
    // 因为动画是异步加载,所以对animateItem的操作需要放在动画加载完成回调里操作
    this.animateItem.addEventListener(
      'DOMLoaded', 
      (args: Object): void => {
        this.animateItem?.changeColor([225, 25, 100, 1])
        this.animateItem?.play()
      }
    )
  }

  destroy() {
    this.animateItem?.removeEventListener("DOMLoaded")
    lottie.destroy(this.animateName)
    this.animateItem = null
  }
}

11.2.2 初始化画布组件

onReady(event: () => void)是Canvas组件初始化完成时的事件回调,调用该事件后,可获取Canvas组件的确定宽高,进一步使用CanvasRenderingContext2D对象和OffscreenCanvasRenderingContext2D对象调用相关API进行图形绘制。

@Entry
@ComponentV2
struct Usage02 {
  private settings: RenderingContextSettings =
    new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(this.settings)

  build() {
    Canvas(this.context)
      .width('100%')
      .height('100%')
      .backgroundColor('#F5DC62')
      .onReady(() => {
        this.context.fillStyle = '#0097D4'
        this.context.fillRect(50, 50, 100, 100)
      })
  }
}

11.2.3 画布组件绘制方式

在Canvas组件生命周期接口onReady()调用之后,可以直接使用canvas组件进行绘制。或者可以脱离Canvas组件和onReady()生命周期,单独定义Path2d对象构造理想的路径,并在onReady()调用之后使用Canvas组件进行绘制。

11.2.3.1 直接绘制

通过CanvasRenderingContext2D对象和OffscreenCanvasRenderingContext2D对象直接调用相关API进行绘制。

@Entry
@ComponentV2
struct Usage0301 {
  private settings: RenderingContextSettings =
    new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(this.settings)

  build() {
    Canvas(this.context)
      .width('100%')
      .height('100%')
      .backgroundColor('#F5DC62')
      .onReady(() => {
        this.context.beginPath()
        this.context.moveTo(50, 50)
        this.context.lineTo(280, 160)
        this.context.stroke()
      })
  }
}

11.2.3.2 单独定义path2d对象绘制

先单独定义path2d对象构造理想的路径,再通过调用CanvasRenderingContext2D对象和OffscreenCanvasRenderingContext2D对象的stroke接口或者fill接口进行绘制。

@Entry
@ComponentV2
struct Usage0302 {
  private settings: RenderingContextSettings =
    new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(this.settings)

  build() {
    Canvas(this.context)
      .width('100%')
      .height('100%')
      .backgroundColor('#F5DC62')
      .onReady(() => {
        let region = new Path2D()
        region.arc(100, 75, 50, 0, 6.28)
        this.context.stroke(region)
      })
  }
}

11.2.4 画布组件常用方法

OffscreenCanvasRenderingContext2D对象和CanvasRenderingContext2D对象提供了大量的属性和方法,可以用来绘制文本、图形,处理像素等,是Canvas组件的核心。常用接口有fill(对封闭路径进行填充)、clip(设置当前路径为剪切路径)、stroke(进行边框绘制操作)等等,同时提供了fillStyle(指定绘制的填充色)、globalAlpha(设置透明度)与strokeStyle(设置描边的颜色)等属性修改绘制内容的样式。将通过以下几个方面简单介绍画布组件常见使用方法:

11.2.4.1 基础形状绘制

可以通过arc(绘制弧线路径)、 ellipse(绘制一个椭圆)、rect(创建矩形路径)等接口绘制基础形状。

@Entry
@ComponentV2
struct Usage0401 {
  private settings: RenderingContextSettings =
    new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(this.settings)

  build() {
    Canvas(this.context)
      .width('100%')
      .height('100%')
      .backgroundColor('#F5DC62')
      .onReady(() => {
        //绘制矩形
        this.context.beginPath()
        this.context.rect(100, 50, 100, 100)
        this.context.stroke()
        //绘制圆形
        this.context.beginPath()
        this.context.arc(150, 250, 50, 0, 6.28)
        this.context.stroke()
        //绘制椭圆
        this.context.beginPath()
        this.context.ellipse(
          150, 450, 50, 100, Math.PI * 0.25, 
          Math.PI * 0, Math.PI * 2
        )
        this.context.stroke()
      })
  }
}

11.2.4.2 文本绘制

可以通过fillText(文本填充)、strokeText(文本描边)等接口进行文本绘制,示例中设置了font为50像素高加粗的"sans-serif"字体,然后调用fillText方法在(50, 100)处绘制文本"Hello World!",设置strokeStyle为红色,lineWidth为2,font为50像素高加粗的"sans-serif"字体,然后调用strokeText方法在(50, 150)处绘制文本"Hello World!"的轮廓。

@Entry
@ComponentV2
struct Usage0402 {
  private settings: RenderingContextSettings =
    new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(this.settings)

  build() {
    Canvas(this.context)
      .width('100%')
      .height('100%')
      .backgroundColor('#F5DC62')
      .onReady(() => {
        // 文本填充
        this.context.font = '50px bolder sans-serif'
        this.context.fillText("Hello World!", 50, 100)
        // 文本描边
        this.context.strokeStyle = "#ff0000"
        this.context.lineWidth = 2
        this.context.font = '50px bolder sans-serif'
        this.context.strokeText("Hello World!", 50, 150)
      })
  }
}

11.2.4.3 绘制图片和图像像素信息处理

可以通过drawImage(图像绘制)、putImageData(使用ImageData数据填充新的矩形区域)等接口绘制图片,通过createImageData(创建新的ImageData 对象)、getPixelMap(以当前canvas指定区域内的像素创建PixelMap对象)、getImageData(以当前canvas指定区域内的像素创建ImageData对象)等接口进行图像像素信息处理。

@Entry
@ComponentV2
struct GetImageData {
  private settings: RenderingContextSettings =
    new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(this.settings)
  private offCanvas: OffscreenCanvas = new OffscreenCanvas(600, 600)
  private img: ImageBitmap = new ImageBitmap('/common/images/camera.jpeg')

  build() {
    Flex({
      direction: FlexDirection.Column,
      alignItems: ItemAlign.Center,
      justifyContent: FlexAlign.Center
    }) {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .backgroundColor('#F5DC62')
        .onReady(() => {
          let offContext = this.offCanvas.getContext("2d", this.settings)
          // 使用drawImage接口将图片画在(0,0)为起点,宽高130的区域
          offContext.drawImage(this.img, 0, 0, 130, 130)
          // 使用getImageData接口,
          // 获得canvas组件区域中,(50,50)为起点,宽高130范围内的绘制内容
          let imageData = offContext.getImageData(50, 50, 130, 130)
          // 使用putImageData接口将得到的ImageData画在起点为(150, 150)的区域中
          offContext.putImageData(imageData, 150, 150)
          // 将离屏绘制的内容画到canvas组件上
          let image = this.offCanvas.transferToImageBitmap()
          this.context.transferFromImageBitmap(image)
        })
    }
    .width('100%')
    .height('100%')
  }
}

11.2.4.4 其他方法

Canvas中还提供其他类型的方法。渐变(CanvasGradient对象)相关的方法:createLinearGradient(创建一个线性渐变色)、createRadialGradient(创建一个径向渐变色)等。

@Entry
@ComponentV2
struct Usage0404 {
  private settings: RenderingContextSettings =
    new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(this.settings)

  build() {
    Canvas(this.context)
      .width('100%')
      .height('100%')
      .backgroundColor('#F5DC62')
      .onReady(() => {
        //创建一个径向渐变色的CanvasGradient对象
        let grad = this.context.createRadialGradient(200, 200, 50, 200, 200, 200)
        //为CanvasGradient对象设置渐变断点值,包括偏移和颜色
        grad.addColorStop(0.0, '#E87361')
        grad.addColorStop(0.5, '#FFFFF0')
        grad.addColorStop(1.0, '#BDDB69')
        //用CanvasGradient对象填充矩形
        this.context.fillStyle = grad
        this.context.fillRect(0, 0, 400, 400)
      })
  }
}

11.2.5 场景示例

本节实现了规则基础形状绘制和不规则图形绘制两个例子。

11.2.5.1 规则基础形状绘制

@Entry
@ComponentV2
struct ClearRect {
  private settings: RenderingContextSettings =
    new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D =
    new CanvasRenderingContext2D(this.settings)

  build() {
    Flex({
      direction: FlexDirection.Column,
      alignItems: ItemAlign.Center,
      justifyContent: FlexAlign.Center
    }) {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .backgroundColor('#F5DC62')
        .onReady(() => {
          // 设定填充样式,填充颜色设为蓝色
          this.context.fillStyle = '#0097D4'
          // 以(50, 50)为左上顶点,画一个宽高200的矩形
          this.context.fillRect(50, 50, 200, 200)
          // 以(70, 70)为左上顶点,清除宽150高100的区域
          this.context.clearRect(70, 70, 150, 100)
        })
    }
    .width('100%')
    .height('100%')
  }
}

11.2.5.2 不规则图形绘制

@Entry
@ComponentV2
struct Path2d {
  private settings: RenderingContextSettings = 
    new RenderingContextSettings(true)
  private context: CanvasRenderingContext2D = 
    new CanvasRenderingContext2D(this.settings)

  build() {
    Row() {
      Column() {
        Canvas(this.context)
          .width('100%')
          .height('100%')
          .backgroundColor('#F5DC62')
          .onReady(() => {
            // 使用Path2D的接口构造一个五边形
            let path = new Path2D()
            path.moveTo(150, 50)
            path.lineTo(50, 150)
            path.lineTo(100, 250)
            path.lineTo(200, 250)
            path.lineTo(250, 150)
            path.closePath()
            // 设定填充色为蓝色
            this.context.fillStyle = '#0097D4'
            // 使用填充的方式,将Path2D描述的五边形绘制在canvas组件内部
            this.context.fill(path)
          })
      }
      .width('100%')
    }
    .height('100%')
  }
}

11.3 案例实战

本节通过两个大案例,展示基于 Canvas 的应用开发,涵盖抽奖转盘和自定义画布。

1 1.3.1 基于Canvas实现抽奖转盘

本案例基于画布组件、显式动画,实现的一个自定义抽奖圆形转盘。包含如下功能:

  1. 通过画布组件Canvas,画出抽奖圆形转盘。
  2. 通过显式动画启动抽奖功能。
  3. 通过自定义弹窗弹出抽中的奖品。

11.3.1.1 案例效果截图

11.3.1.2 案例运用到的知识点

  1. 核心知识点
  • Stack组件:堆叠容器,子组件按照顺序依次入栈,后一个子组件覆盖前一个子组件。
  • Canvas:画布组件,用于自定义绘制图形。
  • CanvasRenderingContext2D对象:使用RenderingContext在Canvas组件上进行绘制,绘制对象可以是矩形、文本、图片等。
  • 显式动画:提供全局animateTo显式动画接口来指定由于闭包代码导致的状态变化插入过渡动效。
  • 自定义弹窗:通过CustomDialogController类显示自定义弹窗。
  • 获取屏幕的宽高:通过window.getLastWindow获取屏幕的宽高。
  1. 其他知识点
  • ArkTS 语言基础
  • V2版状态管理:@ComponentV2/@Local/@Prop
  • 自定义组件和组件生命周期
  • 内置组件:Image/Column/Text
  • 日志管理类的编写
  • 常量与资源分类的访问
  • MVVM模式

11.3.1.3 代码结构

├──entry/src/main/ets	            // 代码区
│  ├──common
│  │  ├──constants
│  │  │  ├──ColorConstants.ets      // 颜色常量类
│  │  │  ├──CommonConstants.ets     // 公共常量类 
│  │  │  └──StyleConstants.ets      // 样式常量类 
│  │  └──utils
│  │     ├──CheckEmptyUtils.ets     // 数据判空工具类
│  │     └──Logger.ets              // 日志打印类
│  ├──entryability
│  │  └──EntryAbility.ts            // 程序入口类
│  ├──pages
│  │  └──CanvasPage.ets             // 主界面	
│  ├──view
│  │  └──PrizeDialog.ets            // 中奖信息弹窗类
│  └──viewmodel
│     ├──DrawModel.ets              // 画布相关方法类
│     ├──FillArcData.ets            // 绘制圆弧数据实体类
│     └──PrizeData.ets              // 中奖信息实体类
└──entry/src/main/resources         // 资源文件目录

11.3.1.4 公共文件与资源

本案例涉及到的常量类和工具类代码如下:

  1. 通用常量类
// main/ets/common/utils/CommonConstants.ets
export default class CommonConstants {
  static readonly WATERMELON_IMAGE_URL: string = 'resources/base/media/ic_watermelon.png'
  static readonly HAMBURG_IMAGE_URL: string = 'resources/base/media/ic_hamburg.png'
  static readonly CAKE_IMAGE_URL: string = 'resources/base/media/ic_cake.png'
  static readonly BEER_IMAGE_URL: string = 'resources/base/media/ic_beer.png'
  static readonly SMILE_IMAGE_URL: string = 'resources/base/media/ic_smile.png'
  static readonly TRANSFORM_ANGLE: number = -120
  static readonly CIRCLE: number = 360
  static readonly HALF_CIRCLE: number = 180
  static readonly COUNT: number = 6
  static readonly SMALL_CIRCLE_COUNT: number = 8
  static readonly IMAGE_SIZE: number = 40
  static readonly ANGLE: number = 270
  static readonly DURATION: number = 4000
  static readonly ONE: number = 1
  static readonly TWO: number = 2
  static readonly THREE: number = 3
  static readonly FOUR: number = 4
  static readonly FIVE: number = 5
  static readonly SIX: number = 6
  static readonly FLOWER_POINT_Y_RATIOS: number = 0.255
  static readonly FLOWER_RADIUS_RATIOS: number = 0.217
  static readonly FLOWER_INNER_RATIOS: number = 0.193
  static readonly OUT_CIRCLE_RATIOS: number = 0.4
  static readonly SMALL_CIRCLE_RATIOS: number = 0.378
  static readonly SMALL_CIRCLE_RADIUS: number = 4.1
  static readonly INNER_CIRCLE_RATIOS: number = 0.356
  static readonly INNER_WHITE_CIRCLE_RATIOS: number = 0.339
  static readonly INNER_ARC_RATIOS: number = 0.336
  static readonly IMAGE_DX_RATIOS: number = 0.114
  static readonly IMAGE_DY_RATIOS: number = 0.052
  static readonly ARC_START_ANGLE: number = 34
  static readonly ARC_END_ANGLE: number = 26
  static readonly TEXT_ALIGN: CanvasTextAlign = 'center'
  static readonly TEXT_BASE_LINE: CanvasTextBaseline = 'middle'
  static readonly CANVAS_FONT: string = 'px sans-serif'
}

export enum EnumeratedValue {
  ONE = 1,
  TWO = 2,
  THREE = 3,
  FOUR = 4,
  FIVE = 5,
  SIX = 6
}

2. 样式常量类

// main/ets/common/utils/StyleConstants.ets
export default class StyleConstants {
  static readonly FONT_WEIGHT: number = 500
  static readonly FULL_PERCENT: string = '100%'
  static readonly NINETY_PERCENT: string = '90%'
  static readonly BACKGROUND_IMAGE_SIZE: string = '38.7%'
  static readonly CENTER_IMAGE_WIDTH: string = '19.3%'
  static readonly CENTER_IMAGE_HEIGHT: string = '10.6%'
  static readonly DIALOG_BORDER_RADIUS: number = 30
  static readonly ARC_TEXT_SIZE: number = fp2px(14)
}

3. 颜色常量类

// main/ets/common/utils/ColorConstants.ets
export default class ColorConstants {
  static readonly FLOWER_OUT_COLOR: string = '#ED6E21'
  static readonly FLOWER_INNER_COLOR: string = '#F8A01E'
  static readonly OUT_CIRCLE_COLOR: string = '#F7CD03'
  static readonly WHITE_COLOR: string = '#FFFFFF'
  static readonly INNER_CIRCLE_COLOR: string = '#F8A01E'
  static readonly ARC_PINK_COLOR: string = '#FFC6BD'
  static readonly ARC_YELLOW_COLOR: string = '#FFEC90'
  static readonly ARC_GREEN_COLOR: string = '#ECF9C7'
  static readonly TEXT_COLOR: string = '#ED6E21'
}

4. 用于检查数据是否为空的工具类

// main/ets/common/utils/CheckEmptyUtils.ets
class CheckEmptyUtils {
  isEmptyObj(obj: object | string) {
    return (typeof obj === 'undefined' || obj === null || obj === '')
  }

  isEmptyStr(str: string) {
    return str.trim().length === 0
  }

  isEmptyArr(arr: Array<string>) {
    return arr.length === 0
  }
}

export default new CheckEmptyUtils()

5. 日志类

// main/ets/common/utils/Logger.ets
import { hilog } from '@kit.PerformanceAnalysisKit'

class Logger {
  private domain: number
  private prefix: string
  private format: string = '%{public}s, %{public}s'

  constructor(prefix: string = 'MyApp', domain: number = 0xFF00) {
    this.prefix = prefix
    this.domain = domain
  }

  debug(...args: string[]): void {
    hilog.debug(this.domain, this.prefix, this.format, args)
  }

  info(...args: string[]): void {
    hilog.info(this.domain, this.prefix, this.format, args)
  }

  warn(...args: string[]): void {
    hilog.warn(this.domain, this.prefix, this.format, args)
  }

  error(...args: string[]): void {
    hilog.error(this.domain, this.prefix, this.format, args)
  }
}

export default new Logger('[CanvasComponent]', 0xFF00)

本案例涉及到的资源文件如下:

  1. string.json
// main/resources/base/element/string.json
{
  "string": [
    {
      "name": "module_desc",
      "value": "模块描述"
    },
    {
      "name": "EntryAbility_desc",
      "value": "description"
    },
    {
      "name": "EntryAbility_label",
      "value": "CanvasComponent"
    },
    {
      "name": "text_confirm",
      "value": "确定"
    },
    {
      "name": "text_smile",
      "value": "谢谢"
    },
    {
      "name": "text_hamburger",
      "value": "汉堡"
    },
    {
      "name": "text_cake",
      "value": "蛋糕"
    },
    {
      "name": "text_beer",
      "value": "啤酒"
    },
    {
      "name": "text_watermelon",
      "value": "西瓜"
    },
    {
      "name": "prize_text_smile",
      "value": "sorry,您没有中奖"
    },
    {
      "name": "prize_text_hamburger",
      "value": "恭喜您,抽中奖品汉堡"
    },
    {
      "name": "prize_text_cake",
      "value": "恭喜您,抽中奖品蛋糕"
    },
    {
      "name": "prize_text_beer",
      "value": "恭喜您,抽中奖品啤酒"
    },
    {
      "name": "prize_text_watermelon",
      "value": "恭喜您,抽中奖品西瓜"
    }
  ]
}

2. float.json

// main/resources/base/element/float.json
{
  "float": [
    {
      "name": "dialog_image_size",
      "value": "90vp"
    },
    {
      "name": "dialog_image_top",
      "value": "55vp"
    },
    {
      "name": "dialog_image_bottom",
      "value": "48vp"
    },
    {
      "name": "dialog_font_size",
      "value": "16fp"
    },
    {
      "name": "dialog_message_bottom",
      "value": "20vp"
    },
    {
      "name": "dialog_height",
      "value": "277vp"
    }
  ]
}

3. color.json

// main/resources/base/element/color.json
{
  "color": [
    {
      "name": "start_window_background",
      "value": "#FFFFFF"
    },
    {
      "name": "dialog_background",
      "value": "#FFFFFF"
    },
    {
      "name": "text_font_color",
      "value": "#007DFF"
    }
  ]
}

其他资源请到源码中获取。

11.3.1.5 绘制主界面

实现主界面的页面构建,并通过Canvas绘制静态抽奖转盘。

  1. 页面构建
// main/ets/pages/Index.ets
import { window } from '@kit.ArkUI'
import Logger from '../common/utils/Logger'
import DrawModel from '../viewmodel/DrawModel'
import StyleConstants from '../common/constants/StyleConstants'
import CommonConstants from '../common/constants/CommonConstants'

let context = getContext(this)

@Entry
@ComponentV2
struct CanvasPage {
  private settings: RenderingContextSettings = 
    new RenderingContextSettings(true)
  private canvasContext: CanvasRenderingContext2D = 
    new CanvasRenderingContext2D(this.settings)

  @Local screenWidth: number = 0
  @Local screenHeight: number = 0
  @Local rotateDegree: number = 0
  @Local drawModel: DrawModel = new DrawModel()

  aboutToAppear() {
    window.getLastWindow(context)
      .then((windowClass: window.Window) => {
        windowClass.setWindowLayoutFullScreen(true)
        let windowProperties = windowClass.getWindowProperties()
        this.screenWidth = px2vp(windowProperties.windowRect.width)
        this.screenHeight = px2vp(windowProperties.windowRect.height)
      })
      .catch((error: Error) => {
        Logger.error('Error: ' + JSON.stringify(error))
      })
  }

  build() {
    Stack({ alignContent: Alignment.Center }) {
      Canvas(this.canvasContext)
        .width(StyleConstants.FULL_PERCENT)
        .height(StyleConstants.FULL_PERCENT)
        .onReady(() => {
          this.drawModel.draw(
            this.canvasContext, 
            this.screenWidth, this.screenHeight
          )
        })
        .rotate({
          x: 0,
          y: 0,
          z: 1,
          angle: this.rotateDegree,
          centerX: this.screenWidth / CommonConstants.TWO,
          centerY: this.screenHeight / CommonConstants.TWO
        })

      Image($r('app.media.ic_center'))
        .width(StyleConstants.CENTER_IMAGE_WIDTH)
        .height(StyleConstants.CENTER_IMAGE_HEIGHT)
    }
    .width(StyleConstants.FULL_PERCENT)
    .height(StyleConstants.FULL_PERCENT)
    .backgroundImage($r('app.media.ic_background'), ImageRepeat.NoRepeat)
    .backgroundImageSize({
      width: StyleConstants.FULL_PERCENT,
      height: StyleConstants.BACKGROUND_IMAGE_SIZE
    })
  }
}

关键代码说明:

  • 在绘制抽奖圆形转盘前,首先需要在CanvasPage.ets的aboutToAppear方法中获取屏幕的宽高。
  • 在CanvasPage.ets布局界面中添加Canvas组件,在onReady方法中进行绘制。
  • 在DrawModel.ets中,通过draw方法逐步进行自定义圆形抽奖转盘的绘制。
  1. 绘制类(ViewModel)

通过DrawModel类的draw方法实现绘制外部圆盘和内部扇形抽奖区域。

// main/ets/viewmodel/DrawModel.ets
import ColorConstants from '../common/constants/ColorConstants'
import CommonConstants from '../common/constants/CommonConstants'
import StyleConstants from '../common/constants/StyleConstants'
import CheckEmptyUtils from '../common/utils/CheckEmptyUtils'
import Logger from '../common/utils/Logger'
import FillArcData from '../model/FillArcData'

// 画抽奖圆形转盘
export default class DrawModel {
  private startAngle: number = 0
  private avgAngle: number = CommonConstants.CIRCLE / CommonConstants.COUNT
  private screenWidth: number = 0
  private canvasContext?: CanvasRenderingContext2D

  draw(
    canvasContext: CanvasRenderingContext2D,
    screenWidth: number,
    screenHeight: number
  ) {
    if (CheckEmptyUtils.isEmptyObj(canvasContext)) {
      Logger.error('[DrawModel][draw] canvasContext is empty.')
      return
    }
    this.canvasContext = canvasContext
    this.screenWidth = screenWidth
    this.canvasContext.clearRect(0, 0, this.screenWidth, screenHeight)
    // 将画布沿X和Y轴平移指定距离
    this.canvasContext.translate(this.screenWidth / CommonConstants.TWO,
      screenHeight / CommonConstants.TWO)
    // 涂绘的外盘花瓣
    this.drawFlower()
    // 画外圆盘和小圆
    this.drawOutCircle()
    // 绘制内圆盘
    this.drawInnerCircle()
    // 绘制内部扇形抽奖区域
    this.drawInnerArc()
    // 在内部扇形区域绘制文本
    this.drawArcText()
    // 在内部扇形区域绘制与奖品对应的图片
    this.drawImage()
    this.canvasContext.translate(-this.screenWidth / CommonConstants.TWO,
      -screenHeight / CommonConstants.TWO)
  }

  // 画弧线方法
  fillArc(fillArcData: FillArcData, fillColor: string) {
    if (CheckEmptyUtils.isEmptyObj(fillArcData) ||
    CheckEmptyUtils.isEmptyStr(fillColor)
    ) {
      Logger.error('[DrawModel][fillArc] fillArcData or fillColor is empty.')
      return
    }
    if (this.canvasContext !== undefined) {
      this.canvasContext.beginPath()
      this.canvasContext.fillStyle = fillColor
      this.canvasContext.arc(fillArcData.x, fillArcData.y, fillArcData.radius,
        fillArcData.startAngle, fillArcData.endAngle)
      this.canvasContext.fill()
    }
  }

  // 画外部圆盘的花瓣
  drawFlower() {
    let beginAngle = this.startAngle + this.avgAngle
    const pointY = this.screenWidth * CommonConstants.FLOWER_POINT_Y_RATIOS
    const radius = this.screenWidth * CommonConstants.FLOWER_RADIUS_RATIOS
    const innerRadius = this.screenWidth * CommonConstants.FLOWER_INNER_RATIOS
    for (let i = 0; i < CommonConstants.COUNT; i++) {
      this.canvasContext?.save()
      this.canvasContext?.rotate(
        beginAngle * Math.PI / CommonConstants.HALF_CIRCLE
      )
      this.fillArc(
        new FillArcData(0, -pointY, radius, 0, Math.PI * CommonConstants.TWO),
        ColorConstants.FLOWER_OUT_COLOR
      )

      this.fillArc(
        new FillArcData(
          0, -pointY, innerRadius, 0, Math.PI * CommonConstants.TWO
        ),
        ColorConstants.FLOWER_INNER_COLOR
      )
      beginAngle += this.avgAngle
      this.canvasContext?.restore()
    }
  }

  drawOutCircle() {
    // 绘制外盘
    this.fillArc(
      new FillArcData(0, 0,
        this.screenWidth * CommonConstants.OUT_CIRCLE_RATIOS, 0,
        Math.PI * CommonConstants.TWO
      ),
      ColorConstants.OUT_CIRCLE_COLOR
    )

    let beginAngle = this.startAngle
    // 绘制小圆圈
    for (let i = 0; i < CommonConstants.SMALL_CIRCLE_COUNT; i++) {
      this.canvasContext?.save()
      this.canvasContext?.rotate(
        beginAngle * Math.PI / CommonConstants.HALF_CIRCLE
      )
      this.fillArc(
        new FillArcData(
          this.screenWidth * CommonConstants.SMALL_CIRCLE_RATIOS, 0,
          CommonConstants.SMALL_CIRCLE_RADIUS, 0, Math.PI * CommonConstants.TWO
        ),
        ColorConstants.WHITE_COLOR
      )
      beginAngle = beginAngle
        + CommonConstants.CIRCLE / CommonConstants.SMALL_CIRCLE_COUNT
      this.canvasContext?.restore()
    }
  }

  // 绘制内盘
  drawInnerCircle() {
    this.fillArc(
      new FillArcData(0, 0,
        this.screenWidth * CommonConstants.INNER_CIRCLE_RATIOS, 0,
        Math.PI * CommonConstants.TWO
      ),
      ColorConstants.INNER_CIRCLE_COLOR
    )
    this.fillArc(
      new FillArcData(0, 0,
        this.screenWidth * CommonConstants.INNER_WHITE_CIRCLE_RATIOS, 0,
        Math.PI * CommonConstants.TWO
      ),
      ColorConstants.WHITE_COLOR
    )
  }

  // 绘制内部扇形抽奖区域
  drawInnerArc() {
    let colors = [
      ColorConstants.ARC_PINK_COLOR, ColorConstants.ARC_YELLOW_COLOR,
      ColorConstants.ARC_GREEN_COLOR, ColorConstants.ARC_PINK_COLOR,
      ColorConstants.ARC_YELLOW_COLOR, ColorConstants.ARC_GREEN_COLOR
    ]
    let radius = this.screenWidth * CommonConstants.INNER_ARC_RATIOS
    for (let i = 0; i < CommonConstants.COUNT; i++) {
      this.fillArc(
        new FillArcData(0, 0, radius,
          this.startAngle * Math.PI / CommonConstants.HALF_CIRCLE,
          (this.startAngle + this.avgAngle)
            * Math.PI / CommonConstants.HALF_CIRCLE
        ),
        colors[i]
      )
      this.canvasContext?.lineTo(0, 0)
      this.canvasContext?.fill()
      this.startAngle += this.avgAngle
    }
  }

  // 画内部扇形区域文字
  drawArcText() {
    if (this.canvasContext !== undefined) {
      this.canvasContext.textAlign = CommonConstants.TEXT_ALIGN
      this.canvasContext.textBaseline = CommonConstants.TEXT_BASE_LINE
      this.canvasContext.fillStyle = ColorConstants.TEXT_COLOR
      this.canvasContext.font =
        StyleConstants.ARC_TEXT_SIZE + CommonConstants.CANVAS_FONT
    }
    // 需要绘制的文本数组集合
    let textArrays = [
      $r('app.string.text_smile'),
      $r('app.string.text_hamburger'),
      $r('app.string.text_cake'),
      $r('app.string.text_smile'),
      $r('app.string.text_beer'),
      $r('app.string.text_watermelon')
    ]
    let arcTextStartAngle = CommonConstants.ARC_START_ANGLE
    let arcTextEndAngle = CommonConstants.ARC_END_ANGLE
    for (let i = 0; i < CommonConstants.COUNT; i++) {
      this.drawCircularText(this.getResourceString(
        textArrays[i]),
        (this.startAngle + arcTextStartAngle)
          * Math.PI / CommonConstants.HALF_CIRCLE,
        (this.startAngle + arcTextEndAngle)
          * Math.PI / CommonConstants.HALF_CIRCLE)
      this.startAngle += this.avgAngle
    }
  }

  getResourceString(resource: Resource): string {
    if (CheckEmptyUtils.isEmptyObj(resource)) {
      Logger.error('[getResourceString] resource is empty.')
      return ''
    }
    let resourceString: string = ''
    try {
      resourceString =
        getContext(this).resourceManager.getStringSync(resource.id)
    } catch (error) {
      Logger.error(`[getResourceString] error : ${JSON.stringify(error)}.`)
    }
    return resourceString
  }

  // 绘制圆弧文本
  drawCircularText(textString: string, startAngle: number, endAngle: number) {
    if (CheckEmptyUtils.isEmptyStr(textString)) {
      Logger.error('[drawCircularText] textString is empty.')
      return
    }

    class CircleText {
      x: number = 0
      y: number = 0
      radius: number = 0
    }

    let circleText: CircleText = {
      x: 0,
      y: 0,
      radius: this.screenWidth * CommonConstants.INNER_ARC_RATIOS
    }
    // 圆的半径
    let radius = circleText.radius - circleText.radius / CommonConstants.COUNT
    // 每个字母所占的弧度
    let angleDecrement = (startAngle - endAngle) / (textString.length - 1)
    let angle = startAngle
    let index = 0
    let character: string

    while (index < textString.length) {
      character = textString.charAt(index)
      this.canvasContext?.save()
      this.canvasContext?.beginPath()
      this.canvasContext?.translate(circleText.x + Math.cos(angle) * radius,
        circleText.y - Math.sin(angle) * radius)
      this.canvasContext?.rotate(Math.PI / CommonConstants.TWO - angle)
      this.canvasContext?.fillText(character, 0, 0)
      angle -= angleDecrement
      index++
      this.canvasContext?.restore()
    }
  }

  // 画内部扇形区域文字对应的图片
  drawImage() {
    let beginAngle = this.startAngle
    let imageSrc: string[] = [
      CommonConstants.WATERMELON_IMAGE_URL, CommonConstants.BEER_IMAGE_URL,
      CommonConstants.SMILE_IMAGE_URL, CommonConstants.CAKE_IMAGE_URL,
      CommonConstants.HAMBURG_IMAGE_URL, CommonConstants.SMILE_IMAGE_URL
    ]
    for (let i = 0; i < CommonConstants.COUNT; i++) {
      let image = new ImageBitmap(imageSrc[i])
      this.canvasContext?.save()
      this.canvasContext?.rotate(
        beginAngle * Math.PI / CommonConstants.HALF_CIRCLE
      )
      this.canvasContext?.drawImage(image,
        this.screenWidth * CommonConstants.IMAGE_DX_RATIOS,
        this.screenWidth * CommonConstants.IMAGE_DY_RATIOS,
        CommonConstants.IMAGE_SIZE,
        CommonConstants.IMAGE_SIZE
      )
      beginAngle += this.avgAngle
      this.canvasContext?.restore()
    }
  }
}

关键代码说明:

  • 画外部圆盘的花瓣:通过调用rotate()方法,将画布旋转指定角度。再通过调用save()和restore()方法,使画布保存最新的绘制状态。根据想要绘制的花瓣个数,改变旋转角度,循环画出花瓣效果。
  • 画外部圆盘、圆盘边上的小圈圈:在指定的X、Y(0, 0)坐标处,画一个半径为this.screenWidth * CommonConstants.OUT_CIRCLE_RATIOS的圆形。接下来通过一个for循环,且角度每次递增CommonConstants.CIRCLE / CommonConstants.SMALL_CIRCLE_COUNT,来绘制圆环上的小圈圈。
  • 画内部圆盘、内部扇形抽奖区域:使用fillArc方法绘制内部圆盘。通过一个for循环,角度每次递增this.avgAngle。然后不断更改弧线的起始弧度this.startAngle * Math.PI / CommonConstants.HALF_CIRCLE和弧线的终止弧度(this.startAngle + this.avgAngle) * Math.PI / CommonConstants.HALF_CIRCLE。最后用fill()方法对路径进行填充。
  • 画内部抽奖区域文字:用for循环,通过drawCircularText()方法绘制每组文字。drawCircularText()方法接收三个参数,分别是字符串,起始弧度和终止弧度。绘制文本前需要设置每个字母占的弧度angleDecrement,然后设置水平和垂直的偏移量。垂直偏移量circleText.y - Math.sin(angle) * radius就是朝着圆心移动的距离;水平偏移circleText.x + Math.cos(angle) * radius,是为了让文字在当前弧范围文字居中。最后使用fillText方法绘制文本。
  • 画内部抽奖区域文字对应图片:使用drawImage方法绘制抽奖区域文字对应图片,该方法接收该方法接收五个参数,分别是图片资源、绘制区域左上角的X和Y轴坐标、绘制区域的宽度和高度。
  1. 数据模型类(Model)

通过FillArcData类定义圆盘的数据模型。

// main/ets/model/FillArcData.ets
export default class FillArcData {
  x: number
  y: number
  radius: number
  startAngle: number
  endAngle: number

  constructor(x: number, y: number, radius: number, startAngle: number, endAngle: number) {
    this.x = x
    this.y = y
    this.radius = radius
    this.startAngle = startAngle
    this.endAngle = endAngle
  }
}

11.3.1.6 点击“开始”抽奖

实现抽奖功能。

  1. 在主界面代码里给“开始”按钮绑定单击事件,实现旋转轮盘抽奖效果。
// main/ets/pages/Index.ets
// ...

@Entry
@ComponentV2
struct CanvasPage {
  // ...
  
  build() {
    Stack({ alignContent: Alignment.Center }) {
      // ...

      Image($r('app.media.ic_center'))
        // ...
        .enabled(this.enableFlag)
        .onClick(() => {
          this.enableFlag = !this.enableFlag
          this.startAnimator()
        })
    }
    
    //...
  }

  startAnimator() {
    let randomAngle = Math.round(Math.random() * CommonConstants.CIRCLE)

    animateTo({
      duration: CommonConstants.DURATION,
      curve: Curve.Ease,
      delay: 0,
      iterations: 1,
      playMode: PlayMode.Normal,
      onFinish: () => {
        this.rotateDegree = CommonConstants.ANGLE - randomAngle
      }
    }, () => {
      this.rotateDegree = CommonConstants.CIRCLE * CommonConstants.FIVE +
      CommonConstants.ANGLE - randomAngle
    })
  }
}

关键代码说明:

  • 给“开始”图片按钮添加单击事件,单击后使按钮不可点击,并启动抽奖动画。
  • 应用animateTo定义显示过渡动效。
  1. 在绘制类里添加获取和现实获奖结果信息。
// main/ets/viewmodel/DrawModel.ets
//...
import PrizeData from '../model/PrizeData'
import { EnumeratedValue } from '../common/constants/CommonConstants'

// 画抽奖圆形转盘
export default class DrawModel {
  // ...
  private avgAngle: number = CommonConstants.CIRCLE / CommonConstants.COUNT
  //...

  //...
  
  showPrizeData(randomAngle: number): PrizeData {
    for (let i = 1; i <= CommonConstants.COUNT; i++) {
      if (randomAngle <= i * this.avgAngle) {
        return this.getPrizeData(i)
      }
    }
    return new PrizeData()
  }

  getPrizeData(scopeNum: number): PrizeData {
    let prizeData: PrizeData = new PrizeData()
    switch (scopeNum) {
      case EnumeratedValue.ONE:
        prizeData.message = $r('app.string.prize_text_watermelon')
        prizeData.imageSrc = CommonConstants.WATERMELON_IMAGE_URL
        break
      case EnumeratedValue.TWO:
        prizeData.message = $r('app.string.prize_text_beer')
        prizeData.imageSrc = CommonConstants.BEER_IMAGE_URL
        break
      case EnumeratedValue.THREE:
        prizeData.message = $r('app.string.prize_text_smile')
        prizeData.imageSrc = CommonConstants.SMILE_IMAGE_URL
        break
      case EnumeratedValue.FOUR:
        prizeData.message = $r('app.string.prize_text_cake')
        prizeData.imageSrc = CommonConstants.CAKE_IMAGE_URL
        break
      case EnumeratedValue.FIVE:
        prizeData.message = $r('app.string.prize_text_hamburger')
        prizeData.imageSrc = CommonConstants.HAMBURG_IMAGE_URL
        break
      case EnumeratedValue.SIX:
        prizeData.message = $r('app.string.prize_text_smile')
        prizeData.imageSrc = CommonConstants.SMILE_IMAGE_URL
        break
      default:
        break
    }
    return prizeData
  }
}

关键代码说明:

  • 定义avgAngle变量,计算转盘每个奖品的平均角度。
  • showPrizeData方法,根据随机角度计算中奖结果,并返回对应的奖品数据。
  • getPrizeData方法,根据中奖编号获取对应的奖品信息。
  1. 定义获奖结果数据模型
// main/ets/model/PrizeData.ets
export default class PrizeData {
  message?: Resource
  imageSrc?: string
}

11.2.1.7 显示抽奖结果

通过自定义弹窗显示抽奖的结果。

  1. 在主界面页面中添加 自定义弹窗,并在抽奖动画停止后打开弹窗。
// main/ets/pages/Index.ets
//...
import { ComponentContent } from '@kit.ArkUI'
import { PromptActionClass } from '../common/dialog/PromptActionClass'
import DialogData from '../model/DialogData'
import buildPrizeDialog from '../view/PrizeDialog'

@Entry
@ComponentV2
struct CanvasPage {
  //...
  
  @Local enableFlag: boolean = true
  @Local prizeData: PrizeData = new PrizeData()

  @Local enableFlag: boolean = true
  @Local prizeData: PrizeData = new PrizeData()

  private ctx: UIContext = this.getUIContext()
  private contentNode: ComponentContent<Object> =
    new ComponentContent(
      this.ctx,
      wrapBuilder(buildPrizeDialog),
      new DialogData(this.prizeData, () => {})
    )

  aboutToAppear() {
    //...

    PromptActionClass.setContext(this.ctx)
    PromptActionClass.setContentNode(this.contentNode)
    PromptActionClass.setOptions({ alignment: DialogAlignment.Center})
  }

  //...

  startAnimator() {
    //...
    
    // 获取奖品信息
    this.prizeData = this.drawModel.showPrizeData(randomAngle)

    animateTo({
      //...
      onFinish: () => {
        // ...
        
        // 显示奖品信息弹出窗口
        PromptActionClass.openDialog()
        // 更新弹窗数据
        this.contentNode.update(
          new DialogData(
            this.prizeData, 
            // 点击弹窗"确定"时,执行此函数,才可再次抽奖
            () => this.enableFlag = !this.enableFlag
          )
        )
      }
    }, () => {
      //...
    })
  }
}

关键代码说明:

  • this.contentNode.update更新弹出框中自定义组件的内容
  • 由于不允许在@Builder装饰的函数内部修改参数值,本例采用传递函数作为参数的方式,从而实现在组件作用域里修改enableFlag的状态。
  1. 显示获奖结果弹窗的全局Builder
// main/ets/view/PrizeDialog.ets
import StyleConstants from '../common/constants/StyleConstants'
import CommonConstants from '../common/constants/CommonConstants'
import { PromptActionClass } from '../common/dialog/PromptActionClass'
import DialogData from '../model/DialogData'

@Builder
export default function buildPrizeDialog(params: DialogData) {
  Column() {
    Image(params.prizeData?.imageSrc !== undefined ? params.prizeData.imageSrc : '')
      .width($r('app.float.dialog_image_size'))
      .height($r('app.float.dialog_image_size'))
      .margin({
        top: $r('app.float.dialog_image_top'),
        bottom: $r('app.float.dialog_image_bottom')
      })
      .rotate({
        x: 0,
        y: 0,
        z: 1,
        angle: CommonConstants.TRANSFORM_ANGLE
      })

    Text(params.prizeData?.message)
      .fontSize($r('app.float.dialog_font_size'))
      .textAlign(TextAlign.Center)
      .margin({ bottom: $r('app.float.dialog_message_bottom') })

    Text($r('app.string.text_confirm'))
      .fontColor($r('app.color.text_font_color'))
      .fontWeight(StyleConstants.FONT_WEIGHT)
      .fontSize($r('app.float.dialog_font_size'))
      .textAlign(TextAlign.Center)
      .onClick(() => {
        PromptActionClass.closeDialog()
        params.changeEnableFlag()
      })
  }
  .backgroundColor($r('app.color.dialog_background'))
  .width(StyleConstants.NINETY_PERCENT)
  .borderRadius(StyleConstants.DIALOG_BORDER_RADIUS)
  .height($r('app.float.dialog_height'))
}

关键代码说明:

  • PromptActionClass.closeDialog(),关闭弹窗。
  • params.changeEnableFlag(),执行父组件传递过来的函数,激活“开始”按钮。
  1. 定义对话框数据模型
// main/ets/model/DialogData.ets
import PrizeData from "./PrizeData"

export default class DialogData {
  prizeData: PrizeData
  changeEnableFlag: () => void

  constructor(prizeData: PrizeData, changeEnableFlag: () => void) {
    this.prizeData = prizeData
    this.changeEnableFlag = changeEnableFlag
  }
}

4. 不依赖UI组件的全局自定义弹出框公共类

// main/ets/common/dialog/PromptActionClass.ets
import { BusinessError } from '@kit.BasicServicesKit'
import { ComponentContent, promptAction } from '@kit.ArkUI'
import { UIContext } from '@ohos.arkui.UIContext'

export class PromptActionClass {
  static ctx: UIContext
  static contentNode: ComponentContent<Object>
  static options: promptAction.BaseDialogOptions

  static setContext(context: UIContext) {
    PromptActionClass.ctx = context
  }

  static setContentNode(node: ComponentContent<Object>) {
    PromptActionClass.contentNode = node
  }

  static setOptions(options: promptAction.BaseDialogOptions) {
    PromptActionClass.options = options
  }

  static openDialog() {
    if (PromptActionClass.contentNode !== null) {
      PromptActionClass.ctx.getPromptAction()
        .openCustomDialog(
          PromptActionClass.contentNode, PromptActionClass.options)
        .then(() => {
          console.info('OpenCustomDialog complete.')
        })
        .catch((error: BusinessError) => {
          let message = (error as BusinessError).message
          let code = (error as BusinessError).code
          console.error(`error code:${code}, message is ${message}`)
        })
    }
  }

  static closeDialog() {
    if (PromptActionClass.contentNode !== null) {
      PromptActionClass.ctx.getPromptAction()
        .closeCustomDialog(PromptActionClass.contentNode)
        .then(() => {
          console.info('CloseCustomDialog complete.')
        })
        .catch((error: BusinessError) => {
          let message = (error as BusinessError).message
          let code = (error as BusinessError).code
          console.error(`error code is ${code}, message is ${message}`)
        })
    }
  }

  static updateDialog(options: promptAction.BaseDialogOptions) {
    if (PromptActionClass.contentNode !== null) {
      PromptActionClass.ctx.getPromptAction()
        .updateCustomDialog(PromptActionClass.contentNode, options)
        .then(() => {
          console.info('UpdateCustomDialog complete.')
        })
        .catch((error: BusinessError) => {
          let message = (error as BusinessError).message
          let code = (error as BusinessError).code
          console.error(`error code is ${code}, message is ${message}`)
        })
    }
  }
}

代码说明:

此类为自定义弹框的公共类,复制代码直接使用即可。不依赖UI组件的全局自定义弹出框 (openCustomDialog)是推荐实现方法,不再推荐使用@CustomDialog装饰器。

11.3.1.8 代码与视频教程

完整案例代码与视频教程请参见:

代码:Code-11-01.zip。

视频:《基于Canvas实现抽奖转盘》。

11.3.2 基于Canvas实现画布

案例实现的功能和操作说明如下:

  1. 进入首页后,下方有五个按钮,画笔默认选中,可以在空白部分进行绘画,默认粗细是3,颜色是黑色不透明。
  2. 进行绘制后,撤回按钮会高亮可点击,点击后可以撤回上一步的笔画;同时重做按钮会高亮可点击,点击重做后还原上一步撤销的笔画。
  3. 点击橡皮擦按钮后,手指绘画就会实现擦除效果,画笔按钮取消高亮。
  4. 点击清空按钮会清空整个画布的所有绘制,同时清空按钮高亮。
  5. 点击画笔按钮,会弹出半模态弹窗,在弹窗内可以选择画笔的类型、颜色、不透明度和粗细。
  6. 本示例只展示圆珠笔和马克笔两种类型,圆珠笔为默认类型,这里可以点击马克笔,关闭弹窗再次进行绘画,笔画就变为马克笔。
  7. 再次点击画笔唤起弹窗,可以切换颜色、不透明度和粗细,不透明度只有马克笔可以切换,圆珠笔不可以切换。
  8. 双指捏合画布缩小,双指外扩画布放大,缩放时画笔按钮取消高亮,缩放结束后,点击画笔就可以再次进行绘制。

11.3.2.1 案例效果截图

11.3.2.2 案例运用到的知识点

  1. 核心知识点
  • Canvas:画布组件,用于自定义绘制图形。
  • 手势事件:onTouch/gesture。
  • 折叠屏适配:display。
  1. 其他知识点
  • ArkTS 语言基础
  • V2版状态管理:@ComponentV2/@Local/@Param/@Event/!!语法/@Provider/@Consumer/@Monitor
  • 渲染控制:if/ForEach
  • 自定义组件和组件生命周期
  • 自定义构建函数@Builder
  • 内置组件:Stack/Slider/Image/Column/Row/Text/Button
  • 常量与资源分类的访问
  • MVVM模式

11.3.2.3 代码结构

├──entry/src/main/ets/
│  ├──common
│  │  └──CommonConstants.ets         // 公共常量类
│  ├──entryability
│  │  └──EntryAbility.ets            // 程序入口类
│  ├──pages                  
│  │  └──Index.ets                   // 首页
│  ├──view   
│  │  └──myPaintSheet.ets            // 半模态页面
│  └──viewmodel
│     ├──DrawInvoker.ets             // 绘制方法
│     ├──IBrush.ets                  // 笔刷接口
│     ├──IDraw.ets                   // 绘制类
│     └──Paint.ets                   // 绘制属性类
└──entry/src/main/resources          // 应用静态资源目录

11.3.2.4 公共文件与资源

本案例涉及到的常量类和工具类代码如下:

  1. 通用常量类
// main/ets/common/utils/CommonConstants.ets
export class CommonConstants {
  static readonly ZERO: number = 0
  static readonly ONE: number = 1
  static readonly NEGATIVE_ONE: number = -1
  static readonly THREE: number = 3
  static readonly TEN: number = 10
  static readonly TWENTY_ONE: number = 21
  static readonly CANVAS_WIDTH: number = 750
  static readonly ONE_HUNDRED: number = 100
  static readonly COLOR_STRING: string = ''
  static readonly SIGN: string = '%'
  static readonly BLACK: string = 'black'
  static readonly ONE_HUNDRED_PERCENT: string = '100%'
  static readonly COLOR_ARR: string[] = ['#E90808', '#63B959', '#0A59F7', '#E56224', '#F6C800', '#5445EF', '#A946F1',
    '#000000']
  static readonly WHITE: string = '#ffffff'
  static readonly DETENTS: [Length, Length] = [550, 600]
}

本案例涉及到的资源文件如下:

  1. string.json
// main/resources/base/element/string.json
{
  "string": [
    {
      "name": "module_desc",
      "value": "module description"
    },
    {
      "name": "EntryAbility_desc",
      "value": "description"
    },
    {
      "name": "EntryAbility_label",
      "value": "自定义Canvas画布"
    },
    {
      "name": "paint",
      "value": "画笔"
    },
    {
      "name": "brash",
      "value": "笔刷"
    },
    {
      "name": "ballpoint",
      "value": "圆珠笔"
    },
    {
      "name": "marker",
      "value": "马克笔"
    },
    {
      "name": "pencil",
      "value": "铅笔"
    },
    {
      "name": "fountain_pen",
      "value": "钢笔"
    },
    {
      "name": "laser_pointer",
      "value": "激光笔"
    },
    {
      "name": "color",
      "value": "颜色"
    },
    {
      "name": "opacity",
      "value": "不透明度"
    },
    {
      "name": "thicknesses",
      "value": "粗细"
    },
    {
      "name": "rubber",
      "value": "橡皮擦"
    },
    {
      "name": "redo",
      "value": "撤回"
    },
    {
      "name": "undo",
      "value": "重做"
    },
    {
      "name": "clear",
      "value": "清空"
    }
  ]
}

2. float.json

// main/resources/base/element/float.json
{
  "float": [
    {
      "name": "font_size",
      "value": "14fp"
    },
    {
      "name": "margin_bottom",
      "value": "20vp"
    },
    {
      "name": "back_width",
      "value": "32vp"
    },
    {
      "name": "image_width",
      "value": "24vp"
    },
    {
      "name": "border_radius",
      "value": "16vp"
    },
    {
      "name": "border_radius_m",
      "value": "12vp"
    },
    {
      "name": "margin_top",
      "value": "10vp"
    },
    {
      "name": "font_size_m",
      "value": "10fp"
    },
    {
      "name": "font_size_l",
      "value": "12fp"
    },
    {
      "name": "brash_width",
      "value": "58vp"
    },
    {
      "name": "brash_height",
      "value": "59vp"
    },
    {
      "name": "padding_left",
      "value": "18vp"
    },
    {
      "name": "title_bottom",
      "value": "30vp"
    },
    {
      "name": "paint_width",
      "value": "72vp"
    },
    {
      "name": "paint_height",
      "value": "52vp"
    },
    {
      "name": "slider_width",
      "value": "242vp"
    },
    {
      "name": "number",
      "value": "47vp"
    },
    {
      "name": "bottom",
      "value": "5vp"
    },
    {
      "name": "height",
      "value": "550vp"
    }
  ]
}

3. color.json

// main/resources/base/element/color.json
{
  "color": [
    {
      "name": "start_window_background",
      "value": "#FFFFFF"
    },
    {
      "name": "theme_color",
      "value": "#0A59F7"
    },
    {
      "name": "paint_color",
      "value": "#D8D8D8"
    },
    {
      "name": "linear_start",
      "value": "#000A59F7"
    },
    {
      "name": "linear_end",
      "value": "#F70A59F7"
    },
    {
      "name": "number_color",
      "value": "#0D000000"
    }
  ]
}

其他资源请到源码中获取。

11.3.2.5 画布主界面

// main/ets/pages/Index.ets
// 引入 ArkUI 显示模块
import { display } from '@kit.ArkUI'
// 引入命令模式实现类(用于管理绘图命令)
import DrawInvoker from '../viewmodel/DrawInvoker'
// 引入绘制路径接口
import DrawPath from '../viewmodel/IDraw'
// 引入画笔接口及实现
import { IBrush } from '../viewmodel/IBrush'
import NormalBrush from '../viewmodel/IBrush'
// 引入颜料属性类
import Paint from '../viewmodel/Paint'
// 引入通用常量
import { CommonConstants } from '../common/CommonConstants'
// 引入自定义底部设置面板组件
import { myPaintSheet } from '../view/myPaintSheet'

@Entry
@ComponentV2
struct DrawCanvas {
  // 定义组件的状态变量
  // 本地状态变量,用于管理组件内部状态
  @Local isDrawing: boolean = false // 是否正在绘制
  @Local unDoDraw: boolean = false // 是否可以撤销
  @Local redoDraw: boolean = false // 是否可以重做
  @Local isPaint: boolean = true // 是否为画笔模式(true 为画笔,false 为橡皮擦)
  @Local isShow: boolean = false // 是否显示设置面板
  @Local isMarker: boolean = false // 是否为标记模式(未使用)
  @Local scaleValueX: number = 1 // X 轴缩放比例
  @Local scaleValueY: number = 1 // Y 轴缩放比例
  @Local pinchValueX: number = 1 // 捏合手势的 X 轴基准值
  @Local pinchValueY: number = 1 // 捏合手势的 Y 轴基准值
  @Local strokeWidth: number = 3 // 画笔粗细
  @Local alpha: number = 1 // 透明度(0-1)
  @Local color: string = '#000000' // 画笔颜色
  @Local thicknessesValue: number = 3 // 显示用的粗细值
  @Local index: number = -1 // 手势操作索引
  @Local clean: boolean = false // 是否已清空画布
  @Local percent: string = '100' // 缩放百分比
  // 提供全局访问的颜料属性对象
  @Provider() mPaint: Paint = new Paint(0, '', 1)
  // 提供全局访问的画笔工具
  @Provider() mBrush: IBrush = new NormalBrush()

  // 画布渲染设置(开启抗锯齿)
  private setting: RenderingContextSettings = new RenderingContextSettings(true)
  // 2D 画布上下文对象
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.setting)
  // 命令执行器(管理绘制命令)
  private drawInvoker: DrawInvoker = new DrawInvoker()
  // 当前绘制的路径对象
  private path2Db: Path2D = new Path2D()
  // 绘制路径数据对象(包含颜料属性和路径)
  private mPath: DrawPath = new DrawPath(this.mPaint, this.path2Db)
  // 触摸点坐标缓存数组(用于手势识别)
  private arr: number[] = []

  // 监听 isDrawing 变化的回调方法
  @Monitor('isDrawing')
  createDraw() {
    if (this.isDrawing) {
      // 用白色填充画布背景
      this.context.fillStyle = Color.White
      this.context.fillRect(CommonConstants.ZERO, CommonConstants.ZERO, this.context.width, this.context.height)
      // 执行所有绘制命令
      this.drawInvoker.execute(this.context)
      this.isDrawing = false
    }
  }

  aboutToAppear(): void {
    // 初始化默认画笔属性
    this.mPaint = new Paint(CommonConstants.ZERO, CommonConstants.COLOR_STRING, CommonConstants.ONE)
    this.mPaint.setStrokeWidth(CommonConstants.THREE) // 设置默认笔触宽度为 3
    this.mPaint.setColor(CommonConstants.BLACK) // 设置默认颜色为黑色
    this.mPaint.setGlobalAlpha(CommonConstants.ONE) // 设置默认完全不透明
    // 使用普通画笔
    this.mBrush = new NormalBrush()

    // 监听折叠屏状态变化事件
    display.on('foldStatusChange', (data: display.FoldStatus) => {
      if (data === 2) {
        this.scaleValueX = 0.5
        this.pinchValueX = 0.5
        this.scaleValueY = 1
        this.pinchValueY = 1
        this.context.clearRect(0, 0, this.context.width, this.context.height)
        this.drawInvoker.execute(this.context)
      } else if (data === 1) {
        this.scaleValueX = 1
        this.scaleValueY = 1
        this.pinchValueX = 1
        this.pinchValueY = 1
        this.context.clearRect(0, 0, this.context.width, this.context.height)
        this.drawInvoker.execute(this.context)
      }
    })
  }

  /**
   * 添加绘制路径到命令执行器
   * @param path 要添加的绘制路径对象
   */
  add(path: DrawPath): void {
    this.drawInvoker.add(path)
  }

  // 更新画笔属性(颜色/粗细/透明度)
  ToggleThicknessColor(): void {
    // 创建新 Paint 对象并更新属性
    this.mPaint = new Paint(CommonConstants.ZERO, CommonConstants.COLOR_STRING, CommonConstants.ONE)
    this.mPaint.setStrokeWidth(this.strokeWidth) // 设置笔触宽度
    this.mPaint.setColor(this.color) // 设置颜色
    this.mPaint.setGlobalAlpha(this.alpha) // 设置透明度
    // 使用普通画笔
    this.mBrush = new NormalBrush()
  }

  // 执行撤销操作
  drawOperateUndo(): void {
    this.drawInvoker.undo() // 执行撤销
    this.isDrawing = true // 标记需要重绘
    // 更新按钮状态
    if (!this.drawInvoker.canUndo()) {
      this.unDoDraw = false
    }
    this.redoDraw = true
  }

  // 执行重做操作
  drawOperateRedo(): void {
    this.drawInvoker.redo() // 执行重做
    this.isDrawing = true // 标记需要重绘
    // 更新按钮状态
    if (!this.drawInvoker.canRedo()) {
      this.redoDraw = false
    }
    this.unDoDraw = true
  }

  // 清空画布操作
  clear(): void {
    this.drawInvoker.clear() // 清除所有绘制命令
    this.isDrawing = true // 标记需要重绘
    // 重置按钮状态
    this.redoDraw = false
    this.unDoDraw = false
  }

  // 构建底部设置面板的 Builder 方法
  @Builder
  myPaintSheet() {
    Column() {
      // 使用自定义设置面板组件
      myPaintSheet({
        isMarker: this.isMarker!!, // 传递标记模式状态
        alpha: this.alpha!!, // 当前透明度
        percent: this.percent!!, // 缩放百分比
        color: this.color!!, // 当前颜色
        thicknessesValue: this.thicknessesValue!!, // 显示用粗细值
        strokeWidth: this.strokeWidth!!, // 实际笔触宽度
      })
    }
  }

  build() {
    Stack({ alignContent: Alignment.Bottom }) {
      Canvas(this.context)
        .width(CommonConstants.CANVAS_WIDTH)
        .height(CommonConstants.CANVAS_WIDTH)
        .backgroundColor($r('sys.color.white'))
        .onTouch((event: TouchEvent) => { // 触摸事件处理
          this.clean = false // 重置清空状态
          // 多指操作或正在缩放时返回
          if (this.index === 1 || event.touches.length > 1) {
            return
          }
          // 记录触摸点坐标
          this.arr.push(event.touches[0].x + event.touches[0].y)

          // 手指按下事件处理
          if (event.touches.length === 1 && event.touches[0].id === 0 && event.type === TouchType.Down) {
            // 创建新路径
            this.mPath = new DrawPath(this.mPaint, this.path2Db)
            this.mPath.paint = this.mPaint
            this.mPath.path = new Path2D()
            // 记录起始点
            this.mBrush.down(this.mPath.path, event.touches[0].x, event.touches[0].y)
          }

          // 手指移动事件处理
          if (event.touches.length === 1 && event.touches[0].id === 0 && event.type === TouchType.Move) {
            // 更新路径
            this.mBrush.move(this.mPath.path, event.touches[0].x, event.touches[0].y)
            // 清空并重绘
            this.context.clearRect(0, 0, this.context.width, this.context.height)
            this.drawInvoker.execute(this.context)
            // 绘制当前路径(超过 4 个点后显示)
            if (this.arr.length > 4) {
              this.mPath.draw(this.context)
            }
          }

          // 手指抬起事件处理
          if (event.touches.length === 1 && event.touches[0].id === 0 && event.type === TouchType.Up) {
            this.add(this.mPath) // 添加路径到命令列表
            this.arr = [] // 清空坐标缓存
            // 更新按钮状态
            this.redoDraw = false
            this.unDoDraw = true
            this.isDrawing = true
            // 清空并重绘
            this.context.clearRect(0, 0, this.context.width, this.context.height)
            this.drawInvoker.execute(this.context)
          }
        })
          // 应用缩放变换
        .scale({
          x: this.scaleValueX,
          y: this.scaleValueY,
          z: CommonConstants.ONE
        })
          // 捏合手势处理
        .gesture(
          PinchGesture()
            .onActionStart(() => {
              this.index = 1 // 标记手势开始
            })
            .onActionUpdate((event: GestureEvent) => {
              this.context.clearRect(0, 0, this.context.width, this.context.height)
              this.drawInvoker.execute(this.context)
              if (event) {
                // 更新缩放值
                this.scaleValueX = this.pinchValueX * event.scale
                this.scaleValueY = this.pinchValueY * event.scale
              }
            })
            .onActionEnd(() => {
              // 保存当前缩放值为基准值
              this.pinchValueX = this.scaleValueX
              this.pinchValueY = this.scaleValueY
              this.context.clearRect(0, 0, this.context.width, this.context.height)
              this.drawInvoker.execute(this.context)
            })
        )

      // 手势操作层(覆盖整个画布)
      Column()
        .width(CommonConstants.ONE_HUNDRED_PERCENT)
        .height(CommonConstants.ONE_HUNDRED_PERCENT)
        .backgroundColor(Color.Transparent)
        .zIndex(this.index) // 控制层级
        .gesture(
          PinchGesture()
            .onActionStart(() => {
              this.index = 1
            })
            .onActionUpdate((event: GestureEvent) => {
              this.context.clearRect(0, 0, this.context.width, this.context.height)
              this.drawInvoker.execute(this.context)
              if (event) {
                this.scaleValueX = this.pinchValueX * event.scale
                this.scaleValueY = this.pinchValueY * event.scale
              }
            })
            .onActionEnd(() => {
              this.context.clearRect(0, 0, this.context.width, this.context.height)
              this.drawInvoker.execute(this.context)
              this.pinchValueX = this.scaleValueX
              this.pinchValueY = this.scaleValueY
            })
        )

      // 底部工具栏
      Row() {
        // 画笔工具按钮
        Stack() {
          Column() {
            // 动态切换按钮图标
            Image(this.isPaint && this.index === CommonConstants.NEGATIVE_ONE ? $r('app.media.paintbrush_active') :
            $r('app.media.paintbrush'))
              .width($r('app.float.image_width'))
              .height($r('app.float.image_width'))
              .margin({ bottom: $r('app.float.bottom') })
            // 按钮文字
            Text($r('app.string.paint'))
              .fontSize($r('app.float.font_size_m'))
              .fontColor(this.isPaint && this.index === CommonConstants.NEGATIVE_ONE ? $r('app.color.theme_color') :
              $r('sys.color.mask_secondary'))
          }
          .width(CommonConstants.ONE_HUNDRED_PERCENT)
          .height(CommonConstants.ONE_HUNDRED_PERCENT)

          // 透明按钮覆盖
          Button({ type: ButtonType.Normal })
            .backgroundColor(Color.Transparent)
            .width(CommonConstants.ONE_HUNDRED_PERCENT)
            .height(CommonConstants.ONE_HUNDRED_PERCENT)
            .onClick(() => {
              this.ToggleThicknessColor() // 更新画笔属性
              this.isPaint = true // 切换为画笔模式
              this.isShow = !this.isShow // 切换设置面板显示
              this.index = -1 // 重置手势索引
              this.arr = [] // 清空坐标缓存
            })
        }
        // 绑定底部设置面板
        .bindSheet($$this.isShow, this.myPaintSheet(), {
          height: $r('app.float.height'),
          backgroundColor: Color.White,
          title: {
            title: $r('app.string.paint')
          },
          detents: CommonConstants.DETENTS
        })
        .width($r('app.float.paint_width'))
        .height($r('app.float.paint_height'))

        // 橡皮擦工具按钮(结构类似画笔按钮)
        Stack() {
          Column() {
            Image(this.isPaint || this.index === CommonConstants.ONE ? $r('app.media.rubbers') :
            $r('app.media.rubbers_active'))
              .width($r('app.float.image_width'))
              .height($r('app.float.image_width'))
              .margin({ bottom: $r('app.float.bottom') })
            Text($r('app.string.rubber'))
              .fontSize($r('app.float.font_size_m'))
              .fontColor(this.isPaint || this.index === CommonConstants.ONE ? $r('sys.color.mask_secondary') :
              $r('app.color.theme_color'))

          }
          .width(CommonConstants.ONE_HUNDRED_PERCENT)
          .height(CommonConstants.ONE_HUNDRED_PERCENT)

          Button({ type: ButtonType.Normal })
            .backgroundColor(Color.Transparent)
            .width(CommonConstants.ONE_HUNDRED_PERCENT)
            .height(CommonConstants.ONE_HUNDRED_PERCENT)
            .onClick(() => {
              this.mPaint = new Paint(CommonConstants.ZERO, CommonConstants.COLOR_STRING, CommonConstants.ONE)
              this.mPaint.setStrokeWidth(CommonConstants.TEN)
              this.mPaint.setColor(CommonConstants.WHITE)
              this.mPaint.setGlobalAlpha(CommonConstants.ONE)
              this.isPaint = false
            })
        }
        .width($r('app.float.paint_width'))
        .height($r('app.float.paint_height'))

        // 撤销按钮
        Stack() {
          Column() {
            Image(this.unDoDraw ? $r('app.media.recall_active') : $r('app.media.recall'))
              .width($r('app.float.image_width'))
              .height($r('app.float.image_width'))
              .margin({ bottom: $r('app.float.bottom') })
            Text($r('app.string.redo'))
              .fontSize($r('app.float.font_size_m'))
              .fontColor(this.unDoDraw ? $r('app.color.theme_color') : $r('sys.color.mask_secondary'))
          }
          .width(CommonConstants.ONE_HUNDRED_PERCENT)
          .height(CommonConstants.ONE_HUNDRED_PERCENT)

          Button({ type: ButtonType.Normal })
            .backgroundColor(Color.Transparent)
            .enabled(this.unDoDraw)
            .width(CommonConstants.ONE_HUNDRED_PERCENT)
            .height(CommonConstants.ONE_HUNDRED_PERCENT)
            .onClick(async () => {
              this.drawOperateUndo()
              this.context.clearRect(0, 0, this.context.width, this.context.height)
              this.drawInvoker.execute(this.context)
            })
        }
        .width($r('app.float.paint_width'))
        .height($r('app.float.paint_height'))

        // 重做按钮(结构类似撤销按钮)
        Stack() {
          Column() {
            Image(this.redoDraw ? $r('app.media.redo_active') : $r('app.media.redo'))
              .width($r('app.float.image_width'))
              .height($r('app.float.image_width'))
              .margin({ bottom: $r('app.float.bottom') })
            Text($r('app.string.undo'))
              .fontSize($r('app.float.font_size_m'))
              .fontColor(this.redoDraw ? $r('app.color.theme_color') : $r('sys.color.mask_secondary'))
          }
          .width(CommonConstants.ONE_HUNDRED_PERCENT)
          .height(CommonConstants.ONE_HUNDRED_PERCENT)

          Button({ type: ButtonType.Normal })
            .backgroundColor(Color.Transparent)
            .enabled(this.redoDraw)
            .width(CommonConstants.ONE_HUNDRED_PERCENT)
            .height(CommonConstants.ONE_HUNDRED_PERCENT)
            .onClick(async () => {
              this.drawOperateRedo()
              this.context.clearRect(0, 0, this.context.width, this.context.height)
              this.drawInvoker.execute(this.context)
            })
        }
        .width($r('app.float.paint_width'))
        .height($r('app.float.paint_height'))

        // 清空按钮
        Stack() {
          Column() {
            Image(this.clean ? $r('app.media.clear_active') : $r('app.media.clear'))
              .width($r('app.float.image_width'))
              .height($r('app.float.image_width'))
              .margin({ bottom: $r('app.float.bottom') })
            Text($r('app.string.clear'))
              .fontSize($r('app.float.font_size_m'))
              .fontColor(this.clean ? $r('app.color.theme_color') : $r('sys.color.mask_secondary'))
          }
          .width(CommonConstants.ONE_HUNDRED_PERCENT)
          .height(CommonConstants.ONE_HUNDRED_PERCENT)

          Button({ type: ButtonType.Normal })
            .backgroundColor(Color.Transparent)
            .width(CommonConstants.ONE_HUNDRED_PERCENT)
            .height(CommonConstants.ONE_HUNDRED_PERCENT)
            .onClick(async () => {
              this.clear()
              this.context.clearRect(0, 0, this.context.width, this.context.height)
              this.drawInvoker.execute(this.context)
              this.clean = true
            })
        }
        .width($r('app.float.paint_width'))
        .height($r('app.float.paint_height'))
      }
      .justifyContent(FlexAlign.SpaceBetween)
      .alignItems(VerticalAlign.Center)
      .zIndex(CommonConstants.TEN)
    }
    .backgroundColor($r('sys.color.comp_background_focus'))
    .width(CommonConstants.ONE_HUNDRED_PERCENT)
    .height(CommonConstants.ONE_HUNDRED_PERCENT)
  }
}

11.3.2.6 管理绘图命令类

// main/ets/viewmodel/DrawInvoker.ets
import { List } from '@kit.ArkTS'
import DrawPath from './IDraw'

export default class DrawInvoker {
  // Draw list.
  private drawPathList: List<DrawPath> = new List<DrawPath>()
  // Redo list.
  private redoList: Array<DrawPath> = new Array<DrawPath>()

  add(command: DrawPath): void {
    this.drawPathList.add(command)
    this.redoList = []
  }

  clear(): void {
    if (this.drawPathList.length > 0 || this.redoList.length > 0) {
      this.drawPathList.clear()
      this.redoList = []
    }
  }

  undo(): void {
    if (this.drawPathList.length > 0) {
      let undo: DrawPath = this.drawPathList.get(this.drawPathList.length - 1)
      this.drawPathList.removeByIndex(this.drawPathList.length - 1)
      this.redoList.push(undo)
    }
  }

  redo(): void {
    if (this.redoList.length > 0) {
      let redoCommand = this.redoList[this.redoList.length - 1]
      this.redoList.pop()
      this.drawPathList.add(redoCommand)
    }
  }

  execute(context: CanvasRenderingContext2D): void {
    if (this.drawPathList !== null) {
      this.drawPathList.forEach((element: DrawPath) => {
        element.draw(context)
      })
    }
  }

  canRedo(): boolean {
    return this.redoList.length > 0
  }

  canUndo(): boolean {
    return this.drawPathList.length > 0
  }
}

11.3.2.7 绘制路径接口

// main/ets/viewmodel/IDraw.ets
import Paint from './Paint'

export interface IDraw {
  draw(context: CanvasRenderingContext2D): void
}

export default class DrawPath implements IDraw {
  public paint: Paint
  public path: Path2D

  constructor(paint: Paint, path: Path2D) {
    this.paint = paint
    this.path = path
  }

  draw(context: CanvasRenderingContext2D): void {
    context.lineWidth = this.paint.lineWidth
    context.strokeStyle = this.paint.StrokeStyle
    context.globalAlpha = this.paint.globalAlpha
    context.lineCap = 'round'
    context.stroke(this.path)
  }
}

11.3.2.8 画笔接口

// main/ets/viewmodel/IBrush.ets
export interface IBrush {
  down(path: Path2D, x: number, y: number): void;
  move(path: Path2D, x: number, y: number): void;
  up(path: Path2D, x: number, y: number): void;
}

export default class NormalBrush implements IBrush {
  down(path: Path2D, x: number, y: number): void {
    path.moveTo(x, y);
  }
  move(path: Path2D, x: number, y: number): void {
    path.lineTo(x, y);
  }
  up(path: Path2D, x: number, y: number): void {}
}

11.3.2.9 绘制类

// main/ets/viewmodel/Paint.ets
export default class Paint {
  lineWidth: number
  StrokeStyle: string
  globalAlpha: number

  constructor(lineWidth: number, StrokeStyle: string, globalAlpha: number) {
    this.lineWidth = lineWidth
    this.StrokeStyle = StrokeStyle
    this.globalAlpha = globalAlpha
  }

  setColor(color: string) {
    this.StrokeStyle = color
  }

  setStrokeWidth(width: number) {
    this.lineWidth = width
  }

  setGlobalAlpha(alpha: number) {
    this.globalAlpha = alpha
  }
}

11.3.2.10 自定义底部设置面板组件

// main/ets/view/myPaintSheet.ets
// 引入通用常量
import { CommonConstants } from '../common/CommonConstants'
// 引入画笔接口及实现
import { IBrush } from '../viewmodel/IBrush'
import NormalBrush from '../viewmodel/IBrush'
// 引入颜料属性类
import Paint from '../viewmodel/Paint'

// 组件装饰器(V2 版本)
@ComponentV2
export struct myPaintSheet {
  // region [参数和事件定义]
  // 是否为马克笔模式(参数)
  @Param isMarker: boolean = false
  // 更新 isMarker 的事件回调
  @Event $isMarker: (val: boolean) => void = (val: boolean) => {}

  // 透明度(参数)
  @Param alpha: number = 1
  // 更新透明度的事件回调
  @Event $alpha: (val: number) => void = (val: number) => {}

  // 透明度百分比(参数)
  @Param percent: string = '100'
  // 更新百分比的事件回调
  @Event $percent: (val: string) => void = (val: string) => {}

  // 画笔颜色(参数)
  @Param color: string = '#000000'
  // 更新颜色的事件回调
  @Event $color: (val: string) => void = (val: string) => {}

  // 画笔粗细值(参数)
  @Param thicknessesValue: number = 3
  // 更新粗细值的事件回调
  @Event $thicknessesValue: (val: number) => void = (val: number) => {}

  // 画笔宽度(参数)
  @Param strokeWidth: number = 3
  // 更新画笔宽度的事件回调
  @Event $strokeWidth: (val: number) => void = (val: number) => {}

  // 消费全局的颜料属性对象
  @Consumer() mPaint: Paint = new Paint(0, '', 1)
  // 消费全局的画笔工具
  @Consumer() mBrush: IBrush = new NormalBrush()
  // endregion

  // 更新画笔属性的方法
  ToggleThicknessColor() {
    // 创建新的 Paint 对象并更新属性
    this.mPaint = new Paint(CommonConstants.ZERO, CommonConstants.COLOR_STRING, CommonConstants.ONE)
    this.mPaint.setStrokeWidth(this.strokeWidth) // 设置笔触宽度
    this.mPaint.setColor(this.color) // 设置颜色
    this.mPaint.setGlobalAlpha(this.alpha) // 设置透明度
    // 使用普通画笔
    this.mBrush = new NormalBrush()
  }

  // 主构建方法
  build() {
    Column() {
      // 画笔类型选择区域
      Column() {
        // 标题
        Text($r('app.string.brash'))
          .textAlign(TextAlign.Start)
          .fontSize($r('app.float.font_size'))
          .fontColor($r('sys.color.mask_secondary'))
          .margin({ bottom: $r('app.float.margin_bottom') })

        // 画笔类型选项
        Row() {
          // 圆珠笔选项
          Column() {
            Stack() {
              // 背景
              Text()
                .width($r('app.float.back_width'))
                .height($r('app.float.back_width'))
                .backgroundColor(this.isMarker ? $r('app.color.paint_color') : $r('app.color.theme_color'))
                .borderRadius($r('app.float.border_radius'))
              // 图标
              Image(this.isMarker ? $r('app.media.Ballpoint') : $r('app.media.Ballpoint_active'))
                .width($r('app.float.image_width'))
                .height($r('app.float.image_width'))
              // 透明按钮
              Button({ type: ButtonType.Normal })
                .width($r('app.float.back_width'))
                .height($r('app.float.back_width'))
                .borderRadius($r('app.float.border_radius'))
                .backgroundColor(Color.Transparent)
                .onClick(() => {
                  this.$isMarker(false) // 设置为非马克笔模式
                  this.$alpha(1) // 设置透明度为 100%
                  this.$percent('100') // 设置百分比为 100%
                  this.ToggleThicknessColor() // 更新画笔属性
                })
            }
            // 标签
            Text($r('app.string.ballpoint'))
              .fontSize($r('app.float.font_size'))
              .fontColor(this.isMarker ? $r('sys.color.mask_secondary') : $r('app.color.theme_color'))
              .margin({ top: $r('app.float.margin_top') })
          }
          .width($r('app.float.brash_width'))
          .height($r('app.float.brash_height'))

          // 马克笔选项(结构与圆珠笔类似)
          Column() { /* ... */ }

          // 铅笔选项(仅显示,无交互)
          Column() { /* ... */ }

          // 钢笔选项(仅显示,无交互)
          Column() { /* ... */ }

          // 激光笔选项(仅显示,无交互)
          Column() { /* ... */ }
        }
        .padding({
          left: $r('app.float.margin_top'),
          right: $r('app.float.margin_top')
        })
        .width(CommonConstants.ONE_HUNDRED_PERCENT)
        .justifyContent(FlexAlign.SpaceBetween)
      }
      .padding({
        left: $r('app.float.padding_left'),
        right: $r('app.float.padding_left'),
        top: $r('app.float.margin_bottom')
      })
      .alignItems(HorizontalAlign.Start)
      .width(CommonConstants.ONE_HUNDRED_PERCENT)
      .margin({ bottom: $r('app.float.title_bottom') })

      // 颜色选择区域
      Column() {
        // 标题
        Text($r('app.string.color'))
          .textAlign(TextAlign.Start)
          .fontSize($r('app.float.font_size'))
          .fontColor($r('sys.color.mask_secondary'))
          .margin({ bottom: $r('app.float.margin_bottom') })

        // 颜色选项
        Row() {
          // 遍历颜色数组,生成颜色选项
          ForEach(CommonConstants.COLOR_ARR, (item: string) => {
            Text()
              .width($r('app.float.image_width'))
              .height($r('app.float.image_width'))
              .borderRadius($r('app.float.border_radius_m'))
              .backgroundColor(item) // 设置背景颜色
              .onClick(() => {
                this.$color(item) // 更新颜色
                setTimeout(() => {
                  this.ToggleThicknessColor() // 更新画笔属性
                }, 100)
              })
          }, (item: string) => JSON.stringify(item)) // 使用颜色值作为唯一键
        }
        .padding({
          left: $r('app.float.margin_top'),
          right: $r('app.float.margin_top')
        })
        .justifyContent(FlexAlign.SpaceBetween)
        .width(CommonConstants.ONE_HUNDRED_PERCENT)
      }
      .padding({
        left: $r('app.float.padding_left'),
        right: $r('app.float.padding_left')
      })
      .alignItems(HorizontalAlign.Start)
      .width(CommonConstants.ONE_HUNDRED_PERCENT)
      .margin({ bottom: $r('app.float.margin_bottom') })

      // 透明度调整区域
      Column() {
        // 标题
        Text($r('app.string.opacity'))
          .textAlign(TextAlign.Start)
          .fontSize($r('app.float.font_size'))
          .fontColor($r('sys.color.mask_secondary'))
          .margin({ bottom: $r('app.float.margin_top') })

        // 滑块和百分比显示
        Row() {
          Stack() {
            // 透明度滑块
            Slider({
              style: SliderStyle.InSet,
              value: this.alpha * CommonConstants.ONE_HUNDRED
            })
              .height($r('app.float.brash_width'))
              .width($r('app.float.slider_width'))
              .selectedColor(Color.Transparent)
              .minResponsiveDistance(CommonConstants.ONE)
              .trackColor(new LinearGradient([
                { color: $r('app.color.linear_start'), offset: CommonConstants.ZERO },
                { color: $r('app.color.linear_end'), offset: CommonConstants.ONE }
              ]))
              .onChange((value: number) => {
                if (this.isMarker) { // 仅在马克笔模式下生效
                  this.$alpha(value / 100) // 更新透明度
                  this.$percent(value.toFixed(0)) // 更新百分比
                  this.ToggleThicknessColor() // 更新画笔属性
                }
              })

            // 非马克笔模式下禁用滑块
            if (!this.isMarker) {
              Row()
                .backgroundColor(Color.Transparent)
                .width($r('app.float.slider_width'))
                .height($r('app.float.brash_width'))
            }
          }

          // 百分比显示
          Text(this.percent + CommonConstants.SIGN)
            .width($r('app.float.number'))
            .height($r('app.float.image_width'))
            .fontSize($r('app.float.font_size_l'))
            .borderRadius($r('app.float.border_radius_m'))
            .textAlign(TextAlign.Center)
            .backgroundColor($r('app.color.number_color'))
        }
        .padding({
          left: $r('app.float.margin_top'),
          right: $r('app.float.margin_top')
        })
        .justifyContent(FlexAlign.SpaceBetween)
        .width(CommonConstants.ONE_HUNDRED_PERCENT)
      }
      .padding({
        left: $r('app.float.padding_left'),
        right: $r('app.float.padding_left')
      })
      .alignItems(HorizontalAlign.Start)
      .width(CommonConstants.ONE_HUNDRED_PERCENT)
      .margin({ bottom: $r('app.float.margin_bottom') })

      // 画笔粗细调整区域
      Column() {
        // 标题
        Text($r('app.string.thicknesses'))
          .textAlign(TextAlign.Start)
          .fontSize($r('app.float.font_size'))
          .fontColor($r('sys.color.mask_secondary'))
          .margin({ bottom: $r('app.float.margin_bottom') })

        // 粗细调整控件
        Row() {
          // 减号按钮
          Image($r('app.media.minuses'))
            .width($r('app.float.image_width'))
            .height($r('app.float.image_width'))
            .onClick(() => {
              this.$thicknessesValue(this.thicknessesValue - 1) // 减小粗细值
              this.$strokeWidth(this.thicknessesValue) // 更新画笔宽度
              this.ToggleThicknessColor() // 更新画笔属性
            })

          // 粗细滑块
          Slider({
            value: this.thicknessesValue,
            min: CommonConstants.THREE,
            max: CommonConstants.TWENTY_ONE
          })
            .width($r('app.float.slider_width'))
            .minResponsiveDistance(CommonConstants.ONE)
            .onChange((value: number, _mode: SliderChangeMode) => {
              this.$thicknessesValue(value) // 更新粗细值
              this.$strokeWidth(value) // 更新画笔宽度
              this.ToggleThicknessColor() // 更新画笔属性
            })

          // 加号按钮
          Image($r('app.media.add'))
            .width($r('app.float.image_width'))
            .height($r('app.float.image_width'))
            .onClick(() => {
              this.$thicknessesValue(this.thicknessesValue + 1) // 增加粗细值
              this.$strokeWidth(this.thicknessesValue) // 更新画笔宽度
              this.ToggleThicknessColor() // 更新画笔属性
            })
        }
        .padding({
          left: $r('app.float.margin_bottom'),
          right: $r('app.float.margin_bottom')
        })
        .justifyContent(FlexAlign.SpaceBetween)
        .width(CommonConstants.ONE_HUNDRED_PERCENT)
      }
      .padding({
        left: $r('app.float.padding_left'),
        right: $r('app.float.padding_left')
      })
      .alignItems(HorizontalAlign.Start)
      .width(CommonConstants.ONE_HUNDRED_PERCENT)
    }
    .width(CommonConstants.ONE_HUNDRED_PERCENT)
    .height(CommonConstants.ONE_HUNDRED_PERCENT)
  }
}

11.3.2.11 代码与视频教程

完整案例代码与视频教程请参见:

代码:Code-11-02.zip。

视频:《基于Canvas实现画布》。

11.4 本章小结

本章聚焦图形绘制,先介绍绘制几何图形的组件创建、视口设置与样式自定义方法。接着讲解 Canvas 绘制自定义图形的多种方式、组件初始化及常用方法。最后通过基于 Canvas 实现抽奖转盘和自定义画布两个案例,阐述开发过程、涉及知识点、代码结构等,帮助你掌握在应用开发中利用相关技术绘制图形的技能,从基础理论到实践应用,为图形绘制开发提供实战指导。