/*
* 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 {
Column, Row, ComponentV2, Button, Text, CustomDialog, $r, Flex, List, HoverEvent, ImageLoadResult,
Canvas, BlendMode, Scroller, ForEach, AttributeModifier, GestureModifier, ListItem, BuilderParam,
ResourceStr, UIGestureEvent, LongPressGestureHandler, PopupOptions, PopupStateChangeParam, CustomPopupOptions,
CustomDialogController, ScrollOptions, ScrollAnimationOptions, SwiperController, ResourceColor, Area,
RenderingContextSettings, CanvasRenderingContext2D, Builder, FontWeight, FlexAlign, KeyEvent,
TouchEvent, ItemAlign, Stack, Alignment, EdgeEffect, Axis, BarState, BlendApplyType, Swiper, Curve,
Color, ButtonType, SymbolGlyph, BorderStyle, TouchType, KeyType, Placement, GeometryInfo, Layoutable,
ConstraintSizeOptions, focusControl, BorderOptions, HitTestMode, SymbolEffect, ImageCompleteEvent, Resource,
Image, Callback, ImageFit, VerticalAlign, TextOverflow, TextAlign, ShadowStyle, BlurStyle, Margin, Padding,
CommonMethod, ButtonOptions, ImageError, ImageOnCompleteCallback, ImageErrorCallback,
ClickEvent, LocalizedPadding, LengthMetrics
} from "@ohos.arkui.component";
import { BusinessError } from '@ohos.base';
import { SymbolGlyphModifier } from '@ohos.arkui.modifier';
import { Param, Local, Once, ObservedV2, Trace } from '@ohos.arkui.stateManagement';
import { Context, UIContext, MeasureUtils } from "@ohos.arkui.UIContext";
import { KeyCode } from '@ohos.multimodalInput.keyCode';
import resourceManager from '@ohos.resourceManager';
import { MeasureOptions } from '@ohos.measure';
import common from '@ohos.app.ability.common';
import window from '@ohos.window';
import hilog from '@ohos.hilog';
import promptAction from '@ohos.promptAction';
import i18n from '@ohos.i18n';
/**
* Callback function when click on this menu item.
*/
type OnActionCallback = () => void;
/**
* Options for creating a menu item instance.
*/
export interface TabTitleBarV2MenuItemParams {
value: ResourceStr;
symbolStyle?: SymbolGlyphModifier;
label?: ResourceStr;
isEnabled?: boolean;
action?: OnActionCallback;
accessibilityText?: ResourceStr;
accessibilityDescription?: ResourceStr;
accessibilityLevel?: string;
}
/**
* Declaration of the menu item on the right side.
*/
@ObservedV2
export class TabTitleBarV2MenuItem {
@Trace
value: ResourceStr = '';
@Trace
symbolStyle?: SymbolGlyphModifier;
@Trace
label?: ResourceStr;
@Trace
isEnabled?: boolean;
@Trace
action?: OnActionCallback;
@Trace
accessibilityText?: ResourceStr;
@Trace
accessibilityDescription?: ResourceStr;
@Trace
accessibilityLevel?: string;
constructor(params?: TabTitleBarV2MenuItemParams) {
this.value = params?.value ?? '';
this.symbolStyle = params?.symbolStyle;
this.label = params?.label;
this.isEnabled = params?.isEnabled;
this.action = params?.action;
this.accessibilityText = params?.accessibilityText;
this.accessibilityDescription = params?.accessibilityDescription;
this.accessibilityLevel = params?.accessibilityLevel;
}
}
/**
* Options for creating a tab item instance.
*/
export interface TabTitleBarV2TabItemParams {
title: ResourceStr;
icon?: ResourceStr;
symbolStyle?: SymbolGlyphModifier;
}
/**
* Declaration of the tab item.
*/
@ObservedV2
export class TabTitleBarV2TabItem {
@Trace
title: ResourceStr = '';
@Trace
icon?: ResourceStr;
@Trace
symbolStyle?: SymbolGlyphModifier;
constructor(params?: TabTitleBarV2TabItemParams) {
this.title = params?.title ?? '';
this.icon = params?.icon;
this.symbolStyle = params?.symbolStyle;
}
}
/**
* Content builder. Each component corresponds to a tab item.
*/
@Builder
type OnSwiperContentCallback = () => void;
const PUBLIC_MORE: Resource = $r('sys.symbol.dot_grid_2x2');
const TEXT_EDITABLE_DIALOG = '18.3fp';
const IMAGE_SIZE = '64vp';
const MAX_DIALOG = '256vp';
const MIN_DIALOG = '216vp';
const RESOURCE_TYPE_SYMBOL = 40000;
export class ButtonGestureModifier implements GestureModifier {
public static readonly longPressTime: number = 500;
public static readonly minFontSize: number = 1.75;
public gestureCallBack?: (event: UIGestureEvent) => void = undefined;
applyGesture(event: UIGestureEvent): void {
this.gestureCallBack?.(event);
}
}
@ComponentV2
export struct TabTitleBarV2 {
public static readonly totalHeight: number = 56;
public static readonly correctionOffset: number = -40.0;
public static readonly gradientMaskWidth: number = 24;
private static instanceCount: number = 0;
@Param tabItems: Array<TabTitleBarV2TabItem> = new Array<TabTitleBarV2TabItem>();
@Param menuItems?: Array<TabTitleBarV2MenuItem> = new Array<TabTitleBarV2MenuItem>();
@BuilderParam swiperContent: OnSwiperContentCallback = this.emptyBuilder;
@Local tabWidth: number = 0;
@Local currentIndex: number = 0;
@Local swiperCurrentIndex: int = 0;
@Local fontSize: number = 1;
private menuSectionWidth: number = 0;
private tabOffsets: Array<number> = new Array<number>();
private imageWidths: Array<number> = new Array<number>();
private scroller: Scroller = new Scroller();
private swiperController: SwiperController = new SwiperController();
private settings: RenderingContextSettings = new RenderingContextSettings(true);
private leftContext2D: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
private rightContext2D: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
@Builder
GradientMask(context2D: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number) {
Column() {
Canvas(context2D)
.width(TabTitleBarV2.gradientMaskWidth)
.height(TabTitleBarV2.totalHeight)
.onReady(() => {
let grad = context2D.createLinearGradient(x0, y0, x1, y1);
grad.addColorStop(0.0, '#ffffffff');
grad.addColorStop(1, '#00ffffff');
context2D.fillStyle = grad;
context2D.fillRect(0, 0, TabTitleBarV2.gradientMaskWidth, TabTitleBarV2.totalHeight);
} as () => void)
}
.blendMode(BlendMode.DST_OUT)
.width(TabTitleBarV2.gradientMaskWidth)
.height(TabTitleBarV2.totalHeight)
}
@Builder
emptyBuilder() {
}
aboutToAppear() {
if (!this.swiperContent) {
this.swiperContent = this.emptyBuilder;
}
this.tabItems.forEach((_elem) => {
this.imageWidths.push(0);
})
this.loadOffsets();
}
loadOffsets() {
this.tabOffsets.length = 0;
let tabOffset: number = 0;
this.tabOffsets.push(tabOffset);
tabOffset += TabContentItemV2.marginFirst;
this.tabItems.forEach((tabItem: TabTitleBarV2TabItem, index: int) => {
if (tabItem.icon !== undefined || tabItem.symbolStyle !== undefined) {
if (Math.abs(this.imageWidths[index]) > TabContentItemV2.imageHotZoneWidth) {
tabOffset += this.imageWidths[index.toInt()];
} else {
tabOffset += TabContentItemV2.imageHotZoneWidth;
}
} else {
tabOffset += TabContentItemV2.paddingLeft;
let uiContextMeasure: MeasureUtils = this.getUIContext().getMeasureUtils();
tabOffset += this.getUIContext().px2vp(uiContextMeasure.measureText({
textContent: tabItem.title.toString(),
fontSize: 18,
fontWeight: FontWeight.Medium,
} as MeasureOptions));
tabOffset += TabContentItemV2.paddingRight;
}
this.tabOffsets.push(tabOffset);
})
}
private isRTL(): boolean {
try {
return i18n.isRTL(i18n.System.getSystemLanguage() as string) ?? false;
} catch (exception) {
const code: undefined | int = (exception as BusinessError)?.code;
const message: undefined | string = (exception as BusinessError)?.message;
hilog.error(0x3900, 'AdvancedTabTitleBar', `Failed to decideFontScale,cause, code: ${code}, message: ${message}`);
return false;
}
}
build() {
Column() {
Flex({
justifyContent: FlexAlign.SpaceBetween,
alignItems: ItemAlign.Stretch
}) {
Stack({ alignContent: Alignment.End }) {
Stack({ alignContent: Alignment.Start }) {
Column() {
List({ initialIndex: 0, scroller: this.scroller, space: 0 }) {
ForEach(this.tabItems, (tabItem: TabTitleBarV2TabItem, index: int) => {
ListItem() {
TabContentItemV2({
item: tabItem,
index: index,
maxIndex: this.tabItems.length - 1,
currentIndex: this.currentIndex,
onCustomClick: (itemIndex: number) => {
this.currentIndex = itemIndex;
},
onImageComplete: (width: number) => {
this.imageWidths[index.toInt()] = width;
this.loadOffsets();
}
})
}
}, (item: TabTitleBarV2TabItem, index: int) => {
return JSON.stringify(item.title + '-' + index)
})
}
.width('100%')
.height(TabTitleBarV2.totalHeight)
.constraintSize({ maxWidth: this.tabWidth })
.edgeEffect(EdgeEffect.Spring)
.listDirection(Axis.Horizontal)
.scrollBar(BarState.Off)
}
if (this.isRTL()) {
this.GradientMask(this.rightContext2D, TabTitleBarV2.gradientMaskWidth,
TabTitleBarV2.totalHeight / 2, 0, TabTitleBarV2.totalHeight / 2)
} else {
this.GradientMask(this.leftContext2D, 0, TabTitleBarV2.totalHeight / 2,
TabTitleBarV2.gradientMaskWidth, TabTitleBarV2.totalHeight / 2)
}
}
if (this.isRTL()) {
this.GradientMask(this.leftContext2D, 0, TabTitleBarV2.totalHeight / 2,
TabTitleBarV2.gradientMaskWidth, TabTitleBarV2.totalHeight / 2)
} else {
this.GradientMask(this.rightContext2D, TabTitleBarV2.gradientMaskWidth,
TabTitleBarV2.totalHeight / 2, 0, TabTitleBarV2.totalHeight / 2)
}
}
.blendMode(BlendMode.SRC_OVER, BlendApplyType.OFFSCREEN)
if (this.menuItems !== undefined && this.menuItems!.length > 0) {
CollapsibleMenuSectionV2({
menuItems: this.menuItems,
index: 1 + TabTitleBarV2.instanceCount++,
})
.height(TabTitleBarV2.totalHeight)
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.menuSectionWidth = newValue.width as number
})
}
}
.backgroundColor($r('sys.color.ohos_id_color_background'))
.margin({
end: LengthMetrics.resource($r('sys.float.ohos_id_max_padding_end'))
} as LocalizedPadding)
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.tabWidth = newValue.width as number - this.menuSectionWidth;
})
Column() {
Swiper(this.swiperController as SwiperController) {
this.swiperContent();
}
.index(this.currentIndex.toInt())
.itemSpace(0)
.indicator(false)
.width('100%')
.height('100%')
.curve(Curve.Friction)
.onChange((index: int) => {
if (index < this.tabOffsets.length) {
const offset = this.tabOffsets[index.toInt()] + TabTitleBarV2.correctionOffset;
this.currentIndex = index;
this.scroller.scrollTo({
xOffset: offset > 0 ? offset : 0,
yOffset: 0,
animation: {
duration: 300,
curve: Curve.EaseInOut
} as ScrollAnimationOptions
} as ScrollOptions)
}
} as Callback<int>)
.onAppear(() => {
this.scroller.scrollToIndex(Double.toInt(this.currentIndex));
this.scroller.scrollBy(TabTitleBarV2.correctionOffset, 0);
})
}
}
}
}
@ComponentV2
struct CollapsibleMenuSectionV2 {
@Param menuItems: Array<TabTitleBarV2MenuItem> = new Array<TabTitleBarV2MenuItem>();
@Param index: number = 0;
@Param item: TabTitleBarV2MenuItem = new TabTitleBarV2MenuItem({
value: PUBLIC_MORE,
symbolStyle: new SymbolGlyphModifier(PUBLIC_MORE) as SymbolGlyphModifier,
label: $r('sys.string.ohos_toolbar_more'),
});
@Param @Once fontSize: number = 1;
minFontSize: number = 1.75;
maxFontSize: number = 3.2;
screenWidth: number = 640;
verticalScreenLines: int = 6;
horizontalsScreenLines: int = 1;
isFollowingSystemFontScale: boolean = false;
maxFontScale: number = 1;
systemFontScale?: number = 1;
public static readonly maxCountOfVisibleItems: number = 1;
private static readonly focusPadding: number = 4;
private static readonly marginsNum: number = 2;
private firstFocusableIndex: number = -1;
@Local isPopupShown: boolean = false;
@Local isMoreIconOnFocus: boolean = false;
@Local isMoreIconOnHover: boolean = false;
@Local isMoreIconOnClick: boolean = false;
@Local dialogId?: int = undefined;
@Local buttonGestureModifier: ButtonGestureModifier = new ButtonGestureModifier();
@Local maxLines: int = 1;
@Local tabTitleDialogItem: TabTitleBarV2MenuItem = new TabTitleBarV2MenuItem({ value: '' });
@Local tabTitleBarDialogText?: ResourceStr = '';
getMoreIconFgColor(): ResourceColor {
return this.isMoreIconOnClick
? $r('sys.color.ohos_id_color_titlebar_icon_pressed')
: $r('sys.color.ohos_id_color_titlebar_icon');
}
getMoreIconBgColor(): ResourceColor {
if (this.isMoreIconOnClick) {
return $r('sys.color.ohos_id_color_click_effect');
} else if (this.isMoreIconOnHover) {
return $r('sys.color.ohos_id_color_hover');
} else {
return Color.Transparent;
}
}
aboutToAppear() {
try {
let uiContent: UIContext = this.getUIContext();
this.isFollowingSystemFontScale = uiContent.isFollowingSystemFontScale();
this.maxFontScale = uiContent.getMaxFontScale();
let context = uiContent.getHostContext() as common.UIAbilityContext;
let mainWindowStage = context.windowStage.getMainWindowSync();
let properties: window.WindowProperties = mainWindowStage.getWindowProperties() as window.WindowProperties;
let rect = properties.windowRect;
if (this.getUIContext().px2vp(rect.height) > this.screenWidth) {
this.maxLines = this.verticalScreenLines;
} else {
this.maxLines = this.horizontalsScreenLines;
}
} catch (exception) {
let code: int | undefined = (exception as ClassCastError)?.code;
let message: string | undefined = (exception as ClassCastError)?.message;
hilog.error(0x3900, 'AdvancedTabTitleBar', `Failed to decideFontScale,cause, code: ${code}, message: ${message}`);
}
this.menuItems.forEach((item: TabTitleBarV2MenuItem, index: int) => {
if (item.isEnabled && this.firstFocusableIndex === -1 &&
index > CollapsibleMenuSectionV2.maxCountOfVisibleItems - 2) {
this.firstFocusableIndex = this.index * 1000 + index + 1
}
})
this.fontSize = this.decideFontScale();
this.tabTitleDialogItem = this.item;
this.tabTitleBarDialogText = this.item.label ? this.item.label : '';
this.buttonGestureModifier.gestureCallBack = (event: UIGestureEvent) => {
if (this.fontSize >= ButtonGestureModifier.minFontSize) {
event.addGesture(
new LongPressGestureHandler({ repeat: false, duration: ButtonGestureModifier.longPressTime.toInt() })
.onAction(() => {
if (event) {
this.openDialog();
}
})
.onActionEnd(() => {
this.closeDialog();
})
)
} else {
event.clearGestures();
}
}
}
decideFontScale(): number {
let uiContent: UIContext = this.getUIContext();
if (uiContent.getHostContext()) {
this.systemFontScale = (uiContent.getHostContext() as common.UIAbilityContext)?.config?.fontSizeScale ?? 1;
}
if (!this.isFollowingSystemFontScale) {
return 1;
}
return Math.min(this.systemFontScale as number, this.maxFontScale);
}
private openDialog(): void {
this.getUIContext().getPromptAction().openCustomDialog({
builder: () => {
this.dialogBuilder();
} as () => void,
onWillDisappear: () => {
this.isMoreIconOnClick = false;
} as () => void,
maskColor: Color.Transparent,
isModal: true
} as promptAction.CustomDialogOptions
).then((id: int) => {
this.dialogId = id;
});
}
private closeDialog(): void {
if (this.dialogId !== undefined) {
this.getUIContext().getPromptAction().closeCustomDialog(this.dialogId as int);
this.dialogId = undefined;
}
}
build() {
Column() {
Row() {
if (this.menuItems.length <= CollapsibleMenuSectionV2.maxCountOfVisibleItems) {
ForEach(this.menuItems, (item: TabTitleBarV2MenuItem, index: int) => {
ImageMenuItemV2({ item: item, index: this.index * 1000 + index + 1 })
}, (item: TabTitleBarV2MenuItem, index: int) => {
return JSON.stringify(item.value + '' + index)
})
} else {
ForEach(this.menuItems.slice(0, CollapsibleMenuSectionV2.maxCountOfVisibleItems.toInt() - 1),
(item: TabTitleBarV2MenuItem, index: int) => {
ImageMenuItemV2({ item: item, index: this.index * 1000 + index + 1 })
}, (item: TabTitleBarV2MenuItem, index: int) => {
return JSON.stringify(item.value + '' + index)
})
Button({ type: ButtonType.Normal, stateEffect: true } as ButtonOptions) {
SymbolGlyph(PUBLIC_MORE)
.fontSize(TabContentItemV2.symbolSize)
.draggable(false)
.fontColor([$r('sys.color.icon_primary')])
.focusable(true)
}
.accessibilityText(this.toStringFormat($r('sys.string.ohos_toolbar_more')))
.width(ImageMenuItemV2.imageHotZoneWidth)
.height(ImageMenuItemV2.imageHotZoneWidth)
.borderRadius(ImageMenuItemV2.buttonBorderRadius)
.foregroundColor(this.getMoreIconFgColor())
.backgroundColor(this.getMoreIconBgColor())
.stateStyles({
focused: (instance: CommonMethod) => {
instance.border({
radius: $r('sys.float.ohos_id_corner_radius_clicked'),
width: ImageMenuItemV2.focusBorderWidth,
color: $r('sys.color.ohos_id_color_focused_outline'),
style: BorderStyle.Solid
})
},
normal: (instance: CommonMethod) => {
instance.border({
radius: $r('sys.float.ohos_id_corner_radius_clicked'),
width: 0
})
}
})
.onFocus(() => {
this.isMoreIconOnFocus = true;
})
.onBlur(() => {
this.isMoreIconOnFocus = false;
})
.onHover((isOn: boolean, event: HoverEvent) => {
this.isMoreIconOnHover = isOn;
})
.onKeyEvent((event: KeyEvent): boolean => {
return Util.handleKeyEvent(event,
() => { this.isMoreIconOnClick = true; },
() => { this.isMoreIconOnClick = false; }
);
} as Callback<KeyEvent, boolean>)
.onTouch((event: TouchEvent) => {
Util.handleTouchEvent(event,
() => { this.isMoreIconOnClick = true; },
() => {
this.isMoreIconOnClick = false;
if (this.fontSize >= this.minFontSize) {
this.closeDialog();
}
}
);
})
.onClick((event?: ClickEvent) => {
this.isPopupShown = true;
} as () => void)
.gestureModifier(this.buttonGestureModifier)
.bindPopup(this.isPopupShown as boolean, {
builder: this.popupBuilder,
placement: Placement.Bottom,
popupColor: Color.White,
enableArrow: false,
onStateChange: (e: PopupStateChangeParam) => {
this.isPopupShown = e.isVisible;
if (!e.isVisible) {
this.isMoreIconOnClick = false;
}
}
} as CustomPopupOptions)
}
}
}
.height('100%')
.justifyContent(FlexAlign.Center)
}
onPlaceChildren(selfLayoutInfo: GeometryInfo, children: Layoutable[], constraint: ConstraintSizeOptions): void {
children.forEach((child) => {
child.layout({ x: 0, y: 0 });
})
this.fontSize = this.decideFontScale();
}
private toStringFormat(resource: ResourceStr | undefined): string | undefined {
if (typeof resource === 'string') {
return resource as string;
} else if (typeof resource === 'undefined') {
return ' ';
} else {
let resourceString: undefined | string = ' ';
try {
resourceString = resourceManager.getSysResourceManager().getStringSync((resource as Resource).id as Long);
} catch (exception) {
let code: int | undefined = (exception as ClassCastError)?.code;
let message: string | undefined = (exception as ClassCastError)?.message;
hilog.error(0x3900, 'AdvancedTabTitleBar', `Failed to TabTitleBar toStringFormat,code: ${code},
message:${message}`);
}
return resourceString;
}
}
@Builder
dialogBuilder() {
Column() {
if (this.tabTitleDialogItem.symbolStyle) {
SymbolGlyph()
.fontColor([$r('sys.color.font_primary')])
.attributeModifier(this.tabTitleDialogItem.symbolStyle)
.fontSize(IMAGE_SIZE)
.draggable(false)
.focusable(this.tabTitleDialogItem?.isEnabled)
.margin({
top: $r('sys.float.padding_level24'),
bottom: $r('sys.float.padding_level8'),
} as Margin)
.symbolEffect(new SymbolEffect(), false)
} else if (this.tabTitleDialogItem.value) {
if (Util.isSymbolResource(this.tabTitleDialogItem.value)) {
SymbolGlyph(this.tabTitleDialogItem.value as Resource)
.fontColor([$r('sys.color.font_primary')])
.fontSize(IMAGE_SIZE)
.draggable(false)
.focusable(this.tabTitleDialogItem?.isEnabled)
.margin({
top: $r('sys.float.padding_level24'),
bottom: $r('sys.float.padding_level8'),
} as Margin)
} else {
Image(this.tabTitleDialogItem.value)
.width(IMAGE_SIZE)
.height(IMAGE_SIZE)
.margin({
top: $r('sys.float.padding_level24'),
bottom: $r('sys.float.padding_level8'),
} as Margin)
.fillColor($r('sys.color.icon_primary'))
}
}
Column() {
Text(this.tabTitleBarDialogText)
.fontSize(TEXT_EDITABLE_DIALOG)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.maxLines(this.maxLines)
.width('100%')
.textAlign(TextAlign.Center)
.fontColor($r('sys.color.font_primary'))
}
.width('100%')
.padding({
left: $r('sys.float.padding_level4'),
right: $r('sys.float.padding_level4'),
bottom: $r('sys.float.padding_level12'),
} as Padding)
}
.width(this.fontSize === this.maxFontSize ? MAX_DIALOG : MIN_DIALOG)
.constraintSize({ minHeight: this.fontSize === this.maxFontSize ? MAX_DIALOG : MIN_DIALOG })
.backgroundBlurStyle(BlurStyle.COMPONENT_ULTRA_THICK, undefined, { disableSystemAdaptation: true })
.shadow(ShadowStyle.OUTER_DEFAULT_LG)
.borderRadius($r('sys.float.corner_radius_level10'))
}
@Builder
popupBuilder() {
Column() {
ForEach(this.menuItems.slice(CollapsibleMenuSectionV2.maxCountOfVisibleItems.toInt() - 1, this.menuItems.length),
(item: TabTitleBarV2MenuItem, index: int) => {
ImageMenuItemV2({
item: item as TabTitleBarV2MenuItem, index: this.index * 1000 +
CollapsibleMenuSectionV2.maxCountOfVisibleItems + index
});
}, (item: TabTitleBarV2MenuItem, index: int) => {
return JSON.stringify(item.value + '' + index)
})
}
.width(ImageMenuItemV2.imageHotZoneWidth
+ CollapsibleMenuSectionV2.focusPadding * CollapsibleMenuSectionV2.marginsNum)
.margin({ top: CollapsibleMenuSectionV2.focusPadding, bottom: CollapsibleMenuSectionV2.focusPadding } as Margin)
.onAppear(() => {
focusControl.requestFocus(ImageMenuItemV2.focusablePrefix + this.firstFocusableIndex);
})
}
}
@ComponentV2
struct TabContentItemV2 {
@Param item: TabTitleBarV2TabItem = new TabTitleBarV2TabItem({ title: '' });
@Param index: number = 0;
@Param maxIndex: number = 0;
@Param onCustomClick?: (index: number) => void = undefined;
@Param onImageComplete?: (width: number) => void = undefined;
@Param currentIndex: number = 0;
@Local isOnFocus: boolean = false;
@Local isOnHover: boolean = false;
@Local isOnClick: boolean = false;
@Local tabWidth: number = 0;
@Local imageWidth: number = 24;
@Local imageHeight: number = 24;
public static readonly imageSize: number = 24;
public static readonly symbolSize: string = '24vp';
public static readonly imageHotZoneWidth: number = 48;
public static readonly imageMagnificationFactor: number = 1.4;
public static readonly buttonBorderRadius: number = 8;
public static readonly focusBorderWidth: number = 2;
public static readonly paddingLeft: number = 8;
public static readonly paddingRight: number = 8;
public static readonly marginFirst: number = 16;
getBgColor(): ResourceColor {
if (this.isOnClick) {
return $r('sys.color.ohos_id_color_click_effect');
} else if (this.isOnHover) {
return $r('sys.color.ohos_id_color_hover');
} else {
return Color.Transparent;
}
}
getBorderAttr(): BorderOptions {
if (this.isOnFocus) {
return {
radius: $r('sys.float.ohos_id_corner_radius_clicked'),
width: TabContentItemV2.focusBorderWidth,
color: $r('sys.color.ohos_id_color_focused_outline'),
style: BorderStyle.Solid
};
}
return { width: 0 }
}
getImageScaleFactor(): number {
return this.index === this.currentIndex ? TabContentItemV2.imageMagnificationFactor : 1;
}
getImageLayoutWidth(): number {
return TabContentItemV2.imageSize / Math.max(this.imageHeight, 1.0) * this.imageWidth;
}
private completeCallback: ImageOnCompleteCallback | undefined = (info?: ImageCompleteEvent) => {
let onImageCompleteValue: number =
this.getUIContext().px2vp(info?.componentWidth as int)
+ TabContentItemV2.paddingLeft + TabContentItemV2.paddingRight;
this.onImageComplete?.(onImageCompleteValue);
}
private onErrorCallback: ImageErrorCallback | undefined = (event: ImageError) => {
if (!this.onImageComplete) {
return;
}
let onImageComplete: number =
this.getUIContext().px2vp(event.componentWidth as int)
+ TabContentItemV2.paddingLeft + TabContentItemV2.paddingRight
this.onImageComplete?.(onImageComplete);
}
private toStringFormat(resource: ResourceStr | undefined): string | undefined {
if (typeof resource === 'string') {
return resource as string;
} else if (typeof resource === 'undefined') {
return ' ';
} else {
let resourceString: undefined | string = ' ';
try {
resourceString = resourceManager.getSysResourceManager().getStringSync((resource as Resource).id as Long);
} catch (exception) {
let code: int | undefined = (exception as ClassCastError)?.code;
let message: string | undefined = (exception as ClassCastError)?.message;
hilog.error(0x3900, 'AdvancedTabTitleBar', `Failed to TabTitleBar toStringFormat,code: ${code},
message:${message}`);
}
return resourceString;
}
}
build() {
Stack() {
Row() {
Column() {
if (this.item.icon === undefined && this.item.symbolStyle === undefined) {
Text(this.item.title)
.fontSize(this.index === this.currentIndex
? $r('sys.float.ohos_id_text_size_headline7')
: $r('sys.float.ohos_id_text_size_headline9'))
.fontColor(this.index === this.currentIndex
? $r('sys.color.ohos_id_color_titlebar_text')
: $r('sys.color.ohos_id_color_titlebar_text_off'))
.fontWeight(FontWeight.Medium)
.focusable(true)
.animation({ duration: 300 })
.padding({
top: this.index === this.currentIndex ? 6 : 10,
left: TabContentItemV2.paddingLeft,
bottom: 2,
right: TabContentItemV2.paddingRight
} as Padding)
.onFocus(() => {
this.isOnFocus = true;
})
.onBlur(() => {
this.isOnFocus = false;
})
.onHover((isOn: boolean, event: HoverEvent) => {
this.isOnHover = isOn;
})
.onKeyEvent((event: KeyEvent): boolean => {
return Util.handleKeyEvent(event,
() => { this.isOnClick = true; },
() => { this.isOnClick = false; }
);
} as Callback<KeyEvent, boolean>)
.onTouch((event: TouchEvent) => {
Util.handleTouchEvent(event,
() => { this.isOnClick = true; },
() => { this.isOnClick = false; }
);
})
.onClick(() => {
if (typeof this.onCustomClick === 'function') {
this.onCustomClick?.(this.index);
}
} as () => void)
.accessibilitySelected(this.index === this.currentIndex)
} else {
Row() {
if (this.item.symbolStyle) {
SymbolGlyph()
.fontColor([$r('sys.color.icon_primary')])
.attributeModifier(this.item.symbolStyle)
.fontSize(TabContentItemV2.symbolSize)
.width(this.getImageLayoutWidth())
.height(TabContentItemV2.imageSize)
.accessibilityText(this.toStringFormat(this.item.title))
.scale({
x: this.getImageScaleFactor(),
y: this.getImageScaleFactor()
})
.animation({ duration: 300 })
.hitTestBehavior(HitTestMode.None)
.focusable(true)
.symbolEffect(new SymbolEffect(), false)
} else {
if (Util.isSymbolResource(this.item.icon)) {
SymbolGlyph(this.item.icon as Resource)
.fontColor([$r('sys.color.icon_primary')])
.fontSize(TabContentItemV2.symbolSize)
.width(this.getImageLayoutWidth())
.height(TabContentItemV2.imageSize)
.accessibilityText(this.toStringFormat(this.item.title))
.scale({
x: this.getImageScaleFactor(),
y: this.getImageScaleFactor()
})
.animation({ duration: 300 })
.hitTestBehavior(HitTestMode.None)
.focusable(true)
} else {
Image((this.item?.icon ?? '') as ResourceStr)
.alt(this.item.title)
.width(this.getImageLayoutWidth())
.height(TabContentItemV2.imageSize)
.objectFit(ImageFit.Fill)
.accessibilityText(this.toStringFormat(this.item.title))
.scale({
x: this.getImageScaleFactor(),
y: this.getImageScaleFactor()
})
.animation({ duration: 300 })
.hitTestBehavior(HitTestMode.None)
.focusable(true)
.onComplete(this.completeCallback)
.onError(this.onErrorCallback)
}
}
}
.width(this.getImageLayoutWidth() * this.getImageScaleFactor() +
TabContentItemV2.paddingLeft + TabContentItemV2.paddingRight)
.constraintSize({
minWidth: TabContentItemV2.imageHotZoneWidth,
minHeight: TabContentItemV2.imageHotZoneWidth
})
.animation({ duration: 300 })
.justifyContent(FlexAlign.Center)
.onFocus(() => {
this.isOnFocus = true;
} as () => void)
.onBlur(() => {
this.isOnFocus = false;
} as () => void)
.onHover((isOn: boolean, event: HoverEvent) => {
this.isOnHover = isOn;
})
.onKeyEvent((event: KeyEvent): boolean => {
return Util.handleKeyEvent(event,
() => { this.isOnClick = true; },
() => { this.isOnClick = false; }
);
} as Callback<KeyEvent, boolean>)
.onTouch((event: TouchEvent) => {
Util.handleTouchEvent(event,
() => { this.isOnClick = true; },
() => { this.isOnClick = false; }
);
})
.onClick(() => {
if (typeof this.onCustomClick === 'function') {
this.onCustomClick?.(this.index as number);
}
} as () => void)
.accessibilitySelected(this.index === this.currentIndex)
}
}
.justifyContent(FlexAlign.Center)
}
.height(TabTitleBarV2.totalHeight)
.alignItems(VerticalAlign.Center)
.justifyContent(FlexAlign.Center)
.borderRadius(TabContentItemV2.buttonBorderRadius)
.backgroundColor(this.getBgColor())
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.tabWidth = newValue.width as number;
})
if (this.isOnFocus && this.tabWidth > 0) {
Row()
.width(this.tabWidth)
.height(TabTitleBarV2.totalHeight)
.hitTestBehavior(HitTestMode.None)
.borderRadius(TabContentItemV2.buttonBorderRadius)
.stateStyles({
focused: (instance: CommonMethod) => {
instance.border(this.getBorderAttr())
},
normal: (instance: CommonMethod) => {
instance.border({
radius: $r('sys.float.ohos_id_corner_radius_clicked'),
width: 0
})
}
})
}
}
.margin({
start: LengthMetrics.vp(this.index === 0 ? TabContentItemV2.marginFirst : 0),
end: LengthMetrics.vp(this.index === this.maxIndex ? 12 : 0)
} as LocalizedPadding)
}
}
@ComponentV2
struct ImageMenuItemV2 {
@Param item: TabTitleBarV2MenuItem = new TabTitleBarV2MenuItem({ value: '' });
@Param index: number = 0;
static readonly imageSize: number = 24;
static readonly imageHotZoneWidth: number = 48;
static readonly buttonBorderRadius: number = 8;
static readonly focusBorderWidth: number = 2;
static readonly disabledImageOpacity: number = 0.4;
static readonly focusablePrefix: string = "Id-TabTitleBar-ImageMenuItem-";
@Local isOnFocus: boolean = false;
@Local isOnHover: boolean = false;
@Local isOnClick: boolean = false;
getFgColor(): ResourceColor {
return this.isOnClick
? $r('sys.color.ohos_id_color_titlebar_icon_pressed')
: $r('sys.color.ohos_id_color_titlebar_icon');
}
getBgColor(): ResourceColor {
if (this.isOnClick) {
return $r('sys.color.ohos_id_color_click_effect');
} else if (this.isOnHover) {
return $r('sys.color.ohos_id_color_hover');
} else {
return Color.Transparent;
}
}
private toStringFormat(resource: ResourceStr | undefined): string | undefined {
if (typeof resource === 'string') {
return resource as string;
} else if (typeof resource === 'undefined') {
return ' ' as string;
} else {
let resourceString: undefined | string = ' ';
try {
resourceString = resourceManager.getSysResourceManager().getStringSync((resource as Resource).id as Long);
} catch (exception) {
let code: int | undefined = (exception as ClassCastError)?.code;
let message: string | undefined = (exception as ClassCastError)?.message;
hilog.error(0x3900, 'AdvancedTabTitleBar', `Failed to TabTitleBar toStringFormat,code: ${code},
message:${message}`);
}
return resourceString;
}
}
private getAccessibilityReadText(): string | undefined {
if (this.item.value === PUBLIC_MORE) {
try {
return resourceManager.getSysResourceManager().getStringByNameSync('ohos_toolbar_more');
} catch (exception) {
let code: int | undefined = (exception as ClassCastError)?.code;
let message: string | undefined = (exception as ClassCastError)?.message;
hilog.error(0x3900, 'AdvancedTabTitleBar', `Failed to TabTitleBar toStringFormat,code: ${code},
message:${message}`);
}
} else if (this.item.accessibilityText) {
if (typeof this.item.accessibilityText === 'string') {
return this.item.accessibilityText as string;
} else if (typeof this.item.accessibilityText === 'undefined') {
return ' ' as string;
} else {
let accessibilityText: string = ' ';
try {
accessibilityText = resourceManager.getSysResourceManager()
.getStringSync((this.item.accessibilityText as Resource).id as Long);
} catch (exception) {
let code: int | undefined = (exception as ClassCastError)?.code;
let message: string | undefined = (exception as ClassCastError)?.message;
hilog.error(0x3900, 'AdvancedTabTitleBar',
`Failed to TabTitleBar toStringFormat,code: ${code},message:${message}`);
}
return accessibilityText as string;
}
} else if (this.item.label) {
if (typeof this.item.label === 'string') {
return this.item.label as string;
} else if (typeof this.item.label === 'undefined') {
return ' ' as string;
} else {
let label: string = ' ';
try {
label = resourceManager.getSysResourceManager().getStringSync((this.item.label as Resource).id as Long);
} catch (exception) {
let code: int | undefined = (exception as ClassCastError)?.code;
let message: string | undefined = (exception as ClassCastError)?.message;
hilog.error(0x3900, 'AdvancedTabTitleBar', `Failed to TabTitleBar toStringFormat,code: ${
code},message:${message}`);
}
return label as string;
}
}
return ' ';
}
private getAccessibilityReadDescription(): string | undefined {
if (this.item.accessibilityDescription) {
if (typeof this.item.accessibilityDescription === 'string') {
return this.item.accessibilityDescription as string;
} else if (typeof this.item.accessibilityDescription === 'undefined') {
return ' ' as string;
} else {
let accessibilityDescription: string = ' ';
try {
accessibilityDescription = resourceManager.getSysResourceManager()
.getStringSync((this.item.accessibilityDescription as Resource).id as Long);
} catch (exception) {
let code: int | undefined = (exception as ClassCastError)?.code;
let message: string | undefined = (exception as ClassCastError)?.message;
hilog.error(0x3900, 'AdvancedTabTitleBar', `Failed to TabTitleBar toStringFormat,code: ${code},message:${message}`);
}
return accessibilityDescription as string;
}
}
return ' ';
}
build() {
Button({ type: ButtonType.Normal, stateEffect: this.item.isEnabled } as ButtonOptions) {
if (this.item.symbolStyle) {
SymbolGlyph()
.fontColor([$r('sys.color.font_primary')])
.attributeModifier(this.item.symbolStyle)
.fontSize(TabContentItemV2.symbolSize)
.draggable(false)
.focusable(this.item?.isEnabled)
.key(ImageMenuItemV2.focusablePrefix + this.index)
.symbolEffect(new SymbolEffect(), false)
} else {
if (Util.isSymbolResource(this.item.value)) {
SymbolGlyph(this.item.value as Resource)
.fontColor([$r('sys.color.font_primary')])
.fontSize(TabContentItemV2.symbolSize)
.draggable(false)
.focusable(this.item?.isEnabled)
.key(ImageMenuItemV2.focusablePrefix + this.index)
} else {
Image(this.item.value)
.width(ImageMenuItemV2.imageSize)
.height(ImageMenuItemV2.imageSize)
.focusable(this.item.isEnabled)
.key(ImageMenuItemV2.focusablePrefix + this.index)
.draggable(false)
}
}
}
.accessibilityText(this.getAccessibilityReadText())
.accessibilityLevel(this.item?.accessibilityLevel ?? 'auto')
.accessibilityDescription(this.getAccessibilityReadDescription())
.width(ImageMenuItemV2.imageHotZoneWidth)
.height(ImageMenuItemV2.imageHotZoneWidth)
.borderRadius(ImageMenuItemV2.buttonBorderRadius)
.foregroundColor(this.getFgColor())
.backgroundColor(this.getBgColor())
.enabled(this.item.isEnabled ? this.item.isEnabled : false)
.stateStyles({
focused: (instance: CommonMethod) => {
instance.border({
radius: $r('sys.float.ohos_id_corner_radius_clicked'),
width: ImageMenuItemV2.focusBorderWidth,
color: $r('sys.color.ohos_id_color_focused_outline'),
style: BorderStyle.Solid
})
},
normal: (instance: CommonMethod) => {
instance.border({
radius: $r('sys.float.ohos_id_corner_radius_clicked'),
width: 0
})
}
})
.onFocus(() => {
if (!this.item.isEnabled) {
return;
}
this.isOnFocus = true;
})
.onBlur(() => {
this.isOnFocus = false;
} as () => void)
.onHover((isOn: boolean, event: HoverEvent) => {
if (this.item.isEnabled) {
this.isOnHover = isOn;
}
})
.onKeyEvent((event: KeyEvent): boolean => {
if (!this.item.isEnabled) {
return false;
}
return Util.handleKeyEvent(event,
() => { this.isOnClick = true; },
() => { this.isOnClick = false; }
);
} as Callback<KeyEvent, boolean>)
.onTouch((event: TouchEvent) => {
if (!this.item.isEnabled) {
return;
}
Util.handleTouchEvent(event,
() => { this.isOnClick = true; },
() => { this.isOnClick = false; }
);
})
.onClick(() => {
if (this.item.isEnabled && typeof this.item?.action === 'function') {
(this.item?.action as () => void)();
}
} as () => void)
}
}
class Util {
public static isSymbolResource(resourceStr: ResourceStr | undefined | null): boolean {
if (!Util.isResourceType(resourceStr)) {
return false;
}
let resource = resourceStr as Resource;
return resource.type === RESOURCE_TYPE_SYMBOL;
}
public static isResourceType(resource: ResourceStr | Resource | undefined | null): boolean {
if (!resource) {
return false;
}
if (typeof resource === 'string' || typeof resource === 'undefined') {
return false;
}
return true;
}
public static handleKeyEvent(event: KeyEvent, onPressDown: () => void, onPressUp: () => void): boolean {
if (event.keyCode !== KeyCode.KEYCODE_ENTER && event.keyCode !== KeyCode.KEYCODE_SPACE) {
return false;
}
if (event.type === KeyType.Down) {
onPressDown();
}
if (event.type === KeyType.Up) {
onPressUp();
}
return true;
}
public static handleTouchEvent(event: TouchEvent, onPressDown: () => void, onPressUp: () => void): void {
if (event.type === TouchType.Down) {
onPressDown();
}
if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
onPressUp();
}
}
}