HarmonyOS 数据可视化实战:封装自定义值机控件实操记录与复盘

HarmonyOS 数据可视化实战:封装自定义值机控件实操记录与复盘

马上五一过节了,大家又开始了日常抢票。

高铁票、机票、酒店、景区门票,一轮操作下来,很多人对"选座位"这件事已经形成肌肉记忆了。尤其是机票值机,大家往往会下意识先看两件事:

  • 能不能选到靠窗
  • 前排或者过道还有没有位置

这类页面看起来很普通,但真要自己动手从 0 到 1 写一个小 Demo,会发现里面还是有点小坑。

  • 为什么值机控件不能只当成一个 Grid
  • 旧实现为什么是一个反面案例
  • 点击命中为什么会漂移
  • 正确写法为什么必须回到局部坐标
  • 最后怎样把它收束成一个可复用的业务控件

一、先别急着写代码,值机控件本质上是"规则布局 + 状态流转"

很多同学拿到这个需求,第一反应可能是:

"不就是一个二维数组加一堆格子吗?"

这句话只对了一半。

如果你只是做一个静态排版,那确实像二维数组。但一旦你真的按照值机场景去思考,会发现它同时带着好几层业务约束:

  • 头等舱和经济舱不是一个布局
  • 机舱中间有过道,不是纯矩阵
  • 有些区域是留白,不应该长得像禁用座位
  • 座位有可选、已选、已锁定等不同状态
  • 点击命中必须精确,否则用户会直接不信任这个页面

所以这个控件如果要做对,至少要拆成三层:

  1. 布局规则层:哪些位置是座位,哪些不是
  2. 渲染层:不同状态如何绘制
  3. 交互层:点击如何准确命中座位

在本地工程里,我最后把它拆成了下面几个文件:

  • 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 === 5
  • x !== 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 顶部偏移
  • 再减掉状态栏高度

逻辑甚至有点像"手工坐标换算"。

但它有两个根本问题:

  1. CANVAS_Y 是一个静态经验值,不是真实组件位置
  2. 页面一旦滚动、顶部内容变化、安全区变化,这个值立刻失真

也就是说,这种写法不是"准不准"的问题,而是"迟早会错"的问题。

错误写法 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/displayY
  • area.position.x/y
  • Scroll 的视觉位置
  • 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);
}

这个细节其实很有意思。

如果你把非座位区画成灰色方块,用户会误解为:

  • 这些是不是坏座位
  • 这些是不是不可选座位
  • 为什么机舱中间也有"禁用位"

而一旦你把它们处理成留白和过道,整个页面就会自然很多。

六、一个值机控件能不能复用,关键看状态流转是不是清楚

做业务组件时,最怕的是"页面能点,但数据很乱"。

值机控件真正值得封装的不是画布本身,而是这一套稳定的状态流转:

  1. 点击可选座位,变成已选
  2. 已选列表里删除,恢复可选
  3. 点击确认,已选变成已锁定

渲染入口统一通过 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,而会真正成长为一个可复用的业务控件。

相关推荐
李李李勃谦2 小时前
基于鸿蒙PC多窗口架构的任务管理与番茄钟工作流实践
华为·架构·harmonyos
零度@2 小时前
鸿蒙应用发布到华为应用市(AppGallery)的完整流程
华为·harmonyos
m0_640309302 小时前
HarmonyOS 5.0 IoT开发实战:构建分布式智能设备控制中枢与边缘计算网关
分布式·物联网·harmonyos
nashane2 小时前
HarmonyOS嵌套滚动场景下Slider与Scroll的协同拖动解决方案
华为·harmonyos·harmonyos 5·harmony app
财经资讯数据_灵砚智能2 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年4月27日
人工智能·python·信息可视化·自然语言处理·ai编程
liulian09162 小时前
【Flutter for OpenHarmony第三方库】Flutter for OpenHarmony 底部导航栏交互设计与性能优化实践
flutter·华为·交互·学习方法·harmonyos
Lanren的编程日记10 小时前
Flutter 鸿蒙应用智能推荐功能实战:协同过滤+混合推荐算法,打造个性化内容体验
flutter·华为·harmonyos·推荐算法
纪伊路上盛名在17 小时前
单细胞转录组数据可视化
信息可视化·生信·单细胞·转录组
小成Coder18 小时前
【Jack实战】如何用防窥保护给 HarmonyOS 应用敏感页面加一层系统蒙层
华为·harmonyos