1. 目标
我们要实现的是一个屏幕识别区域设置功能,用户可以通过拖拽手势动态调整识别区域的位置和大小。这种交互方式在答题类应用、OCR识别工具、截图标注等场景中都有广泛应用。
核心需求
- 用户可以通过触摸拖拽调整识别区域
- 实时显示调整过程中的区域变化
- 提供视觉反馈增强用户体验
- 智能边界控制防止区域超出有效范围
2. PanGesture手势识别核心实现
1. 手势基础配置
首先,我们来看手势的基础配置:
- 主要代码
// 主要识别区域
Column() {
// 上部分蒙层
Row()
.width('100%')
.height(this.cropAreaY)
// 识别区域
Stack() {
// 识别区域背景
Column()
.width('100%')
.height('100%')
.backgroundColor(this.isDragging ? '#fffc8d56' : '#FD7D3F')
Image($r("app.media.ic_area"))
.height(this.cropAreaHeight < 50 ? '100%' : 50)
.objectFit(ImageFit.Contain)
}
.width('100%')
.opacity(this.selectedImageUri ? 0.5 : 1)
.height(this.cropAreaHeight)
// 下部分蒙层
Row()
.width('100%')
.layoutWeight(1)
}
.layoutWeight(1)
.gesture(
PanGesture({ fingers: 1, distance: 1 })
.onActionStart((event: GestureEvent) => {
this.isDragging = true;
this.startPoint = {
x: event.fingerList[0].globalX,
y: event.fingerList[0].globalY
};
console.log('开始拖拽识别');
})
.onActionUpdate((event: GestureEvent) => {
this.handlePanGesture(event);
})
.onActionEnd((event: GestureEvent) => {
this.isDragging = false;
console.log('结束拖拽识别');
})
)
- 识别手势代码
.gesture(
PanGesture({ fingers: 1, distance: 1 })
.onActionStart((event: GestureEvent) => { ... })
.onActionUpdate((event: GestureEvent) => { ... })
.onActionEnd((event: GestureEvent) => { ... })
)
配置参数解析:
fingers: 1- 指定需要1根手指触发手势,确保是单指操作distance: 1- 设置最小触发距离为1像素,让手势响应更加敏感
原理:通过设置顶部的高度来确定识别区域 cropAreaY为首次触碰的纵坐标
2. 手势生命周期详解
onActionStart - 手势开始阶段
.onActionStart((event: GestureEvent) => {
this.isDragging = true;
this.startPoint = {
x: event.fingerList[0].globalX,
y: event.fingerList[0].globalY
};
console.log('开始拖拽识别');
})
关键功能:
- 状态标记:设置
isDragging = true,标识进入拖拽状态 - 起始点记录:保存手指触摸的全局坐标
globalX和globalY - 状态初始化:为后续的区域计算提供基准点
技术要点:
- 使用
event.fingerList[0]获取第一个手指的信息 globalX/globalY提供相对于整个屏幕的绝对坐标- 状态管理确保只有在拖拽时才进行区域更新
onActionUpdate - 手势更新阶段
.onActionUpdate((event: GestureEvent) => {
this.handlePanGesture(event);
})
这里调用了核心的手势处理函数handlePanGesture,我们来详细分析:
private handlePanGesture = (event: GestureEvent) => {
if (!this.isDragging) {
return; // 防护性检查
}
const currentY = event.fingerList[0].globalY;
// 计算识别区域参数
const minY = Math.min(this.startPoint.y, currentY);
const maxY = Math.max(this.startPoint.y, currentY);
// 定义底部操作区域高度
const bottomOperationAreaHeight = 100;
// 计算可用的最大高度
const maxAvailableHeight = this.pageHeight - bottomOperationAreaHeight;
// 应用边界限制
this.cropAreaY = Math.max(0, minY);
// 限制识别区域高度
const calculatedHeight = maxY - minY;
const maxAllowedHeight = maxAvailableHeight - this.cropAreaY;
this.cropAreaHeight = Math.min(calculatedHeight, maxAllowedHeight);
// 确保识别区域高度不为负数
if (this.cropAreaHeight < 0) {
this.cropAreaHeight = 0;
}
}
实现亮点:
- 智能区域计算
-
- 使用
Math.min/Math.max自动确定矩形区域的上下边界 - 支持从上往下拖拽和从下往上拖拽两种操作方式
- 使用
- 动态边界控制
-
- 上边界限制:
Math.max(0, minY)确保不超出屏幕顶部 - 下边界保护:预留100像素操作区域,防止覆盖按钮
- 高度约束:动态计算最大允许高度
- 上边界限制:
- 实时状态更新
-
- 直接更新
@State变量,触发UI自动重渲染 - 无需手动调用刷新方法,响应速度快
- 直接更新
onActionEnd - 手势结束阶段
.onActionEnd((event: GestureEvent) => {
this.isDragging = false;
console.log('结束拖拽识别');
})
清理工作:
- 重置拖拽状态标记
- 结束拖拽模式,恢复正常显示状态
3. 视觉反馈机制
在拖拽过程中,系统提供了丰富的视觉反馈:
// 识别区域背景色动态变化
.backgroundColor(this.isDragging ? '#fffc8d56' : '#FD7D3F')
- 正常状态:橙色背景
#FD7D3F - 拖拽状态:半透明黄色
#fffc8d56 - 实时响应:基于
isDragging状态自动切换
3. 坐标系统与数据持久化
坐标转换机制
// 保存时:vp -> px -> 归一化
let region: image.Region = {
x: 0,
y: vp2px(this.cropAreaY) / screenHeight,
size: {
width: 0,
height: vp2px(this.cropAreaHeight) / screenHeight
}
};
// 加载时:归一化 -> px -> vp
this.cropAreaY = px2vp(region.y * screenHeight);
this.cropAreaHeight = px2vp(region.size.height * screenHeight);
设计优势:
- 设备适配:使用归一化坐标适配不同屏幕尺寸
- 精度保持:通过标准坐标转换保证显示一致性
- 数据压缩:相对坐标占用更少存储空间
4. 实际应用效果
用户操作流程
- 触摸开始 → 记录起始坐标,进入拖拽模式
- 拖拽移动 → 实时计算区域范围,更新UI显示
- 松开手指 → 确定最终区域,退出拖拽模式
- 保存设置 → 归一化坐标并持久化存储
5. 完整代码
import photoAccessHelper from '@ohos.file.photoAccessHelper';
import { BusinessError } from '@ohos.base';
import image from '@ohos.multimedia.image';
import { promptAction } from "@kit.ArkUI";
import display from '@ohos.display';
import { PreferencesConstants, preferencesUtils } from 'utils';
const TAG = 'ScreenReadSetting';
@Component
export struct ScreenReadSetting {
@Consume('pathStack') pathStack: NavPathStack;
@State title: string = '读屏设置';
@State selectedImageUri: string = '';
// 识别相关状态
@State startPoint: Position = { x: 0, y: 0 }; // 起始点
@State cropAreaY: number = 60; // 识别区域Y坐标,默认60
@State cropAreaHeight: number = 200; // 识别区域高度,默认200
@State isDragging: boolean = false;
@State pageHeight: number = 0; // 页面高度
// 组件即将出现时的回调
aboutToAppear() {
this.loadSettings();
}
// 加载之前保存的设置
private async loadSettings() {
try {
const savedSettings = preferencesUtils.getString(PreferencesConstants.SCREEN_READ_SETTING, '');
let region: image.Region
if (savedSettings) {
region = JSON.parse(savedSettings);
} else {
region =
{ x: 0, y: 0.0721, size: { width: 0, height: 0.493 } }
console.log(TAG, '没有找到保存的设置,使用默认值');
}
console.log(TAG, '加载保存的设置:', JSON.stringify(region));
// 获取屏幕高度
const displayInfo = display.getDefaultDisplaySync();
const screenHeight = displayInfo.height;
// 将归一化的坐标转换回像素值,再转换为vp
this.cropAreaY = px2vp(region.y * screenHeight);
this.cropAreaHeight = px2vp(region.size.height * screenHeight);
console.log(TAG, '恢复的识别区域 - Y:', this.cropAreaY, 'Height:', this.cropAreaHeight);
} catch (error) {
console.error(TAG, '加载设置失败:', error);
// 如果加载失败,保持默认值
}
}
// 从相册选取
private async selectFromGallery() {
console.log('从相册选取');
try {
const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
photoSelectOptions.maxSelectNumber = 1;
const photoPicker = new photoAccessHelper.PhotoViewPicker();
let photoSelectResult = await photoPicker.select(photoSelectOptions);
if (photoSelectResult && photoSelectResult.photoUris && photoSelectResult.photoUris.length > 0) {
this.selectedImageUri = photoSelectResult.photoUris[0];
console.log('选择的图片URI:', this.selectedImageUri);
} else {
console.log('用户取消了图片选择');
}
} catch (error) {
const err = error as BusinessError;
console.error('选择图片失败:', err.code, err.message);
}
}
// 保存设置
private saveSettings() {
console.log('保存设置');
// 获取屏幕宽度
const displayInfo = display.getDefaultDisplaySync();
const screenHeight = displayInfo.height;
console.log(TAG, 'screenHeight =' + JSON.stringify(screenHeight))
// 输出符合image.Region格式的区域信息
let region: image.Region = {
x: 0,
y: vp2px(this.cropAreaY) / screenHeight, // 区域左上角纵坐标
size: {
width: 0,
height: vp2px(this.cropAreaHeight) / screenHeight
}
};
// 存入首选项
preferencesUtils.putString(PreferencesConstants.SCREEN_READ_SETTING, JSON.stringify(region))
console.log(TAG, 'region =' + JSON.stringify(region))
promptAction.showToast({
message: '保存成功',
})
// 返回上一页
this.pathStack.pop();
}
// 处理拖拽手势 - 自由拖拽调整识别区域
private handlePanGesture = (event: GestureEvent) => {
if (!this.isDragging) {
return;
}
const currentY = event.fingerList[0].globalY;
// 计算识别区域参数
const minY = Math.min(this.startPoint.y, currentY);
const maxY = Math.max(this.startPoint.y, currentY);
// 定义底部操作区域高度
const bottomOperationAreaHeight = 100;
// 计算可用的最大高度(页面高度减去底部操作区域高度)
const maxAvailableHeight = this.pageHeight - bottomOperationAreaHeight;
// 应用边界限制
this.cropAreaY = Math.max(0, minY);
// 限制识别区域高度,确保不超过底部操作区域
const calculatedHeight = maxY - minY;
const maxAllowedHeight = maxAvailableHeight - this.cropAreaY;
this.cropAreaHeight = Math.min(calculatedHeight, maxAllowedHeight);
// 确保识别区域高度不为负数
if (this.cropAreaHeight < 0) {
this.cropAreaHeight = 0;
}
console.log(TAG, JSON.stringify(this.cropAreaY))
console.log(TAG, JSON.stringify(this.cropAreaY))
}
build() {
NavDestination() {
Stack({ alignContent: Alignment.Top }) {
Column() {
// 主要识别区域
Column() {
// 上部分蒙层
Row()
.width('100%')
.height(this.cropAreaY)
// 识别区域
Stack() {
// 识别区域背景
Column()
.width('100%')
.height('100%')
.backgroundColor(this.isDragging ? '#fffc8d56' : '#FD7D3F')
Image($r("app.media.ic_area"))
.height(this.cropAreaHeight < 50 ? '100%' : 50)
.objectFit(ImageFit.Contain)
}
.width('100%')
.opacity(this.selectedImageUri ? 0.5 : 1)
.height(this.cropAreaHeight)
// 下部分蒙层
Row()
.width('100%')
.layoutWeight(1)
}
.layoutWeight(1)
.gesture(
PanGesture({ fingers: 1, distance: 1 })
.onActionStart((event: GestureEvent) => {
this.isDragging = true;
this.startPoint = {
x: event.fingerList[0].globalX,
y: event.fingerList[0].globalY
};
console.log('开始拖拽识别');
})
.onActionUpdate((event: GestureEvent) => {
this.handlePanGesture(event);
})
.onActionEnd((event: GestureEvent) => {
this.isDragging = false;
console.log('结束拖拽识别');
})
)
// 底部操作区域
Column() {
Row() {
// 选取按钮
Column() {
Image($r('app.media.ic_photo'))
.width(22)
.height(22)
.fillColor('#333333')
Text('选取')
.fontSize(14)
.fontColor('#333333')
.margin({ top: 4 })
}
.padding({ left: 20 })
.onClick(() => {
this.selectFromGallery();
})
Blank()
// 取消按钮
Button('取消')
.backgroundColor(Color.White)
.fontColor('#FF7F50')
.fontSize(18)
.borderWidth(2)
.borderColor('#FF7F50')
.borderRadius(25)
.width(152)
.height(50)
.onClick(() => {
this.pathStack.pop();
})
// 保存按钮
Button('保存')
.backgroundColor('#FF7F50')
.fontColor(Color.White)
.fontSize(18)
.borderRadius(25)
.width(152)
.height(50)
.margin({ left: 12 })
.onClick(() => {
this.saveSettings();
})
}
.width('100%')
.height(54)
Text('提示:先在答题界面截图,选取图片设置题目区域更精确。')
.fontSize(12)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.height(20)
.textAlign(TextAlign.Center)
.margin({ top: 8 })
}
.width('100%')
.height(100)
.backgroundColor(Color.White)
}
.width('100%')
.height('100%')
}
.onAreaChange((oldValue: Area, newValue: Area) => {
// 获取页面尺寸
this.pageHeight = Number(newValue.height);
console.log(TAG, '页面尺寸:', this.pageHeight)
})
}
.hideTitleBar(true)
.backgroundColor(this.selectedImageUri ? Color.Transparent : '')
.backgroundImage(this.selectedImageUri)
.backgroundImageSize(ImageSize.Cover)
}
}
// 位置接口定义
interface Position {
x: number;
y: number;
}