/*
* Copyright (c) 2025 Huawei Device Co., Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// [Start quick_start_4_b]
import { type NodeItem, PartReuse, RecyclerView, WaterFlowManager } from '@hadss/scroll_components';
// [End quick_start_4_b]
import { BlogData, type Params } from '../model/types';
import { type collections, JSON, type MessageEvents, taskpool, util, worker } from '@kit.ArkTS';
import { CommonConstants } from '../common/constants/CommonConstants';
import { Logger } from '../common/util/Logger';
import { ComponentContent } from '@kit.ArkUI';
import type { SceneMockData } from '../workers/GetNetworkData';
import { generateImages } from '../model/mock';
import type { common } from '@kit.AbilityKit';
import { isChinese } from '../model/mock';
import type { BusinessError } from '@kit.BasicServicesKit';
@Concurrent
async function generateRandomBlogData(): Promise<BlogData[]> {
let array: collections.Array<SceneMockData> = await generateImages();
const module = await import('../model/mock');
let data = module.generateRandomBlogData(array.length);
for (let index = 0; index < array.length; index++) {
data[index].content = array[index].title;
data[index].images = [array[index].thumbnails];
}
return data;
}
class MyWaterFlowManager extends WaterFlowManager {
public onWillCreateItem(index: number, data: BlogData): NodeItem<Params> | null {
let node: NodeItem<Params> | null = this.dequeueReusableNodeByType('BlogItemContainer');
node?.setData({ blogItem: data });
return node;
}
}
enum FooterState {
Loading = 0,
End = 1
}
class MyParams {
public footerState: FooterState = FooterState.Loading;
constructor(footerState: FooterState) {
this.footerState = footerState;
}
}
@Builder
function buildText(params: MyParams) {
Column() {
if (params.footerState === FooterState.Loading) {
Row() {
LoadingProgress().width(30).height(30);
Text($r('app.string.loading'))
.fontSize(12)
.height(30)
.margin({ top: 2 });
};
} else if (params.footerState === FooterState.End) {
Text($r('app.string.load_done'))
.fontSize(12)
.height(30)
.align(Alignment.Center)
.margin({ top: 2 });
} else {
Text($r('app.string.loading'))
.fontSize(12)
.height(30)
.align(Alignment.Center)
.margin({ top: 2 });
}
}
.width('100%')
.alignItems(HorizontalAlign.Center);
}
@Entry
@Component
struct WaterFlowPage {
@State footerState: FooterState = FooterState.Loading;
waterFlowView: WaterFlowManager = new MyWaterFlowManager({
defaultNodeItem: 'BlogItemContainer',
context: this.getUIContext()
});
scroller: Scroller = new Scroller();
@State data: Array<BlogData> = [];
private fetches: Map<number, BlogData> = new Map();
footerContent: ComponentContent<MyParams> =
new ComponentContent<MyParams>(this.getUIContext(), wrapBuilder<[MyParams]>(buildText),
new MyParams(this.footerState));
@State isLoadMore: boolean = false;
fetchAgent = new worker.ThreadWorker('entry/ets/workers/FetchAgent.ets', { name: 'fetchAgent' });
cachePath = (this.getUIContext().getHostContext() as common.Context).getApplicationContext().cacheDir;
imageCaches: Map<string, string> = new Map();
// [Start PreCreate]
public aboutToAppear(): void {
// [StartExclude PreCreate]
this.initView();
// [Start Prefetch_1]
// Registers the callback that prefetcher invokes when a data referenced by a data source item needs to be fetched.
this.waterFlowView.registerFetchCallback(this.fetchCallback);
/**
* Registers the callback that prefetcher invokes when a specific fetch should be
* canceled to avoid wasting system resources, such as network bandwidth.
*/
this.waterFlowView.registerCancelCallback(this.cancelCallback);
taskpool.execute(generateRandomBlogData).then((data: ESObject) => {
this.data = data;
this.waterFlowView.setDataSource(data);
}).catch((err: Error) => {
const error = err as BusinessError;
Logger.error(`generateRandomBlogData failed, code = ${error.code} message = ${error.message}`);
});
// [End Prefetch_1]
// [EndExclude PreCreate]
// register components.
this.waterFlowView.registerNodeItem('BlogItemContainer', wrapBuilder(blogItemContainerBuilder));
this.waterFlowView.registerNodeItem('AdaptiveTextComponent', wrapBuilder(AdaptiveTextComponentContainer));
this.waterFlowView.registerNodeItem('GridImageViewContainer', wrapBuilder(gridImageViewContainerBuilder));
this.waterFlowView.preCreate('BlogItemContainer', 30);
this.waterFlowView.preCreate('AdaptiveTextComponent', 30);
this.waterFlowView.preCreate('GridImageViewContainer', 30);
// [StartExclude PreCreate]
Logger.info('WaterFlowPage==> nodePool : ' + JSON.stringify(util.getHash(this.waterFlowView.getRecyclePool())));
this.workerOnMessage();
// [EndExclude PreCreate]
}
// [End PreCreate]
public aboutToDisappear(): void {
try {
this.fetchAgent.terminate();
} catch (err) {
const error = err as BusinessError;
Logger.error(`FetchAgent terminate failed. code = ${error.code}, message = ${error.message}`);
}
this.imageCaches.clear();
this.fetches.clear();
}
public initView(): void {
// [Start Prefetch_3]
this.waterFlowView.setViewStyle({
scroller: this.scroller
})
// [StartExclude Prefetch_3]
.width(CommonConstants.FULL_WIDTH)
.height(CommonConstants.FULL_HEIGHT)
.columnsTemplate(CommonConstants.WATER_FLOW_COLUMNS_TEMPLATE)
.columnsGap(CommonConstants.COLUMNS_GAP)
.rowsGap(CommonConstants.ROWS_GAP)
.edgeEffect(EdgeEffect.Spring)
.padding({
top: CommonConstants.PADDING,
left: CommonConstants.PADDING,
right: CommonConstants.PADDING
})// [EndExclude Prefetch_3]
.onScrollIndex((start: number, end: number) => {
if (end > 0) {
/**
* Call this method when the visible area boundaries change.
* The prefetcher will start prefetching after the first call to this method
* in all cases where the autoStart option is not set to false.
*/
this.waterFlowView.visibleAreaChanged(start, end);
}
// [StartExclude Prefetch_3]
if (end + 30 > this.waterFlowView.nodeAdapter.totalNodeCount) {
if (!this.isLoadMore) {
this.isLoadMore = true;
taskpool.execute(generateRandomBlogData).then((data: ESObject) => {
this.waterFlowView.nodeAdapter.pushData(data);
this.isLoadMore = false;
});
}
}
// [EndExclude Prefetch_3]
})
.onVisibleAreaChange([0.0, 1.0], (isVisible: boolean) => {
/**
* By default, the prefetcher begins invoking user code to fetch data with the first call to
* the visibleAreaChanged method.Sometimes, this can waste resources because, in practice,
* onScrollIndex triggers the callback even when the component is not actually visible to the user.
* To avoid this, subscribe to the onVisibleAreaChange event.
*/
if (isVisible) {
// Call this method to start prefetching.
this.waterFlowView.nodeAdapter.prefetcher?.start();
} else {
// Call this method to stop prefetching. For instance,
// this should be done if a related component becomes invisible.
this.waterFlowView.nodeAdapter.prefetcher?.stop();
}
});
// [End Prefetch_3]
}
public workerOnMessage(): void {
this.fetchAgent.onmessage = (e: MessageEvents): void => {
let data = this.fetches.get(e.data.fetchId);
if (data === undefined) {
return;
}
switch (e.data.type) {
case 'SUCCESS':
if (data.callback) {
data.callback(e.data.fetchUrl);
} else {
data.fetchUrl = e.data.fetchUrl;
}
this.imageCaches.set(data.images[0], e.data.fetchUrl);
this.fetches.delete(e.data.fetchId);
break;
case 'FAIL':
this.fetches.delete(e.data.fetchId);
break;
default:
break;
}
};
}
// [Start Prefetch_2]
fetchCallback: (item: ESObject, fetchId: number) => Promise<void> = (item: ESObject, fetchId: number) => {
let data = item as BlogData;
if (data.images.length === 0) {
return Promise.resolve();
}
let url = data.images[0];
if (this.imageCaches.has(url)) {
// If cached, skip re-downloading and load the data directly.
if (data.callback) {
const cachedUrl = this.imageCaches.get(url);
if (cachedUrl) {
data.callback(cachedUrl);
}
} else {
data.fetchUrl = this.imageCaches.get(url) as string;
}
return Promise.resolve();
}
this.fetches.set(fetchId, data);
this.fetchAgent.postMessageWithSharedSendable({
type: 'fetch',
cachePath: this.cachePath,
url: data.images[0],
fetchId: fetchId
});
return Promise.resolve();
};
cancelCallback: (fetchId: number) => void = (fetchId: number) => {
this.fetches.delete(fetchId);
this.fetchAgent.postMessageWithSharedSendable({ type: 'cancel', fetchId: fetchId });
};
// [End Prefetch_2]
@Builder
getWaterFlow(): void {
RecyclerView({
viewManager: this.waterFlowView
});
}
public build(): void {
Column() {
// Header navigation bar.
Row() {
Text($r('app.string.scenario_waterFlow2'))
.fontSize($r('app.float.title_font_size'))
.fontWeight(FontWeight.Bold)
.fontColor($r('app.color.text_color'));
}
.padding(CommonConstants.PADDING)
.width(CommonConstants.FULL_WIDTH)
.backgroundColor(Color.White);
this.getWaterFlow();
}
.height(CommonConstants.FULL_HEIGHT)
.backgroundColor('#F5F5F5');
}
}
@Builder
function blogItemContainerBuilder($$: Params): void {
BlogItem({ blogItem: $$.blogItem });
}
// [Start quick_start_4_b]
@Component
struct BlogItem {
@State blogItem: BlogData = new BlogData();
// In reusable components, you must use aboutToReuse to update data, just like native recycling.
public aboutToReuse(params: ESObject): void {
this.blogItem = params.blogItem;
}
public aboutToRecycle(): void {
this.blogItem.callback = undefined;
this.blogItem.fetchUrl = ImageContent.EMPTY;
}
public build(): void {
Column({ space: 12 }) {
HeaderComponent({ blogItem: this.blogItem });
if (this.blogItem?.content.length > 0) {
// cache component.
PartReuse({
type: 'AdaptiveTextComponent',
builder: wrapBuilder(AdaptiveTextComponentContainer),
data: { blogItem: this.blogItem }
});
}
if (this.blogItem?.images && this.blogItem.images.length > 0) {
PartReuse({
type: 'GridImageViewContainer',
builder: wrapBuilder(gridImageViewContainerBuilder),
data: { blogItem: this.blogItem }
});
}
BottomContent({ blogItem: this.blogItem });
}
.padding(12)
.backgroundColor(Color.White)
.borderRadius(12);
}
}
// [End quick_start_4_b]
@Component
export struct HeaderComponent {
@ObjectLink blogItem: BlogData;
public build(): void {
Row({ space: 8 }) {
Image(this.blogItem.user.avatar)
.width(40)
.height(40)
.borderRadius(20)
.objectFit(ImageFit.Cover);
Column() {
Text(this.blogItem.user.name)
.fontSize(16)
.fontColor('#333');
Text(this.blogItem.time)
.fontSize(12)
.fontColor('#999');
}.alignItems(HorizontalAlign.Start)
.layoutWeight(1);
};
}
}
// [Start quick_start_4_b]
@Builder
export function AdaptiveTextComponentContainer($$: Params): void {
AdaptiveTextComponent({ blogItem: $$.blogItem });
}
// [End quick_start_4_b]
@Component
export struct AdaptiveTextComponent {
@State private showFullText: boolean = false;
@State blogItem: BlogData = new BlogData();
// In reusable components, you must use aboutToReuse to update data, just like native recycling.
public aboutToReuse(params: ESObject): void {
this.blogItem = params.blogItem;
}
public build(): void {
Stack() {
// Text container.
Text(isChinese() ? this.blogItem.content : 'The protection of personal privacy is urgently needed.')
.fontSize(16)
.fontColor($r('app.color.text_color'))
.lineHeight(20)
.maxLines(this.showFullText ? undefined : 3)
.textOverflow({ overflow: TextOverflow.None })
.width('auto')
.height('auto');
}
.width('auto')
.height('auto');
}
}
// [Start quick_start_4_b]
@Builder
function gridImageViewContainerBuilder($$: Params): void {
GridImageView({ blogItem: $$.blogItem });
}
// [End quick_start_4_b]
@Component
struct GridImageView {
@State blogItem: BlogData = new BlogData();
@State fetchUrl: string | ImageContent = ImageContent.EMPTY;
public callback = (fetchUrl: string): void => {
this.blogItem.fetchUrl = fetchUrl;
this.fetchUrl = fetchUrl;
};
public aboutToAppear(): void {
this.blogItem.callback = this.callback;
this.fetchUrl = this.blogItem.fetchUrl;
}
public aboutToRecycle(): void {
this.blogItem.callback = undefined;
this.fetchUrl = ImageContent.EMPTY;
}
public aboutToReuse(params: ESObject): void {
this.blogItem = params.blogItem;
this.blogItem.callback = this.callback;
this.fetchUrl = this.blogItem.fetchUrl;
}
public build(): void {
// [Start Prefetch_4]
// Preload images using prefetch.
Image(this.fetchUrl)
.sourceSize({ width: 100, height: 100 })
.width(CommonConstants.FULL_WIDTH)
.aspectRatio(1)
.objectFit(ImageFit.Cover);
// [End Prefetch_4]
}
}
@Component
export struct BottomContent {
@ObjectLink blogItem: BlogData;
public build(): void {
// Interactive toolbar
Row({ space: 20 }) {
Row() {
Image($r('app.media.icon_like'))
.width(14)
.height(14);
Text(this.blogItem.likes.toString())
.fontSize(14)
.margin({ left: 4 });
}
.backgroundColor(Color.Transparent);
Row() {
Image($r('app.media.icon_comment'))
.width(14)
.height(14);
Text(this.blogItem.comments.toString())
.fontSize(14)
.margin({ left: 4 });
}
.backgroundColor(Color.Transparent);
Row() {
Image($r('app.media.icon_share'))
.width(14)
.height(14);
Text(this.blogItem.reposts.toString())
.fontSize(14)
.margin({ left: 2 });
}
.backgroundColor(Color.Transparent);
}
.width('50%')
.justifyContent(FlexAlign.SpaceAround)
.padding({ top: 8 });
}
}