在折叠屏上做一个待办列表,开发者容易把注意力放在单栏变双栏:窗口宽度超过某个阈值,就把详情移到右侧。真正有风险的动作却可能发生在更小的地方:用户在窄屏里把任务左滑到“删除”按钮刚刚露出,恰好展开设备,列表项换了宽度、位置甚至所在列,旧划出回调仍试图删除原来的行。屏幕看起来只是切了布局,业务却可能提交了一次用户没有明确确认的危险操作。

本篇不讲Grid拖拽排序,也不重复上篇的图集修订快照。示例FoldSwipeLedger专门处理ListItem.swipeAction在窗口结构变化时的收口问题:任务SWP-1010-16,12条待办,当前任务T-108;窄窗口420×840vp时一列列表,展开到780×600vp后显示列表与详情两栏,布局代次从5变为6。固定夹具模拟划出1次、布局换代主动关闭1次、过期删除回调拦截1次,最终业务deleteCommitted=0,状态SWIPE_CLOSED。数据代表演示事件序列,不是设备实测手势。

一、先判断什么时候不应该继续让菜单开着

传统手机列表里,左滑露出按钮后点击删除通常发生在同一块屏幕上。宽屏设备的变化更多:用户可以缩放应用窗口,可以从折叠态转为展开态,也可能进入分栏、调整导航栏宽度。ListItem的视觉位置、实际宽度和触控热区可能同时改变。即使业务数据保持12条不变,原本手势所针对的视觉对象也不应无条件获得新布局里的删除资格。

这里有两个需要刻意分开的状态。SwipeActionState.EXPANDED描述组件划出操作项已经展示,这是ArkUI组件层状态;SWIPE_CLOSED是本项目维护的业务安全态,表示当前没有任何被允许的危险动作。两者相关,却不能互相代替。旧组件可能在销毁路径里再发一次回调,应用如果只凭“当前项目ID等于T-108”判断,仍可能误触发业务删除。

更保守的方案是:一旦窗口结构发生足以改变ListItem布局的变化,先把当前操作上下文作废,要求用户在新布局中重新展开并明确点击。它牺牲一次流畅操作,却能把“用户重新确认”变成系统可观测的条件。对于标记已读、收藏等可撤销动作可以放宽,对于删除、取消预约和转移所有权这种操作建议保持严格门禁。

官方ListItem文档明确提供swipeAction、onOffsetChange和onStateChange等参数,ListScroller提供关闭划出操作项的方式。本文使用的layoutEpoch、SwipeTicket、blockedAction都是应用层字段,不是系统通知自动携带的版本。不能将事件中不存在的版本号写进API签名,必须在用户开始交互时由应用自己建立关联。

二、先建立“不误删”的验收合同

示例里有12条待办,T-108是窄屏选中项。页面宽度从420vp变为780vp,layoutEpoch=5→6,展示结构从一栏变成两栏。openCount=1表示存在一次有效划出展开意图,closeCount=1表示窗口切换触发一次主动关闭,blockedAction=1表示旧代次危险回调被拒,deleteCommitted=0是本轮核心安全结论。最终界面状态SWIPE_CLOSED,T-108仍在列表且仍被选中。

这些数值不能被混为一谈。blockedAction=1不是用户真的按了删除1次,也不等于平台拦截1次硬件手势;它是我们准备的模拟事件里,一条旧操作票据在业务层无法提交。deleteCommitted=0需要核对后台数据集仍有12个稳定ID,而不是简单看页面上有没有删除动画。若后续正式接入数据库,提交结果还要由事务成功与版本前置条件共同确认。

项目结构简化为SwipeInboxPage.ets负责列表视图,SwipeAuditPage.ets负责事件回放,SwipeTicketGate.ets维护操作票据,TaskLedger.ets记录待办稳定ID和删除修订。临时索引不进入票据:窗口重排之后,index=7可能不再指向T-108,但业务ID不会因为排版而改变。索引可用于当前帧渲染,不能用于跨异步或跨窗口变化的危险写入。

三、先接通系统划出组件,再把业务动作分离

第一段代码只完成官方ListItem划出菜单的接线,不在onOffsetChange里做数据库删除。原因是偏移量只是手势过程,不是用户授权。SwipeActionItem可以配置builder与onAction,但长距离触发删除需要明确的距离阈值和产品说明;本Demo只用普通按钮,将业务提交交给单独的门禁函数。

@Entry
@Component
struct SwipeInboxPage {
  @State tasks: string[] = ['T-101', 'T-102', 'T-103', 'T-104', 'T-105',
                            'T-106', 'T-107', 'T-108', 'T-109', 'T-110',
                            'T-111', 'T-112'];
  private scroller: ListScroller = new ListScroller();

  @Builder
  itemEnd(taskId: string) {
    Row() {
      Button('删除')
        .onClick(() => this.requestDelete(taskId));
    }.width(92).height('100%').justifyContent(FlexAlign.Center)
  }

  build() {
    List({ scroller: this.scroller }) {
      ForEach(this.tasks, (id: string) => {
        ListItem() {
          Text(id).fontSize(17).padding(18)
        }
        .swipeAction({ end: this.itemEnd(id), edgeEffect: SwipeEdgeEffect.None })
      }, (id: string) => id)
    }.cachedCount(4)
  }

  private requestDelete(taskId: string): void {
    // 真实业务在这里消费带layoutEpoch的确认票据
  }
}

本示例有意避免把swipeAction当作删库指令。实际代码可以配合onStateChange建立“用户已看见操作按钮”的记录,但不能单纯因为该状态达到EXPANDED就自动删数据。SwipeEdgeEffect.None只改变划出边界的交互效果,不改变危险操作权限。cachedCount(4)是列表预创建数量的UI优化参数,也不是操作票据可保存多久的期限。

还有一个容易忽略的实现限制:SwipeActionOptions里的自定义builder顶层应生成单一组件。例子用一个Row包住按钮,避免在builder最外层同时放多个同级节点而出现未定义行为。多列显示时,不宜把划出菜单设置得过宽,文档也提示划出手势只在ListItem区域内生效。我们在示例里把按钮宽度固定为92vp,正式项目还应根据屏幕密度、字体放大和无障碍触控目标做验证。

四、把布局变化看成操作票据的过期事件

用户手指离开屏幕时,UI回调可能已经排进事件队列。仅调用closeAllSwipeActions()可以要求组件收起菜单,却不能撤回已经进入应用层的回调;而只在业务里增加epoch检查也无法让屏幕马上收起旧菜单。两个动作需要成对:组件层主动关闭,应用层让既有票据失效。只有这样才能同时满足“看见的是安全画面”和“数据不会被误删”。

布局代次由应用维护。layoutEpoch=5时创建的票据只能在同一代次消费。进入layoutEpoch=6后,同一个任务ID再出现在新列表里,不代表旧票据自动有效。若产品希望保留用户“正准备操作”的状态,可以保留选中ID或搜索条件,但不保留危险动作的授权。我们用T-108一直被选中来展示这种区别:选中属于阅读上下文,删除票据属于短时授权。

下面的纯ArkTS类没有调用系统私有接口,只记录操作票据、关闭原因和消费资格。它将“关闭菜单”与“删除业务项”分成两个阶段,计数可作为夹具断言。为了演示最容易复现的错误,本轮只使用单活动票据;多指并发和跨窗口同时操作应扩展为按窗口ID维护独立上下文。

interface SwipeTicket { taskId: string; epoch: number; nonce: number }

class SwipeTicketGate {
  private epoch: number = 5;
  private nonce: number = 0;
  private active?: SwipeTicket;
  openCount: number = 0;
  closeCount: number = 0;
  blockedAction: number = 0;

  open(taskId: string): SwipeTicket {
    const ticket: SwipeTicket = { taskId, epoch: this.epoch, nonce: ++this.nonce };
    this.active = ticket;
    this.openCount++;
    return ticket;
  }
  changeLayout(): void {
    this.epoch++;
    this.active = undefined;
    this.closeCount++;
  }
  canDelete(ticket: SwipeTicket, currentTaskId: string): boolean {
    const ok: boolean = ticket.epoch === this.epoch &&
      ticket.taskId === currentTaskId && this.active?.nonce === ticket.nonce;
    if (!ok) this.blockedAction++;
    return ok;
  }
}

这里没有使用时间戳去猜“回调是否足够新”。一条5毫秒前的消息也可能来自旧布局,一条200毫秒后的消息只要仍在同一代次并由用户明确触发,也可能合法。单调递增的epoch比设定任意毫秒窗口更容易解释。nonce防止同一布局里多次展开菜单时旧票据复用,但只在当前应用实例里有效;如果页面重建,还要重新建立会话身份,不能将这个整数当成全局唯一标识。

图02为拟真开发界面示意,代码与布局需在目标DevEco版本编译验证。

五、窗口重排与关闭回调应按顺序安排

如果窗口宽度变化导致列表重新分栏,首先将旧票据失效,再调用Scroller关闭菜单,之后更新栏数和对应详情内容。这个顺序不是为了追求视觉动画上的绝对先后,而是为了避免某次关闭过程同步触发业务回调时仍然持有有效票据。反过来,先改布局再禁用票据,中间可能产生很窄的误操作窗口。

第三段代码演示如何调用华为文档中公开的ListScroller.closeAllSwipeActions()。它不假设该调用必然成功,也不把捕获异常之后的业务状态伪装成已完成视图收口。如果closeAllSwipeActions()失败,应用仍保持危险操作票据失效、记录关闭故障,并让实际UI在下一次重建时不再恢复旧菜单。closeAllSwipeActions是组件管理能力,而invalidate是我们业务的提交资格控制。

import { BusinessError } from '@kit.BasicServicesKit';

function closeOnResize(scroller: ListScroller,
                       gate: SwipeTicketGate): string {
  gate.changeLayout(); // 先失效旧代次的危险动作
  try {
    scroller.closeAllSwipeActions();
    return 'SWIPE_CLOSED';
  } catch (error) {
    const detail = error as BusinessError;
    console.error(`closeAllSwipeActions failed: ${detail.code}`);
    return 'CLOSE_NEEDS_REBUILD';
  }
}

这里的错误打印不包含任务名称或用户个人信息。真实项目调用时还需要确认Scroller已经绑定到当前List实例。页面刚出现、尚未构建完成时强行关闭并不能保证有组件可操作。可以在窗口几何变更事件确定后集中处理,或者在页面切换中先禁用提交再安排关闭;触发频繁时避免每个像素变化都做完整操作,而是根据结构模式从单栏到双栏的变化做一次事务。

aboutToDisappear要处理另一类问题:页面退场时即使没有发生宽度变化,也必须让活动票据失效,移除业务订阅,拒绝任何异步删除提交。ListScroller属于当前List实例,不应被一个全局单例长期持有。若为了跨页保存滚动位置,可以只保存稳定任务ID和独立滚动偏移,在重新创建页面后生成新的Scroller;不要把旧组件实例和未完成手势一并塞进共享状态容器。

六、如何证明没有“看上去关了、实际删除了”

测试脚本先在420vp宽度的单栏列表展开T-108的删除操作,得到openCount=1;再模拟窗口展开到780vp,使业务epoch由5变6,并请求组件收起,得到closeCount=1。最后让旧菜单上的删除动作以原票据到达,门禁必须返回false、blockedAction=1。核对当前任务仍为12条,T-108存在且选中不变,deleteCommitted=0。这比只看一个红色警告条更可复核。

演示里状态为SWIPE_CLOSED,对应组件关闭请求完成后的业务解释。这个状态并不证明在所有设备形态上都没有残留动画;真实验收还应通过界面截图或交互脚本检查组件是否真的收起,以及重新展开后点击是否能按产品预期操作。不能把模型日志当成系统的onStateChange事件,也不能因为显示了12条就默认数据库事务没有在后台执行。

尤其要设计“误删门禁反向用例”:如果在同一布局代次下用户真正点击了删除,并且票据匹配,那么canDelete应该放行进入下一层确认,而不是永远拒绝。这里放行也只说明动作获得资格,是否真的删除仍取决于业务事务、服务端版本校验和用户确认。对需要撤销的业务,可以在删除成功后短时间显示撤销入口,但回滚操作应拥有自己的事务ID,不应复用划出手势票据。

双栏结构还可能把列表项隐藏在左栏,而右栏仍展示它的详情。安全处理是让选中ID维持一致:T-108没有删除,因此详情可以继续展示;如果某个真实的数据库变更最终删除了T-108,右侧详情必须响应数据源变更并给出空态或下一个明确选择。不能把布局重排和数据删除混在一个onAreaChange回调里完成,否则查日志时很难判断谁修改了业务数据。

七、用诊断账本追溯一次完整操作

SwipeAuditPage记录的是模型事件:10:24:10打开T-108划出项、10:24:11窗口尺寸从420×840变为780×600、10:24:11失效epoch5并关闭菜单、10:24:12收到旧代次操作并拒绝、10:24:13确认列表仍为12条。时序不是从用户真实设备的操作系统追踪中采集,也不代表设备返回顺序;它是专门用于测试应用提交门禁的固定事件序列。

诊断信息应分级。普通用户只需要知道“窗口已调整,请重新划出操作”;开发者日志才记录taskId、layoutEpoch、ticketNonce、blockedAction和删除事务ID。即使任务标题里含有敏感信息,也不应直接写进生产日志。为了让数值可比,所有日志都标清单位:420vp是窗口宽度,5是业务epoch,1是拦截次数;不要把像素、帧数、毫秒与提交计数放进同一列求和。

在大字体、RTL语言和鼠标拖拽场景下,划出方向还受文本方向与设备输入方式影响。官方ListItemSwipeActionDirection把START/END与语言方向关联,不能假定“END永远是屏幕右边”。本文的安全规则基于业务ID和操作票据,不依赖菜单实际位于左右哪一边,因此更容易扩展到国际化设备。但具体手势热区和操作按钮大小,仍需在真机、折叠态和平板窗口模式下分别验收。

八、把演示规则带进真实工程时的取舍

本案例刻意没有把关闭组件这一步写成绝对同步成功。UI帧调度、页面切换和列表数据源通知可能交错;即使调用closeAllSwipeActions返回,也应让危险票据保持无效,直到用户在新布局里重新做出明确操作。组件层状态、业务确认和数据提交必须分别记录。这样做看起来比“一次onClick直接删列表”多了一些代码,但它换来的是一个能解释误删根因、可以回放的协议。

对大量列表数据也要注意缓存影响。List预加载和LazyForEach复用可能让某个ListItem暂时离屏、再进入可见区。复用控件时,如果把“已展开”保存在位置索引里,新的业务行可能继承旧行菜单。列表渲染必须使用稳定任务ID作为Key,组件短时划出状态在重用时应重新初始化。对于删改操作,服务层仍需条件更新;UI层门禁不能取代持久层的并发保护。

最终我们只声称模型完成了open1/close1/block1/delete0这组预期判断。ArkUI的swipeAction和ListScroller提供划出与关闭的系统能力,窗口变化时哪些业务动作应失效、哪些阅读上下文应保留,需要由应用设计。真实编译、组件动画、不同设备输入方式及后端删除事务仍未验证。对于有破坏性的列表动作,把“已经收起的菜单仍有权删数据”从设计上排除,比事后补一条撤销提示更可靠。

补充:触控中断时的用户提示不应制造新的竞态

菜单收起后需要提示用户操作已取消,但提示组件也可能在窗口宽度改变时重复创建。建议使用一次性的提示事件,由页面当前实例在恢复稳定后消费,而不是由旧ListItem持有Toast回调跨页面执行。若同一秒连续经历多次窗口尺寸变更,可以把收口动作按布局模式改变合并,而不是每次宽度微调都增加一次closeCount。这样,日志中一次收口才代表一次真正的结构变化,统计不会被动画帧放大。

对于鼠标右键菜单、键盘快捷键、读屏辅助操作,也应复用同一删除业务门禁。不能只防住触摸划出,另一条菜单入口却绕过epoch和数据修订。跨入口共享的是任务ID、确认票据和提交版本,不是一个全局的“当前菜单已展开”布尔值。每个入口创建自己的短时操作凭证,最终写入由统一的删除事务检查。手势交互可以不同,数据安全协议必须一致。

一旦引入服务端同步,还要注意本地失效并不等于远端操作失败。提交请求在窗口变更前已经发出时,客户端不能简单把deleteCommitted恢复为0;必须等待服务端事务回执,必要时发起查询以确认最终状态。当前教学模型没有网络服务,因此把删除提交严格限定在门禁之前,0就是预期安全值。将来接入远端时,建议把“未发出”“处理中”“服务端拒绝”“已提交”四种状态分开,而不是让一个布尔值承担全部语义。

在设置日志字段时,还应保留触发入口,例如gesture、mouse、keyboard或accessibility,以便确认某类输入不会绕过门禁。这属于应用自定义审计标签,不对应系统隐式传入的安全证明。

此外,删除操作若支持撤销,也需要考虑列表项被其它设备同时修改的情况。撤销应检查对象仍可恢复、其权限没有变化,并以新的操作ID记录。旧划出菜单里携带的nonce仅能证明某次本地意图的来源,不能作为跨设备业务唯一事务ID。把各层身份分开保存,才不会因为一个交互细节变化而影响整套同步协议。

参考资料与适用边界

  • 华为ListItem接口与SwipeActionOptions、ListScroller示例:https://developer.huawei.com/consumer/cn/doc/doccenter-capabilities/api/ts-container-listitem
  • 华为List预加载与键索引说明:https://developer.huawei.com/consumer/cn/doc/doccenter-references/api/ts-container-list
  • 华为多形态设备适配入口:https://developer.huawei.com/consumer/cn/multidevice/adaptive-apps/

资料用于确定系统组件、参数与官方约束。SwipeTicketGate、layoutEpoch、SWIPE_CLOSED及演示统计均为本文业务模拟,不是SDK返回值或真机测量。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐