Creating a Waterfall Flow (WaterFlow)
You can use the WaterFlow component in ArkUI to create a waterfall flow layout, which is commonly used to display image collections, especially in e-commerce and news applications.
ArkUI provides the WaterFlow container component for building a waterfall layout. The WaterFlow component supports conditional rendering, cyclic rendering, and lazy loading to generate subcomponents.
NOTE
This topic presents key code excerpts. For complete executable code, see the WaterFlow example.
Layout and Constraints
The waterfall flow supports both horizontal and vertical layouts. In a vertical layout, you can set the number of columns using columnsTemplate. In a horizontal layout, you can set the number of rows using rowsTemplate.
In the vertical layout, child nodes in the first row are arranged from left to right. From the second row onward, each child node is placed in the column with the smallest total height. If multiple columns have the same total height, they are filled in order from left to right. The following figure shows this arrangement logic.

In the horizontal layout, each child node is placed in the row with the smallest total width. If multiple rows have the same width, they are filled in order from left to right.

Infinite Scrolling
Adding Data When Reaching the End
The waterfall flow layout is often used for infinite scrolling feeds. You can add new data to LazyForEach in the onReachEnd event callback when the WaterFlow component reaches the end position, and create a footer that indicates loading new data (using the LoadingProgress component).
@Builder
itemFoot() {
Row() {
LoadingProgress()
.color(Color.Blue).height(50).aspectRatio(1).width('20%')
// Replace $r('app.string.waterFlow_text1') with the actual resource file. In this example, the value in the resource file is "Loading."
Text($r('app.string.waterFlow_text1'))
.fontSize(20)
.width('30%')
.height(50)
.align(Alignment.Center)
.margin({ top: 2 })
}.width('100%').justifyContent(FlexAlign.Center)
}
build() {
NavDestination() {
Column({ space: 12 }) {
// ...
WaterFlow({ footer: this.itemFoot(), layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW }) {
LazyForEach(this.dataSource, (item: number) => {
FlowItem() {
ReusableFlowItem({ item: item })
}
.width('100%')
.aspectRatio(this.itemHeightArray[item % 100] / this.itemWidthArray[item%100])
.backgroundColor(this.colors[item % 5])
}, (item: string) => item)
}
.columnsTemplate('1fr '.repeat(this.columns))
.backgroundColor(0xFAEEE0)
.width('100%')
.height('100%')
.layoutWeight(1)
// Load data once the component reaches the bottom.
.onReachEnd(() => {
setTimeout(() => {
this.dataSource.addNewItems(100);
}, 1000)
})
}
// ...
}
.backgroundColor('#f1f2f3')
// Replace $r('app.string.WaterFlowInfiniteScrolling_title') with the actual resource file. In this example, the value in the resource file is "Infinite Scrolling (Adding Data When Reaching the End)."
.title($r('app.string.WaterFlowInfiniteScrolling_title'))
}
Always append data to the end of the data array (dataArray) instead of modifying the array directly using the onDataReloaded API of LazyForEach.
Since the heights of the child nodes in the WaterFlow component are inconsistent, the position of the lower nodes depends on the upper nodes. Therefore, reloading all data triggers full layout recalculation, potentially causing lag. When data is appended to the end of the data, you should call onDataAdd to notify the component, so that the waterfall flow can recognize the newly added data and continue loading it, while avoiding redundant processing of existing data.

Pre-loading Data
Triggering data loading at onReachEnd() can cause noticeable pause when the component scrolls to the bottom.
To enable smooth infinite scrolling, you need to adjust the timing of adding new data. For example, you can preload new data when there are still several items left to be traversed in LazyForEach. The following code monitors the scroll position (distance of the last displayed child node from the end of the dataset) in the onScrollIndex API of WaterFlow and pre-loads new data at the right time to achieve smooth infinite scrolling.
build() {
NavDestination() {
Column({ space: 12 }) {
// ...
WaterFlow({ layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW }) {
LazyForEach(this.dataSource, (item: number) => {
FlowItem() {
ReusableFlowItem({ item: item })
}
.width('100%')
.aspectRatio(this.itemHeightArray[item % 100] / this.itemWidthArray[item%100])
.backgroundColor(this.colors[item % 5])
}, (item: string) => item)
}
.columnsTemplate('1fr '.repeat(this.columns))
.backgroundColor(0xFAEEE0)
.width('100%')
.height('100%')
.layoutWeight(1)
// Pre-load data when approaching the bottom.
.onScrollIndex((first: number, last: number) => {
if (last + 20 >= this.dataSource.totalCount()) {
setTimeout(() => {
this.dataSource.addNewItems(100);
}, 1000);
}
})
}
// ...
}
.backgroundColor('#f1f2f3')
// Replace $r('app.string.WaterFlowInfiniteScrollingEarly_title') with the actual resource file. In this example, the value in the resource file is "Infinite Scrolling (Adding Data in Advance)."
.title($r('app.string.WaterFlowInfiniteScrollingEarly_title'))
}

Dynamically Adjusting the Column Count
Dynamically adjusting the column count allows applications to switch between list and waterfall flow modes or adapt to screen width changes. For faster transitions, use the sliding window layout mode.
@Reusable
@Component
struct ReusableListItem {
@State item: number = 0;
aboutToReuse(params: Record<string, number>) {
this.item = params.item;
}
build() {
Row() {
Image('res/waterFlow(' + this.item % 5 + ').JPG')
.objectFit(ImageFit.Fill)
.height(100)
.aspectRatio(1)
Text('N' + this.item).fontSize(12).height('16').layoutWeight(1).textAlign(TextAlign.Center)
}
}
}
@Entry
@Component
export struct WaterFlowDynamicSwitchover {
// Use a state variable to manage the column count and trigger layout updates.
@State columns: number = 2;
// ...
build() {
NavDestination() {
Column({ space: 12 }) {
// ...
Column({ space: 2 }) {
// Replace $r('app.string.waterFlow_text2') with the actual resource file. In this example, the value in the resource file is "Switching the number of columns."
Button($r('app.string.waterFlow_text2')).fontSize(20).onClick(() => {
if (this.columns === 2) {
this.columns = 1;
} else {
this.columns = 2;
}
})
WaterFlow({ layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW }) {
LazyForEach(this.dataSource, (item: number) => {
FlowItem() {
if (this.columns === 1) {
ReusableListItem({ item: item })
} else {
ReusableFlowItem({ item: item })
}
}
.width('100%')
.aspectRatio(this.columns === 2 ? this.itemHeightArray[item % 100] / this.itemWidthArray[item % 100] : 0)
.backgroundColor(this.colors[item % 5])
}, (item: string) => item)
}
.columnsTemplate('1fr '.repeat(this.columns))
.backgroundColor(0xFAEEE0)
.width('100%')
.height('100%')
.layoutWeight(1)
// Pre-load data when approaching the bottom.
.onScrollIndex((first: number, last: number) => {
if (last + 20 >= this.dataSource.totalCount()) {
setTimeout(() => {
this.dataSource.addNewItems(100);
}, 1000);
}
})
// ...
}
}
// ...
}
.backgroundColor('#f1f2f3')
// Replace $r('app.string.WaterFlowDynamicSwitchover_title') with the actual resource file. In this example, the value in the resource file is "Switch Columns."
.title($r('app.string.WaterFlowDynamicSwitchover_title'))
}
}

Mixed Section Layout
Many application UIs feature supplementary content above the WaterFlow component. This scenario can be implemented by nesting a WaterFlow within a Scroll or List container, as illustrated in the following figure:

When child nodes from different sections can be merged into a single data source, using WaterFlowSections enables mixed layouts within a single WaterFlow container. This approach simplifies scroll event handling logic compared to nested scrolling implementations.
Each WaterFlow section can individually set its own number of columns, row spacing, column spacing, margin, and total number of child nodes. The following code can achieve the above effect:
@Entry
@Component
export struct WaterFlowGroupingMixing {
minSize: number = 80;
maxSize: number = 180;
colors: number[] = [0xFFC0CB, 0xDA70D6, 0x6B8E23, 0x6A5ACD, 0x00FFFF, 0x00FF7F];
dataSource: WaterFlowDataSource = new WaterFlowDataSource(100);
private itemWidthArray: number[] = [];
private itemHeightArray: number[] = [];
private gridItems: number[] = [];
@State sections: WaterFlowSections = new WaterFlowSections();
sectionMargin: Margin = {
top: 10,
left: 5,
bottom: 10,
right: 5
};
oneColumnSection: SectionOptions = {
itemsCount: 1,
crossCount: 1,
columnsGap: 5,
rowsGap: 10,
margin: this.sectionMargin,
};
twoColumnSection: SectionOptions = {
itemsCount: 98,
crossCount: 2,
};
// Use the last section as a footer, since footers are not supported with sections.
lastSection: SectionOptions = {
itemsCount: 1,
crossCount: 1,
};
// Calculate the FlowItem width and height.
getSize() {
let ret = Math.floor(Math.random() * this.maxSize);
return (ret > this.minSize ? ret : this.minSize);
}
// Set the FlowItem size array.
setItemSizeArray() {
for (let i = 0; i < 100; i++) {
this.itemWidthArray.push(this.getSize());
this.itemHeightArray.push(this.getSize());
}
}
aboutToAppear() {
this.setItemSizeArray();
for (let i = 0; i < 15; ++i) {
this.gridItems.push(i);
}
// The total number of itemCount values across sections must match the data source item count of the WaterFlow.
let sectionOptions: SectionOptions[] = [this.oneColumnSection, this.twoColumnSection, this.lastSection];
this.sections.splice(0, 0, sectionOptions);
}
build() {
NavDestination() {
// ...
WaterFlow({ layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW, sections: this.sections }) {
LazyForEach(this.dataSource, (item: number) => {
FlowItem() {
if (item === 0) {
Grid() {
ForEach(this.gridItems, (day: number) => {
GridItem() {
Text('GridItem').fontSize(14).height(16)
}.backgroundColor(0xFFC0CB)
}, (day: number) => day.toString())
}
.height('30%')
.rowsGap(5)
.columnsGap(5)
.columnsTemplate('1fr '.repeat(5))
.rowsTemplate('1fr '.repeat(3))
} else {
ReusableFlowItem({ item: item })
}
}
.width('100%')
.aspectRatio(item != 0 ? this.itemHeightArray[item % 100] / this.itemWidthArray[item % 100] : 0)
.backgroundColor(item != 0 ? this.colors[item % 5] : Color.White)
}, (item: string) => item)
}
.backgroundColor(0xFAEEE0)
.height('100%')
// Pre-load data when approaching the bottom.
.onScrollIndex((first: number, last: number) => {
if (last + 20 >= this.dataSource.totalCount()) {
setTimeout(() => {
this.dataSource.addNewItems(100);
// Update the itemCount values for sections after adding data.
this.twoColumnSection.itemsCount += 100;
this.sections.update(1, this.twoColumnSection);
}, 1000);
}
})
.margin(10)
}
// ...
}
}
NOTE
Footers are not supported in mixed section layouts. Use the last section as a footer instead.
Always update the corresponding itemCount when adding or removing data to maintain layout consistency.