HarmonyOS 数据可视化实战:封装自定义值机控件实操记录与复盘
-
- [一、先别急着写代码,值机控件本质上是"规则布局 + 状态流转"](#一、先别急着写代码,值机控件本质上是“规则布局 + 状态流转”)
- 二、设计机舱座位图矩阵
- 三、反面案例:我一开始就是这样写点击命中的,结果越修越错
-
- [错误写法 1:硬编码一个大概的 Y 偏移](#错误写法 1:硬编码一个大概的 Y 偏移)
- [错误写法 2:监听组件位置,再用绝对坐标去抵消](#错误写法 2:监听组件位置,再用绝对坐标去抵消)
- [四、正确解法:别算页面坐标,直接拿 Canvas 自己的触点坐标](#四、正确解法:别算页面坐标,直接拿 Canvas 自己的触点坐标)
- [五、Canvas 绘制不要只想着"把座位画出来",要把机舱的语义画出来](#五、Canvas 绘制不要只想着“把座位画出来”,要把机舱的语义画出来)
-
- [1. 通过横向偏移做出过道感](#1. 通过横向偏移做出过道感)
- [2. 用分舱背景强化业务感知](#2. 用分舱背景强化业务感知)
- [3. 过道区域应该画成留白](#3. 过道区域应该画成留白)
- 六、一个值机控件能不能复用,关键看状态流转是不是清楚
- 结语
马上五一过节了,大家又开始了日常抢票。
高铁票、机票、酒店、景区门票,一轮操作下来,很多人对"选座位"这件事已经形成肌肉记忆了。尤其是机票值机,大家往往会下意识先看两件事:
- 能不能选到靠窗
- 前排或者过道还有没有位置

这类页面看起来很普通,但真要自己动手从 0 到 1 写一个小 Demo,会发现里面还是有点小坑。
- 为什么值机控件不能只当成一个 Grid
- 旧实现为什么是一个反面案例
- 点击命中为什么会漂移
- 正确写法为什么必须回到局部坐标
- 最后怎样把它收束成一个可复用的业务控件
一、先别急着写代码,值机控件本质上是"规则布局 + 状态流转"

很多同学拿到这个需求,第一反应可能是:
"不就是一个二维数组加一堆格子吗?"
这句话只对了一半。
如果你只是做一个静态排版,那确实像二维数组。但一旦你真的按照值机场景去思考,会发现它同时带着好几层业务约束:
- 头等舱和经济舱不是一个布局
- 机舱中间有过道,不是纯矩阵
- 有些区域是留白,不应该长得像禁用座位
- 座位有可选、已选、已锁定等不同状态
- 点击命中必须精确,否则用户会直接不信任这个页面
所以这个控件如果要做对,至少要拆成三层:
- 布局规则层:哪些位置是座位,哪些不是
- 渲染层:不同状态如何绘制
- 交互层:点击如何准确命中座位
在本地工程里,我最后把它拆成了下面几个文件:
CanvasPage.ets:页面入口,负责整页滚动和安全区适配MovieInfo.ets:顶部航班信息、图例和说明区CinemaInfo.ets:核心值机控件,负责座位布局、绘制、点击、状态流转CustomDialogExample.ets:确认值机与成功弹窗CanvasData.ets:图例和行信息数据StyleConstants.ets:尺寸、颜色、间距等常量
这个拆法的优点是非常明确的:
- UI 文案可以换
- 场景可以从值机切到高铁或影院
- 弹窗和页面结构可以调整
- 但核心"选座控件"逻辑保持稳定
这就是业务组件和页面代码最根本的区别:
页面是一次性的,控件应该是可以复用的。
二、设计机舱座位图矩阵

为了让页面一眼看上去更像"值机",我先明确了两个布局规则:
- 头等舱:前三排,
2-2 - 经济舱:后七排,
3-3
而且头等舱不是靠两边,而是要靠近中轴,视觉上更接近真实机舱。
所以我的做法不是写死每个座位的绝对像素,而是保留二维数组,外加规则函数去判断"某个格子是不是座位"。
核心代码如下:
ts
private isFirstClassRow(y: number): boolean {
return y < StyleConstants.FIRST_CLASS_ROW_COUNT;
}
private isSeatCell(x: number, y: number): boolean {
if (this.isFirstClassRow(y)) {
return x === 1 || x === 2 || x === 4 || x === 5;
}
return x !== StyleConstants.AISLE_COLUMN_INDEX;
}
这段代码其实很关键。
因为它把"布局语义"抽出来了。
你看这两句规则:
x === 1 || x === 2 || x === 4 || x === 5x !== StyleConstants.AISLE_COLUMN_INDEX
它表达的不是像素,不是位置微调,而是明确的业务含义:
- 头等舱是靠中间的
2-2 - 经济舱是中间留过道的
3-3
一旦布局逻辑抽象成规则,后续要改就很轻松了。比如:
- 商务舱改
2-3-2 - 某排设置成紧急出口
- 某几个座位不可售
- 某些位置显示靠窗标签
都不需要推翻整体结构。
初始化状态时,也只需要根据规则决定是不是座位:
ts
aboutToAppear(): void {
for (let i = 0; i < this.WIDTH; i++) {
this.seats.push([]);
for (let j = 0; j < this.HEIGHT; j++) {
if (this.isSeatCell(i, j)) {
this.seats[i].push(0);
} else {
this.seats[i].push(3);
}
}
}
}
这里的状态定义是:
0:可选1:已选2:已锁定3:非座位区域
这一步改完后,布局已经有"机舱的样子"了,但这时候我还不知道,后面真正的大坑正在等着我。
三、反面案例:我一开始就是这样写点击命中的,结果越修越错

布局改完后,我本来觉得最难的部分已经过去了。
结果一上手点,就发现事情不对。
最早的情况大概是这样:
- 刚进入页面时,点击没有反应
- 往下滚动一点以后,开始能选中了
- 但点
5A,实际选中的是8A - 点上面,选中下面
这个症状其实已经很典型了:
不是布局错了,是命中坐标错了。
但人第一次遇到这种问题,很容易本能地去补偏移量。
错误写法 1:硬编码一个大概的 Y 偏移
最开始我用的是这种方式:
ts
handleClick(e: ClickEvent) {
const localX = e.displayX - StyleConstants.CANVAS_LEFT_MARGIN;
const localY = e.displayY - StyleConstants.CANVAS_Y -
this.getUIContext().px2vp(this.topRectHeight);
const seatIndex = this.locateSeatIndex(localX, localY);
}
这段代码看起来好像挺合理:
displayX/displayY是点击位置- 减掉左边距
- 减掉 Canvas 顶部偏移
- 再减掉状态栏高度
逻辑甚至有点像"手工坐标换算"。
但它有两个根本问题:
CANVAS_Y是一个静态经验值,不是真实组件位置- 页面一旦滚动、顶部内容变化、安全区变化,这个值立刻失真
也就是说,这种写法不是"准不准"的问题,而是"迟早会错"的问题。
错误写法 2:监听组件位置,再用绝对坐标去抵消
后来我不甘心,又试了第二种做法:
ts
@State canvasGlobalX: number = 0;
@State canvasGlobalY: number = 0;
.onAreaChange((_: Area, area: Area) => {
this.canvasGlobalX = this.getUIContext().vp2px(parseFloat(String(area.position.x)));
this.canvasGlobalY = this.getUIContext().vp2px(parseFloat(String(area.position.y)));
})
handleClick(e: ClickEvent) {
const localX = e.displayX - this.canvasGlobalX;
const localY = e.displayY - this.canvasGlobalY;
}
这个思路的问题在于:
它虽然比硬编码偏移"高级一点",但本质上还是在做同一件事:
用页面级的绝对坐标,反推组件内部的局部坐标
而页面结构其实是这样的:
ts
Scroll() {
Column() {
Title()
CinemaInfo()
}
}
也就是说,Canvas 并不是直接贴在屏幕上的。它外面还包着:
Scroll- 顶部标题区
- 航班信息卡片
- 页面 padding
- 安全区偏移
这个时候你去混用:
displayX/displayYarea.position.x/yScroll的视觉位置padding的布局位置
就等于在拿多个参考系做运算。
所以最后的结果是:
- 页面没滚动时,一套值
- 页面滚动后,又是另一套值
- 顶部模块高度变了,再错一次
这就是这次最值得拿来当反面案例的地方:
只要你的命中逻辑依赖"页面绝对坐标推局部坐标",这个控件迟早会出事故。
四、正确解法:别算页面坐标,直接拿 Canvas 自己的触点坐标
我最后把这个问题彻底修好,靠的不是继续加补丁,而是直接换思路。
不是"点击发生在页面哪里",而是"点击发生在 Canvas 内部哪里"。
所以最终方案不再使用 onClick + displayX/displayY,而是改成 onTouch,直接读取 Canvas 组件内部的触点坐标。
先把真正的命中逻辑抽出来:
ts
private handleSeatSelection(localX: number, localY: number) {
const seatIndex = this.locateSeatIndex(localX, localY);
const x = seatIndex[0];
const y = seatIndex[1];
if (x < 0 || y < 0) {
return;
}
if (this.seats[x][y] === 1) {
this.showSelectedToast();
return;
}
if (this.seats[x][y] === 2) {
this.showConfirmedToast();
return;
}
this.drawRect(x, y, 1);
}
然后在触摸事件里直接把局部坐标传进来:
ts
handleTouch(event: TouchEvent) {
if (event.type !== TouchType.Down || event.touches.length === 0) {
return;
}
this.handleSeatSelection(event.touches[0].x, event.touches[0].y);
}
Canvas 上这样绑定:
ts
Canvas(this.context)
.width(StyleConstants.CANVAS_WIDTH)
.height(StyleConstants.CANVAS_HEIGHT)
.onReady(() => {
this.drawSeatMap();
})
.onTouch((event: TouchEvent) => {
this.handleTouch(event);
})
现在的 x/y 不再来自页面,不再来自屏幕,不再来自"减出来的绝对位置",而是:
Canvas 组件内部自己的坐标
这意味着:
- 顶部有多少元素,不影响
- 页面有没有滚动,不影响
- 外层是不是
Scroll,不影响 - 页面有没有安全区内边距,不影响
参考系一旦选对,问题会一下子变简单很多。
这也是我想反复强调的一句结论:
画布类业务控件一旦涉及热区命中,优先使用局部触点坐标,而不是页面级绝对坐标。
五、Canvas 绘制不要只想着"把座位画出来",要把机舱的语义画出来

点击问题解决后,再来看渲染。
如果只是把所有座位画成小矩形,这个页面最多只能算"能用"。但值机页这种东西,用户其实是很看第一眼感知的。
如果视觉上不像机舱,那再完整的逻辑也会显得廉价。
所以在这个 Demo 里,我没有把非座位区做成禁用灰块,而是做成了过道与留白。
1. 通过横向偏移做出过道感
ts
private getSeatLeft(x: number): number {
const aisleOffset = x > StyleConstants.AISLE_COLUMN_INDEX
? StyleConstants.AISLE_EXTRA_GAP
: 0;
return StyleConstants.OFFSET_X +
x * (StyleConstants.SEAT_WIDTH + StyleConstants.SEAT_HORIZONTAL_GAP) +
aisleOffset;
}
有了这个 aisleOffset,右半边座位会整体右移,从而形成中间过道。
2. 用分舱背景强化业务感知
ts
private drawCabinBackdrop() {
this.context.fillStyle = '#F7FBFF';
this.context.fillRect(0, 0, StyleConstants.CANVAS_WIDTH, StyleConstants.CANVAS_HEIGHT);
this.context.fillStyle = '#E8F1FF';
this.context.fillRect(0, 0, StyleConstants.CANVAS_WIDTH,
this.getSeatTop(StyleConstants.FIRST_CLASS_ROW_COUNT) - 6);
this.context.strokeStyle = '#D8E5F5';
this.context.lineWidth = 2;
this.context.beginPath();
this.context.moveTo(0, this.getSeatTop(StyleConstants.FIRST_CLASS_ROW_COUNT) - 6);
this.context.lineTo(StyleConstants.CANVAS_WIDTH,
this.getSeatTop(StyleConstants.FIRST_CLASS_ROW_COUNT) - 6);
this.context.stroke();
}
头等舱背景更浅、更整洁,经济舱保持白底,分隔线清楚地把两个舱位区分开。
3. 过道区域应该画成留白
ts
private drawAisleCell(x: number, y: number) {
const left = this.getSeatLeft(x);
const top = this.getSeatTop(y);
this.context.fillStyle = this.isFirstClassRow(y) ? '#E8F1FF' : '#FFFFFF';
this.context.fillRect(left, top, StyleConstants.SEAT_WIDTH, StyleConstants.SEAT_HEIGHT);
}
这个细节其实很有意思。
如果你把非座位区画成灰色方块,用户会误解为:
- 这些是不是坏座位
- 这些是不是不可选座位
- 为什么机舱中间也有"禁用位"
而一旦你把它们处理成留白和过道,整个页面就会自然很多。
六、一个值机控件能不能复用,关键看状态流转是不是清楚
做业务组件时,最怕的是"页面能点,但数据很乱"。
值机控件真正值得封装的不是画布本身,而是这一套稳定的状态流转:
- 点击可选座位,变成已选
- 已选列表里删除,恢复可选
- 点击确认,已选变成已锁定
渲染入口统一通过 drawRect() 管理:
ts
drawRect(x: number, y: number, id: number) {
const left = this.getSeatLeft(x);
const top = this.getSeatTop(y);
if (id === 0) {
this.context.fillStyle = StyleConstants.DEFAULT_COLOR;
this.context.fillRect(left, top, StyleConstants.SEAT_WIDTH, StyleConstants.SEAT_HEIGHT);
} else if (id === 1) {
this.seats[x][y] = 1;
this.count++;
this.selectedSeats.push([x, y]);
this.context.fillStyle = StyleConstants.SELECT_COLOR;
this.context.fillRect(left, top, StyleConstants.SEAT_WIDTH, StyleConstants.SEAT_HEIGHT);
} else {
this.context.fillStyle = StyleConstants.SELECTED_COLOR;
this.context.fillRect(left, top, StyleConstants.SEAT_WIDTH, StyleConstants.SEAT_HEIGHT);
}
}
确认值机时,再把已选座位整体置为锁定态:
ts
confirm() {
for (let i = 0; i < this.selectedSeats.length; i++) {
this.drawRect(this.selectedSeats[i][0], this.selectedSeats[i][1], 2);
this.seats[this.selectedSeats[i][0]][this.selectedSeats[i][1]] = 2;
}
this.count = 0;
this.selectedSeats = [];
}
为了方便展示和确认,还会把内部坐标翻译成用户看得懂的座位文本:
ts
private getSeatDisplayText(x: number, y: number): string {
return `${y + 1}${this.getSeatLetter(x, y)} ${this.getCabinName(y)}`;
}
比如:
1A 头等舱5C 经济舱8F 经济舱
这一步非常重要,因为业务组件不能只会"内部运算",它还必须能输出业务可读信息。
结语
我觉得最有价值的收获不是页面多漂亮,而是确认了一件事:
自定义交互控件最容易出错的地方,往往不是绘制,而是坐标参考系。
这次的反面案例
下面这些做法,短期可能能跑,长期大概率会反复返工:
- 硬编码一个
CANVAS_Y - 用
displayX/displayY结合页面偏移推局部坐标 - 监听组件位置后继续做绝对坐标抵消
- 把机舱过道画成禁用座位
- 靠固定高度和大外边距顶出下半部分布局
这次最终落地的正确方案
- 用规则函数表达头等舱
2-2和经济舱3-3 - 用 Canvas 渲染高密度、不规则但可控的座位区域
- 用
onTouch + 局部触点坐标做命中 - 用统一状态管理可选、已选、已锁定
- 用滚动 + 安全区处理整页布局边界
如果要把这篇文章压缩成一句话,我会这样说:
写自定义值机控件,最怕的不是多画几个矩形,而是用错了坐标系。
一旦参考系对了,布局、命中、滚动、适配这些问题都会顺很多。
如果你准备继续把这个 Demo 往生产级能力推进,我建议下一步继续做这几件事:
- 把机舱布局抽成 JSON 配置
- 支持后端下发锁定座位
- 增加靠窗、靠过道、额外腿部空间等标签
- 支持多乘机人联动选座
- 给命中判断和状态流转补单元测试
这样它就不只是一个节前练手的小 Demo,而会真正成长为一个可复用的业务控件。