ArkUI Stack+ImageFit:折叠屏图片标注归一化坐标与裁切回映【鸿蒙心迹】
技术栈:HarmonyOS 7 / ArkUI Stack / Image / ImageFit.Contain / ArkTS
工程项目:CropLedger|任务 PM-1111|图片 IMG-027

一、折叠一下,标注框却没有跟着图片走
做图片标注时,我最不愿意看到的现象,并不是框线变细,而是用户明明框住了山峰,展开折叠屏后框却挪到湖面。更糟的是,界面看起来还能保存,保存到源图的坐标却已经错位。这类问题靠简单地让标注框跟着容器宽度变大,并不能解决。
在 CropLedger 这个演示项目里,我把问题收窄到一张静态图片:业务图片编号 IMG-027,原图 2400×1600 像素。窄幅演示区 360×240 vp,宽幅演示区 720×480 vp。标注的不是一组绝对像素,而是同一片区域:起点占图片宽度的四分之一、高度的五分之一,框宽是图片的一半、框高是图片高度的百分之四十五。任务统一使用 PM-1111,以便让页面、日志和配图可以逐项对照。文中数值为构造数据,配图为界面示意,不冒充真机测量。
最终我们期望宽屏上的标注框出现在 x=180、y=96、宽360、高216 vp;切换到窄幅则是 x=90、y=48、宽180、高108 vp。回写源图时又必须得到同一组像素:x=600、y=320、宽1200、高720。三个尺寸空间各有职责,不能在一次交互中互相覆盖。
二、先把“数据真值”从布局中剥离
实际项目往往同时持有原始图片坐标、渲染容器尺寸、缩放后的图像显示范围和用户拖动结果。它们看着都是四个数字,含义却不同。我的取舍是只把归一化矩形当作业务真值;容器坐标每次重算,源图坐标在导出时再反解,不在切屏时写回业务记录。
下面的纯函数先解决一个问题:输入非法尺寸时不能返回一个看起来正常、却已经越界的矩形。代码没有依赖窗口接口,也没有把示意数据写成系统API,因此既能放进 ArkTS 工具模块,也方便用普通单元测试覆盖。
export interface NormalBox {
x: number;
y: number;
w: number;
h: number;
}
export interface RectVp {
x: number;
y: number;
w: number;
h: number;
}
export function checkBox(b: NormalBox): boolean {
return [b.x, b.y, b.w, b.h].every((v: number) => Number.isFinite(v)) &&
b.x >= 0 && b.y >= 0 && b.w > 0 && b.h > 0 &&
b.x + b.w <= 1 && b.y + b.h <= 1;
}
export function toView(b: NormalBox, w: number, h: number): RectVp {
if (!checkBox(b) || !Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
throw new Error('invalid annotation geometry');
}
return { x: b.x * w, y: b.y * h, w: b.w * w, h: b.h * h };
}
这里刻意没有把数字四舍五入。对于 UI 边框,小数 vp 的绘制误差通常可接受;但如果每次折叠都提前取整,再把结果存回归一化区域,累计误差会顺着保存链路扩大。特别是多次从窄幅切回宽幅,理论上没有编辑行为,也不能让标注框的位置逐渐漂移。
这个演示的归一化区域固定为 x=0.25、y=0.20、w=0.50、h=0.45。看到宽幅布局时的 180,96,360,216,只是这组真值乘上显示大小之后的派生结果,并不意味着业务数据库里应该保存这四个 vp 值。
三、图片留白是独立的一层坐标
不少实现只在图片恰好铺满容器时正确。一旦使用 ImageFit.Contain,原图长宽比和组件长宽比不一致,图片两侧或上下就会出现留白。手指按到留白区域和按到真实图片区域,是两件不同的事情。将组件左上角直接当作图片左上角,会让所有框线产生一个系统性的偏移。
我用“实际显示矩形”作为中间层:先根据原图宽高算出等比缩放系数,再算左右或上下的居中留白。触摸坐标必须先减掉留白偏移,才允许反算为归一化比例。这个过程只处理几何关系,不会自动执行图片裁切,更不涉及外部图像超分算法。
export function containRect(srcW: number, srcH: number,
boxW: number, boxH: number): RectVp {
if ([srcW, srcH, boxW, boxH].some((v: number) => !Number.isFinite(v) || v <= 0)) {
throw new Error('invalid image or container size');
}
const scale = Math.min(boxW / srcW, boxH / srcH);
const w = srcW * scale;
const h = srcH * scale;
return { x: (boxW - w) / 2, y: (boxH - h) / 2, w, h };
}
export function pointToNormal(x: number, y: number, image: RectVp): { x: number; y: number } | null {
if (x < image.x || y < image.y || x > image.x + image.w || y > image.y + image.h) {
return null; // 留白触摸,不生成错误的图片点
}
return { x: (x - image.x) / image.w, y: (y - image.y) / image.h };
}
这里的异常返回比强行压到边界更重要。业务若要支持从留白区开始拖动,可以另外约定“移动到最近图片边缘”,但这属于产品交互选择,不能混在通用数学函数里。遇到横竖像素方向不一致、EXIF方向尚未归一化或内容被二次裁切时,还需要先统一源图片的方向定义,再执行这套反算。
对于本例,2400:1600 与 360:240、720:480 的比例都相同,因此 Contain 没有留白。故意选择这个容易人工验算的基准用例,是为了先确认数据流正确;验收时还必须追加 1:1 容器和竖图用例,防止简单样本掩盖居中偏移问题。
四、UI 只投影,不改写标注真值
页面层要做的是展示:在 Stack 中让图片和标注框处在同一个可度量的坐标系里。图片与框必须受同一套宽高约束,不能图片走 Contain 而框按整个窗口宽度计算。下面只保留关键结构;preview 资源须由实际项目准备,本例没有假装生成出可安装工程。
@Entry
@Component
struct MeasurePage {
@State private wide: boolean = true;
private readonly id: string = 'IMG-027';
private readonly box: NormalBox = { x: 0.25, y: 0.20, w: 0.50, h: 0.45 };
private get viewWidth(): number { return this.wide ? 720 : 360; }
private get viewHeight(): number { return this.wide ? 480 : 240; }
build() {
Column({ space: 12 }) {
Text('CropLedger 图片标注 · PM-1111')
Stack({ alignContent: Alignment.TopStart }) {
Image($r('app.media.preview'))
.width(this.viewWidth).height(this.viewHeight)
.objectFit(ImageFit.Contain)
Column()
.width(toView(this.box, this.viewWidth, this.viewHeight).w)
.height(toView(this.box, this.viewWidth, this.viewHeight).h)
.border({ width: 2, color: Color.Orange })
.position({
x: toView(this.box, this.viewWidth, this.viewHeight).x,
y: toView(this.box, this.viewWidth, this.viewHeight).y
})
}.width(this.viewWidth).height(this.viewHeight)
Button('切换视图').onClick(() => { this.wide = !this.wide; })
Text(`当前图片:${this.id}`)
}
}
}
图片和标注框展示使用同一个 viewWidth/viewHeight,这是示例能成立的前提。如果真实页面的容器还要容纳工具栏、系统安全区域或者分栏边距,应从布局测量结果取得图片实际绘制宽高,而不是把 720 当作所有大屏设备的常数。演示中的“切换视图”是确定性模拟操作,不等同于系统折叠状态回调。

五、保存前先反算,而不是截图裁切
保存动作分两层:一层是把归一化矩形存进自己的标注记录,另一层才是按源图坐标交给真正的裁切服务。就本例而言,归一化区域乘以 2400×1600 得到 600,320,1200,720。应在导出前检查像素坐标是否超出原图,必要时以统一规则处理像素取整,并保留源图ID及方向元数据。
UI里显示 ALIGNED 只表示当前示例的投影和反算符合预设几何条件,不是系统提供的状态常量,也不是实际裁切文件已经写入成功。若保存任务异步完成,页面离开或用户切换 IMG-027 到另一张图片,需要携带任务ID和图片ID核对结果;返回时ID已经变化,则丢弃该页面的过期回调,不能把旧矩形覆盖到新图片。
真正的产品还要处理屏幕密度:vp 是布局单位,像素是图像数据单位,二者不能直接互换。验收表应同时记下显示尺寸、源图尺寸、归一化比例、计算出的源图像素框与图片方向。也要检查极小框、零尺寸、无限值、手指按在留白区域以及重新进入页面的恢复行为。

六、把验收条件写成可证伪的检查
我会把通过条件收紧为三个可观察事实。首先,PM-1111 / IMG-027 在窄幅是 90,48,180,108,在宽幅是 180,96,360,216,切换之后业务记录的 0.25,0.20,0.50,0.45 不变。其次,无论哪种视图,反算到源图片时应保持 600,320,1200,720。最后,任何不合法的尺寸或落在留白处的触摸都必须被明确拒绝或走产品定义的补偿流程,不能悄悄保存。
上面的日志时间 19:40:10 至 19:40:12 是为三张素材固定的演示时钟,非真实HiLog采集。若后续要投产,还得在多款折叠屏、分屏和横竖屏状态下做正式设备检查,并对容器的实时尺寸变化增加节流;在页面销毁时解除观察或取消尚未完成的业务保存回调。几何关系简单,工程风险却集中在谁持有真值以及什么时候允许回写。还要给标注记录引入明确的版本号:用户开始拖拽时读取版本,结束时仅提交属于同一图片、同一版本的结果;如果后台替换了原图或图片方向发生变化,则提示重新检查标注,避免旧回调无声覆盖新数据。对撤销、重做和保存失败,应优先恢复归一化业务记录,而不是试图从一张已被拉伸过的预览截图找回精度。
参考与范围:华为 ArkUI 官方 Stack 布局文档说明叠放和定位关系;ArkUI 官方组件能力与容器断点指南说明响应式布局背景。本示例仅使用 ArkUI 的通用布局能力,归一化算法为作者定义的业务层代码,并未声称已在 DevEco 编译或任何设备验证。
更多推荐





所有评论(0)