ArkUI ListScroller:文搜图筛选可视锚点恢复【鸿蒙心迹】
文搜图页面最容易被忽略的交互,并不是“检索到几张照片”,而是用户筛选完以后还站在什么位置。假设一个相册已经列出两百多条候选,用户滚到中段,正对着一张编号为 PH-084 的桥面照片核对。此时追加“雨后拱桥”筛选条件,候选集合变成 48 条。新列表如果从顶部出现,用户得重新找照片;如果照搬旧的第 72 行,又可能跳到完全不同的结果。这两个表现都谈不上舒服。
这里讨论一个范围很小但适合做工程拆解的 Demo:PhotoAnchor。它不训练检索模型,也不声称完成了真正的语义向量搜索;输入是预先准备好的本地候选数组,重点检验 ArkUI 长列表在筛选前后的“可视锚点”能否对齐。以下 PH-084、72、18、48 都是固定的说明数据,不是真机测试测得的性能数字。

一、把位置从索引换成业务标识
1. 索引只能描述当前数组
在旧数组里,PH-084 恰好位于第 72 个索引。筛选结束后,同一张图在新数组中排到索引 18。单独保存 firstVisibleIndex=72,能恢复的是一个“数字”,不是用户刚才关注的照片。真正应该跨数组存活的是照片的稳定 ID。索引仍然有用,但它只能在当前版本的数据集里解释。
因此页面需要保存两层状态:滚动事件带回的首个可见索引,以及由它换算出来的 anchorId。筛选结果返回时先用 ID 找目标在新数组的位置,再交给滚动控制器定位。目标被筛除时,则选择合理的回退:定位到新列表中不越界的相近索引,同时提示“原锚点不在本次结果中”。不要静默地把其他照片冒充成原照片。
2. 不把可视位置写成持久业务数据
这份锚点属于当前页面会话。它可以在页面状态里保存,却不适合无条件写入持久化存储:同一个关键词稍后可能得到不同结果,旧索引失去意义。需要跨页面恢复时应同时记录检索条件、数据集版本和锚点 ID,并在恢复时重新校验,不能把屏幕坐标当成唯一事实。
二、先整理可替换的数据源
要解决筛选重排,就要让列表在数据更新时知道“旧集合已经失效”。这里使用官方 LazyForEach 支持的 IDataSource 协议。它比直接把巨大数组交给整批渲染更容易约束可见项目创建,但不能据此宣称某个固定百分比的性能收益。下方只展示与筛选有关的数据源逻辑,其他业务字段按项目补齐。
interface PhotoItem { id: string; title: string }
class PhotoSource implements IDataSource {
private rows: PhotoItem[] = [];
private listeners: DataChangeListener[] = [];
constructor(initial: PhotoItem[]) { this.rows = initial; }
totalCount(): number { return this.rows.length; }
getData(index: number): PhotoItem { return this.rows[index]; }
registerDataChangeListener(l: DataChangeListener): void {
if (!this.listeners.includes(l)) { this.listeners.push(l); }
}
unregisterDataChangeListener(l: DataChangeListener): void {
const pos = this.listeners.indexOf(l);
if (pos >= 0) { this.listeners.splice(pos, 1); }
}
replaceAll(next: PhotoItem[]): void {
this.rows = next;
this.listeners.forEach(l => l.onDataReloaded());
}
findById(id: string): number {
return this.rows.findIndex(item => item.id === id);
}
}
onDataReloaded() 的含义是要求框架按新数据集合重新获取项目,它不保证滚动位置自动保持。特别需要留意官方文档对 maintainVisibleContentPosition 的限制:在 LazyForEach 重新加载整组数据的场景,不能依赖该属性自动保住原来的可见内容。插入一项与整体筛选重排,虽然都改变列表,实际是两种不同的工程问题。
业务 ID 必须稳定且唯一;不要拿数组下标作为 LazyForEach 的 key,否则排序变化时组件复用可能错位。数据量很大时,数据源不要反复拷贝整张照片的二进制内容,这个示例只传 ID、标题等轻量元数据。图片解码、缩略图缓存以及滚动项复用是另外一层职责。
三、滚动时只记住“谁在眼前”
下一段解决的是锚点采样。ListScroller 与 List 一对一绑定;onScrollIndex 报告可见首尾索引,在官方 API 说明里,它并不是每一帧都回调一次的绝对坐标采集器。对于普通同类列表,首个可见项足够作为初始方案。
@Entry
@Component
struct PhotoAnchorPage {
private listScroller: ListScroller = new ListScroller();
private source: PhotoSource = new PhotoSource([]);
@State firstVisibleIndex: number = 0;
@State anchorId: string = '';
@State pendingIndex: number = -1;
build() {
List({ scroller: this.listScroller, space: 8 }) {
LazyForEach(this.source, (photo: PhotoItem) => {
ListItem() { Text(`${photo.id} ${photo.title}`).padding(16) }
}, (photo: PhotoItem) => photo.id)
}
.onScrollIndex((start: number, end: number) => {
if (start < 0 || start >= this.source.totalCount()) { return; }
this.firstVisibleIndex = start;
this.anchorId = this.source.getData(start).id;
console.info(`PhotoAnchor visible=${start} anchor=${this.anchorId}`);
})
}
}
end 本例暂不参与决策,但可用于诊断可见区的跨度。对于瀑布流、带分组标题或者高度差距很大的项目,只记录第一个可见索引还不够精确,可能需要可见内容信息或尺寸记录。这里刻意选用同一行高的图片结果列表,避免把“可恢复一个业务项”写成“能精确复原每一像素”。
注意,日志出自拟定的调试路径,不代表下面的演示界面已经在设备上跑通。实际联调时可以过滤 PhotoAnchor 标签并配合滑动、筛选和返回操作逐次观察。

四、筛选完成后重新找锚点
筛选前,页面在 PHOTO 集合中记录 anchorId=PH-084,对应旧索引 72;筛选条件为“雨后拱桥”,新数组只有 48 条。新集合生成后,通过稳定 ID 算得目标索引 18,并不是直接对旧索引做减法。这一步建议在新数组被确认后立即完成,避免另一次筛选插进来,导致待恢复索引又失效。
// 以下两个方法放在 PhotoAnchorPage 内;由业务筛选完成与列表布局完成时分别调用。
applyFilteredRows(next: PhotoItem[]): void {
const savedId: string = this.anchorId;
const oldIndex: number = this.firstVisibleIndex;
this.source.replaceAll(next);
const matched: number = this.source.findById(savedId);
this.pendingIndex = matched >= 0 ? matched :
Math.min(oldIndex, Math.max(0, next.length - 1));
console.info(`PhotoAnchor anchor=${savedId} old=${oldIndex} new=${this.pendingIndex}`);
}
restoreAfterLayout(): void {
if (this.pendingIndex < 0 || this.source.totalCount() === 0) { return; }
const target: number = this.pendingIndex;
this.pendingIndex = -1;
this.listScroller.scrollToIndex(target, false);
console.info(`PhotoAnchor restore index=${target}`);
}
这段代码刻意拆成两个方法:第一步替换数据并计算索引,第二步等待新列表真正完成布局后再滚动。不要拿 setTimeout(..., 0) 当作布局已经结束的证据。 在工程里需要结合页面可用的布局完成时机调用 restoreAfterLayout(),并避免布局回调重复触发造成跳动;如果数据层又收到新筛选,则取消尚未消费的恢复动作。
本例中的可视结果预期是:筛选结果 48 条,锚点 PH-084 仍存在且新索引为 18,因此恢复后能再次看见该照片。用户若把 PH-084 排除在筛选条件之外,应该看到回退提示,而不是继续显示“锚点已恢复”。这是产品语义要求,也是验收用例。

五、用失败用例检查交互边界
实际验收至少覆盖四条路径。第一条,从旧索引 72 筛到 48 条,目标移动到索引 18,确认没有误定位;第二条,把目标从新结果删除,确认回退目标有效、提示文案准确;第三条,连续快速更改筛选条件,检查前一次的待恢复动作不会挟持后一批结果;第四条,在折叠屏展开或窗口高度变化后重新判断第一可见项,而不是复用旧的物理偏移量。
调试时,建议为每次数据替换引入独立的 filterRevision,在业务层判定新旧结果;这和此前讨论过的 Worker 回包版本屏障不是同一问题:那篇保护的是结果到达顺序,本篇保护的是结果集合改变以后用户正在看的业务对象。它们可组合,但不要混为一谈。
此外,滚动位置也不是数据库事务。界面允许短暂重新布局,真正不能错的是业务 ID 与结果版本。需要毫秒级、像素级恢复时,还要结合图片高度、首项内部偏移和可见区变更,不应把这一小段示意代码当成所有机型的完整适配方案。
六、适用边界与工程取舍
这个方案适合本地图片结果、文件检索、离线素材库等列表式界面,前提是每个项目具有稳定 ID、可重建当前数据顺序、并明确在何时执行恢复。对于实时无限流、分组嵌套和瀑布流,需要更细的定位模型;对于筛选返回空数组,应该进入空态,不调用越界 scrollToIndex。
相较于“保存滚动条位置”,锚点方案多维护了一个 ID 和一次索引映射,却换来更可解释的用户体验:列表可以重新排序,用户仍知道自己在看哪条内容。这里给出的是可复现的实现思路和演示数据,不是经过真机测量的流畅度结论。上线前仍需在目标 SDK、真机和实际缩略图负载下复核组件复用、空态与连续筛选行为。
官方资料: ArkUI List 与 ListScroller API、LazyForEach 数据源使用检查。文内接口以开发项目所选 SDK 的实际 API 声明为准。
更多推荐




所有评论(0)