
【HarmonyOS 5】鸿蒙用户头像编辑功能实践
【HarmonyOS 5】鸿蒙用户头像编辑功能实践
一、前言
1、应用背景
在鸿蒙化开发过程中,我们发现最基本常见的功能--用户头像的编辑,实现方式和Android与IOS有极大的不同。
在实际开发和调研的过程中,我们发现并总结了鸿蒙隐私处理与业内Android和IOS的差异性。发现隐私保护对标其他两个,上升了一个大台阶。并且针对开发者来说,也更加人性化,便利化。
2、业务需求拆解
用户头像编辑功能流程图如下所示:
(1) 用户首先触发头像编辑功能(如用户点击 “编辑头像” 按钮)。
(2) 用户打开设备相册,选择目标图片
此时会获取用户选择的图片,可能没有选择,用户取消,或者用户没有给权限。
(3) 手势裁剪图片:
进入裁剪界面,支持手势缩放、拖动、旋转图片,划定裁剪区域。
(4) 上传图片至服务器:
裁剪完成后,将图片压缩并上传至服务器,等待返回成功响应。
(5) 更新头像显示
3、技术调研目标
经过完整的需求拆解,实际需要调研的功能点只有三个:
(1)鸿蒙中如何获取用户的图片
(2)鸿蒙中如何实现图片的裁剪
(3)鸿蒙中如何实现图片的手势操控
二、用户相册图片获取的三种方式
1、用户相册图片获取功能的行业技术路线方案对比:
在鸿蒙调研过程中,我们发现,相当于Android和IOS的获取用户相册图片的方式,鸿蒙大有不同。
目前Android获取用户相册图片的技术路线有:
(1)调用系统原生相册,选取图片后传递给三方应用进行图片处理。隐式 Intent 调用系统相册或者SAF(Storage Access Framework )。
(2)申请用户相册权限,获取用户相册内所有的图片,在三方应用自定义的相册界面进行展示和图片选择逻辑。MediaStore 直接查询系统相册。
目前IOS获取用户相册图片的技术路线有:
(1)通过系统提供的控制器直接调用相册,UIImagePickerController(快速选择)
(2)申请用户相册权限,获取用户相册内所有的图片,在三方应用自定义的相册界面进行展示和图片选择逻辑。通过 PHPhotoLibrary 框架直接访问相册数据库。
当然Android和IOS集成三方SDK也可实现获取用户相册图片,但是其实最终原理还是以上,所以不单独列出。
目前在鸿蒙中对于用户图片的获取有以下三种方式:
(1)需要三方应用申请受限权限,获取文件读写的权限,(调用需要ohos.permission.READ_IMAGEVIDEO权限),这样就可以读取相册媒体库中的媒体资源。
(2)通过鸿蒙系统提供的安全控件PhotoAccessHelper,用户触发操作后即表示同意授权,不需要三方应用再去授权,可以将图片临时授权给应用处理。
(3)针对高度定制化三方应用的需求,不希望相册界面使用系统组件,保持APP的美观和一致性。鸿蒙提供了AlbumPicker,开发者可以在布局中嵌入AlbumPickerComponent组件,通过此组件,应用无需申请权限,即可访问公共目录中的相册列表。
2、用户相册图片获取功能的技术选项
综上所述,我们可以对比发现。鸿蒙在针对用户隐私保护上,比Android和IOS做的都好。极大的保护了用户的隐私安全。
虽然IOS使用系统控制器的方式也可达到鸿蒙的效果,但是市面上既有的APP几乎都是采用,先进入自己应用的相册,然后调用控制器,逻辑操作繁琐,并且很多APP没有在自己的应用相册界面中添加触发【+】加号入口。目前微信是有做,像饿了么京东都没做。需要去系统设置中自己手动添加可以访问的图片给应用。
说实话,我在使用IOS手机时,就喜欢权限设置里带的访问选择图片功能。不像安卓一样,获取用户授权后,APP就能访问到用户相册所有的图片。而是用户勾选开发给APP的图片,APP只能访问这些。这是IOS的做法。当然IOS也保留了,和Android类似的所有图片开放权限。
获取相册所有图片,是开发者最常见的操作了,目前华为是不提倡APP访问用户所有相册资源。鸿蒙的隐私保护效果好,但是对于开发者就有点痛苦了,特别是产品的要求要与Android和IOS一致的情况下。
DEMO验证阶段我们发现,鸿蒙方案一,申请读取权限,该权限是管制权限,需要三方应用去通过场景申请,非常严格并且几乎无法申请通过。【申请使用受限权限】 所以PASS。
"requestPermissions": [
{
"name": "ohos.permission.READ_IMAGEVIDEO",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
},
"reason": "$string:CAMERA"
}
]
// 创建申请权限明细
async reqPermissionsFromUser(): Promise<number[]> {
let context = getContext() as common.UIAbilityContext;
let atManager = abilityAccessCtrl.createAtManager();
let grantStatus = await atManager.requestPermissionsFromUser(context, ['ohos.permission.READ_IMAGEVIDEO']);
return grantStatus.authResults;
}
// 用户申请权限
async requestPermission() {
let grantStatus = await this.reqPermissionsFromUser();
for (let i = 0; i < grantStatus.length; i++) {
if (grantStatus[i] === 0) {
// 用户授权,可以继续访问目标操作
}
}
}
鸿蒙方案二在930阶段启用,使用也很方便,虽然损失了APP的美观和一致性。使用系统提供的Picker组件,以弹框的形式显示,让用户选择图片,点击完成后,自动收起。效果如下图所示:
/**
* 相册选择图片
*/
private async getPictureFromAlbum() {
// 创建一个 PhotoSelectOptions 对象,用于配置相册选择的相关选项
let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
// 设置选择的文件 MIME 类型为图片类型,这样在相册选择时只会显示图片文件
PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
// 设置最大选择数量为 1,即只能从相册中选择一张图片
PhotoSelectOptions.maxSelectNumber = 1;
// 设置推荐选项,这里指定推荐类型为二维码或条形码,可能会优先展示符合此类型的图片
PhotoSelectOptions.recommendationOptions = {
recommendationType: photoAccessHelper.RecommendationType.QR_OR_BAR_CODE
}
// 创建一个 PhotoViewPicker 对象,用于启动相册选择器
let photoPicker = new photoAccessHelper.PhotoViewPicker();
// 调用 select 方法,传入配置好的选项,等待用户从相册中选择图片
// 返回一个 PhotoSelectResult 对象,包含用户选择的图片的相关信息
let photoSelectResult: photoAccessHelper.PhotoSelectResult = await photoPicker.select(PhotoSelectOptions);
// 从 PhotoSelectResult 对象中获取用户选择的第一张图片的 URI 路径
let albumPath = photoSelectResult.photoUris[0];
// 在控制台输出日志,记录获取到的图片路径,方便调试和查看信息
console.info(this.TAG, 'getPictureFromAlbum albumPath= ' + albumPath);
// 调用 getImageByPath 方法,传入图片路径,用于根据路径获取图片的具体内容
await this.getImageByPath(albumPath);
}
方案三是今年系统API升级后公开提供的API,从应用市场下载APP操作对比,使用上看应该是去年给大厂APP,微信微博他们先使用后,才公开的方案。我是比较推荐该方案,搞定定制化,符合APP的整体调性。效果如下图所示:
// 从 @ohos.file.PhotoPickerComponent 模块导入所需的类和类型
// 这些类和类型用于构建和配置图片选择器组件
import {
PhotoPickerComponent, // 图片选择器组件类
PickerController, // 图片选择器控制器类,用于控制组件行为
PickerOptions, // 图片选择器的配置选项类
DataType, // 数据类型枚举
BaseItemInfo, // 基础项信息类
ItemInfo, // 项信息类,包含更详细的项信息
PhotoBrowserInfo, // 图片浏览器信息类
ItemType, // 项类型枚举
ClickType, // 点击类型枚举
MaxCountType, // 最大数量类型枚举
PhotoBrowserRange, // 图片浏览器范围枚举
ReminderMode, // 提醒模式枚举
} from '@ohos.file.PhotoPickerComponent';
// 导入照片访问辅助工具模块
import photoAccessHelper from '@ohos.file.photoAccessHelper';
// 标记为页面入口组件
@Entry
// 定义一个名为 AlbumTestPage 的组件
@Component
struct AlbumTestPage {
// 组件初始化时设置参数信息
// 创建一个 PickerOptions 实例,用于配置图片选择器的各种选项
pickerOptions: PickerOptions = new PickerOptions();
// 组件初始化完成后,可控制组件部分行为
// 使用 @State 装饰器,使 pickerController 成为响应式状态变量
// 创建一个 PickerController 实例,用于控制图片选择器的行为
@State pickerController: PickerController = new PickerController();
// 已选择的图片
// 使用 @State 装饰器,使 selectUris 成为响应式状态变量
// 用于存储已选择图片的 URI 数组
@State selectUris: Array<string> = new Array<string>();
// 目前选择的图片
// 使用 @State 装饰器,使 currentUri 成为响应式状态变量
// 用于存储当前选中图片的 URI
@State currentUri: string = '';
// 是否显示大图
// 使用 @State 装饰器,使 isBrowserShow 成为响应式状态变量
// 用于控制是否显示图片浏览器(大图模式)
@State isBrowserShow: boolean = false;
// 组件即将显示时调用的生命周期函数
aboutToAppear() {
// 设置 picker 宫格页数据类型
// 将选择器的 MIME 类型设置为图片和视频类型,即图片和视频都会在选择器中显示
this.pickerOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_VIDEO_TYPE;
// 最大选择数量
// 设置图片选择的最大数量为 5 张
this.pickerOptions.maxSelectNumber = 5;
// 超出最大选择数量时
// 当选择数量超过最大限制时,以 Toast 形式提醒用户
this.pickerOptions.maxSelectedReminderMode = ReminderMode.TOAST;
// 是否展示搜索框,默认 false
// 开启选择器中的搜索框功能
this.pickerOptions.isSearchSupported = true;
// 是否支持拍照,默认 false
// 开启选择器中的拍照功能
this.pickerOptions.isPhotoTakingSupported = true;
}
// 资源被选中回调,返回资源的信息,以及选中方式
// 当图片选择器中的项被点击时触发的回调函数
private onItemClicked(itemInfo: ItemInfo, clickType: ClickType): boolean {
// 若传入的项信息为空,则直接返回 false
if (!itemInfo) {
return false;
}
// 获取项的类型
let type: ItemType | undefined = itemInfo.itemType;
// 获取项的 URI
let uri: string | undefined = itemInfo.uri;
// 若项类型为相机
if (type === ItemType.CAMERA) {
// 点击相机 item
// 返回 true 则拉起系统相机,若应用需要自行处理则返回 false
return true;
} else {
// 若点击类型为选中
if (clickType === ClickType.SELECTED) {
// 应用做自己的业务处理
if (uri) {
// 将选中图片的 URI 添加到已选择数组中
this.selectUris.push(uri);
// 更新选择器的预选中 URI 数组
this.pickerOptions.preselectedUris = [...this.selectUris];
}
// 返回 true 则勾选,否则则不响应勾选
return true;
} else {
if (uri) {
// 若点击类型为取消选中,从已选择数组中过滤掉该 URI
this.selectUris = this.selectUris.filter((item: string) => {
return item != uri;
});
// 更新选择器的预选中 URI 数组
this.pickerOptions.preselectedUris = [...this.selectUris];
}
}
return true;
}
}
// 进入大图的回调
// 当进入图片浏览器(大图模式)时触发的回调函数
private onEnterPhotoBrowser(photoBrowserInfo: PhotoBrowserInfo): boolean {
// 设置显示大图标志为 true
this.isBrowserShow = true;
return true;
}
// 退出大图的回调
// 当退出图片浏览器(大图模式)时触发的回调函数
private onExitPhotoBrowser(photoBrowserInfo: PhotoBrowserInfo): boolean {
// 设置显示大图标志为 false
this.isBrowserShow = false;
return true;
}
// 接收到该回调后,便可通过 pickerController 相关接口向 picker 发送数据,在此之前不生效
// 当图片选择器控制器准备好时触发的回调函数
private onPickerControllerReady(): void {
// 这里可以添加向选择器发送数据的逻辑
}
// 大图左右滑动的回调
// 当在图片浏览器(大图模式)中左右滑动图片时触发的回调函数
private onPhotoBrowserChanged(browserItemInfo: BaseItemInfo): boolean {
// 更新当前选中图片的 URI
this.currentUri = browserItemInfo.uri ?? '';
return true;
}
// 已勾选图片被删除时的回调
// 当已勾选的图片被删除时触发的回调函数
private onSelectedItemsDeleted(baseItemInfos: Array<BaseItemInfo>): void {
// 这里可以添加处理已勾选图片被删除的逻辑
}
// 超过最大选择数量再次点击时的回调
// 当选择数量超过最大限制再次点击时触发的回调函数
private onExceedMaxSelected(exceedMaxCountType: MaxCountType): void {
// 这里可以添加处理超过最大选择数量的逻辑
}
// 当前相册被删除时的回调
// 当当前选择的相册被删除时触发的回调函数
private onCurrentAlbumDeleted(): void {
// 这里可以添加处理当前相册被删除的逻辑
}
// 组件构建函数,用于定义组件的 UI 结构
build() {
// 创建一个垂直方向的 Flex 布局容器
Flex({
direction: FlexDirection.Column,
alignItems: ItemAlign.Start
}) {
// 使用 PhotoPickerComponent 组件
PhotoPickerComponent({
pickerOptions: this.pickerOptions, // 传入图片选择器的配置选项
// 传入项点击回调函数
onItemClicked: (itemInfo: ItemInfo, clickType: ClickType): boolean => this.onItemClicked(itemInfo, clickType),
// 传入进入图片浏览器回调函数
onEnterPhotoBrowser: (photoBrowserInfo: PhotoBrowserInfo): boolean => this.onEnterPhotoBrowser(photoBrowserInfo),
// 传入退出图片浏览器回调函数
onExitPhotoBrowser: (photoBrowserInfo: PhotoBrowserInfo): boolean => this.onExitPhotoBrowser(photoBrowserInfo),
// 传入选择器控制器准备好回调函数
onPickerControllerReady: (): void => this.onPickerControllerReady(),
// 传入图片浏览器滑动回调函数
onPhotoBrowserChanged: (browserItemInfo: BaseItemInfo): boolean => this.onPhotoBrowserChanged(browserItemInfo),
pickerController: this.pickerController, // 传入图片选择器控制器
})
// 这里模拟应用侧底部的选择栏
// 若处于图片浏览器(大图模式)
if (this.isBrowserShow) {
// 已选择的图片缩影图
// 创建一个水平方向的 Row 布局容器
Row() {
// 遍历已选择的图片 URI 数组
ForEach(this.selectUris, (uri: string) => {
// 若当前 URI 为当前选中的图片 URI
if (uri === this.currentUri) {
// 显示带有红色边框的图片缩略图
Image(uri).height(50).width(50)
.onClick(() => {
})
.borderWidth(1)
.borderColor('red')
} else {
// 显示普通图片缩略图,点击时设置选择器数据并切换到对应图片
Image(uri).height(50).width(50).onClick(() => {
this.pickerController.setData(DataType.SET_SELECTED_URIS, this.selectUris);
this.pickerController.setPhotoBrowserItem(uri, PhotoBrowserRange.ALL);
})
}
}, (uri: string) => JSON.stringify(uri))
}.alignSelf(ItemAlign.Center).margin(this.selectUris.length ? 10 : 0)
} else {
// 进入大图,预览已选择的图片
// 创建一个按钮,点击时进入图片浏览器预览已选择的第一张图片
Button('预览').width('33%').alignSelf(ItemAlign.Start).height('5%').margin(10).onClick(() => {
if (this.selectUris.length > 0) {
this.pickerController.setPhotoBrowserItem(this.selectUris[0], PhotoBrowserRange.SELECTED_ONLY);
}
})
}
}
}
}
三、头像裁剪实现
裁剪界面,一般是原型或者方形。这个看APP产品的设计来。实现方式从Android和IOS来对比看,有的是提供了系统组件,有的是需要自己使用画布实现。
目前鸿蒙刚刚发展,系统组件也没提供类似完整的组件效果,当然肯定没有开源组件用了。所以这里我们使用画布去实现取景框的效果,如下图所示:
// 定义一个接口 LoadResult,用于描述图片加载完成后的结果信息
interface LoadResult {
// 图片的原始宽度
width: number;
// 图片的原始高度
height: number;
// 组件的宽度
componentWidth: number;
// 组件的高度
componentHeight: number;
// 加载状态,用数字表示不同的加载状态
loadingStatus: number;
// 内容的宽度
contentWidth: number;
// 内容的高度
contentHeight: number;
// 内容在 X 轴上的偏移量
contentOffsetX: number;
// 内容在 Y 轴上的偏移量
contentOffsetY: number;
}
// 定义一个鸿蒙 ArkTS 组件 CropView
@Component
export struct CropView {
// 定义组件的日志标签,用于在控制台输出日志时标识该组件
private TAG: string = "CropView";
// 创建一个 RenderingContextSettings 对象,用于配置画布渲染上下文的设置
// 传入 true 表示开启抗锯齿等优化设置
private mRenderingContextSettings: RenderingContextSettings = new RenderingContextSettings(true);
// 创建一个 CanvasRenderingContext2D 对象,用于在画布上进行 2D 绘图操作
// 使用之前创建的渲染上下文设置进行初始化
private mCanvasRenderingContext2D: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.mRenderingContextSettings);
// 使用 @Link 装饰器,将 mImg 绑定到外部传入的 PixelMap 对象
// 当外部的 PixelMap 对象发生变化时,该组件会自动更新
@Link mImg: PixelMap;
// 定义一个回调函数,当图片加载完成时调用
// msg 参数为 LoadResult 类型,包含图片加载完成后的相关信息
private onLoadImgComplete = (msg: LoadResult) => {
// 这里可以添加图片加载完成后的处理逻辑,当前为空
}
// 定义一个回调函数,当画布准备好进行绘制时调用
private onCanvasReady = () => {
// 检查画布渲染上下文对象是否为空
if (!this.mCanvasRenderingContext2D) {
// 如果为空,在控制台输出错误日志
console.error(this.TAG, "onCanvasReady error mCanvasRenderingContext2D null !");
return;
}
// 获取画布渲染上下文对象,方便后续使用
let cr = this.mCanvasRenderingContext2D;
// 设置画布的填充颜色,这里是半透明的黑色
cr.fillStyle = '#AA000000';
// 获取画布的高度
let height = cr.height;
// 获取画布的宽度
let width = cr.width;
// 在画布上填充一个矩形,覆盖整个画布区域
cr.fillRect(0, 0, width, height);
// 计算圆形的中心点的 X 坐标
let centerX = width / 2;
// 计算圆形的中心点的 Y 坐标
let centerY = height / 2;
// 计算圆形的半径,取画布宽度和高度的最小值的一半再减去 100 像素
let radius = Math.min(width, height) / 2 - 100;
// 设置全局合成操作模式为 'destination-out'
// 该模式表示在已有内容的基础上,清除与新绘制图形重叠的部分
cr.globalCompositeOperation = 'destination-out'
// 设置填充颜色为白色
cr.fillStyle = 'white'
// 开始一个新的路径
cr.beginPath();
// 在画布上绘制一个圆形
cr.arc(centerX, centerY, radius, 0, 2 * Math.PI);
// 填充圆形,由于之前设置了 'destination-out' 模式,会清除圆形区域的内容
cr.fill();
// 设置全局合成操作模式为 'source-over'
// 该模式表示新绘制的图形会覆盖在已有内容之上
cr.globalCompositeOperation = 'source-over';
// 设置描边颜色为白色
cr.strokeStyle = '#FFFFFF';
// 开始一个新的路径
cr.beginPath();
// 在画布上绘制一个圆形
cr.arc(centerX, centerY, radius, 0, 2 * Math.PI);
// 关闭路径
cr.closePath();
// 设置线条宽度为 1 像素
cr.lineWidth = 1;
// 绘制圆形的边框
cr.stroke();
}
// 组件的构建函数,用于定义组件的 UI 结构
build() {
// 创建一个 Stack 布局容器,将子组件堆叠在一起显示
Stack() {
// 创建一个 Row 组件,作为黑色底图
// 设置宽度和高度为 100%,背景颜色为黑色
Row().width("100%").height("100%").backgroundColor(Color.Black)
// 创建一个 Image 组件,用于显示用户传入的图片
Image(this.mImg)
// 设置图片的填充模式为填充整个容器
.objectFit(ImageFit.Fill)
// 设置图片的宽度为 100%
.width('100%')
// 设置图片的宽高比为 1:1
.aspectRatio(1)
// 绑定图片加载完成的回调函数
.onComplete(this.onLoadImgComplete)
// 创建一个 Canvas 组件,用于绘制取景框
Canvas(this.mCanvasRenderingContext2D)
// 设置画布的宽度为 100%
.width('100%')
// 设置画布的高度为 100%
.height('100%')
// 设置画布的背景颜色为透明
.backgroundColor(Color.Transparent)
// 绑定画布准备好的回调函数
.onReady(this.onCanvasReady)
// 开启裁剪功能
.clip(true)
// 设置画布的背景颜色为半透明的黑色
.backgroundColor("#00000080")
}
// 设置 Stack 布局容器的宽度和高度为 100%
.width("100%").height("100%")
}
}
最终效果演示与DEMO源码分享
import { photoAccessHelper } from '@kit.MediaLibraryKit';
import { image } from '@kit.ImageKit';
import { fileIo as fs } from '@kit.CoreFileKit';
import { router } from '@kit.ArkUI';
import { cameraPicker as picker } from '@kit.CameraKit';
import { camera } from '@kit.CameraKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { CropView } from './CropView';
@Entry
@Component
struct Index {
private TAG: string = "imageTest";
@State mUserPixel: image.PixelMap | undefined = undefined;
@State mTargetPixel: image.PixelMap | undefined = undefined;
/**
* 拍照获取图片
*/
private async getPictureFromCamera(){
try {
let pickerProfile: picker.PickerProfile = {
// 相机的位置。
cameraPosition: camera.CameraPosition.CAMERA_POSITION_BACK
};
let pickerResult: picker.PickerResult = await picker.pick(
getContext(),
[picker.PickerMediaType.PHOTO],
pickerProfile
);
console.log(this.TAG, "the pick pickerResult is:" + JSON.stringify(pickerResult));
// 成功才处理
if(pickerResult && pickerResult.resultCode == 0){
await this.getImageByPath(pickerResult.resultUri);
}
} catch (error) {
let err = error as BusinessError;
console.error(this.TAG, `the pick call failed. error code: ${err.code}`);
}
}
/**
* 相册选择图片
*/
private async getPictureFromAlbum() {
let PhotoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
PhotoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
PhotoSelectOptions.maxSelectNumber = 1;
let photoPicker = new photoAccessHelper.PhotoViewPicker();
let photoSelectResult: photoAccessHelper.PhotoSelectResult = await photoPicker.select(PhotoSelectOptions);
let albumPath = photoSelectResult.photoUris[0];
console.info(this.TAG, 'getPictureFromAlbum albumPath= ' + albumPath);
await this.getImageByPath(albumPath);
}
/**
* 获取图片pixelMap
* @param path
*/
private async getImageByPath(path: string) {
console.info(this.TAG, 'getImageByPath path: ' + path);
try {
// 读取图片为buffer
const file = fs.openSync(path, fs.OpenMode.READ_ONLY);
let photoSize = fs.statSync(file.fd).size;
console.info(this.TAG, 'Photo Size: ' + photoSize);
let buffer = new ArrayBuffer(photoSize);
fs.readSync(file.fd, buffer);
fs.closeSync(file);
// 解码成PixelMap
const imageSource = image.createImageSource(buffer);
console.log(this.TAG, 'imageSource: ' + JSON.stringify(imageSource));
this.mUserPixel = await imageSource.createPixelMap({});
} catch (e) {
console.info(this.TAG, 'getImage e: ' + JSON.stringify(e));
}
}
build() {
Scroll(){
Column() {
Text("点击拍照")
.fontSize(50)
.fontWeight(FontWeight.Bold)
.onClick(() => {
this.getPictureFromCamera();
})
Text("相册选择")
.fontSize(50)
.fontWeight(FontWeight.Bold)
.onClick(() => {
this.getPictureFromAlbum();
})
Image(this.mUserPixel)
.objectFit(ImageFit.Fill)
.width('100%')
.aspectRatio(1)
Text("图片裁剪")
.fontSize(50)
.fontWeight(FontWeight.Bold)
.onClick(() => {
this.cropImage();
// router.pushUrl({
// url: "pages/crop"
// })
})
CropView({ mImg: $mUserPixel })
.width('100%')
.aspectRatio(1)
Text("裁剪效果")
.fontSize(50)
.fontWeight(FontWeight.Bold)
Image(this.mTargetPixel)
.width('100%')
.aspectRatio(1)
.borderRadius(200)
}
.height(3000)
.width('100%')
}
.height('100%')
.width('100%')
}
private async cropImage(){
if(!this.mUserPixel){
return;
}
let cp = await this.copyPixelMap(this.mUserPixel);
let region: image.Region = { x: 0, y: 0, size: { width: 400, height: 400 } };
cp.cropSync(region);
}
async copyPixelMap(pixel: PixelMap): Promise<PixelMap> {
const info: image.ImageInfo = await pixel.getImageInfo();
const buffer: ArrayBuffer = new ArrayBuffer(pixel.getPixelBytesNumber());
await pixel.readPixelsToBuffer(buffer);
const opts: image.InitializationOptions = {
editable: true,
pixelFormat: image.PixelMapFormat.RGBA_8888,
size: { height: info.size.height, width: info.size.width }
};
return image.createPixelMap(buffer, opts);
}
}
// 导入路由模块,用于页面跳转
import router from '@ohos.router';
// 导入自定义图片工具模块,提供图片处理功能
import { image } from '@kit.ImageKit';
// 导入矩阵变换模块,用于处理图片的平移、缩放等变换
import Matrix4 from '@ohos.matrix4';
// 定义图片加载结果接口,包含图片尺寸、组件尺寸、加载状态等信息
export class LoadResult {
width: number = 0; // 图片原始宽度
height: number = 0; // 图片原始高度
componentWidth: number = 0; // 组件宽度
componentHeight: number = 0; // 组件高度
loadingStatus: number = 0; // 加载状态(0:未加载, 1:加载中, 2:加载完成等)
contentWidth: number = 0; // 内容区域宽度
contentHeight: number = 0; // 内容区域高度
contentOffsetX: number = 0; // 内容在X轴偏移量
contentOffsetY: number = 0; // 内容在Y轴偏移量
}
// 标记为页面入口组件
@Entry
// 定义裁剪页面组件
@Component
export struct CropPage {
private TAG: string = "CropPage"; // 日志标签,用于控制台输出标识
// 画布渲染上下文配置(开启抗锯齿)
private mRenderingContextSettings: RenderingContextSettings = new RenderingContextSettings(true);
// 画布2D渲染上下文,用于绘制取景框等图形
private mCanvasRenderingContext2D: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.mRenderingContextSettings);
// 响应式状态变量:加载的图片像素图(可选类型,初始为undefined)
@State mImg: PixelMap | undefined = undefined;
// 响应式状态变量:图片矩阵变换参数(初始为单位矩阵,包含平移和缩放变换)
@State mMatrix: object = Matrix4.identity()
.translate({ x: 0, y: 0 }) // 初始平移量为0
.scale({ x: 1, y: 1}); // 初始缩放比例为1:1
@State mImageInfo: ImageInfo = new ImageInfo(); // 图片信息对象(包含缩放、偏移等状态)
private tempScale = 1; // 临时缩放比例,用于手势缩放过程中保存中间状态
private startOffsetX: number = 0; // 拖动手势开始时的X轴偏移量
private startOffsetY: number = 0; // 拖动手势开始时的Y轴偏移量
// 组件即将显示时的生命周期函数(类似onStart)
aboutToAppear(): void {
console.log(this.TAG, "aboutToAppear start");
let temp = mSourceImg; // 假设mSourceImg为外部传入的原始图片
console.log(this.TAG, "aboutToAppear temp: " + JSON.stringify(temp));
this.mImg = temp; // 将原始图片赋值给组件状态变量
console.log(this.TAG, "aboutToAppear end");
}
// 获取图片信息的辅助方法
private getImgInfo(){
return this.mImageInfo;
}
// 取消按钮点击事件处理:返回上一页
onClickCancel = ()=>{
router.back(); // 调用路由返回接口
}
// 确认按钮点击事件处理(异步函数)
onClickConfirm = async ()=>{
if(!this.mImg){
console.error(this.TAG, " onClickConfirm mImg error null !");
return;
}
// 此处省略图片裁剪保存逻辑(...)
router.back(); // 处理完成后返回上一页
}
/**
* 复制图片像素图
* @param pixel 原始像素图对象
* @returns 复制后的像素图对象(Promise异步返回)
*/
async copyPixelMap(pixel: PixelMap): Promise<PixelMap> {
const info: image.ImageInfo = await pixel.getImageInfo(); // 获取图片信息
const buffer: ArrayBuffer = new ArrayBuffer(pixel.getPixelBytesNumber()); // 创建像素数据缓冲区
await pixel.readPixelsToBuffer(buffer); // 将像素数据读取到缓冲区
// 初始化选项:可编辑、像素格式、尺寸
const opts: image.InitializationOptions = {
editable: true,
pixelFormat: image.PixelMapFormat.RGBA_8888,
size: { height: info.size.height, width: info.size.width }
};
return image.createPixelMap(buffer, opts); // 创建并返回新的像素图
}
/**
* 图片加载完成回调函数
* @param msg 加载结果信息,更新图片信息对象并检查缩放比例
*/
private onLoadImgComplete = (msg: LoadResult) => {
this.getImgInfo().loadResult = msg; // 将加载结果存入图片信息对象
this.checkImageScale(); // 检查并调整图片缩放比例(代码中未实现,需后续补充)
}
/**
* 画布准备完成回调函数:绘制取景框
*/
private onCanvasReady = ()=>{
if(!this.mCanvasRenderingContext2D){
console.error(this.TAG, "onCanvasReady error mCanvasRenderingContext2D null !");
return;
}
let cr = this.mCanvasRenderingContext2D;
// 绘制半透明黑色背景
cr.fillStyle = '#AA000000'; // 设置填充颜色(80%透明度黑色)
let height = cr.height; // 获取画布高度
let width = cr.width; // 获取画布宽度
cr.fillRect(0, 0, width, height); // 填充整个画布
// 计算圆形取景框参数
let centerX = width / 2; // 圆心X坐标(画布中心)
let centerY = height / 2; // 圆心Y坐标(画布中心)
let radius = Math.min(width, height) / 2 - px2vp(100); // 半径=画布短边的一半减100虚拟像素
// 设置合成模式:清除圆形区域内的背景(实现镂空效果)
cr.globalCompositeOperation = 'destination-out';
cr.fillStyle = 'white'; // 设置填充颜色为白色(用于清除区域)
cr.beginPath(); // 开始路径绘制
cr.arc(centerX, centerY, radius, 0, 2 * Math.PI); // 绘制圆形路径
cr.fill(); // 填充路径,清除圆形区域背景
// 绘制白色边框
cr.globalCompositeOperation = 'source-over'; // 恢复正常绘制模式
cr.strokeStyle = '#FFFFFF'; // 设置边框颜色为白色
cr.beginPath(); // 重新开始路径
cr.arc(centerX, centerY, radius, 0, 2 * Math.PI); // 绘制圆形路径
cr.closePath(); // 闭合路径
cr.lineWidth = 1; // 设置线条宽度
cr.stroke(); // 绘制边框
}
// 组件UI构建函数
build() {
// 相对布局容器(子组件可相对于容器定位)
RelativeContainer() {
// 黑色背景层
Row().width("100%").height("100%").backgroundColor(Color.Black)
// 图片显示组件
Image(this.mImg)
.objectFit(ImageFit.Contain) // 图片适应容器,保持宽高比
.width('100%') // 宽度占满容器
.height('100%') // 高度占满容器
.transform(this.mMatrix) // 应用矩阵变换(平移/缩放)
.alignRules({ // 布局对齐规则:水平垂直居中
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.onComplete(this.onLoadImgComplete) // 绑定图片加载完成回调
// 取景框画布组件
Canvas(this.mCanvasRenderingContext2D)
.width('100%') // 画布宽度占满容器
.height('100%') // 画布高度占满容器
.alignRules({ // 布局对齐规则:水平垂直居中
center: { anchor: '__container__', align: VerticalAlign.Center },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.backgroundColor(Color.Transparent) // 画布背景透明
.onReady(this.onCanvasReady) // 绑定画布准备完成回调
.clip(true) // 开启裁剪(超出画布的内容隐藏)
.backgroundColor("#00000080") // 半透明黑色背景(与画布绘制的镂空区域形成对比)
// 底部按钮栏(取消/确定按钮)
Row(){
Button("取消") // 取消按钮
.size({ width: px2vp(450), height: px2vp(200) }) // 设置按钮尺寸(虚拟像素转换)
.onClick(this.onClickCancel) // 绑定取消事件处理
Blank() // 空白间隔
Button("确定") // 确定按钮
.size({ width: px2vp(450), height: px2vp(200) })
.onClick(this.onClickConfirm) // 绑定确定事件处理
}
.width("100%") // 按钮栏宽度占满容器
.height(px2vp(200)) // 按钮栏高度
.margin({ bottom: px2vp(500) }) // 底部边距
.alignRules({ // 布局对齐规则:底部居中
center: { anchor: '__container__', align: VerticalAlign.Bottom },
middle: { anchor: '__container__', align: HorizontalAlign.Center }
})
.justifyContent(FlexAlign.Center) // 子组件水平居中排列
}
.width("100%").height("100%") // 容器占满整个页面
.priorityGesture( // 注册优先级手势(双击手势)
TapGesture({ // 点击手势配置
count: 2, // 双击触发
fingers: 1 // 单指操作
}).onAction((event: GestureEvent)=>{
console.log(this.TAG, "TapGesture onAction start");
if(!event){
return;
}
// 双击时切换缩放比例(1倍和2倍之间切换)
if(this.getImgInfo().scale != 1){
this.getImgInfo().scale = 1; // 恢复1倍缩放
this.getImgInfo().offsetX = 0; // 重置X轴偏移
this.getImgInfo().offsetY = 0; // 重置Y轴偏移
}else{
this.getImgInfo().scale = 2; // 放大至2倍
}
// 更新矩阵变换参数(平移+缩放)
this.mMatrix = Matrix4.identity()
.translate({ x: this.getImgInfo().offsetX, y: this.getImgInfo().offsetY })
.scale({ x: this.getImgInfo().scale, y: this.getImgInfo().scale });
console.log(this.TAG, "TapGesture onAction end");
})
)
.gesture(GestureGroup( // 注册手势组(支持并行手势)
GestureMode.Parallel, // 手势模式:并行处理(缩放和拖动可同时进行)
// 双指缩放手势
PinchGesture({ // 缩放手势配置
fingers: 2 // 双指触发
})
.onActionStart(()=>{ // 手势开始时记录当前缩放比例
this.tempScale = this.getImgInfo().scale;
})
.onActionUpdate((event)=>{ // 手势更新时计算新的缩放比例
if(event){
this.getImgInfo().scale = this.tempScale * event.scale; // 基于手势缩放因子更新
// 更新矩阵变换(保持当前偏移量,应用新的缩放比例)
this.mMatrix = Matrix4.identity()
.translate({ x: this.getImgInfo().offsetX, y: this.getImgInfo().offsetY })
.scale({ x: this.getImgInfo().scale, y: this.getImgInfo().scale });
}
})
,
// 单指拖动手势
PanGesture() // 拖动手势配置
.onActionStart(()=>{ // 手势开始时记录初始偏移量
this.startOffsetX = this.getImgInfo().offsetX;
this.startOffsetY = this.getImgInfo().offsetY;
})
.onActionUpdate((event)=>{ // 手势更新时计算新的偏移量(考虑缩放比例)
if(event){
// 偏移量转换:虚拟像素转物理像素,并除以当前缩放比例
let distanceX: number = this.startOffsetX + vp2px(event.offsetX) / this.getImgInfo().scale;
let distanceY: number = this.startOffsetY + vp2px(event.offsetY) / this.getImgInfo().scale;
this.getImgInfo().offsetX = distanceX;
this.getImgInfo().offsetY = distanceY;
// 更新矩阵变换(应用新的平移和缩放)
this.mMatrix = Matrix4.identity()
.translate({ x: this.getImgInfo().offsetX, y: this.getImgInfo().offsetY })
.scale({ x: this.getImgInfo().scale, y: this.getImgInfo().scale });
}
})
))
}
}
更多推荐
所有评论(0)