【HarmonyOS】头像裁剪圆形遮罩效果实现demo
【起因】
需要实现头像剪裁的圆形遮罩。
看到一个第三方库oh-crop是方形遮罩的
https://ohpm.openharmony.cn/#/cn/detail/@xinyansoft%2Foh-crop
【经过】
查看源码,修改.onReady改为圆形遮罩
【完整示例】
import { image } from '@kit.ImageKit'; import { picker } from '@kit.CoreFileKit'; import Matrix4 from '@ohos.matrix4' import fs from '@ohos.file.fs'; import { hilog } from '@kit.PerformanceAnalysisKit'; @Component export struct CropView { @State private model: CropModel = new CropModel(); private settings: RenderingContextSettings = new RenderingContextSettings(true); private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings); @State private matrix: object = Matrix4.identity() .translate({ x: 0, y: 0 }) .scale({ x: this.model.scale, y: this.model.scale}); /** 临时变量,无需手动赋值 **/ private tempScale = 1; /** 临时变量,无需手动赋值 **/ private startOffsetX: number = 0; /** 临时变量,无需手动赋值 **/ private startOffsetY: number = 0; build() { Stack() { Image(this.model.src) .width('100%') .height('100%') .alt(this.model.previewSource) .objectFit(ImageFit.Contain) .transform(this.matrix) .onComplete((msg) => { if (msg) { // 图片加载成功 this.model.imageWidth = msg.width; this.model.imageHeight = msg.height; this.model.componentWidth = msg.componentWidth; this.model.componentHeight = msg.componentHeight; this.checkImageAdapt(); if (this.model.imageLoadEventListener != null && msg.loadingStatus == 1) { this.model.imageLoadEventListener.onImageLoaded(msg); } } }) .onError((error) => { if (this.model.imageLoadEventListener != null) { this.model.imageLoadEventListener.onImageLoadError(error); } }) Canvas(this.context) .width('100%') .height('100%') .backgroundColor(Color.Transparent) .onReady(() => { if (this.context == null) { return } let height = this.context.height let width = this.context.width this.context.fillStyle= this.model.maskColor; this.context.fillRect(0, 0, width, height) // 计算圆形的中心点和半径 let centerX = width / 2; let centerY = height / 2; let minDimension = Math.min(width, height); let frameRadiusInVp = (minDimension - px2vp(this.model.frameWidth)) / 2; // 减去边框宽度 // 把中间的取景框透出来 this.context.globalCompositeOperation = 'destination-out' this.context.fillStyle = 'white' let frameWidthInVp = px2vp(this.model.frameWidth); let frameHeightInVp = px2vp(this.model.getFrameHeight()); let x = (width - px2vp(this.model.frameWidth)) / 2; let y = (height - px2vp(this.model.getFrameHeight())) / 2; // this.context.fillRect(x, y, frameWidthInVp, frameHeightInVp) console.info(`width:${width}`) console.info(`height:${height}`) console.info(`x:${x}`) console.info(`y:${y}`) console.info(`this.model.frameWidth:${this.model.frameWidth}`) console.info(`this.model.getFrameHeight():${this.model.getFrameHeight()}`) console.info(`frameWidthInVp:${frameWidthInVp}`) console.info(`frameHeightInVp:${frameHeightInVp}`) console.info(`frameRadiusInVp:${frameRadiusInVp}`) this.context.beginPath(); this.context.arc(centerX, centerY, px2vp(this.model.frameWidth/2), 0, 2 * Math.PI); this.context.fill(); // 设置综合操作模式为源覆盖,以便在现有图形上添加新的图形 this.context.globalCompositeOperation = 'source-over'; // 设置描边颜色 this.context.strokeStyle = this.model.strokeColor; // 计算圆形的半径,这里我们取正方形边框的较短边的一半作为半径 let radius = Math.min(frameWidthInVp, frameHeightInVp) / 2; // 开始绘制路径 this.context.beginPath(); // 使用 arc 方法绘制圆形 this.context.arc(centerX, centerY, radius, 0, 2 * Math.PI); // 关闭路径 this.context.closePath(); // 描绘圆形边框 this.context.lineWidth = 1; // 边框宽度 this.context.stroke(); }) .enabled(false) } .clip(true) .width('100%') .height('100%') .backgroundColor("#00000080") .priorityGesture( TapGesture({ count: 2, fingers: 1 }) .onAction((event:GestureEvent) => { if(!event){ return } if (this.model.zoomEnabled) { if (this.model.scale != 1) { this.model.scale = 1; this.model.reset(); this.updateMatrix(); } else { this.zoomTo(2); } } this.checkImageAdapt(); }) ) .gesture( GestureGroup(GestureMode.Parallel, // 拖动手势 PanGesture({}) .onActionStart(() => { hilog.info(0, "CropView", "Pan gesture start"); this.startOffsetX = this.model.offsetX; this.startOffsetY = this.model.offsetY; }) .onActionUpdate((event:GestureEvent) => { hilog.info(0, "CropView", `Pan gesture update: ${JSON.stringify(event)}`); if (event) { if (this.model.panEnabled) { let distanceX: number = this.startOffsetX + vp2px(event.offsetX) / this.model.scale; let distanceY: number = this.startOffsetY + vp2px(event.offsetY) / this.model.scale; this.model.offsetX = distanceX; this.model.offsetY = distanceY; this.updateMatrix() } } }) .onActionEnd(() => { hilog.info(0, "CropView", "Pan gesture end"); this.checkImageAdapt(); }), // 缩放手势处理 PinchGesture({ fingers: 2 }) .onActionStart(() => { this.tempScale = this.model.scale }) .onActionUpdate((event) => { if (event) { if (!this.model.zoomEnabled) return; this.zoomTo(this.tempScale * event.scale); } }) .onActionEnd(() => { this.checkImageAdapt(); }) ) ) } /** * 检查手势操作后,图片是否填满取景框,没填满则进行调整 */ private checkImageAdapt() { let offsetX = this.model.offsetX; let offsetY = this.model.offsetY; let scale = this.model.scale; hilog.info(0, "CropView", `offsetX: ${offsetX}, offsetY: ${offsetY}, scale: ${scale}`) // 图片适配控件的时候也进行了缩放,计算出这个缩放比例 let widthScale = this.model.componentWidth / this.model.imageWidth; let heightScale = this.model.componentHeight / this.model.imageHeight; let adaptScale = Math.min(widthScale, heightScale); hilog.info(0, "CropView", `Image scale ${adaptScale} while attaching the component[${this.model.componentWidth}, ${this.model.componentHeight}]`) // 经过两次缩放(适配控件、手势)后,图片的实际显示大小 let showWidth = this.model.imageWidth * adaptScale * this.model.scale; let showHeight = this.model.imageHeight * adaptScale * this.model.scale; let imageX = (this.model.componentWidth - showWidth) / 2; let imageY = (this.model.componentHeight - showHeight) / 2; hilog.info(0, "CropView", `Image left top is (${imageX}, ${imageY})`) // 取景框的左上角坐标 let frameX = (this.model.componentWidth - this.model.frameWidth) / 2; let frameY = (this.model.componentHeight - this.model.getFrameHeight()) / 2; // 图片左上角坐标 let showX = imageX + offsetX * scale; let showY = imageY + offsetY * scale; hilog.info(0, "CropView", `Image show at (${showX}, ${showY})`) if(this.model.frameWidth > showWidth || this.model.getFrameHeight() > showHeight) { // 图片缩放后,大小不足以填满取景框 let xScale = this.model.frameWidth / showWidth; let yScale = this.model.getFrameHeight() / showHeight; let newScale = Math.max(xScale, yScale); this.model.scale = this.model.scale * newScale; showX *= newScale; showY *= newScale; } // 调整x轴方向位置,使图像填满取景框 if(showX > frameX) { showX = frameX; } else if(showX + showWidth < frameX + this.model.frameWidth) { showX = frameX + this.model.frameWidth - showWidth; } // 调整y轴方向位置,使图像填满取景框 if(showY > frameY) { showY = frameY; } else if(showY + showHeight < frameY + this.model.getFrameHeight()) { showY = frameY + this.model.getFrameHeight() - showHeight; } this.model.offsetX = (showX - imageX) / scale; this.model.offsetY = (showY - imageY) / scale; this.updateMatrix(); } public zoomTo(scale: number): void { this.model.scale = scale; this.updateMatrix(); } public updateMatrix(): void { this.matrix = Matrix4.identity() .translate({ x: this.model.offsetX, y: this.model.offsetY }) .scale({ x: this.model.scale, y: this.model.scale }) } } interface ImageLoadedEvent { width: number; height: number; componentWidth: number; componentHeight: number; loadingStatus: number; contentWidth: number; contentHeight: number; contentOffsetX: number; contentOffsetY: number; } export interface ImageLoadEventListener { onImageLoaded(msg: ImageLoadedEvent): void; onImageLoadError(error: ImageError): void; } export class CropModel { /** * 图片uri * 类型判断太麻烦了,先只支持string,其他形式的需要先转换成路径 */ src: string = ''; /** * 图片预览 */ previewSource: string | Resource = ''; /** * 是否可以拖动 */ panEnabled: boolean = true; /** * 是否可以缩放 */ zoomEnabled: boolean = true; /** * 取景框宽度 */ frameWidth = 1000; /** * 取景框宽高比 */ frameRatio = 1; /** * 遮罩颜色 */ maskColor: string = '#AA000000'; /** * 取景框边框颜色 */ strokeColor: string = '#FFFFFF'; /** * 图片加载监听 */ imageLoadEventListener: ImageLoadEventListener | null = null; /// 以下变量不要手动赋值 /// /** * 图片宽度,加载完成之后才会赋值 */ imageWidth: number = 0; /** * 图片高度,加载完成之后才会赋值 */ imageHeight: number = 0; /** * 控件宽度 */ componentWidth: number = 0; /** * 控件高度 */ componentHeight: number = 0; /** * 手势缩放比例 * 图片经过了两重缩放,一是适配控件的时候进行了缩放,二是手势操作的时候进行了缩放 */ scale: number = 1; /** * x轴方向偏移量 */ offsetX: number = 0; /** * y轴方向偏移量 */ offsetY: number = 0; ///////////////////////////////////////////// public setImage(src: string, previewSource?: string | Resource): CropModel { this.src = src; if (!!previewSource) { this.previewSource = previewSource; } return this; } public setScale(scale: number): CropModel { this.scale = scale; return this; } public isPanEnabled(): boolean { return this.panEnabled; } public setPanEnabled(panEnabled: boolean): CropModel { this.panEnabled = panEnabled; return this; } public setZoomEnabled(zoomEnabled: boolean): CropModel { this.zoomEnabled = zoomEnabled; return this; } public setFrameWidth(frameWidth: number) : CropModel { this.frameWidth = frameWidth; return this; } public setFrameRatio(frameRatio: number) : CropModel { this.frameRatio = frameRatio; return this; } public setMaskColor(color: string) : CropModel { this.maskColor = color; return this; } public setStrokeColor(color: string) : CropModel { this.strokeColor = color; return this; } public setImageLoadEventListener(listener: ImageLoadEventListener) : CropModel { this.imageLoadEventListener = listener; return this; } public getScale(): number { return this.scale; } public isZoomEnabled(): boolean { return this.zoomEnabled; } public getImageWidth(): number { return this.imageWidth; } public getImageHeight(): number { return this.imageHeight; } public getFrameHeight() { return this.frameWidth / this.frameRatio; } public reset(): void { this.scale = 1; this.offsetX = 0; this.offsetY = 0; } public async crop(format: image.PixelMapFormat) : Promise<image.PixelMap> { if(!this.src || this.src == '') { throw new Error('Please set src first'); } if(this.imageWidth == 0 || this.imageHeight == 0) { throw new Error('The image is not loaded'); } // 图片适配控件的时候也进行了缩放,计算出这个缩放比例 let widthScale = this.componentWidth / this.imageWidth; let heightScale = this.componentHeight / this.imageHeight; let adaptScale = Math.min(widthScale, heightScale); // 经过两次缩放(适配控件、手势)后,图片的实际显示大小 let totalScale = adaptScale * this.scale; let showWidth = this.imageWidth * totalScale; let showHeight = this.imageHeight * totalScale; let imageX = (this.componentWidth - showWidth) / 2; let imageY = (this.componentHeight - showHeight) / 2; // 取景框的左上角坐标 let frameX = (this.componentWidth - this.frameWidth) / 2; let frameY = (this.componentHeight - this.getFrameHeight()) / 2; // 图片左上角坐标 let showX = imageX + this.offsetX * this.scale; let showY = imageY + this.offsetY * this.scale; let x = (frameX - showX) / totalScale; let y = (frameY - showY) / totalScale; let file = fs.openSync(this.src, fs.OpenMode.READ_ONLY) let imageSource : image.ImageSource = image.createImageSource(file.fd); let decodingOptions : image.DecodingOptions = { editable: true, desiredPixelFormat: image.PixelMapFormat.BGRA_8888, } // 创建pixelMap let pm = await imageSource.createPixelMap(decodingOptions); let cp = await this.copyPixelMap(pm); pm.release(); let region: image.Region = { x: x, y: y, size: { width: this.frameWidth / totalScale, height: this.getFrameHeight() / totalScale } }; cp.cropSync(region); return cp; } async copyPixelMap(pm: PixelMap): Promise<PixelMap> { const imageInfo: image.ImageInfo = await pm.getImageInfo(); const buffer: ArrayBuffer = new ArrayBuffer(pm.getPixelBytesNumber()); // TODO 知识点:通过readPixelsToBuffer实现PixelMap的深拷贝,其中readPixelsToBuffer输出为BGRA_8888 await pm.readPixelsToBuffer(buffer); // TODO 知识点:readPixelsToBuffer输出为BGRA_8888,此处createPixelMap需转为RGBA_8888 const opts: image.InitializationOptions = { editable: true, pixelFormat: image.PixelMapFormat.RGBA_8888, size: { height: imageInfo.size.height, width: imageInfo.size.width } }; return image.createPixelMap(buffer, opts); } } interface MyEvent { result: FileSelectorResult, fileSelector: FileSelectorParam } @Entry @Component struct Page23 { @State pm: PixelMap | undefined = undefined; @State private model: CropModel = new CropModel(); build() { Column() { CropView({ model: this.model, }) .layoutWeight(1) .width('100%') Button('打开相册').onClick(()=>{ this.openPicker() }) Button('测试剪裁').onClick(async () => { try { this.pm = await this.model.crop(image.PixelMapFormat.RGBA_8888); } catch (e) { console.info(`e:${JSON.stringify(e)}`) } }) Text('剪裁结果') Image(this.pm).width('300lpx').height('300lpx').borderRadius('150lpx') } .height('100%') .width('100%') } // 弹出图片选择器方法 async openPicker() { try { // 设置图片选择器选项 const photoSelectOptions = new picker.PhotoSelectOptions(); // 限制只能选择一张图片 photoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; photoSelectOptions.maxSelectNumber = 1; // 创建并实例化图片选择器 const photoViewPicker = new picker.PhotoViewPicker(); // 选择图片并获取图片URI let uris: picker.PhotoSelectResult = await photoViewPicker.select(photoSelectOptions); if (!uris || uris.photoUris.length === 0) return; // 获取选中图片的第一张URI let uri: string = uris.photoUris[0]; this.model.setImage(uri) .setFrameWidth(1000) .setFrameRatio(1); } catch (e) { console.error('openPicker', JSON.stringify(e)); } } handleFileSelection(event: MyEvent) { const PhotoSelectOptions = new picker.PhotoSelectOptions(); PhotoSelectOptions.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE; PhotoSelectOptions.maxSelectNumber = 1; const photoPicker = new picker.PhotoViewPicker(); photoPicker.select(PhotoSelectOptions) .then((PhotoSelectResult) => { if (PhotoSelectResult.photoUris.length === 0) { console.warn('No image selected.'); return; } const srcUri = PhotoSelectResult.photoUris[0]; this.model.setImage(srcUri) .setFrameWidth(1000) .setFrameRatio(1); }) .catch((selectError: object) => { console.error('Failed to invoke photo picker:', JSON.stringify(selectError)); }); return true; } }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· 没有源码,如何修改代码逻辑?
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· DeepSeek 开源周回顾「GitHub 热点速览」
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了