列表包含一系列相同宽度的列表项,适合连续、多行展示同类数据,例如图片和文本。List具备动态数据处理能力,可以循环生成列表项,适应数据变化。它采用滚动加载技术,仅渲染可视项目,从而提升长列表的性能。同时,支持自定义交互逻辑与视觉样式,以满足多样化的交互需求。其使用场景广泛,涵盖列表类信息展示(如新闻、商品清单)、导航菜单设计(底部导航、侧边栏)及分页长内容呈现(如社交媒体动态、评论区),是实现高效数据可视化与交互的核心组件。
当长列表上下滑动时,频繁的子组件创建和测量会大量消耗计算资源,容易造成滑动卡顿。图片等资源的短时集中请求会造成明显的页面滑动白块,影响用户体验。
ScrollComponents作为高性能滑动解决方案,主要解决组件复用问题,支持通过少量的代码实现高性能滑动场景开发,同时开发者无需关注复用池管理和其他性能优化方案的交互细节。可以参考快速开始进行安装配置与快速上手。ScrollComponents三方库提供了下列功能特性:
ScrollComponents三方库基于系统NodeAdapter、BuilderNode、FrameNode、@ohos.arkui.Prefetcher (内容预取)、Class (FrameCallback)的能力,通过高效的组件复用、分帧预创建组件、动态内容预创建和懒加载等技术,实现了高性能的滑动效果。此外,它还基于FrameNode封装了系统List、WaterFlow、Grid组件,提供了一系列ViewManager,为开发者提供了多种系统滑动组件的能力,如列表项样式设置、首尾偏移量调整、滚动监听、边缘渐隐等,旨在简化开发流程的同时满足开发者的常规需求。
下文将通过长列表的跨页面复用、加速首屏渲染、无限滑动、下拉刷新、上拉加载等场景,详细介绍ScrollComponents库在长列表组件中的应用。
ScrollComponents三方库底层封装了NodeContainer+FrameNode,结合NodeAdapter、BuildNode和自定义复用池实现懒加载、组件复用、组件预创建等功能。同时,它为开发者提供了WaterFlowManager、ListManager、GridManager等视图管理组件,以支持系统滑动组件的其他各种能力。在满足开发者正常开发需求的前提下,提供高性能的滑动能力,只需传入数据源和viewManager即可快速实现懒加载和组件复用的开发,使开发者能够更加专注于业务实现。
如下图所示,当节点从可视区域移除时,NodeAdapter会通知视图管理器回收组件,经NodeFactory处理后,组件最终被存入复用池。当需要创建节点时,NodeAdapter通知视图管理器开始创建,NodeFactory会从复用池请求复用节点,获取节点后经过一系列更新和组件拼接,最后由NodeAdapter将节点添加到可视区域。

ListManager仅具备基础的视图功能。若开发者需要使用组件复用功能,需定义一个继承自ListManager的类,并实现onWillCreateItem()方法,从复用池中获取组件,以开启复用能力。具体实现可参考复用子节点模板。
在创建视图管理器的实例对象时,defaultNodeItem属性用于指定默认的列表项模板名称,通常传入唯一标识字符串用于组件复用;context属性用于获取UI上下文,通常传入this.getUIContext()。
import { ListManager, type NodeItem, RecyclerView } from '@hadss/scroll_components';
@Component
struct SameItemListPage {
myListManager: MyListManager = new MyListManager({
defaultNodeItem: 'EasyBlogItemContainer',
context: this.getUIContext()
});
// ...
}
class MyListManager extends ListManager {
public onWillCreateItem(_index: number, data: BlogData): NodeItem<Params> | null {
let node: NodeItem<Params> | null = this.dequeueReusableNodeByType('EasyBlogItemContainer');
node?.setData({ blogData: data });
return node;
}
}
如果开发者希望通过ScrollComponents快速创建长列表,并利用懒加载、预创建等功能来提升滑动效率,而不涉及组件复用,那么可以直接使用ScrollComponents库中提供的ListManager来创建长列表视图管理器。具体的使用方法和配置选项,可以参考快速开始。
this.myListManager.setItemViewStyle((item, index, data: Params) => {
item({ style: ListItemStyle.NONE })
.width('100%')
.height('auto')
.swipeAction({
// ...
})
.onClick(() => Logger.info('index:' + index));
});
this.myListManager.setViewStyle({ space: 10, scroller: this.listScroller })
.cachedCount(2)
.width('100%')
.edgeEffect(EdgeEffect.Spring)
.layoutWeight(1)
.contentStartOffset(20)
.contentEndOffset(20)
.scrollBar(BarState.Off)
// ...
.alignListItem(ListItemAlign.Start)
.lanes(1)
.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(80) })
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST,
scrollBackward: NestedScrollMode.SELF_FIRST
}); import { ListManager, type NodeItem, RecyclerView } from '@hadss/scroll_components';
@Component
struct SameItemListPage {
myListManager: MyListManager = new MyListManager({
defaultNodeItem: 'EasyBlogItemContainer',
context: this.getUIContext()
});
@State myViewModel: SameItemViewModel = new SameItemViewModel(this.myListManager);
// ...
aboutToAppear(): void {
// ...
// Register a reuse template
this.myListManager.registerNodeItem('EasyBlogItemContainer', wrapBuilder(easyBlogItemContainerBuilder));
// ...
// Simulate request data
this.myViewModel.loadData();
}
// ...
build() {
// ...
Column() {
RecyclerView({
viewManager: this.myListManager
});
};
}
.height(CommonConstants.FULL_HEIGHT)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.home_background_color'));
}
.hideTitleBar(true);
}
}
// Define an item template
@Builder
function easyBlogItemContainerBuilder($$: Params): void {
EasyBlogItem({ blogData: $$.blogData });
} @Observed
export class SameItemViewModel {
@Track dataArray: BlogData[] = [];
myListManager: ListManager;
// ...
loadData() {
generateRandomBlogData(300, false).then((dataArray: BlogData[]) => {
this.dataArray = dataArray;
this.myListManager.setDataSource(dataArray);
});
}
// ...
}
1. 在注册子节点模板的方法registerNodeItem()中,目前仅支持使用全局builder函数。
2. 如果开发者使用this.ListView.myListManager.preCreate()来实现组件的预创建,则必须在注册节点模板之前完成此操作。
当ListItem组件的结构相同但数据不同时,直接注册节点模板。
@Component
struct SameItemListPage {
myListManager: MyListManager = new MyListManager({
defaultNodeItem: 'EasyBlogItemContainer',
context: this.getUIContext()
});
// ...
aboutToAppear(): void {
// ...
// Register a reuse template
this.myListManager.registerNodeItem('EasyBlogItemContainer', wrapBuilder(easyBlogItemContainerBuilder));
// ...
}
// ...
}
class MyListManager extends ListManager {
public onWillCreateItem(_index: number, data: BlogData): NodeItem<Params> | null {
let node: NodeItem<Params> | null = this.dequeueReusableNodeByType('EasyBlogItemContainer');
node?.setData({ blogData: data });
return node;
}
}
// Define an item template
@Builder
function easyBlogItemContainerBuilder($$: Params): void {
EasyBlogItem({ blogData: $$.blogData });
} 如果复用的ListItem组件结构基本相同,但存在部分差异,例如头部和尾部组件展示相同,而中间内容可能渲染Text组件或Image组件,通常需要定义两个不同的@Builder函数来实现复用。然而,在这种场景下,ScrollComponents提供了PartReuse功能,确保组件复用的实现,只需定义一个@Builder函数。具体可参考列表项内子组件可拆分。
当组件即将被销毁时,会从视图容器中移除并进入item复用池。当组件即将被创建时,会从item复用池中获取item节点。如果item节点与目标节点类型存在差异,会先将差异部分,即PartReuse中的组件回收到对应的组件复用池,然后从对应的组件复用池中取出目标组件所需的差异组件,并与item节点拼接,形成目标组件,再进入视图容器中。

import { ListManager, type NodeItem, PartReuse, RecyclerView, } from '@hadss/scroll_components';
@Component
struct CombineItemListPage {
myListManager: MyListManager = new MyListManager({
defaultNodeItem: 'BlogItemContainer',
context: this.getUIContext()
});
// ...
aboutToAppear(): void {
// ...
// Register a reuse template
this.myListManager.registerNodeItem('BlogItemContainer', wrapBuilder(blogItemContainerBuilder));
this.myListManager.registerNodeItem('AdaptiveTextComponentContainer',
wrapBuilder(adaptiveTextComponentContainerBuilder));
this.myListManager.registerNodeItem('GridImageViewContainer', wrapBuilder(gridImageViewContainerBuilder));
// ...
}
// ...
}
class MyListManager extends ListManager {
public onWillCreateItem(_index: number, data: BlogData): NodeItem<Params> | null {
let node: NodeItem<Params> | null = this.dequeueReusableNodeByType('BlogItemContainer');
node?.setData({ blogData: data });
return node;
}
}
@Builder
function blogItemContainerBuilder($$: Params): void {
BlogItem({ blogData: $$.blogData });
}
@Component
struct BlogItem {
@State blogData: BlogData = new BlogData();
aboutToReuse(params: Record<string, ESObject>): void {
this.blogData = params.blogData;
}
build() {
Column({ space: 12 }) {
BlogItemHeaderView({ blogData: this.blogData });
if (this.blogData?.content.length > 0) {
PartReuse({
type: 'AdaptiveTextComponent',
builder: wrapBuilder(adaptiveTextComponentContainerBuilder),
data: { blogData: this.blogData }
});
}
if (this.blogData?.images && this.blogData.images.length > 0) {
PartReuse({
type: 'GridImageViewContainer',
builder: wrapBuilder(gridImageViewContainerBuilder),
data: { blogData: this.blogData }
});
}
BottomActionView({ blogData: this.blogData });
}
.justifyContent(FlexAlign.Start)
.padding(12)
.backgroundColor(Color.White);
}
}
@Builder
function adaptiveTextComponentContainerBuilder($$: Params): void {
AdaptiveTextComponent({ blogData: $$.blogData });
}
@Builder
function gridImageViewContainerBuilder($$: Params): void {
GridImageView({ blogData: $$.blogData });
} 开发者可以参考下图所示的日志打印内容,以检验是否成功复用,“generateItem reuse” 表示复用。

日志默认是关闭的,若需开启日志,应在ListManager的初始化方法中设置Config参数,将debug设为true。
如果ListItem结构存在较大差异,包括布局不同、子组件数量差异大、组件类型不同等因素,导致难以直接复用同一个ListItem,则可以定义多个复用模板。
@Component
struct DifferentItemListPage {
myListManager: MyListManager = new MyListManager({
defaultNodeItem: 'EasyBlogItemContainer',
context: this.getUIContext()
});
@State dataArray: BlogData[] = [];
@State myViewModel: DifferentItemViewModel = new DifferentItemViewModel(this.myListManager, this.dataArray);
// ...
aboutToAppear(): void {
// ...
this.myListManager.registerNodeItem('EasyBlogItemContainer', wrapBuilder(easyBlogItemContainerBuilder));
this.myListManager.registerNodeItem('HotVideoBlogItemContainer', wrapBuilder(hotVideoBlogItemContainerBuilder));
this.myListManager.preCreate('HotVideoBlogItemContainer', 30);
this.myListManager.preCreate('EasyBlogItemContainer', 30);
this.myViewModel.loadData();
}
public initView(): void {
this.myListManager.setItemViewStyle((_item, _index, _data: ESObject) => {
});
this.myListManager.setViewStyle({ space: 10, scroller: this.scroller })
// ...
}
@Builder
buildListView() {
RecyclerView({
viewManager: this.myListManager
});
}
build() {
// ...
Refresh({ refreshing: $$this.isRefreshing }) {
Column() {
this.buildListView();
// ...
};
}
// ...
}
}
class MyListManager extends ListManager {
onWillCreateItem(index: number, data: BlogData): NodeItem<Params> | null {
if (index % 4 === 3) {
let node: NodeItem<Params> | null = this.dequeueReusableNodeByType('HotVideoBlogItemContainer');
node?.setData({ blogData: data });
return node;
}
let node: NodeItem<Params> | null = this.dequeueReusableNodeByType('EasyBlogItemContainer');
node?.setData({ blogData: data });
return node;
}
}
// Reusable EasyBlog Component Template
@Builder
function easyBlogItemContainerBuilder($$: Params): void {
EasyBlogItem({ blogData: $$.blogData });
}
// Reusable HotVideoBlog Component Template
@Builder
function hotVideoBlogItemContainerBuilder($$: Params): void {
HotVideoBlogItem({ blogData: $$.blogData });
} 在长列表中支持数据的分组展示,可以使列表结构更加清晰,便于查找,从而提高使用效率。分组列表在实际应用中非常常见,例如下图所示的联系人列表和商品分类展示列表。
在分组场景中,通常会设置Group的header和footer,用于展示组内统一的头部和尾部信息。

@Builder
function goodItemContainerBuilder($$: ParamsGoods): void {
GoodItem({ dataModel: $$.dataModel });
}
@Component
struct GoodItem {
@Prop dataModel: GoodsDataModel = {
titleId: 0,
goodsId: 0,
goodsName: '',
imgUrl: $r('app.media.pic2'),
price: 0
};
aboutToReuse(params: Record<string, ESObject>): void {
this.dataModel = params.dataModel;
}
aboutToAppear(): void {
}
build() {
Row() {
Image(this.dataModel.imgUrl)
.height('100%')
.aspectRatio(1)
.sourceSize({ width: 96, height: 96})
.objectFit(ImageFit.Cover);
Column() {
Text(this.dataModel.goodsName)
.width('100%')
.fontSize(14)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Clip })
.lineHeight(20);
Text('$' + this.dataModel.price)
.fontSize(18)
.fontColor(Color.Red);
}
.height('100%')
.padding(12)
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
.justifyContent(FlexAlign.SpaceBetween);
}
.clip(true)
.width('100%')
.height(96)
.backgroundColor(Color.White)
.borderRadius(18);
}
} class MyListItemGroupManager extends ListItemGroupManager {
onWillCreateItem(_index: number, data: GoodsDataModel): NodeItem | null {
let node: NodeItem<ParamsGoods> | null = this.dequeueReusableNodeByType('GoodItemContainer');
node?.setData({ dataModel: data });
return node;
}
} class MyListManager extends ListManager {
public context: UIContext;
constructor(config: Config) {
super(config);
this.context = config.context;
}
onWillCreateItem(_index: number, data: CategoryModel): NodeItem {
const groupManager = new MyListItemGroupManager({ defaultNodeItem: 'GoodItemContainer', context: this.context });
groupManager.registerNodeItem('GoodItemContainer', wrapBuilder(goodItemContainerBuilder));
groupManager.setDataSource(data.goodsList);
// The header and footer of the group
groupManager.setViewStyle({
space: 12,
// ...
});
groupManager.setItemViewStyle((listItem, index, data: ParamsGoods) => {
// Used to set the scratch component of ListItem.
listItem().swipeAction({
// ...
});
});
return groupManager.getNodeItem(this);
}
} 如果需要设置组头和组尾,可以通过ListItemGroupManager的headerComponent和footerComponent属性实现,并实现其对应的Builder函数。
class MyListManager extends ListManager {
// ...
onWillCreateItem(_index: number, data: CategoryModel): NodeItem {
const groupManager = new MyListItemGroupManager({ defaultNodeItem: 'GoodItemContainer', context: this.context });
groupManager.registerNodeItem('GoodItemContainer', wrapBuilder(goodItemContainerBuilder));
groupManager.setDataSource(data.goodsList);
// The header and footer of the group
groupManager.setViewStyle({
space: 12,
headerComponent: new ComponentContent<Resource>(this.context,
wrapBuilder<[Resource]>(goodsHeaderBuilderPublic),
data.titleName),
// onClick:(...)=>{} can be declared in the DataModel, and data.onClick is used when data is assigned
// headerComponent: new ComponentContent<DataModel>(this.context,
// wrapBuilder<[DataModel]>(groupHeaderBuilderPublic),
// dataModel)
footerComponent: new ComponentContent<[Resource, number]>(this.context,
wrapBuilder<[[Resource, number]]>(goodsFooterBuilderPublic),
[data.titleName, data.titleId]),
});
// ...
return groupManager.getNodeItem(this);
}
}
// The global Builder: passes multiple parameters
@Builder
function goodsHeaderBuilderPublic(headerName: Resource): void {
// ...
}
// The global Builder: passes multiple parameters
@Builder
function goodsFooterBuilderPublic(args: [Resource, number]): void {
// ...
}
header目前支持使用headerComponent属性的写法,且goodsHeaderBuilderPublic只支持全局builder。footer的写法与header相同。
对于需要将内部事件传递到Page页面的场景,可以通过在DataModel中设置一个点击回调属性来实现,并在Page页面传递Data时进行赋值。
目前这种写法不支持同时设置header和footer。后续,ScrollComponents库将支持这一功能。
@Component
struct GroupLayoutListPage {
// ...
myListManager: MyListManager = new MyListManager({ defaultNodeItem: '', context: this.getUIContext() });
private goodsListScroller: ListScroller = new ListScroller();
@State myViewModel: GroupLayoutViewModel = new GroupLayoutViewModel(this.myListManager, this.goodsListScroller);
public aboutToAppear(): void {
this.initView();
this.myListManager.registerNodeItem('GoodItemContainer', wrapBuilder(goodItemContainerBuilder));
this.myListManager.preCreate('GoodItemContainer', 30);
this.myViewModel.loadData();
}
// ...
build() {
// ...
Column() { // 3.Nested Scrolling: Inner List
RecyclerView({
viewManager: this.myListManager
});
}
.height('100%');
// ...
}
}
// ...
@Builder
function goodItemContainerBuilder($$: ParamsGoods): void {
GoodItem({ dataModel: $$.dataModel });
} 
ScrollComponents默认会生成一个RecycledPool对象,通过定义复用池单例存储该pool对象,以便跨页面使用。以下单例仅做参考,开发者可根据需要自行封装。
import type { RecycledPool } from '@hadss/scroll_components';
export class Utils {
// ...
private static utils_: Utils;
public nodePool: RecycledPool | null = null;
// ...
} HMListManager提供getRecyclePool()方法可获取RecyclePool对象,然后存储到全局单例中。
if (Utils.getInstance().nodePool) {
this.myListManager.registerRecyclePool(Utils.getInstance().nodePool!);
} else {
Utils.getInstance().nodePool = this.myListManager.getRecyclePool();
} 通过使用registerRecyclePool()接口,将全局单例中的RecyclePool注册到页面定义的List视图类对象上,实现跨页面HMRecyclerView视图的复用池共享。
@Component
struct TabsCeilingListPage {
private tabArray: Resource[] = [
// ...
];
// ...
aboutToDisappear(): void {
Utils.getInstance().nodePool?.clear();
}
// ...
build() {
// ...
Tabs({ barPosition: BarPosition.Start, controller: this.contentTabController }) {
ForEach(this.tabArray, (item: Resource, index: number) => {
TabContent() {
CustomListPage({ index: index });
}
.tabBar(this.tabBuilder(index, item))
.align(Alignment.Center);
}, (item: string) => item);
}
// ...
}
}
@Component
struct CustomListPage {
index: number = 1;
myListManager: MyListManager = new MyListManager({
defaultNodeItem: 'CustomListItemContainer',
context: this.getUIContext()
});
@State myViewModel: TabsCeilingViewModel = new TabsCeilingViewModel(this.myListManager);
scroller: Scroller = new Scroller();
aboutToAppear(): void {
// ...
// Shared multiplexing pools
if (Utils.getInstance().nodePool) {
this.myListManager.registerRecyclePool(Utils.getInstance().nodePool!);
} else {
Utils.getInstance().nodePool = this.myListManager.getRecyclePool();
}
// ...
this.myListManager.registerNodeItem('CustomListItemContainer', wrapBuilder(customListItemContainerBuilder));
this.myListManager.preCreate('CustomListItemContainer', 5);
this.myViewModel.loadData();
}
build() {
RecyclerView({
viewManager: this.myListManager
});
}
} 冷启动后首次打开长列表页面时,由于页面包含大量图片或视频等媒体资源,可能会出现白屏或白块,需要等待几秒内容才会逐渐加载出来。ScrollComponents库支持组件预创建,使用后可以在打开页面后立即看到文字和图片的骨架,从而减少卡顿。

包括注册模板和复用模板的预创建。重点:必须在注册复用模板之后使用preCreate()接口。
aboutToAppear(): void {
// ...
// Register a reuse template
this.myListManager.registerNodeItem('BlogItemContainer', wrapBuilder(blogItemContainerBuilder));
this.myListManager.registerNodeItem('AdaptiveTextComponentContainer',
wrapBuilder(adaptiveTextComponentContainerBuilder));
this.myListManager.registerNodeItem('GridImageViewContainer', wrapBuilder(gridImageViewContainerBuilder));
/*
* Pre-creation Important: preCreate pre-creates a reusable template,
* which must be done after the reuse template is registered
*
* Pre-create 30 list items to optimize the first-screen performance;
* the number can be adjusted according to actual needs.
*/
this.myListManager.preCreate('BlogItemContainer', 30);
this.myListManager.preCreate('AdaptiveTextComponentContainer', 30);
this.myListManager.preCreate('GridImageViewContainer', 30);
// ...
} 在冷启动场景下加载相同数据的完成时延情况,通过延迟1s来模拟冷启动后的网络请求场景。
@Reusable:在网络请求期间,主线程有大段空闲时间,请求结束后首屏组件的绘帧耗时较长。

在ScrollComponents中,网络请求期间主线程空闲时间较少,请求结束后首屏组件的绘帧耗时较短。

冷启动完成时延 | 主线程空闲时间 | 首屏组件创建时间 | |
|---|---|---|---|
ScrollComponents | 2.5s | 281ms | 223ms |
@Reusable | 2.8s | 998ms | 467ms |
结论:ScrollComponents在冷启动场景下,完成时延优于原生@Reusable。整体完成时延优化300ms。
下拉刷新是提升用户体验的关键功能,它既要确保数据无缝加载,又要保持流畅的交互效果。建议采用懒加载方式刷新数据,以避免媒体资源加载导致的UI渲染阻塞。实现逻辑可参考实现下拉刷新上拉加载更多。

可以通过Refresh组件实现页面下拉操作,并绑定显示刷新Loading动效的容器组件,以达到下拉加载的效果。之后,使用onRefreshing回调重新设置数据,模拟刷新数据的操作。
@Component
struct DifferentItemListPage {
myListManager: MyListManager = new MyListManager({
defaultNodeItem: 'EasyBlogItemContainer',
context: this.getUIContext()
});
@State dataArray: BlogData[] = [];
@State myViewModel: DifferentItemViewModel = new DifferentItemViewModel(this.myListManager, this.dataArray);
@State isRefreshing: boolean = false;
@State isLoadMore: boolean = false;
// ...
@Builder
buildListView() {
RecyclerView({
viewManager: this.myListManager
});
}
build() {
// ...
Refresh({ refreshing: $$this.isRefreshing }) {
Column() {
this.buildListView();
// ...
};
}
.layoutWeight(1)
.onRefreshing(() => {
this.myViewModel.loadData((_isSuccess) => {
this.isRefreshing = false;
});
});
// ...
}
} @Observed
export class DifferentItemViewModel {
@Track public dataArray: BlogData[] = [];
public myListManager: ListManager;
// ...
loadData(callBack?: (isSuccess: boolean) => void): void {
generateRandomBlogData().then((data: BlogData[]) => {
this.dataArray = data;
this.myListManager.setDataSource(data);
if (callBack) {
callBack(true);
}
});
}
// ...
} 在开发涉及大量数据的长列表页面时,需要通过分页请求来加载数据。结合ScrollComponents提供的懒加载功能,可以实现加载更多数据的效果。

可以通过Refresh组件实现页面上拉操作,并绑定显示刷新Loading动效的容器组件,以达到上拉加载的效果。之后,使用onReachEnd回调来新增数据,模拟加载数据的操作。
@Component
struct DifferentItemListPage {
myListManager: MyListManager = new MyListManager({
defaultNodeItem: 'EasyBlogItemContainer',
context: this.getUIContext()
});
@State dataArray: BlogData[] = [];
@State myViewModel: DifferentItemViewModel = new DifferentItemViewModel(this.myListManager, this.dataArray);
@State isRefreshing: boolean = false;
@State isLoadMore: boolean = false;
// ...
public initView(): void {
this.myListManager.setItemViewStyle((_item, _index, _data: ESObject) => {
});
this.myListManager.setViewStyle({ space: 10, scroller: this.scroller })
// ...
.onReachEnd(() => {
if (!this.isLoadMore) {
this.isLoadMore = true;
this.myViewModel.loadDataMore((_isSuccess) => {
this.isLoadMore = false;
});
}
});
}
@Builder
buildListView() {
RecyclerView({
viewManager: this.myListManager
});
}
build() {
// ...
Refresh({ refreshing: $$this.isRefreshing }) {
Column() {
this.buildListView();
if (this.isLoadMore) {
Row() {
LoadingProgress()
.width(20)
.height(20)
.color('#999999');
Text($r('app.string.loading_more'))
.fontSize(14)
.fontColor('#999999')
.margin({ left: 8 });
}
.width(CommonConstants.FULL_WIDTH)
.height(50)
.justifyContent(FlexAlign.Center);
}
};
}
// ...
}
} @Observed
export class DifferentItemViewModel {
// ...
private isLoadingMore: boolean = false;
// ...
public loadDataMore(callBack?: (isSuccess: boolean) => void): void {
if (!this.isLoadingMore) {
this.isLoadingMore = true;
setTimeout(() => {
generateRandomBlogData().then((data: BlogData[]) => {
this.myListManager.nodeAdapter.pushData(data);
this.isLoadingMore = false;
if (callBack) {
callBack(true);
}
});
}, this.networkTime);
}
}
} 为了解决手动上拉加载操作的繁琐问题,在即将下滑到最底端时,提前触发新数据的加载,并将其显示在长列表的最底部,从而实现无限滑动的效果。
以下介绍具体的使用:

@Observed
class ObservedArray<T> extends Array<T> {
}
@Observed
export class ImagePixelMap {
public imageUrl: string = '';
public imagePixelMap: image.PixelMap | undefined = undefined;
}
@Observed
export class BlogData {
// ...
// Multiple graphs define imagePixelMapArray, corresponding to images
public imagePixelMapArray: ObservedArray<ImagePixelMap> = [];
} public fetchCallback: (item: ESObject, fetchId: number) => Promise<void> = (item: ESObject, fetchId: number) => {
/*
* Simulate a simple pre-loading scenario ,This is where you can migrate the preloaded scenarios for your project
* 1、When the component is not displayed or created, the image is obtained. PixelMap,
* when the component is displayed, load the image directly. PixelMap and show no need to wait for the network
* 2、If the image is displayed but the pixelMap is not obtained, the data is bound to the Image component first,
* and the image can be refreshed immediately after the pixelMap is obtained
*/
let data = item as BlogData;
this.fetchesMore.set(fetchId, new Map());
return new Promise(resolve => {
this.loadImagesInBatches(data.imagePixelMapArray, fetchId, 0, resolve);
});
};
private loadImagesInBatches(images: ImagePixelMap[], fetchId: number, startIndex: number, resolve: () => void): void {
if (startIndex >= images.length) {
const fetchMap = this.fetchesMore.get(fetchId);
if (fetchMap && fetchMap.size === 0) {
this.fetchesMore.delete(fetchId);
}
resolve();
return;
}
const batchSize = 1;
const endIndex = Math.min(startIndex + batchSize, images.length);
for (let i = startIndex; i < endIndex; i++) {
const pixMap = images[i];
if (pixMap.imagePixelMap) {
continue;
}
let httpRequest = HttpGet(pixMap.imageUrl, (error: number, pixelMap?: image.PixelMap) => {
let fetchMap = this.fetchesMore.get(fetchId);
if (fetchMap) {
fetchMap.delete(i);
if (fetchMap?.size === 0) {
this.fetchesMore.delete(fetchId);
}
}
if (error === -1) {
return;
}
if (pixelMap) {
/*
* You need to create an array to store the pixelMap, because there is a change in the internal data
* of the array to make the component respond, which is more complicated, the developer can choose
* the preloading scheme by himself: Define the ImagePixelMap class to store the pixelMap,
* encapsulate the Image component as an ObservedImage, add @ObjectLink imagePixelMap: ImagePixelMap property
* to bind the data object, In the preloaded callback fetchCallback, after the image pixelMap is obtained,
* set ImagePixelMap.imagePixelMap, and the image can be refreshed by ObservedImage
*/
pixMap.imagePixelMap = pixelMap;
}
});
let fetchMap = this.fetchesMore.get(fetchId);
if (fetchMap) {
fetchMap.set(i, httpRequest);
}
}
setTimeout(() => {
this.loadImagesInBatches(images, fetchId, endIndex, resolve);
}, 80);
}
public cancelCallback: (fetchId: number) => void = (fetchId: number): void => {
const fetchMap = this.fetchesMore.get(fetchId);
if (fetchMap) {
Array.from(fetchMap.values()).forEach(element => {
element.destroy();
});
fetchMap.clear();
}
this.fetchesMore.delete(fetchId);
}; aboutToAppear(): void {
this.initView();
// Register a preload function callback
this.myViewModel.registerPrefetch();
// ...
} public registerPrefetch(): void {
this.myListManager?.registerFetchCallback(this.fetchCallback);
this.myListManager?.registerCancelCallback(this.cancelCallback);
} @Component
struct CombineItemListPage {
// ...
initView() {
this.myListManager.setViewStyle({ space: 10, scroller: this.scroller })
// ...
.onScrollIndex((start: number, end: number) => {
// Infinite swipe, when the 10th data is counted, new data is requested in advance
if (end > this.myViewModel.dataArray.length - 10) {
this.myViewModel.loadDataMore();
}
// Trigger a preload callback, request an image in advance, and resolve the white block of the image
if (end > 0) {
this.myListManager.visibleAreaChanged(start, end);
}
})
}
// ...
} @Component
struct GridImageView {
@State blogData: BlogData = new BlogData();
aboutToReuse(params: Record<string, ESObject>): void {
this.blogData = params.blogData;
}
build() {
Grid() {
ForEach(this.blogData.imagePixelMapArray, (image: ImagePixelMap | undefined, _index: number) => {
GridItem() {
Stack() {
ObservedImage({ imagePixelMap: image });
};
};
}, (image: ImagePixelMap | undefined, _index: number) => 'image' + _index);
}
.columnsTemplate('1fr 1fr 1fr')
.rowsGap(5)
.columnsGap(5);
}
}
@Component
struct ObservedImage {
@ObjectLink imagePixelMap: ImagePixelMap;
build() {
Image(this.imagePixelMap.imagePixelMap)
.sourceSize({ width: 100, height: 100 })
.width('100%')
.aspectRatio(1)
.objectFit(ImageFit.Cover)
.borderRadius(8);
}
} @Component
struct SameItemListPage {
// ...
public initView(): void {
this.myListManager.setItemViewStyle((item, index, data: Params) => {
item({ style: ListItemStyle.NONE })
.width('100%')
.height('auto')
.swipeAction({
end: { builder: () => this.ItemActionEnd(data.blogData) },
start: { builder: () => this.ItemActionStart(data.blogData) },
onOffsetChange: (offset: number) => Logger.info('offset:' + offset)
})
.onClick(() => Logger.info('index:' + index));
});
// ...
}
@Builder
ItemActionStart(data: BlogData): void {
Row() {
Text($r('app.string.collection'))
.padding({ left: 24, right: 24 })
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.swipe_collection_color'))
.height('100%')
.textAlign(TextAlign.Center)
.align(Alignment.Center);
}
.height('auto')
.backgroundColor($r('app.color.black_0D'))
.onClick(() => {
this.myViewModel.collectionData(data, (isSuccess) => {
if (isSuccess) {
this.listScroller.closeAllSwipeActions();
ToastUtil.success($r('app.string.collection_success'));
}
});
});
}
@Builder
ItemActionEnd(data: BlogData): void {
Row() {
Text($r('app.string.delete'))
.padding({ left: 24, right: 24 })
.fontSize(14)
.fontWeight(FontWeight.Medium)
.fontColor($r('app.color.swipe_delete_color'))
.height('100%')
.textAlign(TextAlign.Center)
.align(Alignment.Center);
}
.height('auto')
.backgroundColor($r('app.color.black_0D'))
.onClick(() => {
this.myViewModel.deleteDataRequest(data, (isSuccess) => {
if (isSuccess) {
this.getUIContext().animateTo({ duration: 200 }, () => {
this.myViewModel.deleteData(data);
});
ToastUtil.success($r('app.string.delete_success'));
}
});
});
}
build() {
// ...
Column() {
RecyclerView({
viewManager: this.myListManager
});
};
}
.height(CommonConstants.FULL_HEIGHT)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
.backgroundColor($r('app.color.home_background_color'));
}
.hideTitleBar(true);
}
} 目前ListItemGroupManage是写在ListManager内部的,侧滑组件groupManager.setItemViewStyle()需要在ListManager.onWillCreateItem()内部设置。
class MyListManager extends ListManager {
// ...
onWillCreateItem(_index: number, data: CategoryModel): NodeItem {
const groupManager = new MyListItemGroupManager({ defaultNodeItem: 'GoodItemContainer', context: this.context });
groupManager.registerNodeItem('GoodItemContainer', wrapBuilder(goodItemContainerBuilder));
// ...
groupManager.setItemViewStyle((listItem, index, data: ParamsGoods) => {
// Used to set the scratch component of ListItem.
listItem().swipeAction({
end: {
builderComponent: new ComponentContent<[number, GoodsDataModel, ListItemGroupManager]>(this.context,
wrapBuilder<[[number, GoodsDataModel, ListItemGroupManager]]>(groupItemActionEndBuilder),
[index, data.dataModel, groupManager]),
onEnterActionArea: () => Logger.info('onEnterActionArea'),
onExitActionArea: () => Logger.info('onExitActionArea'),
onStateChange: (state: SwipeActionState) => Logger.info('onStateChange' + state)
},
start: {
builderComponent: new ComponentContent<[number, GoodsDataModel]>(this.context,
wrapBuilder<[[number, GoodsDataModel]]>(groupItemActionStartBuilder),
[index, data.dataModel]),
},
onOffsetChange: (offset: number) => Logger.info('offset:' + offset),
edgeEffect: SwipeEdgeEffect.Spring,
});
});
return groupManager.getNodeItem(this);
}
}
class MyListItemGroupManager extends ListItemGroupManager {
// ...
}
@Builder
function groupItemActionStartBuilder(args: [number, GoodsDataModel]): void {
// ...
}
@Builder
function groupItemActionEndBuilder(args: [number, GoodsDataModel, ListItemGroupManager]): void {
// ...
}
swipeAction目前仅支持builderComponent属性的写法,且GroupItemActionEnd只能是全局builder。end和start的写法相同。
对于需要将内部事件传递到Page页面的场景,可以通过在DataModel中设置一个点击回调属性来实现,并在Page页面进行数据传递时赋值。
List组件作为整个首页长列表的容器,通过ListItem对不同模块进行视图界面的定制,常用于门户首页、商城首页等多类型视图展示的列表信息流场景。多类型列表项场景(List+ListHeaderView)参考:多类型列表项场景。

通过Scroll内部包含HeaderView+RecyclerView的方式实现,并通过nestedScroll()接口设置RecyclerView的嵌套滚动方式,来达到上图所示的效果。
@Component
struct GroupLayoutListPage {
// ...
initView(): void {
// ...
this.myListManager.setViewStyle({ space: 10, scroller: this.goodsListScroller })
// ...
.nestedScroll({
// 1.Nested scrolling: Set up a scrolling scheme
scrollForward: NestedScrollMode.PARENT_FIRST,
scrollBackward: NestedScrollMode.SELF_FIRST
});
}
// ...
build() {
// ...
// Nested scrolling: Implement the ListHeader effect
Scroll() { // 2.Nested Scroll: Outer Scroll
Column() {
listHeaderViewBuilder();
Column() { // 3.Nested Scrolling: Inner List
RecyclerView({
viewManager: this.myListManager
});
}
.height('100%');
};
}
.layoutWeight(1)
.edgeEffect(EdgeEffect.Spring)
.scrollBar(BarState.Off);
// ...
}
}
@Builder
export function listHeaderViewBuilder() {
Column() {
Stack({ alignContent: Alignment.Center }) {
Image($r('app.media.pic1'))
.objectFit(ImageFit.Cover);
Row() {
Text($r('app.string.i_am'))
.fontSize(16)
.fontColor('#333');
Text(' ListHeaderView')
.fontSize(16)
.fontColor('#333');
};
};
}
.alignItems(HorizontalAlign.Center);
}
在ArkUI中,可通过List内部包含HeaderView+ForEach实现。HeaderView支持添加多种类型组件以实现与List同步滑动的效果,ForEach展示具体的Item。
ScrollComponents中采用了FrameNode实现并自动管理数据源,可通过Scroll内部包含HeaderView+RecyclerView的方式实现。
@Component
struct TabsCeilingListPage {
// ...
build() {
// ...
Column() {
Tabs({ barPosition: BarPosition.Start, controller: this.contentTabController }) {
ForEach(this.tabArray, (item: Resource, index: number) => {
TabContent() {
CustomListPage({ index: index });
}
.tabBar(this.tabBuilder(index, item))
.align(Alignment.Center);
}, (item: string) => item);
}
// Set the height of the tab to 'calc(100% - 63vp)' to ensure that the
// Tabs component leaks out just when the nest slides
.height('calc(100% - 58vp)')
.barHeight(56)
// ...
.onChange((index: number) => this.currentTabIndex = index);
}
.backgroundColor($r('app.color.home_background_color'));
// ...
}
} @Component
struct CustomListPage {
// ...
aboutToAppear(): void {
this.myListManager.setItemViewStyle((_item, _index, _data: ESObject) => {
});
this.myListManager.setViewStyle({ space: 10, scroller: this.scroller })
// ...
}
build() {
RecyclerView({
viewManager: this.myListManager
});
}
}
class MyListManager extends ListManager {
// ...
}
@Builder
function customListItemContainerBuilder($$: ParamsItemData): void {
CustomListItem({ itemData: $$.itemData });
} @Component
struct CustomListPage {
// ...
aboutToAppear(): void {
// ...
this.myListManager.setViewStyle({ space: 10, scroller: this.scroller })
// ...
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST,
scrollBackward: NestedScrollMode.SELF_FIRST
});
// Shared multiplexing pools
// ...
}
// ...
} @Component
struct TabsCeilingListPage {
// ...
build() {
// ...
Column() {
Tabs({ barPosition: BarPosition.Start, controller: this.contentTabController }) {
// ...
}
// Set the height of the tab to 'calc(100% - 63vp)' to ensure that the
// Tabs component leaks out just when the nest slides
.height('calc(100% - 58vp)')
.barHeight(56)
// ...
}
}
存在的问题:当Tabs设置为吸顶时,nestedScroll属性设置不生效。
解决方案:RecyclerView内部默认使用.layoutWeight(1)自动填充高度,需在最外层添加一个布局组件,并设置其高度为.height(calc(100% - 50vp)),以正确设置RecyclerView的高度。
双列表同向联动,左侧分类列表用于快速索引,内容列表依据分类进行分组,常用于商品分类选择、通讯录、城市选择、分组选择等页面。长列表分组吸顶功能参考:分组吸顶场景。
本案例实现商品分类选择页面列表头部分类吸顶效果,如下图所示。

class MyListManager extends ListManager {
// ...
}
class MyListItemGroupManager extends ListItemGroupManager {
// ...
}
@Component
struct GroupLayoutListPage {
// ...
build() {
NavDestination() {
Row() {
List({ scroller: this.categoryScroller }) {
// ...
}
.width(100)
.height('100%')
.scrollBar(BarState.Off);
// Nested scrolling: Implement the ListHeader effect
Scroll() { // 2.Nested Scroll: Outer Scroll
Column() {
listHeaderViewBuilder();
Column() { // 3.Nested Scrolling: Inner List
RecyclerView({
viewManager: this.myListManager
});
}
.height('100%');
};
}
.layoutWeight(1)
.edgeEffect(EdgeEffect.Spring)
.scrollBar(BarState.Off);
}
// ...
}
// ...
}
}
@Builder
function goodItemContainerBuilder($$: ParamsGoods): void {
GoodItem({ dataModel: $$.dataModel });
} initView(): void {
// ...
this.myListManager.setViewStyle({ space: 10, scroller: this.goodsListScroller })
// ...
.sticky(StickyStyle.Header)// Set up grouped ceilings
// ...
} 通过选择左侧的一级列表,右侧的二级列表数据将相应更新,常用于商品分类选择、编辑风格等二级类别选择页面。长列表二级联动功能参考:常见列表流开发实践:二级联动场景。
本场景以商品分类列表页面为例,分别使用List组件展示左侧分类导航和右侧导航内容。进入页面后,点击左侧分类导航,右侧将展示对应的分类详情列表数据;滑动右侧列表内容时,列表标题将吸顶显示,同时左侧对应的导航内容高亮显示。

List({ scroller: this.categoryScroller }) {
ForEach(this.myViewModel.categoryList, (item: CategoryModel, index: number) => {
ListItem() {
Text(item.titleName)
// ...
};
}, (item: CategoryModel) => JSON.stringify(item.titleName));
} class MyListManager extends ListManager {
// ...
}
class MyListItemGroupManager extends ListItemGroupManager {
// ...
}
@Component
struct GroupLayoutListPage {
// ...
public aboutToAppear(): void {
this.initView();
this.myListManager.registerNodeItem('GoodItemContainer', wrapBuilder(goodItemContainerBuilder));
this.myListManager.preCreate('GoodItemContainer', 30);
this.myViewModel.loadData();
}
// ...
build() {
// ...
Column() { // 3.Nested Scrolling: Inner List
RecyclerView({
viewManager: this.myListManager
});
}
.height('100%');
// ...
}
} List({ scroller: this.categoryScroller }) {
ForEach(this.myViewModel.categoryList, (item: CategoryModel, index: number) => {
ListItem() {
Text(item.titleName)
// ...
.onClick(() => this.listChange(index, true));
};
}, (item: CategoryModel) => JSON.stringify(item.titleName));
} initView(): void {
// ...
this.myListManager.setViewStyle({ space: 10, scroller: this.goodsListScroller })
// ...
.onScrollIndex((index: number) => { // Scrolling listeners
this.listChange(index, false);
})
// ...
}
public listChange(index: number, isGoods: boolean): void {
if (this.currentTitleId !== index) {
this.currentTitleId = index;
if (isGoods) {
this.goodsListScroller.scrollToIndex(index);
} else {
this.categoryScroller.scrollToIndex(index);
}
}
} 首先,设置页面可自由旋转,然后监听屏幕尺寸的变化。当发现宽度大于高度时,设置为2列。
@Component
struct SameItemListPage {
// ...
private context = this.getUIContext().getHostContext() as common.UIAbilityContext;
private windowClass = (this.context as common.UIAbilityContext).windowStage.getMainWindowSync();
aboutToDisappear(): void {
this.windowClass.off('windowSizeChange');
}
aboutToAppear(): void {
// The settings page is free to rotate
let orientation = window.Orientation.AUTO_ROTATION;
this.windowClass.setPreferredOrientation(orientation, (err: BusinessError) => {
const errCode: number = err.code;
if (errCode) {
Logger.error('Failed to set window orientation. Cause:' + JSON.stringify(err));
return;
}
Logger.info('Succeed to setting window orientation');
});
this.windowClass.on('windowSizeChange', (size) => {
let viewWidth = size.width;
let viewHeight = size.height;
if (viewWidth > viewHeight) {
this.myListManager.setViewStyle().lanes(2);
} else {
this.myListManager.setViewStyle().lanes(1);
}
});
this.initView();
// ...
}
public initView(): void {
// ...
this.myListManager.setViewStyle({ space: 10, scroller: this.listScroller })
// ...
.alignListItem(ListItemAlign.Start)
.lanes(1)
// ...
}
// ...
}
在Item高度一致的情况下,推荐使用动态改变列数的场景。当Item大小不一致时,动态设置显示的列数,当前行的高度将以最高的Item为准,这可能导致部分空白区域的出现。此时,需要开发者自行选择合适的布局方案。
目前不支持使用@State属性对List的属性进行双向绑定,需通过this.myListManager.setViewStyle().xxxx的格式进行设置。