HarmonyOS 7 精准碰一碰 SlideDrop 实战 02:触碰坐标 + 区域映射实现素材精准落位【鸿蒙心迹】
第一篇里,我先把 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。虽然表面上多了一次“没命中”,但用户下一次只要碰得更明确一些就能成功;比起误把图片插进图表区,这种失败其实更容易理解。
这个选择也让我重新认识“精准”的含义:
精准不是系统每次都猜中用户意图,而是应用应该把可接受区域、不可接受区域和边界规则定义清楚。
所以第二篇结束时,我对区域设计也有了一条新的规则:
- 视觉上可以贴近;
- 逻辑上不要互相重叠;
- 必要时保留明确空隙;
- 未命中允许失败;
- 不用“最近区域吸附”掩盖坐标问题。
这种规则不复杂,却会让后面的素材投递更可信。
十二、实机验证时,我会故意碰五个位置,而不是只碰正中间
如果每次都把手机稳稳碰在图片区正中央,第二篇根本测不出问题。
所以我给自己设计了一套很简单的触点测试:
- 图片区中心;
- 图片区左上角;
- 图片区右下角;
- 图片区和图表区之间的空隙;
- 明确不属于任何区域的空白位置。
我会记录每一次的:
- 原始触点;
- 页面坐标;
- 预期区域;
- 实际区域;
- 是否插入。
为了避免每次都靠手工记,我还给调试版本加了一个很轻的测试记录结构:
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 的第三个问题:素材类型 + 占位槽位,怎么让图片、文字、图表真正按规则插入正确的位置。
更多推荐


所有评论(0)