GridRow + HarmonyOS 出行票夹:大字回流与无障碍焦点验收【票夹系列 04】【共创稿事节】
在出行票夹里,“看得见三张车票”和“能找到自己要用的那张票”是两回事。正常字号的手机屏幕上,标题、出发地、时间和票据状态可以轻松塞进一张卡片;当用户开启大字号、折叠设备从展开切回窄屏,或者借助屏幕朗读逐项寻找车次时,同一套布局可能突然变得难以使用。TravelDeck 的前三节已做完响应式栅格、RDB v2 本地持久化、条件查询与稳定 ID 详情读取。本节不再更换表、不添加一个实际上尚未接入的验票 SDK,而是收束工程中最容易被“漂亮截图”掩盖的交互边界:文字回流、无障碍语义以及窗口变化后的选中状态。

一、验收目标必须比“页面不崩”更明确
先把测试对象固定下来。TravelDeck 仍沿用上一节 TravelTicket 的七个字段:id、category、title、code、time、location、status,示例业务 ID 仍为 rail-01、air-01、visit-01。对应票据是高铁 G105、航班 CZ3552 和海边艺术馆预约。原始 time 是展示文本,例如“11月18日 09:10”,它既未包含年份,也未定义时区,本节不会偷偷将它用于可靠的时间排序,更不会把它包装成可以扫描验真的票券。
需要通过的行为有四类。第一,字号增大时,关键文本可以换行,时间和状态不被压在图标下面;第二,屏幕朗读能够把一张票作为逻辑上完整的对象读取,不让“G105”“待使用”“北京南”变成互不相干的碎片;第三,筛选后切换窗口宽度,选中对象仍由业务 ID 定义,而不是数组位置;第四,数据库为空、票据被删除和查询失败都应有不同的提示,且提示不能只依靠颜色。
真正的适配不是写两个硬编码页面。窄屏、展开屏和辅助功能用户仍要共享同一个 TicketRepository 和同一份筛选规则。若为每种屏宽复制一套列表,开发者迟早会在其中一份代码忘记处理数据库删除或搜索过期响应。合理的模型应当让“数据是谁”与“它排在哪里”完全独立,再对外观与朗读信息分别组织。
二、把一张票改成可伸缩、可朗读的交互单元
最小修改从 TicketHomePage.ets 中的票据卡片开始。旧卡片把状态挤在标题一行的右端,还给信息区固定了高度。这在两列宽屏上看似整齐,但文字放大后要么截断,要么挤掉“11月18日 09:10”。本节将票据身份、状态和时间拆为多行,并使卡片高度由内容决定。字号使用应用提供的显式“大字预览”开关测试;系统字体缩放与应用设置并不是同一回事,因此还应在真实设备系统设置中单独进行辅助功能回归。
// entry/src/main/ets/components/AccessibleTicketCard.ets
import { TravelTicket } from '../model/TravelTicket';
@Component
export struct AccessibleTicketCard {
@Prop ticket: TravelTicket;
@Prop largeText: boolean;
onOpen: (id: string) => void = () => {};
build() {
Column({ space: 10 }) {
Row() {
Text(this.ticket.category).fontSize(this.largeText ? 22 : 16)
.fontColor('#1B569E').fontWeight(FontWeight.Medium)
Blank()
Text(this.ticket.status).fontSize(this.largeText ? 20 : 14)
.fontColor('#1F6B58')
}.width('100%')
Text(this.ticket.title).fontSize(this.largeText ? 28 : 20)
.fontWeight(FontWeight.Bold).width('100%')
Text(this.ticket.code).fontSize(this.largeText ? 22 : 16)
.width('100%')
Text(this.ticket.location).fontSize(this.largeText ? 21 : 15)
.fontColor('#475569').width('100%')
Text(this.ticket.time).fontSize(this.largeText ? 21 : 15)
.fontColor('#475569').width('100%')
}
.padding(18).width('100%').borderRadius(18)
.backgroundColor('#FFFFFF')
.onClick(() => this.onOpen(this.ticket.id))
.accessibilityGroup(true)
.accessibilityText(`${this.ticket.category},${this.ticket.title},` +
`${this.ticket.code},${this.ticket.location},` +
`${this.ticket.time},${this.ticket.status}`)
.accessibilityDescription('双击打开票据详情')
}
}
这段组件没有凭空添加字体倍数的系统 API。fontSize 调整的是此页面明确定义的两个字号等级;当使用系统级放大、Display Zoom 等能力时,仍须按照真实窗口可用宽度检验排版。accessibilityGroup(true) 让无障碍服务把卡片整体视为一个可选单元;accessibilityText 显式声明播报顺序,不依赖 UI 子节点的偶然排列。注意文字里没有“扫码有效”“已出票”等与仓储数据无关的声称。若未来在卡片内部放一个单独的“删除”按钮,就不能直接让整个卡片吞掉子按钮的交互焦点,应重新拆分语义与操作区域。
票据卡的点击只传 ticket.id。这个原则延续 C-03 的 findById(id):详情页根据稳定 ID 读取最新数据库行,无法找到时显示“票据已不存在”,并提供返回列表的路径。要避免只把行索引传给详情:筛选结果的第零项,在切回“全部”后未必仍是同一张车票。这样做不仅防数据错误,也能防止辅助功能把朗读焦点落到另外一张票上。
三、栅格宽度与字号是两个正交变量
第一节已有 GridRow 与 GridCol,而第三节已有 this.tickets 和 selectedId。这次沿用它们,不必另造一个“无障碍数据源”。根据断点调整卡片所占列数,让小屏一列、中屏两列、大屏三列。下方的代码只展示本节需要替换的布局层,reload()、open() 与 findById() 仍由上一节的仓储实现提供。
// TicketHomePage.ets 中保留既有 repository、reload 和 selectedId
@State private largeText: boolean = true;
@Builder
private ticketGrid() {
GridRow({ columns: { sm: 4, md: 8, lg: 12 }, gutter: 12 }) {
ForEach(this.tickets, (item: TravelTicket) => {
GridCol({ span: { sm: 4, md: 4, lg: 4 } }) {
AccessibleTicketCard({
ticket: item,
largeText: this.largeText,
onOpen: (id: string) => { this.selectedId = id; this.openDetail(id); }
})
}
}, (item: TravelTicket) => item.id)
}
.width('100%')
}
真正要注意的是 GridCol 里的宽度由栅格承担,内部卡片只设置 width('100%'),不规定“始终 320vp 宽”。若外层容器带有过大的固定 padding,即使列数从三列变成一列,长地点名称仍会换行过度。也不要在数据层把“高铁票至少两行”当成业务约束:行数是显示结果,业务库只存原始字符串。ForEach 的键仍为 item.id,不会因为切换大字模式或筛选而改变业务身份。

大字模式打开后,一张票至少容纳类别、标题、编号、地点、时间和状态六个信息片段。当展开屏能显示三列时不应强行把单列的字体缩小;组件会根据宽度重排。同理,面对较长的地点“北京南站 → 上海虹桥站”,应优先让它自然换行,而不是用 maxLines(1) 一刀切掉终点站。展示文本与无障碍文本要保持含义一致,但不要求视觉换行位置和朗读停顿位置完全相同。
四、屏幕朗读和窗口回归应该怎样验
一项常见误判是:开发者打开页面,点击三个按钮都正常,就宣布无障碍完成。事实上,“按钮能点”不能证明读屏顺序正确。应当在已启用系统屏幕朗读的设备上,从页面标题开始逐个移动焦点,确认先听到“出行票夹”“大字模式”,再听到分类与搜索条件,最后是每张完整票据;同一张票的标题、时间与状态在一次焦点朗读中保持相互关联。accessibilityDescription 解释“打开详情”而不是重复一遍标题,不应把整份 JSON 或长 URL 播报给用户。
对可操作控件,特别是筛选按钮,不宜用“蓝色”和“灰色”作为唯一提示。示例可以把当前筛选按钮显示为“高铁(已选)”,其他按钮仅显示“航班”“预约”;这样即使用户无法区分颜色,也能知道条件已切换。空结果使用“没有匹配票据”,数据库错误使用“读取票据失败,请重试”,删除后的详情使用“该票据已不存在”。三种状态应分别测试,不要都显示一个空白页。页面切换后的第一焦点和返回后的焦点恢复仍需在目标 SDK 和设备上验证,不应仅凭画出的预览图判断通过。
具体执行时还应检查焦点的操作成本:从搜索框走到第三张票到底需要多少次手势,返回列表后是否还能定位到原来选中的票,以及弹出错误提示时是否打断正在朗读的出发地。不要在页面重绘后无条件把读屏焦点送回标题,那会迫使用户每次切换分类都重新遍历整个页面。对于通过手势打开详情的用户,要确保触控目标周围有足够空间,避免误触相邻卡片。页面可以用不同的边框与文字共同标记选中态,让高对比度模式下的含义仍然清楚。
测试矩阵至少覆盖:窄屏正常字号、窄屏大字、展开屏正常字号、展开屏大字,以及启用屏幕朗读的两种宽度;每一种再结合“全部三条”“筛选一条”“搜索为空”和“详情已删除”四种数据状态。实际执行时可以记录设备型号、SDK、页面宽度、系统字体设置、当前关键词和最终选中 ID,不应记录乘客真实身份、证件信息、二维码内容。这样留下的是能复查的问题条件,而不是漂亮但无法复现的截图。
五、交付前最后一张图:状态归属决定缺陷定位速度

从 C-01 的一套页面适配三种宽度,到 C-02 的 tickets 表结构迁移,再到 C-03 的 RdbPredicates 查询和异步序号防覆盖,最后来到本节的无障碍回归,整条工程主线可以归纳成一句话:事实由数据库维护,选择由稳定 ID 维护,显示由屏幕条件维护,朗读语义由组件明确声明。 它们属于不同维度,任何一个维度都不应该偷偷改写另一个维度。
本连载在应用本地“出行凭证整理”范围内完成闭环,暂未包含联网票务核验、服务端同步、官方电子客票签发或真实支付凭证。特别是旧 time 字段没有年份与统一时区,因此后续若新增“距离出发还剩多久”的功能,应该通过可迁移的结构化日期字段解决,而不能直接解析当前展示字符串当作严肃时间依据。对于产品上线,真实数据的隐私分级、备份恢复、多窗口后台行为和运营边界还需要按照实际业务展开评审。
技术实现可对照华为开发者文档:ArkUI 无障碍属性、GridRow 栅格布局、RdbPredicates。本篇使用的组件无障碍属性起点为 API 10,设备组合与系统屏幕朗读效果应按选定 SDK 进行验证,不把发布宣传能力等同于已接入的公共接口。
更多推荐


所有评论(0)