/*
* Copyright (c) 2026 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.
*/
import { LeftListItem } from './LeftListItem';
import { DEFAULT_TOP_LIST_ITEM_FONT_COLOR, TopListItem } from './TopListItem';
import { RightSubList } from './RightSubList';
import { scheduleRowPrebuild } from '../common/RowPrebuildCallback';
import { BaseTableViewModel } from '../viewmodel/BaseTableViewModel';
import { CommonConstants } from '../constant/CommonConstants';
import { TableGroup, TableRow } from '../model/TableDataModel';
/**
* Default top list header font color
* Used to set font_primary color
*/
export const DEFAULT_TOP_LIST_HEADER_FONT_COLOR: ResourceColor = $r('sys.color.font_primary');
const RIGHT_TABLE_ROW_REUSE_ID: string = 'right_table_row';
// [Start TableSceneConfig]
/**
* Table scene configuration interface
* Used to customize display differences for different scenarios
*/
export interface TableSceneConfig {
/**
* Whether to use grouped list (ListItemGroup + sticky headers).
* true: grouped list for multi-section tables (e.g. car spec).
* false: flat list without ListItemGroup (e.g. stock table).
*/
showGroupHeader: boolean;
/**
* Whether to show top list top border
*/
showTopListTopBorder: boolean;
/**
* Whether to show top list bottom border
*/
showTopListBottomBorder: boolean;
/**
* Whether to show vertical divider lines (column dividers)
*/
showVerticalLine?: boolean;
/**
* Text style configuration
*/
textStyleConfig?: TextStyleConfig;
/**
* Layout dimension configuration
*/
layoutConfig: TableLayoutConfig;
}
// [End TableSceneConfig]
// [Start TableLayoutConfig]
/**
* Table layout configuration interface
* Defines the dimensions of each table area to ensure row and column alignment
*/
export interface TableLayoutConfig {
/**
* Top horizontal list height (vp), also controls left title area height
*/
topListHeight: number;
/**
* Left vertical list width (vp), also controls top title area width
*/
leftListWidth: number;
/**
* Sub-component width (vp), shared by top list items and bottom sub-list items to ensure column alignment
*/
listItemWidth: number;
/**
* Sub-list row height (vp), shared by left list rows and bottom sub-list rows to ensure row alignment
*/
subListHeight: number;
/**
* Group header height (vp)
*/
groupHeaderHeight?: number;
}
// [End TableLayoutConfig]
/**
* Text style configuration interface
* Used to customize text styles for each area
*/
export interface TextStyleConfig {
/**
* Top list header text color
* When not set, uses default color: font_primary
*/
topListHeaderFontColor?: ResourceColor;
/**
* Top list header font size
*/
topListHeaderFontSize?: ResourceStr | number;
/**
* Top list header font weight
*/
topListHeaderFontWeight?: FontWeight;
/**
* Top list item text color
* When not set, uses default color: font_primary
*/
topListItemFontColor?: ResourceColor;
/**
* Top list item font size
*/
topListItemFontSize?: ResourceStr | number;
/**
* Top list item font weight
*/
topListItemFontWeight?: FontWeight;
/**
* Cell text color
* When not set, uses default color: font_secondary
* When data has dynamic colors (e.g., stock rise/fall), dynamic colors take priority
*/
cellItemFontColor?: ResourceColor;
/**
* Cell font size
*/
cellItemFontSize?: ResourceStr | number;
}
// [End TableConfig]
/**
* Dual scroll table universal component
* Provides top horizontal list + bottom left vertical list + bottom right dual scroll list layout
* Supports configuration of group headers, borders, and other display differences
*/
@ComponentV2
// [Start DualScrollTable]
export struct DualScrollTable {
/**
* ViewModel instance, provides data and scroll synchronization logic,
* must be provided externally with concrete implementation
*/
@Param @Require viewModel: BaseTableViewModel;
/**
* Scene configuration
*/
@Param config: TableSceneConfig = {
// [StartExclude DualScrollTable]
showGroupHeader: false,
showTopListTopBorder: true,
showTopListBottomBorder: false,
showVerticalLine: true,
layoutConfig: {
topListHeight: 80,
leftListWidth: 100,
listItemWidth: 155,
subListHeight: 80,
groupHeaderHeight: 44
}
// [EndExclude DualScrollTable]
};
// [StartExclude DualScrollTable]
/**
* Get whether to show vertical divider line
*/
private getShowVerticalLine(): boolean {
return this.config.showVerticalLine ?? true;
}
private getDefaultCellFontColor(): ResourceColor {
return this.config.textStyleConfig?.cellItemFontColor ?? $r('sys.color.font_secondary');
}
private refreshTableLayout(): void {
this.viewModel.refreshHorizontalContentWidth(this.config.layoutConfig.listItemWidth);
}
aboutToAppear(): void {
this.refreshTableLayout();
// Register idle prebuild after page show to warm the pool with full-row BuilderNodes
scheduleRowPrebuild(
this.getUIContext(),
this.viewModel.flatRowDataArray,
this.config.layoutConfig.subListHeight,
this.config.layoutConfig.listItemWidth,
this.getShowVerticalLine(),
this.getDefaultCellFontColor()
);
}
// [EndExclude DualScrollTable]
build() {
Column() {
this.TopList();
Row() {
this.LeftList();
this.RightList();
}
.layoutWeight(1)
}
}
// [StartExclude DualScrollTable]
/**
* Top horizontal scroll list
*/
// [Start TopListTag]
// [Start TopListDesc]
@Builder
TopList() {
Row() {
this.TopListHeader(this.viewModel.leftTitle);
List({ scroller: this.viewModel.topListScroller }) {
// [StartExclude TopListTag]
Repeat(this.viewModel.topListData)
.each((item: RepeatItem<ResourceStr>) => {
ListItem() {
TopListItem({
info: item.item,
itemWidth: this.config.layoutConfig.listItemWidth,
fontColor: this.config.textStyleConfig?.topListItemFontColor ?? DEFAULT_TOP_LIST_ITEM_FONT_COLOR,
fontSize: this.config.textStyleConfig?.topListItemFontSize ?? $r('sys.float.Body_L'),
fontWeight: this.config.textStyleConfig?.topListItemFontWeight ?? FontWeight.Medium
})
}
})
.key((_item: ResourceStr, index: number) => `top_${index}`)
.virtualScroll()
// [EndExclude TopListTag]
}
// [StartExclude TopListTag]
.cachedCount(CommonConstants.LIST_HORIZONTAL_CACHED_COUNT)
.listDirection(Axis.Horizontal)
.width(`calc(100% - ${this.config.layoutConfig.leftListWidth}vp)`)
.height('100%')
// [StartExclude TopListDesc]
.edgeEffect(EdgeEffect.None)
.scrollBar(BarState.Off)
.divider({
strokeWidth: this.getShowVerticalLine() ? CommonConstants.LINE_STROKE_WIDTH : 0,
color: $r('sys.color.comp_divider')
})
// [EndExclude TopListTag]
// Triggered before list scrolls, the list will scroll according to the actual scroll amount returned
.onScrollFrameBegin((offset: number, _state: ScrollState) => {
this.viewModel.syncTopListScrollToSubList(offset);
return { offsetRemain: offset };
})
}
// [StartExclude TopListTag]
.height(this.config.layoutConfig.topListHeight)
.borderWidth({
top: this.config.showTopListTopBorder ? CommonConstants.LINE_STROKE_WIDTH : 0,
bottom: this.config.showTopListBottomBorder ? CommonConstants.LINE_STROKE_WIDTH : 0
})
.borderColor($r('sys.color.comp_divider'))
// [EndExclude TopListTag]
// [EndExclude TopListDesc]
}
// [End TopListTag]
// [End TopListDesc]
/**
* Bottom left vertical scroll list (parameter name column)
*/
@Builder
LeftList() {
if (this.config.showGroupHeader) {
this.LeftGroupList();
} else {
this.LeftFlatList();
}
}
// [Start LeftListTag]
@Builder
LeftGroupList() {
List({ scroller: this.viewModel.leftListScroller }) {
// [StartExclude LeftListTag]
Repeat(this.viewModel.groupDataArray)
.each((group: RepeatItem<TableGroup>) => {
ListItemGroup({ header: this.LeftGroupHeader(group.item.sticky) }) {
Repeat(group.item.sub)
.each((subItem: RepeatItem<TableRow>) => {
ListItem() {
this.LeftListItemContent(subItem.item);
}
})
.key((item: TableRow) => item.id)
.virtualScroll()
}
})
.key((item: TableGroup) => item.id)
.virtualScroll()
// [EndExclude LeftListTag]
}
.onScrollFrameBegin((offset: number, _state: ScrollState) => {
this.viewModel.syncLeftListScrollToRightList(offset);
return { offsetRemain: offset };
})
// [StartExclude LeftListTag]
.cachedCount(CommonConstants.LIST_VERTICAL_CACHED_COUNT)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.None)
.listDirection(Axis.Vertical)
.sticky(StickyStyle.Header)
.width(`${this.config.layoutConfig.leftListWidth}vp`)
.height('100%')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
// [EndExclude LeftListTag]
}
// [End LeftListTag]
@Builder
LeftFlatList() {
List({ scroller: this.viewModel.leftListScroller }) {
Repeat(this.viewModel.flatRowDataArray)
.each((subItem: RepeatItem<TableRow>) => {
ListItem() {
this.LeftListItemContent(subItem.item);
}
})
.key((item: TableRow) => item.id)
.virtualScroll()
}
.onScrollFrameBegin((offset: number, _state: ScrollState) => {
this.viewModel.syncLeftListScrollToRightList(offset);
return { offsetRemain: offset };
})
.cachedCount(CommonConstants.LIST_VERTICAL_CACHED_COUNT)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.None)
.listDirection(Axis.Vertical)
.width(`${this.config.layoutConfig.leftListWidth}vp`)
.height('100%')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
}
@Builder
LeftListItemContent(subItem: TableRow) {
LeftListItem({
name: subItem.part,
itemWidth: this.config.layoutConfig.leftListWidth,
itemHeight: this.config.layoutConfig.subListHeight,
showVerticalLine: this.getShowVerticalLine()
})
}
/**
* Bottom right vertical scroll list (parameter value column)
*/
@Builder
RightList() {
if (this.config.showGroupHeader) {
this.RightGroupList();
} else {
this.RightFlatList();
}
}
// [Start RightListTag]
// [Start RightScrollTag]
@Builder
RightGroupList() {
Scroll(this.viewModel.rightAreaScroller) {
// [StartExclude RightScrollTag]
List({ scroller: this.viewModel.rightListScroller }) {
// [StartExclude RightListTag]
Repeat(this.viewModel.groupDataArray)
.each((group: RepeatItem<TableGroup>) => {
ListItemGroup({ header: this.RightGroupHeader() }) {
Repeat(group.item.sub)
.each((subItem: RepeatItem<TableRow>) => {
ListItem() {
this.RightListItemContent(subItem.item);
}
})
.key((item: TableRow) => item.id)
.virtualScroll({ reusable: false })
}
})
.key((item: TableGroup) => item.id)
.virtualScroll({ reusable: false })
// [EndExclude RightListTag]
}
.onScrollFrameBegin((offset: number, _state: ScrollState) => {
this.viewModel.syncRightListScrollToLeftList(offset);
return { offsetRemain: offset };
})
// [StartExclude RightListTag]
.cachedCount(CommonConstants.LIST_VERTICAL_CACHED_COUNT)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.None)
.listDirection(Axis.Vertical)
.sticky(StickyStyle.Header)
.width(this.viewModel.horizontalContentWidth)
.height('100%')
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
.clip(false)
// [EndExclude RightListTag]
// [EndExclude RightScrollTag]
}
// [StartExclude RightListTag]
// [StartExclude RightScrollTag]
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.None)
.clipContent(ContentClipMode.SAFE_AREA)
.width(`calc(100% - ${this.config.layoutConfig.leftListWidth}vp)`)
.height('100%')
.layoutWeight(1)
// [EndExclude RightScrollTag]
.onScrollFrameBegin((offset: number, _state: ScrollState) => {
this.viewModel.syncSubListScrollToTopList(offset);
return { offsetRemain: offset };
})
// [EndExclude RightListTag]
}
// [End RightListTag]
// [End RightScrollTag]
@Builder
RightFlatList() {
Scroll(this.viewModel.rightAreaScroller) {
List({ scroller: this.viewModel.rightListScroller }) {
Repeat(this.viewModel.flatRowDataArray)
.each((subItem: RepeatItem<TableRow>) => {
ListItem() {
this.RightListItemContent(subItem.item);
}
})
.key((item: TableRow) => item.id)
.virtualScroll({ reusable: false })
}
.onScrollFrameBegin((offset: number, _state: ScrollState) => {
this.viewModel.syncRightListScrollToLeftList(offset);
return { offsetRemain: offset };
})
.cachedCount(CommonConstants.LIST_VERTICAL_CACHED_COUNT)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.None)
.listDirection(Axis.Vertical)
.width(this.viewModel.horizontalContentWidth)
.height('100%')
.clip(false)
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.None)
.clipContent(ContentClipMode.SAFE_AREA)
.onScrollFrameBegin((offset: number, _state: ScrollState) => {
this.viewModel.syncSubListScrollToTopList(offset);
return { offsetRemain: offset };
})
.width(`calc(100% - ${this.config.layoutConfig.leftListWidth}vp)`)
.height('100%')
.layoutWeight(1)
}
@Builder
RightListItemContent(subItem: TableRow) {
RightSubList({
itemModel: subItem,
rowHeight: this.config.layoutConfig.subListHeight,
itemWidth: this.config.layoutConfig.listItemWidth,
showVerticalLine: this.getShowVerticalLine(),
defaultCellFontColor: this.getDefaultCellFontColor(),
}).reuse({ reuseId: () => RIGHT_TABLE_ROW_REUSE_ID })
}
/**
* Top list header
* @param title Title name
*/
@Builder
TopListHeader(title: ResourceStr) {
Text(title)
.fontColor(this.config.textStyleConfig?.topListHeaderFontColor ?? DEFAULT_TOP_LIST_HEADER_FONT_COLOR)
.fontSize(this.config.textStyleConfig?.topListHeaderFontSize ?? $r('sys.float.Body_L'))
.fontWeight(this.config.textStyleConfig?.topListHeaderFontWeight ?? FontWeight.Bold)
.textAlign(TextAlign.Center)
.height('100%')
.width(this.config.layoutConfig.leftListWidth)
.border({
width: { right: this.getShowVerticalLine() ? CommonConstants.LINE_STROKE_WIDTH : 0 },
color: $r('sys.color.comp_divider')
})
}
/**
* Bottom left vertical scroll list header
* @param title Title name
*/
@Builder
LeftGroupHeader(title: string) {
Text(title)
.fontWeight(FontWeight.Bold)
.height(this.config.layoutConfig.groupHeaderHeight ?? CommonConstants.DEFAULT_GROUP_HEADER_HEIGHT)
.textAlign(TextAlign.Start)
.width('100%')
.backgroundColor($r('app.color.list_header_background'))
.fontColor($r('sys.color.font_primary'))
.fontSize($r('sys.float.Body_L'))
.padding({ left: $r('sys.float.padding_level8') })
}
/**
* Bottom right vertical scroll header
* @param index Index value
*/
@Builder
RightGroupHeader() {
Text('')
.fontWeight(FontWeight.Medium)
.height(this.config.layoutConfig.groupHeaderHeight ?? CommonConstants.DEFAULT_GROUP_HEADER_HEIGHT)
.textAlign(TextAlign.End)
.backgroundColor($r('app.color.list_header_background'))
.width(this.viewModel.horizontalContentWidth)
.fontColor($r('sys.color.font_primary'))
.fontSize($r('sys.float.Body_M'))
.padding({ right: $r('sys.float.padding_level8') })
}
// [EndExclude DualScrollTable]
}
// [End DualScrollTable]