第一篇里,我先把 SlideDrop 的主链路跑通了:手机选素材、发起精准碰一碰、大屏拿到触点信息,再把图片插进演示页。那一版已经能看出“碰哪放哪”的雏形,但真连续测试几次,很快就会碰到第二个问题:为什么我明明碰在图片区边缘,有时却会被判断到旁边?同一套坐标,在不同窗口尺寸下为什么又会偏? 这一篇我就专门把这个问题拆开——不再只关心“有没有触点”,而是把系统触点坐标、页面坐标、区域边界和命中判断这条链路真正理顺。

第一篇做完以后,我最先反复测试的,不是换更多素材,而是拿同一张图片,在演示页上不断换位置去碰。

第一次碰图片区中央,成功。第二次碰在图片区右边缘,偶尔命中图表区。再把窗口缩小一点,之前还挺准的区域又开始出现偏移。

这类问题特别有意思,因为它会让你立刻意识到:

精准碰一碰里最关键的“精准”,并不是系统给你一个 x、y 就结束了,而是你怎么把这个 x、y 转成自己页面真正能理解的位置。

HarmonyOS 7 的碰一碰·精准分享能力会识别目标窗口和触碰坐标,让应用有机会把素材投到指定位置。这给了我们一个很好的入口,但应用自己的页面布局、窗口偏移、缩放比例和业务区域仍然要自己处理。换句话说,系统能告诉你“碰在哪里”,业务还要回答“这个位置在 SlideDrop 里到底是什么”。

所以第二篇我不准备扩展素材类型,也先不碰冲突恢复,而是只做一件事:

把“系统触点 → 页面坐标 → 目标区域”这条链路做稳。


一、先把问题说清楚:坐标不准,通常不是 hitTest 一行代码的问题

第一篇里我的 hitTest() 很直接:拿到一个点,遍历所有 rect,看它落在哪个矩形里。

这种写法在固定尺寸 Demo 里没问题,但只要页面稍微复杂一点,就会暴露出几个常见变量:

  • 系统回调给你的坐标,未必就是演示页左上角为原点的坐标;
  • 演示页可能不是铺满整个窗口,中间还有标题栏、边距和工具区;
  • 页面可能因为不同设备尺寸发生缩放;
  • 横竖屏、浮窗、大屏窗口大小变化,都可能改变实际布局;
  • 区域之间如果挨得太近,边界命中也容易产生歧义。

如果直接把系统坐标塞进 hitTest(),第一版当然能演示,但第二篇就必须把这些前提拆开。

我最后把整个定位过程分成三个阶段:

系统触点坐标
   ↓
转换成 SlideDrop 页面坐标
   ↓
页面坐标执行区域命中
   ↓
得到 title / image / chart / note

这三个阶段看起来只是多了一层转换,但工程意义很大。因为从这里开始,坐标来源和业务判断被分开了。

系统坐标怎么变,我只改转换层;页面区域怎么调整,我只改区域模型;最终插入逻辑则只关心命中了什么区域。

这一篇的项目目录先固定下来

第二篇开始以后,我不想再把所有坐标逻辑都堆在 SlidePage.ets 里,所以先把目录整理了一次。现在和“坐标 + 区域映射”有关的文件,我会拆成下面这样:

SlideDropDemo2/
├── AppScope/
├── entry/
│   └── src/
│       └── main/
│           ├── ets/
│           │   ├── pages/
│           │   │   ├── Index.ets
│           │   │   ├── SlidePage.ets
│           │   │   └── DropTarget.ets
│           │   ├── model/
│           │   │   ├── RegionModel.ets
│           │   │   └── TouchDebugInfo.ets
│           │   └── utils/
│           │       ├── CoordinateMapper.ets
│           │       └── HitTestUtil.ets
│           ├── resources/
│           └── module.json5
├── ohosTest/
├── build-profile.json5
└── oh-package.json5

这套目录不复杂,但每个文件的职责已经比较清楚:

  • SlidePage.ets:只负责演示页状态、事件接入和最终插入;
  • RegionModel.ets:定义 title / image / chart / note 四类区域;
  • CoordinateMapper.ets:只做系统坐标到页面坐标的归一化;
  • HitTestUtil.ets:只做命中判断;
  • TouchDebugInfo.ets:专门记录调试数据。

这样一拆,第二篇的工程主线就更容易看懂了:坐标转换、区域模型、命中判断、页面插入,各自只管一层。


二、先抽象一个 CoordinateMapper,别把坐标换算散在页面里

我不太喜欢在 SlidePage.ets 里到处写:

x - offsetX
(y - offsetY) * scale

第一眼看着省事,后面尺寸一变,你会发现所有判断都要一起跟着改。

所以第二篇我专门加了一个 CoordinateMapper。它不依赖某个具体区域,只负责一件事:

把收到的触点信息,转换成演示页面自己的坐标系。

先定义几个很普通的数据类型。

文件位置: entry/src/main/ets/common/CoordinateMapper.ets
用途: 隔离外部触点坐标与 SlideDrop 页面坐标。

export interface Point {
  x: number
  y: number
}

export interface Size {
  width: number
  height: number
}

export interface Rect {
  x: number
  y: number
  width: number
  height: number
}

export interface CoordinateContext {
  windowSize: Size
  pageRect: Rect
  designSize: Size
}

export class CoordinateMapper {
  toPagePoint(rawPoint: Point, context: CoordinateContext): Point {
    const localX = rawPoint.x - context.pageRect.x
    const localY = rawPoint.y - context.pageRect.y

    const scaleX = context.designSize.width / context.pageRect.width
    const scaleY = context.designSize.height / context.pageRect.height

    return {
      x: localX * scaleX,
      y: localY * scaleY
    }
  }
}

这段代码解决的不是“官方接口怎么写”,而是应用层的坐标归一化问题。系统侧具体返回什么字段、坐标原点和单位,以当前 HarmonyOS 7 API 26 精准分享开发指南和实际回调为准;我这里用 Point 做了一层业务抽象,后面只处理 SlideDrop 自己的页面坐标。

这一层一加,调试体验立刻好了很多。

因为日志可以同时打两套值:

系统触点:x=842, y=356
页面坐标:x=421, y=178

如果最终命中错误,我能先判断“转换错了”,还是“区域定义错了”,而不是所有问题混在一起猜。


三、为什么要保留一套 designSize,而不是直接拿当前像素做区域

我第二篇里又做了一个选择:目标区域仍然基于一套固定的“设计尺寸”去描述,而不是完全跟着当前窗口像素走。

比如我可以假设 SlideDrop 的演示页逻辑尺寸是:

720 × 1280

图片区定义为:

x = 40
y = 220
width = 300
height = 220

不管当前设备真实窗口是 1080×1920,还是一个缩小后的大屏窗口,CoordinateMapper 都先把真实触点换算到这套逻辑尺寸里,再做命中。

这种做法有一个特别现实的好处:

区域定义不会因为设备尺寸变化而全部重写。

如果后面演示页换了一套模板,我只需要更新模板对应的逻辑区域;如果窗口尺寸变化,我只更新 pageRect 和换算比例。

这其实就是把“布局”和“设备像素”解耦。

对第一篇那种固定 Demo 来说,这层有点多;但到了第二篇,它已经非常值得加了。

为了让逻辑尺寸真正落到工程里,我把区域模型也单独抽出来。

文件位置: entry/src/main/ets/model/RegionModel.ets
用途: 保存演示页四类区域的逻辑坐标,避免每个页面自己手写一套边界。

import { Rect } from '../utils/CoordinateMapper'

export type RegionType = 'title' | 'image' | 'chart' | 'note' | 'none'

export interface SlideRegion {
  id: string
  type: RegionType
  label: string
  rect: Rect
}

export const DEFAULT_REGIONS: SlideRegion[] = [
  { id: 'title', type: 'title', label: '标题区', rect: { x: 40, y: 80, width: 640, height: 100 } },
  { id: 'image', type: 'image', label: '图片区', rect: { x: 40, y: 220, width: 300, height: 260 } },
  { id: 'chart', type: 'chart', label: '图表区', rect: { x: 380, y: 220, width: 300, height: 260 } },
  { id: 'note', type: 'note', label: '备注区', rect: { x: 40, y: 520, width: 640, height: 160 } }
]

现在后面无论是 CoordinateMapper 还是 hitTestRegion(),都围绕同一份 DEFAULT_REGIONS 工作。对 Demo 来说,这个小调整很值,因为区域一旦改动,不需要去几个页面里同时找魔法数字。


四、类型二的图最适合讲清楚:系统坐标只是入口,区域映射才是业务核心

我越来越喜欢在这套 SlideDrop 连载里保留一张“类型二”的综合讲解图。

原因是精准碰一碰这种能力,如果只看一张 DevEco 截图,很容易把注意力全放在代码;只看真机图,又容易觉得只是“手机传图”。

把几种图组合到一起以后,整个问题就顺了:

  • DevEco 里看坐标转换和 hitTest;
  • 流程图区分系统能力与业务逻辑;
  • 手机端展示素材选择;
  • 实机图展示最后素材真的落到对应位置;
  • 红圈和箭头只负责指关键点,不把整张图做成广告海报。

第二篇这张图我最想强调的就是:

触点坐标 ≠ 最终业务位置

它中间至少还隔着:

窗口偏移
→ 页面缩放
→ 页面坐标
→ 区域边界
→ 业务命中

只要读者把这层关系看懂,后面代码就很容易理解。


五、hitTest 也要从“能判断”升级成“能解释”

坐标转换稳定以后,下一步才是 hitTest。

第一篇里我只是返回 DropZone | undefined。第二篇我稍微改了一下,让它在调试时能给出更多信息。

我给每个区域补上明确的逻辑边界,再让命中结果带回区域类型和点位信息。

文件位置: entry/src/main/ets/utils/HitTestUtil.ets
用途: 判断页面坐标命中哪个投放区域,并给调试日志提供依据。

export type RegionType = 'title' | 'image' | 'chart' | 'note' | 'none'

export interface SlideRegion {
  type: RegionType
  rect: Rect
}

export interface HitTestResult {
  point: Point
  region: RegionType
}

export function hitTestRegion(
  point: Point,
  regions: SlideRegion[]
): HitTestResult {
  for (const region of regions) {
    const { x, y, width, height } = region.rect

    const hit = point.x >= x &&
      point.x <= x + width &&
      point.y >= y &&
      point.y <= y + height

    if (hit) {
      return { point, region: region.type }
    }
  }

  return { point, region: 'none' }
}

我很喜欢把返回值做成这种小对象,而不是只返回一个字符串。因为调试时可以直接打:

pagePoint=(421,178), region=image

以后第三篇要做素材类型判断,第四篇要做失败恢复,这个结果对象也可以继续加信息,不用把接口推翻。

第二篇的主题虽然只是坐标,但我还是会尽量让结构给后面的文章留空间。

真正接到页面事件时,我不希望 SlidePage.ets 里再重复一遍换算和命中流程,所以又把主链路收成一个方法:

private async handlePreciseDrop(
  rawPoint: Point,
  material: MaterialItem
) {
  const pagePoint = this.coordinateMapper.toPagePoint(rawPoint, {
    windowSize: this.windowSize,
    pageRect: this.currentPageRect,
    designSize: this.designSize
  })

  const hitResult = hitTestRegion(pagePoint, this.regionList)

  this.updateDebugInfo(rawPoint, pagePoint, hitResult.region)

  console.info(
    `[SlideDrop] raw=(${rawPoint.x},${rawPoint.y}) ` +
    `page=(${pagePoint.x.toFixed(1)},${pagePoint.y.toFixed(1)}) ` +
    `region=${hitResult.region}`
  )

  if (hitResult.region === 'none') {
    this.statusText = '当前触点没有命中可投放区域'
    return
  }

  await this.insertMaterialToRegion(material, hitResult.region)
  this.statusText = `素材已插入 ${hitResult.region} 区域`
}

这段代码解决的是第二篇真正的“编排”问题:页面只收到一个原始点和一份素材,剩下的流程按固定顺序走。这样后面出现偏移时,我也能非常明确地知道要查哪一步。


六、边界误判怎么处理:不要急着加“魔法数字”,先把问题看明白

连续测试以后,还有一种情况特别常见:触点刚好落在两个区域之间。

比如图片区右边就是图表区,两个区域视觉上只有十几像素的间隔。用户手里拿着手机靠近屏幕时,落点不可能永远像鼠标一样精确到一个像素。

这时很容易想到一个做法:给每个区域都扩大 20 像素。

但我不太建议第一反应就这么做。

因为一旦两个区域同时扩大,就可能出现重叠,新的歧义反而更多。

我更倾向先做三件事:

1)把视觉区域和逻辑区域都画出来

开发模式下直接在模拟器里显示虚线框,明确标题区、图片区、图表区、备注区到底占多大。

2)把系统触点和页面触点同时打印

只要有偏移,一眼就能看出到底是转换层还是命中层的问题。

3)保留一个“none”区域

没有命中就明确返回 none,而不是“离谁近就自动算谁”。

第一版 Demo 很容易为了追求“每次都成功”而过度兜底,但这种兜底会掩盖真实问题。

第二篇我宁愿让边缘触点有时提示“未命中可投放区域”,也不愿把一张图错误塞进图表区。

因为 SlideDrop 的核心体验不是“绝不失败”,而是“成功时位置可信”。


七、我把“坐标调试信息”直接放到手机界面里,排查效率高很多

第二篇我还做了一个很工程化的小改动:在调试版本里,直接加了一块“当前触碰数据”卡片。

它会显示:

  • 系统触点坐标;
  • 转换后的页面坐标;
  • 当前命中区域;
  • 当前页码;
  • 插入结果。

这类信息正式版本当然不需要给用户看,但在写连载和做 Demo 时特别好用。

因为有时候你拍了一张手机截图,过几天再回头看,如果只有一个绿色勾,你已经忘了当时到底碰在哪儿。

有了这块调试卡片,图本身就是证据:

系统触点 x=842, y=356
页面坐标 x=421, y=178
命中区域 image

再配合下面演示页里的红色触点标记,一张图就能把第二篇最关键的逻辑讲清楚。

而且你之前要求“至少一张纯手机截图,不要手机边框”,这类调试页特别适合做纯截图。它不像概念设计稿,更像真的从设备里截出来的一页运行结果。


八、窗口变化以后坐标为什么会偏:真正需要盯的是 pageRect

做坐标映射时,我后来发现最值得盯的不是 screenX 或 screenY 本身,而是 pageRect。

因为只要页面容器的位置或尺寸变了,整个换算都会跟着变。

比如:

  • 顶部工具栏变高了;
  • 演示页左右出现了额外边距;
  • 大屏窗口从全屏变成分屏;
  • 页面因为适配策略整体缩放。

这些变化最终都会反映到:

pageRect.x
pageRect.y
pageRect.width
pageRect.height

所以我在第二篇里会要求每一次开始投递前,尽量获取最新的页面区域信息,而不是应用启动时算一次,后面一直复用。

这一点很像做普通布局适配:

你不能拿昨天量的尺寸,去判断今天窗口里的触点。

从工程角度说,这也是第二篇最重要的一个收获。

我在 Demo 里会把页面尺寸更新单独做成一个方法。具体布局回调与系统 API 以当前工程实际接入方式为准,这里重点展示的是“每次变化都刷新 pageRect”这个思路:

private updatePageRect(rect: Rect) {
  this.currentPageRect = {
    x: rect.x,
    y: rect.y,
    width: rect.width,
    height: rect.height
  }

  console.info(
    `[SlideDrop] pageRect x=${rect.x}, y=${rect.y}, ` +
    `width=${rect.width}, height=${rect.height}`
  )
}

private buildCoordinateContext(): CoordinateContext {
  return {
    windowSize: this.windowSize,
    pageRect: this.currentPageRect,
    designSize: this.designSize
  }
}

这样窗口变化、页面重排或者演示区尺寸改变以后,坐标映射层拿到的都是最新上下文,不会继续沿用第一次启动时的旧数据。

第一篇我们关心的是“事件有没有来”;
第二篇开始关心“事件来的时候,页面当前到底是什么状态”。


九、为什么我没有直接把坐标写成百分比

做到这里,有一个方案看起来很诱人:既然不同设备尺寸会变,那干脆把所有区域都写成百分比不就行了吗?

比如图片区写成:

left = 5%
top = 18%
width = 42%
height = 28%

这样看上去好像天然适配各种尺寸。

但我最后没有把第二篇直接改成纯百分比方案。原因是演示页本身不一定只是“等比例缩放”。真实布局里经常会出现固定高度工具栏、左右边栏、最小边距、内容区留白,甚至某些模板会在大屏上改成完全不同的排列。如果直接把所有区域绑定到整个窗口百分比,等于默认了“页面结构永远等比例变化”,这个假设其实并不稳。

我更愿意把问题拆成两层:

第一层是真实窗口 → 页面容器。
这一步解决页面到底在窗口哪里、当前多大。

第二层是页面容器 → 设计坐标。
这一步才把当前尺寸映射回我们熟悉的逻辑尺寸。

这样做虽然比百分比多了一层,但调试更容易。比如窗口右侧多出一个工具栏,只需要重新测 pageRect,区域模型本身不动;如果模板换了,只需要换一套 regions,CoordinateMapper 也不用改。

这其实就是我在第二篇里一直强调的一个思路:

坐标适配不要和业务区域定义绑死。

两层分开以后,后面第三篇、第四篇想继续演进时,代码会轻很多。


十、我又补了一层调试模型,让“偏了多少”也能被记录下来

只知道“命中错了”还不够,真正排查时我还想知道:到底偏了多少。

所以我在调试版本里又做了一层很轻的 TouchDebugInfo。它不参与业务逻辑,只在开发阶段记录数据。

文件位置: entry/src/main/ets/model/TouchDebugInfo.ets
用途: 保存一次触碰过程中最重要的定位信息,方便日志和调试页面同时使用。

export interface TouchDebugInfo {
  rawPoint: Point
  pagePoint: Point
  region: RegionType
  pageIndex: number
  pageRect: Rect
}

每次触碰完成后,我都会把这几个值记录下来:

private updateDebugInfo(
  rawPoint: Point,
  pagePoint: Point,
  region: RegionType
) {
  this.debugInfo = {
    rawPoint,
    pagePoint,
    region,
    pageIndex: this.currentPageIndex,
    pageRect: this.currentPageRect
  }
}

这个模型看起来很简单,但对写文章和排查问题都很有帮助。

例如一张图本来应该命中图片区,实际却落到了 none。以前我只能重新操作一次,再去看日志;现在直接看调试卡片就能知道:

  • 原始点是不是合理;
  • 页面坐标是不是明显偏小或偏大;
  • 当前 pageRect 是不是和预期一致;
  • 到底在哪一层开始不对。

这类小模型很容易被 Demo 忽略,但我觉得第二篇加上它非常值。因为它开始让“精准”这件事可测、可解释,而不是只能靠肉眼判断。


十一、区域定义也不能只看视觉稿,我会给它留一个最小安全间距

第二篇测试到后面,我还发现一个问题:如果图片区和图表区视觉上贴得太近,用户手持手机靠近屏幕时,哪怕坐标换算完全正确,也可能因为触点本身就在边缘导致命中不稳定。

这时候我不想用“扩大所有区域”这种简单粗暴的方法,而是反过来调整区域布局:给目标区域之间保留明确的安全间距。

比如:

图片区 right = 330
图表区 left = 370

中间保留 40 的逻辑间隔。

这样如果触点落在空隙,就返回 none。虽然表面上多了一次“没命中”,但用户下一次只要碰得更明确一些就能成功;比起误把图片插进图表区,这种失败其实更容易理解。

这个选择也让我重新认识“精准”的含义:

精准不是系统每次都猜中用户意图,而是应用应该把可接受区域、不可接受区域和边界规则定义清楚。

所以第二篇结束时,我对区域设计也有了一条新的规则:

  • 视觉上可以贴近;
  • 逻辑上不要互相重叠;
  • 必要时保留明确空隙;
  • 未命中允许失败;
  • 不用“最近区域吸附”掩盖坐标问题。

这种规则不复杂,却会让后面的素材投递更可信。

十二、实机验证时,我会故意碰五个位置,而不是只碰正中间

如果每次都把手机稳稳碰在图片区正中央,第二篇根本测不出问题。

所以我给自己设计了一套很简单的触点测试:

  1. 图片区中心;
  2. 图片区左上角;
  3. 图片区右下角;
  4. 图片区和图表区之间的空隙;
  5. 明确不属于任何区域的空白位置。

我会记录每一次的:

  • 原始触点;
  • 页面坐标;
  • 预期区域;
  • 实际区域;
  • 是否插入。

为了避免每次都靠手工记,我还给调试版本加了一个很轻的测试记录结构:

interface TouchCaseResult {
  name: string
  rawPoint: Point
  pagePoint: Point
  expected: RegionType
  actual: RegionType
  passed: boolean
}

private buildCaseResult(
  name: string,
  rawPoint: Point,
  pagePoint: Point,
  expected: RegionType,
  actual: RegionType
): TouchCaseResult {
  return {
    name,
    rawPoint,
    pagePoint,
    expected,
    actual,
    passed: expected === actual
  }
}

这样五个测试点跑完以后,我可以直接在日志里输出 passed=true/false,而不是凭印象判断“这一轮好像还挺准”。

只要这五类位置都表现稳定,坐标映射这一篇才算真的过关。

下面这张实机图就是我很想保留的效果:手机端选中同一张山景图,大屏演示页上能看到触碰点标记,同时素材已经落到图片区。

这种图特别适合放在文章后半段,因为它不是单纯“看起来成功”,而是在展示:位置和结果已经对应上了。


十三、第二篇我会怎么验收

第二篇的验收标准,我不再写“能不能插入图片”,因为第一篇已经解决了。

这次我重点看下面六件事。

1)系统触点和页面坐标能同时观察

日志里必须能看到两套值,方便判断偏移发生在哪一层。

2)窗口尺寸变化后,转换结果仍然合理

至少在不同窗口大小下测试一次,不能只在固定模拟器尺寸里验证。

3)四个区域的逻辑边界明确

标题区、图片区、图表区、备注区不能靠肉眼猜,要有可调试的边界定义。

4)命中失败时能返回 none

不要为了“看起来总成功”而强行吸附到最近区域。

5)边缘触点有测试记录

不能只测区域中心,必须测试边界和空隙。

6)页面结果与日志一致

日志说命中 image,最终素材就必须进图片区;日志说 none,页面就不应该偷偷插到别处。

这六条如果都成立,我才会认为第二篇把“精准”两个字真正往前推进了一步。


十四、第二篇做完以后,第三篇自然就来了

第二篇解决的是“你碰到了哪里”。

但接下来第三篇马上会遇到一个新的问题:

碰到图片区以后,如果传过来的是文字怎么办?碰到标题区却传来一张照片,又该怎么办?

也就是说,位置已经准了,下一步就要开始考虑:

  • 素材类型;
  • 目标槽位类型;
  • 两者是否匹配;
  • 不匹配时是拒绝、转换还是给用户二次选择;
  • 已经有内容时是替换、覆盖还是追加。

这也是我为什么把整个 SlideDrop 拆成五篇,而不是一篇写完。

第一篇只跑通链路;
第二篇把位置做准;
第三篇再讨论“什么素材该落到什么槽位”。

这样每一篇都在解决一个真实工程问题,连起来也更像完整的开发过程。

十五、本文小记

如果让我给第二篇下一个一句话总结,我会说:

第一篇证明 SlideDrop 能把素材传过去,第二篇开始证明它能把素材放对地方。

真正值得记住的不是某个 if 判断,而是这套坐标思路:

外部触点
→ 页面归一化
→ 逻辑区域
→ 命中结果
→ 业务插入

只要这条链路足够清楚,后面窗口尺寸变、页面模板变、区域数量变,代码都还有继续演进的空间。

我觉得这正是“精准碰一碰”最适合写连载的地方。它表面上只是一个自然的交互动作,真正落到应用里,却会一路把坐标、布局、数据类型、状态和异常都牵出来。

而这些问题,恰恰比单纯列 API 更值得记录。

下一篇,我们继续处理 SlideDrop 的第三个问题:素材类型 + 占位槽位,怎么让图片、文字、图表真正按规则插入正确的位置。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐