HarmonyOS 高级 UI 实战:手势系统、Canvas 自定义绘制与复杂动画

HarmonyOS 高级 UI 实战:手势系统、Canvas 自定义绘制与复杂动画## 一、前言除了基础组件,HarmonyOS ArkUI 还提供了强大的手势系统、Canvas 自定义绘制能力和复杂动画支持。本文将以 HarmonyOS 5.0.0(API 12)为基础,深入讲解手势识别、Canvas 绘制和高级动画技术。## 二、手势系统### 2.1 手势类型概览| 手势类型 | 用途 ||---------|------|| TapGesture | 点击 || LongPressGesture | 长按 || PanGesture | 拖拽/平移 || PinchGesture | 双指缩放 || RotationGesture | 旋转 || GestureGroup | 手势组合 |### 2.2 单手势示例@Entry

@Component

struct GestureDemo {

@State tapCount: number = 0;

@State panOffset: OffsetResult = { x: 0, y: 0 };

@State scaleValue: number = 1;

@State longPressText: string = '长按我';

@State bgColor: Color = '#FFFFFF';

build() {

Column({ space: 20 }) {

Text('手势交互示例').fontSize(24).fontWeight(FontWeight.Bold)

复制代码
  // 点击手势
  Column() {
    Text(`被点击了 ${this.tapCount} 次`).fontSize(16)
  }
  .width(200).height(60).backgroundColor('#E3F2FD').borderRadius(12)
  .justifyContent(FlexAlign.Center)
  .gesture(TapGesture({ count: 1 }).onAction(() => { this.tapCount++ }))

  // 长按手势
  Column() {
    Text(this.longPressText).fontSize(16).fontColor('#FFF')
  }
  .width(200).height(60).backgroundColor(this.bgColor).borderRadius(12)
  .justifyContent(FlexAlign.Center)
  .gesture(
    LongPressGesture({ repeat: false, duration: 500 })
      .onAction(() => { this.longPressText = '已触发!'; this.bgColor = '#4CAF50' })
      .onActionEnd(() => { this.longPressText = '长按我'; this.bgColor = '#333' })
  )

  // 拖拽手势(带弹回动画)
  Column() {
    Text('拖拽我').fontSize(16)
  }
  .width(160).height(160).backgroundColor('#FF9800').borderRadius(80)
  .justifyContent(FlexAlign.Center)
  .translate({ x: this.panOffset.x, y: this.panOffset.y })
  .gesture(
    PanGesture({ fingers: 1, direction: PanDirection.All })
      .onActionUpdate((event: GestureEvent) => {
        this.panOffset = { x: this.panOffset.x + event.offsetX, y: this.panOffset.y + event.offsetY };
      })
      .onActionEnd(() => {
        animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
          this.panOffset = { x: 0, y: 0 };
        });
      })
  )

  // 缩放手势
  Column() {
    Text('双指缩放').fontSize(12)
  }
  .width(150).height(150).backgroundColor('#E91E63').borderRadius(16)
  .justifyContent(FlexAlign.Center)
  .scale({ x: this.scaleValue, y: this.scaleValue })
  .gesture(
    PinchGesture({ fingers: 2 })
      .onActionUpdate((event: GestureEvent) => {
        this.scaleValue = Math.max(0.5, Math.min(3, event.scale));
      })
  )
}.width('100%').padding(20)

}

}

2.3 手势组合:缩放 + 拖拽@Component

struct GestureGroupDemo {

@State scaleValue: number = 1;

@State offset: OffsetResult = { x: 0, y: 0 };

@State lastOffset: OffsetResult = { x: 0, y: 0 };

build() {

Column() {

Text('手势组合示例').fontSize(24).fontWeight(FontWeight.Bold).padding(20)

复制代码
  Column({ space: 8 }) {
    Image($r('app.media.sample_image'))
      .width('100%').height(250).objectFit(ImageFit.Cover).borderRadius(12)
      .scale({ x: this.scaleValue, y: this.scaleValue })
      .translate({ x: this.offset.x, y: this.offset.y })
      .gesture(
        GestureGroup(GestureMode.Parallel,
          PinchGesture({ fingers: 2 })
            .onActionUpdate((event: GestureEvent) => {
              this.scaleValue = Math.max(1, Math.min(3, event.scale));
            }),
          PanGesture({ fingers: 1, direction: PanDirection.All })
            .onActionStart(() => { this.lastOffset = this.offset })
            .onActionUpdate((event: GestureEvent) => {
              this.offset = {
                x: this.lastOffset.x + event.offsetX,
                y: this.lastOffset.y + event.offsetY
              };
            })
        )
      )
    Text('双指缩放 + 单指拖拽').fontSize(12).fontColor('#888')
  }.width('100%').padding(20)

  Button('重置位置').fontSize(14)
    .onClick(() => {
      animateTo({ duration: 300, curve: Curve.EaseOut }, () => {
        this.scaleValue = 1;
        this.offset = { x: 0, y: 0 };
      });
    })
}.width('100%').height('100%').backgroundColor('#F5F5F5')

}

}

三、Canvas 自定义绘制### 3.1 画板应用interface Point { x: number; y: number; color: string; size: number }

@Entry

@Component

struct CanvasDemo {

@State brushSize: number = 4;

@State brushColor: string = '#000000';

@State points: Point\[\] = \[\];

build() {

Column({ space: 12 }) {

Text('🎨 Canvas 画板').fontSize(24).fontWeight(FontWeight.Bold)

复制代码
  Canvas((context: DrawingRenderingContext) => {
    // 绘制网格背景
    context.strokeStyle = '#F0F0F0';
    context.lineWidth = 1;
    for (let x = 0; x < context.width; x += 30) {
      context.beginPath(); context.moveTo(x, 0); context.lineTo(x, context.height); context.stroke();
    }
    for (let y = 0; y < context.height; y += 30) {
      context.beginPath(); context.moveTo(0, y); context.lineTo(context.width, y); context.stroke();
    }

    // 绘制用户线条
    for (let i = 1; i < this.points.length; i++) {
      const p1 = this.points[i - 1];
      const p2 = this.points[i];
      context.strokeStyle = p2.color;
      context.lineWidth = p2.size;
      context.lineCap = 'round';
      context.beginPath();
      context.moveTo(p1.x, p1.y);
      context.lineTo(p2.x, p2.y);
      context.stroke();
    }
  })
  .width('100%').height(350).backgroundColor('#FFFFFF')
  .border({ width: 1, color: '#E0E0E0' }).borderRadius(8)
  .onTouch((event: TouchEvent) => {
    if (event.type === TouchType.Down || event.type === TouchType.Move) {
      const touch = event.touches[0];
      this.points.push({ x: touch.x, y: touch.y, color: this.brushColor, size: this.brushSize });
    }
  })

  Row({ space: 8 }) {
    Row({ space: 4 }) {
      ForEach(['#000000','#FF4444','#4CAF50','#007AFF','#FF9800','#9C27B0'],
        (color: string) => {
          Circle().width(20).height(20).fill(color)
            .border({ width: this.brushColor === color ? 2 : 0, color: '#FFF' })
            .onClick(() => { this.brushColor = color })
        }
      )
    }.layoutWeight(1)
    Button('清除').fontSize(12).onClick(() => { this.points = [] })
  }.width('100%')
}.width('100%').padding(16)

}

}

3.2 自定义时钟@Entry

@Component

struct ClockCanvas {

@State currentTime: Date = new Date();

private timerId?: number;

aboutToAppear() { this.timerId = setInterval(() => { this.currentTime = new Date() }, 1000) }

aboutToDisappear() { if (this.timerId) clearInterval(this.timerId) }

drawHand(ctx: DrawingRenderingContext, cx: number, cy: number, angle: number, length: number, color: string, width: number) {

const x = cx + Math.cos(angle) * length;

const y = cy + Math.sin(angle) * length;

ctx.strokeStyle = color;

ctx.lineWidth = width;

ctx.lineCap = 'round';

ctx.beginPath();

ctx.moveTo(cx, cy);

ctx.lineTo(x, y);

ctx.stroke();

}

build() {

Column({ space: 16 }) {

Text('🕐 自定义时钟').fontSize(24).fontWeight(FontWeight.Bold)

复制代码
  Canvas((context: DrawingRenderingContext) => {
    const cx = context.width / 2, cy = context.height / 2;
    const radius = Math.min(cx, cy) - 10;
    const h = this.currentTime.getHours() % 12, m = this.currentTime.getMinutes(), s = this.currentTime.getSeconds();

    // 表盘
    context.fillStyle = '#FFF'; context.strokeStyle = '#333'; context.lineWidth = 4;
    context.beginPath(); context.arc(cx, cy, radius, 0, 2*Math.PI); context.fill(); context.stroke();

    // 刻度
    for (let i = 0; i < 12; i++) {
      const angle = (i*30-90)*Math.PI/180;
      const x1 = cx+Math.cos(angle)*(radius-15), y1 = cy+Math.sin(angle)*(radius-15);
      const x2 = cx+Math.cos(angle)*(radius-5), y2 = cy+Math.sin(angle)*(radius-5);
      context.strokeStyle = '#333'; context.lineWidth = 3;
      context.beginPath(); context.moveTo(x1,y1); context.lineTo(x2,y2); context.stroke();
    }

    this.drawHand(context, cx, cy, (h*30+m*0.5-90)*Math.PI/180, radius*0.5, '#333', 6);
    this.drawHand(context, cx, cy, (m*6-90)*Math.PI/180, radius*0.7, '#666', 4);
    this.drawHand(context, cx, cy, (s*6-90)*Math.PI/180, radius*0.85, '#FF4444', 2);

    // 中心
    context.fillStyle = '#333'; context.beginPath(); context.arc(cx, cy, 6, 0, 2*Math.PI); context.fill();
  }).width(300).height(300)
}.width('100%').justifyContent(FlexAlign.Center).padding(20)

}

}

四、高级动画### 4.1 关键帧动画@Entry

@Component

struct KeyframeAnimation {

@State animationValue: number = 0;

build() {

Column({ space: 30 }) {

Text('关键帧动画').fontSize(24).fontWeight(FontWeight.Bold)

复制代码
  Circle().width(50).height(50).fill('#FF6B6B')
    .translate({ y: this.animationValue })
    .animation({
      duration: 2000, curve: Curve.EaseInOut,
      iterations: -1, playMode: PlayMode.Alternate
    })

  Button('开始动画').fontSize(16)
    .onClick(() => { this.animationValue = this.animationValue === 0 ? 200 : 0 })
}.width('100%').padding(20)

}

}

4.2 页面转场动画@Entry

@Component

struct TransitionDemo {

@State showDetail: boolean = false;

@State items: string\[\] = 'Item A', 'Item B', 'Item C', 'Item D';

build() {

Column({ space: 12 }) {

Text('转场动画示例').fontSize(24).fontWeight(FontWeight.Bold)

复制代码
  List({ space: 8 }) {
    ForEach(this.items, (item: string) => {
      ListItem() {
        Row() {
          Text(item).fontSize(16)
          Blank()
          Text('→').fontSize(16).fontColor('#AAA')
        }.width('100%').padding(16).backgroundColor('#FAFAFA').borderRadius(8)
        .onClick(() => { this.showDetail = true })
      }
    })
  }.layoutWeight(1).width('100%')

  if (this.showDetail) {
    Column() {
      Column({ space: 16 }) {
        Text('详情页面').fontSize(24).fontWeight(FontWeight.Bold)
        Text('这是一个带转场动画的详情页').fontSize(16).fontColor('#666')
        Button('关闭').fontSize(16).backgroundColor('#FF4444')
          .onClick(() => { this.showDetail = false })
      }
      .width('100%').height('100%').padding(24).backgroundColor('#FFFFFF')
      .borderRadius({ topLeft: 20, topRight: 20 }).justifyContent(FlexAlign.Start)
      .transition(
        TransitionEffect.OPACITY
          .combine(TransitionEffect.translate({ y: 300 }))
          .animation({ duration: 400, curve: Curve.FastOutSlowIn })
      )
    }
    .width('100%').height('100%').position({ x: 0, y: 0 })
    .backgroundColor('#80000000')
    .transition(TransitionEffect.OPACITY.animation({ duration: 300 }))
  }
}.width('100%').height('100%')

}

}

五、综合实战:图片编辑器interface ImageFilter { brightness: number; contrast: number; saturation: number; blurRadius: number }

@Component

struct ImageEditor {

@State filter: ImageFilter = { brightness: 1, contrast: 1, saturation: 1, blurRadius: 0 };

@State scaleValue: number = 1;

@State rotationAngle: number = 0;

@State offset: OffsetResult = { x: 0, y: 0 };

@State activeTab: string = 'filter';

resetFilter() {

this.filter = { brightness: 1, contrast: 1, saturation: 1, blurRadius: 0 };

this.scaleValue = 1; this.rotationAngle = 0; this.offset = { x: 0, y: 0 };

}

@Builder filterSlider(label: string, value: number, min: number, max: number, onChange: (v: number) => void) {

Row({ space: 8 }) {

Text(label).width(48).fontSize(12).fontColor('#666')

Slider({ value, min, max, step: 0.1 }).layoutWeight(1).blockColor('#007AFF')

.onChange((v: number) => { onChange(v) })

Text(value.toFixed(1)).width(36).fontSize(12).fontColor('#888')

}.width('100%')

}

build() {

Column({ space: 8 }) {

Text('🖼 图片编辑器').fontSize(24).fontWeight(FontWeight.Bold).padding(12)

复制代码
  Stack() {
    Image($r('app.media.sample_image'))
      .width('100%').height(250).objectFit(ImageFit.Contain)
      .brightness(this.filter.brightness)
      .contrast(this.filter.contrast)
      .saturate(this.filter.saturation)
      .blur(this.filter.blurRadius)
      .scale({ x: this.scaleValue, y: this.scaleValue })
      .rotate({ angle: this.rotationAngle })
      .translate({ x: this.offset.x, y: this.offset.y })
      .gesture(
        GestureGroup(GestureMode.Parallel,
          PinchGesture({ fingers: 2 })
            .onActionUpdate((event: GestureEvent) => {
              this.scaleValue = Math.max(0.5, Math.min(3, event.scale));
            }),
          RotationGesture()
            .onActionUpdate((event: GestureEvent) => {
              this.rotationAngle = event.angle;
            })
        )
      )
  }
  .width('100%').height(250).backgroundColor('#F0F0F0').borderRadius(8).clip(true)

  Row() {
    Button('滤镜').fontSize(12)
      .backgroundColor(this.activeTab==='filter'?'#007AFF':'#F0F0F0')
      .fontColor(this.activeTab==='filter'?'#FFF':'#333')
      .onClick(()=>{this.activeTab='filter'}).layoutWeight(1)
    Button('调整').fontSize(12)
      .backgroundColor(this.activeTab==='adjust'?'#007AFF':'#F0F0F0')
      .fontColor(this.activeTab==='adjust'?'#FFF':'#333')
      .onClick(()=>{this.activeTab='adjust'}).layoutWeight(1)
  }.width('100%').padding({ left: 12, right: 12 })

  if (this.activeTab === 'filter') {
    Column({ space: 12 }) {
      this.filterSlider('亮度', this.filter.brightness, 0, 2, (v) => { this.filter.brightness = v })
      this.filterSlider('对比度', this.filter.contrast, 0, 2, (v) => { this.filter.contrast = v })
      this.filterSlider('饱和度', this.filter.saturation, 0, 2, (v) => { this.filter.saturation = v })
    }.width('100%').padding(12)
  }

  if (this.activeTab === 'adjust') {
    Column({ space: 12 }) {
      this.filterSlider('模糊', this.filter.blurRadius, 0, 20, (v) => { this.filter.blurRadius = v })
    }.width('100%').padding(12)
  }

  Row({ space: 8 }) {
    Button('重置所有').fontSize(14).backgroundColor('#FF9800').layoutWeight(1)
      .onClick(() => { this.resetFilter() })
    Button('保存').fontSize(14).backgroundColor('#4CAF50').layoutWeight(1)
  }.width('100%').padding(12)
}.width('100%').height('100%')

}

}

六、常见问题与最佳实践| 问题 | 解答 ||------|------|| 手势冲突? | GestureGroup(GestureMode.Parallel/Sequential/Exclusive) || Canvas 性能? | 避免频繁全量重绘,使用局部刷新 || 动画卡顿? | 使用 @AnimatableExtend 替代 JS 动画 || 手势不响应? | 检查是否被父组件拦截 |## 七、总结| 功能 | 核心 API | 最佳场景 ||------|----------|---------|| 手势系统 | TapGesture/PanGesture/PinchGesture | 交互反馈 || 手势组合 | GestureGroup + GestureMode | 复杂手势 || Canvas 绘图 | Canvas + DrawingRenderingContext | 画板、图表、时钟 || 高级动画 | animateTo + TransitionEffect | 页面转场 |> 参考文档:华为开发者联盟 HarmonyOS 5.0.0 API 12 --- 交互与动画指南