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%')
}
}