鸿蒙手势识别:流畅的拖拽识别区域设置

151 阅读2分钟

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,标识进入拖拽状态
  • 起始点记录:保存手指触摸的全局坐标globalXglobalY
  • 状态初始化:为后续的区域计算提供基准点

技术要点:

  • 使用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;
  }
}

实现亮点:

  1. 智能区域计算
    • 使用Math.min/Math.max自动确定矩形区域的上下边界
    • 支持从上往下拖拽和从下往上拖拽两种操作方式
  1. 动态边界控制
    • 上边界限制:Math.max(0, minY)确保不超出屏幕顶部
    • 下边界保护:预留100像素操作区域,防止覆盖按钮
    • 高度约束:动态计算最大允许高度
  1. 实时状态更新
    • 直接更新@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. 实际应用效果

用户操作流程

  1. 触摸开始 → 记录起始坐标,进入拖拽模式
  2. 拖拽移动 → 实时计算区域范围,更新UI显示
  3. 松开手指 → 确定最终区域,退出拖拽模式
  4. 保存设置 → 归一化坐标并持久化存储

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;
}