1/*
2 * Copyright (c) 2023-2024 Huawei Device Co., Ltd.
3 * Licensed under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License.
5 * You may obtain a copy of the License at
6 *
7 *     http://www.apache.org/licenses/LICENSE-2.0
8 *
9 * Unless required by applicable law or agreed to in writing, software
10 * distributed under the License is distributed on an "AS IS" BASIS,
11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 * See the License for the specific language governing permissions and
13 * limitations under the License.
14 */
15
16import { Theme } from '@ohos.arkui.theme';
17import { LengthMetrics, LengthUnit, ColorMetrics } from '@ohos.arkui.node';
18import { DividerModifier, SymbolGlyphModifier } from '@ohos.arkui.modifier';
19import hilog from '@ohos.hilog';
20import window from '@ohos.window';
21import common from '@ohos.app.ability.common';
22import { BusinessError } from '@ohos.base';
23
24export enum ItemState {
25  ENABLE = 1,
26  DISABLE = 2,
27  ACTIVATE = 3,
28}
29
30// “更多”栏图标
31const PUBLIC_MORE: Resource = $r('sys.media.ohos_ic_public_more');
32const IMAGE_SIZE: string = '24vp';
33const DEFAULT_TOOLBAR_HEIGHT: number = 56;
34const TOOLBAR_MAX_LENGTH: number = 5;
35const MAX_FONT_SIZE = 3.2;
36const DIALOG_IMAGE_SIZE = '64vp';
37const MAX_DIALOG = '256vp';
38const MIN_DIALOG = '216vp';
39const TEXT_TOOLBAR_DIALOG = '18.3fp';
40const FOCUS_BOX_MARGIN: number = -2;
41const FOCUS_BOX_BORDER_WIDTH: number = 2;
42
43interface MenuController {
44  value: ResourceStr;
45  action: () => void;
46  enabled?: boolean;
47}
48
49export interface ToolBarSymbolGlyphOptions {
50  normal?: SymbolGlyphModifier;
51  activated?: SymbolGlyphModifier;
52}
53
54class ButtonGestureModifier implements GestureModifier {
55  public static readonly longPressTime: number = 500;
56  public static readonly minFontSize: number = 1.75;
57  public fontSize: number = 1;
58  public controller: CustomDialogController | null = null;
59
60  constructor(controller: CustomDialogController | null) {
61    this.controller = controller;
62  }
63
64  applyGesture(event: UIGestureEvent): void {
65    if (this.fontSize >= ButtonGestureModifier.minFontSize) {
66      event.addGesture(
67        new LongPressGestureHandler({ repeat: false, duration: ButtonGestureModifier.longPressTime })
68          .onAction(() => {
69            if (event) {
70              this.controller?.open();
71            }
72          })
73          .onActionEnd(() => {
74            this.controller?.close();
75          })
76      )
77    } else {
78      event.clearGestures();
79    }
80  }
81}
82
83@Observed
84export class ToolBarOption {
85  public content: ResourceStr = '';
86  public action?: () => void = undefined;
87  public icon?: Resource = undefined;
88  public state?: ItemState = 1;
89  public iconColor?: ResourceColor = $r('sys.color.icon_primary');
90  public activatedIconColor?: ResourceColor = $r('sys.color.icon_emphasize');
91  public textColor?: ResourceColor = $r('sys.color.font_primary');
92  public activatedTextColor?: ResourceColor = $r('sys.color.font_emphasize');
93  public toolBarSymbolOptions?: ToolBarSymbolGlyphOptions = undefined;
94}
95
96@Observed
97export class ToolBarOptions extends Array<ToolBarOption> {
98}
99
100export class ToolBarModifier implements AttributeModifier<ColumnAttribute> {
101  public backgroundColorValue?: ResourceColor = $r('sys.color.ohos_id_color_toolbar_bg');
102  public heightValue?: LengthMetrics = LengthMetrics.vp(DEFAULT_TOOLBAR_HEIGHT);
103  public stateEffectValue?: boolean = true;
104  public paddingValue?: LengthMetrics = LengthMetrics.resource($r('sys.float.padding_level12'));
105
106  applyNormalAttribute(instance: ColumnAttribute): void {
107    instance.backgroundColor(this.backgroundColorValue);
108  }
109
110  public backgroundColor(backgroundColor: ResourceColor): ToolBarModifier {
111    this.backgroundColorValue = backgroundColor;
112    return this;
113  }
114
115  public height(height: LengthMetrics): ToolBarModifier {
116    this.heightValue = height;
117    return this;
118  }
119
120  public stateEffect(stateEffect: boolean): ToolBarModifier {
121    this.stateEffectValue = stateEffect;
122    return this;
123  }
124
125  public padding(padding: LengthMetrics): ToolBarModifier {
126    this.paddingValue = padding;
127    return this;
128  }
129}
130
131@Component
132export struct ToolBar {
133  @ObjectLink toolBarList: ToolBarOptions;
134  controller: TabsController = new TabsController();
135  @Prop activateIndex: number = -1;
136  @Prop dividerModifier: DividerModifier = new DividerModifier();
137  @Prop toolBarModifier: ToolBarModifier =
138  new ToolBarModifier()
139    .padding(LengthMetrics.resource($r('sys.float.padding_level12')))
140    .stateEffect(true)
141    .height(LengthMetrics.vp(DEFAULT_TOOLBAR_HEIGHT))
142    .backgroundColor('sys.color.ohos_id_color_toolbar_bg');
143  @Prop moreText: ResourceStr = $r('sys.string.ohos_toolbar_more');
144  @State menuContent: MenuController[] = [];
145  @State toolBarItemBackground: ResourceColor[] = [];
146  @State iconPrimaryColor: ResourceColor = $r('sys.color.icon_primary');
147  @State iconActivePrimaryColor: ResourceColor = $r('sys.color.icon_emphasize');
148  @State fontPrimaryColor: ResourceColor = $r('sys.color.font_primary');
149  @State fontActivatedPrimaryColor: ResourceColor = $r('sys.color.font_emphasize');
150  @State symbolEffect: SymbolEffect = new SymbolEffect();
151  @State fontSize: number = 1;
152  isFollowSystem: boolean = false;
153  maxFontSizeScale: number = 3.2;
154  moreIndex: number = 4;
155  moreItem: ToolBarOption = {
156    content: $r('sys.string.ohos_toolbar_more'),
157    icon: PUBLIC_MORE,
158  }
159
160  onWillApplyTheme(theme: Theme) {
161    this.iconPrimaryColor = theme.colors.iconPrimary;
162    this.iconActivePrimaryColor = theme.colors.iconEmphasize;
163    this.fontPrimaryColor = theme.colors.fontPrimary;
164    this.fontActivatedPrimaryColor = theme.colors.fontEmphasize;
165  }
166
167  @Builder
168  MoreTabBuilder(index: number) {
169    Button({ type: ButtonType.Normal, stateEffect: false }) {
170      Column() {
171        Image(PUBLIC_MORE)
172          .width(IMAGE_SIZE)
173          .height(IMAGE_SIZE)
174          .fillColor(this.iconPrimaryColor)
175          .margin({ bottom: $r('sys.float.padding_level1') })
176          .objectFit(ImageFit.Contain)
177          .draggable(false)
178        Text(this.moreText)
179          .fontColor(this.fontPrimaryColor)
180          .fontSize($r('sys.float.ohos_id_text_size_caption'))
181          .fontWeight(FontWeight.Medium)
182          .maxLines(1)
183          .textOverflow({ overflow: TextOverflow.Ellipsis })
184          .textAlign(TextAlign.Center)
185          .focusable(true)
186          .focusOnTouch(true)
187      }
188      .width('100%')
189      .height('100%')
190      .justifyContent(FlexAlign.Center)
191      .padding({
192        start: LengthMetrics.resource($r('sys.float.padding_level2')),
193        end: LengthMetrics.resource($r('sys.float.padding_level2')),
194      })
195      .borderRadius($r('sys.float.ohos_id_corner_radius_clicked'))
196    }
197    .focusable(true)
198    .focusOnTouch(true)
199    .focusBox({
200      margin: LengthMetrics.vp(FOCUS_BOX_MARGIN),
201      strokeWidth: LengthMetrics.vp(FOCUS_BOX_BORDER_WIDTH),
202      strokeColor: ColorMetrics.resourceColor($r('sys.color.ohos_id_color_focused_outline'))
203    })
204    .width('100%')
205    .height('100%')
206    .bindMenu(this.menuContent, { placement: Placement.TopRight, offset: { x: -12, y : -10 } })
207    .borderRadius($r('sys.float.ohos_id_corner_radius_clicked'))
208    .backgroundColor(this.toolBarItemBackground[index])
209    .onHover((isHover: boolean) => {
210      if (isHover) {
211        this.toolBarItemBackground[index] = $r('sys.color.ohos_id_color_hover');
212      } else {
213        this.toolBarItemBackground[index] = Color.Transparent;
214      }
215    })
216    .stateStyles({
217      pressed: {
218        .backgroundColor((!this.toolBarModifier.stateEffectValue) ?
219        this.toolBarItemBackground[index] : $r('sys.color.ohos_id_color_click_effect'))
220      }
221    })
222    .gestureModifier(this.getItemGestureModifier(this.moreItem, index))
223  }
224
225  @Builder
226  TabBuilder(index: number) {
227    Button({ type: ButtonType.Normal, stateEffect: false }) {
228      Column() {
229        if (this.toolBarList[index]?.toolBarSymbolOptions?.normal ||
230          this.toolBarList[index]?.toolBarSymbolOptions?.activated) {
231          SymbolGlyph()
232            .fontSize(IMAGE_SIZE)
233            .symbolEffect(this.symbolEffect, false)
234            .attributeModifier(this.getToolBarSymbolModifier(index))
235            .margin({ bottom: $r('sys.float.padding_level1') })
236        } else {
237          Image(this.toolBarList[index]?.icon)
238            .width(IMAGE_SIZE)
239            .height(IMAGE_SIZE)
240            .fillColor(this.getIconColor(index))
241            .margin({ bottom: $r('sys.float.padding_level1') })
242            .objectFit(ImageFit.Contain)
243            .draggable(false)
244        }
245        Text(this.toolBarList[index]?.content)
246          .fontColor(this.getTextColor(index))
247          .fontSize($r('sys.float.ohos_id_text_size_caption'))
248          .maxFontSize($r('sys.float.ohos_id_text_size_caption'))
249          .minFontSize(9)
250          .fontWeight(FontWeight.Medium)
251          .maxLines(1)
252          .textOverflow({ overflow: TextOverflow.Ellipsis })
253          .textAlign(TextAlign.Center)
254          .focusable(!(this.toolBarList[index]?.state === ItemState.DISABLE))
255          .focusOnTouch(!(this.toolBarList[index]?.state === ItemState.DISABLE))
256      }
257      .justifyContent(FlexAlign.Center)
258      .width('100%')
259      .height('100%')
260      .borderRadius($r('sys.float.ohos_id_corner_radius_clicked'))
261      .padding({
262        start: LengthMetrics.resource($r('sys.float.padding_level2')),
263        end: LengthMetrics.resource($r('sys.float.padding_level2')),
264      })
265    }
266    .enabled(this.toolBarList[index]?.state !== ItemState.DISABLE)
267    .width('100%')
268    .height('100%')
269    .borderRadius($r('sys.float.ohos_id_corner_radius_clicked'))
270    .focusable(!(this.toolBarList[index]?.state === ItemState.DISABLE))
271    .focusOnTouch(!(this.toolBarList[index]?.state === ItemState.DISABLE))
272    .focusBox({
273      margin: LengthMetrics.vp(FOCUS_BOX_MARGIN),
274      strokeWidth: LengthMetrics.vp(FOCUS_BOX_BORDER_WIDTH),
275      strokeColor: ColorMetrics.resourceColor($r('sys.color.ohos_id_color_focused_outline'))
276    })
277    .backgroundColor(this.toolBarItemBackground[index])
278    .onHover((isHover: boolean) => {
279      if (isHover && this.toolBarList[index]?.state !== ItemState.DISABLE) {
280        this.toolBarItemBackground[index] = $r('sys.color.ohos_id_color_hover');
281      } else {
282        this.toolBarItemBackground[index] = Color.Transparent;
283      }
284    })
285    .stateStyles({
286      pressed: {
287        .backgroundColor((this.toolBarList[index]?.state === ItemState.DISABLE) ||
288          (!this.toolBarModifier.stateEffectValue) ?
289        this.toolBarItemBackground[index] : $r('sys.color.ohos_id_color_click_effect'))
290      }
291    })
292    .onClick(() => {
293      this.clickEventAction(index);
294    })
295    .gestureModifier(this.getItemGestureModifier(this.toolBarList[index], index))
296  }
297
298  private getFontSizeScale(): number {
299    let context = this.getUIContext();
300    let fontScaleSystem = (context.getHostContext() as common.UIAbilityContext)?.config?.fontSizeScale ?? 1;
301    if (!this.isFollowSystem) {
302      return 1;
303    } else {
304      return Math.min(fontScaleSystem, this.maxFontSizeScale);
305    }
306  }
307
308  private getToolBarSymbolModifier(index: number): SymbolGlyphModifier | undefined {
309    if ((!this.toolBarList[index]?.toolBarSymbolOptions?.activated) &&
310      (!this.toolBarList[index]?.toolBarSymbolOptions?.normal)) {
311      return undefined;
312    }
313    if (this.activateIndex === index && (this.toolBarList[index]?.state === ItemState.ACTIVATE)) {
314      return this.toolBarList[index]?.toolBarSymbolOptions?.activated;
315    }
316    return this.toolBarList[index]?.toolBarSymbolOptions?.normal;
317  }
318
319  private getIconColor(index: number): ResourceColor {
320    if (this.activateIndex === index && (this.toolBarList[index]?.state === ItemState.ACTIVATE)) {
321      return this.toolBarList[index]?.activatedIconColor ?? this.iconActivePrimaryColor;
322    }
323    return this.toolBarList[index]?.iconColor ?? this.iconPrimaryColor;
324  }
325
326  private getTextColor(index: number): ResourceColor {
327    if (this.activateIndex === index && (this.toolBarList[index]?.state === ItemState.ACTIVATE)) {
328      return this.toolBarList[index]?.activatedTextColor ?? this.fontActivatedPrimaryColor;
329    }
330    return this.toolBarList[index]?.textColor ?? this.fontPrimaryColor;
331  }
332
333  private toLengthString(value: LengthMetrics | undefined): string {
334    if (value === void (0)) {
335      return '';
336    }
337    const length: number = value.value;
338    let lengthString: string = '';
339    switch (value.unit) {
340      case LengthUnit.PX:
341        lengthString = `${length}px`;
342        break;
343      case LengthUnit.FP:
344        lengthString = `${length}fp`;
345        break;
346      case LengthUnit.LPX:
347        lengthString = `${length}lpx`;
348        break;
349      case LengthUnit.PERCENT:
350        lengthString = `${length * 100}%`;
351        break;
352      case LengthUnit.VP:
353        lengthString = `${length}vp`;
354        break;
355      default:
356        lengthString = `${length}vp`;
357        break;
358    }
359    return lengthString;
360  }
361
362  private clickEventAction(index: number): void {
363    let toolbar = this.toolBarList[index];
364    if (toolbar.state === ItemState.ACTIVATE) {
365      if (this.activateIndex === index) {
366        this.activateIndex = -1;
367      } else {
368        this.activateIndex = index;
369      }
370    }
371    if (!(toolbar.state === ItemState.DISABLE)) {
372      toolbar.action && toolbar.action();
373    }
374  }
375
376  private getItemGestureModifier(item: ToolBarOption, index: number): ButtonGestureModifier {
377    let buttonGestureModifier: ButtonGestureModifier = new ButtonGestureModifier(null);
378    if (item?.icon || item?.toolBarSymbolOptions?.activated || item?.toolBarSymbolOptions?.normal) {
379      buttonGestureModifier = new ButtonGestureModifier(new CustomDialogController({
380        builder: ToolBarDialog({
381          itemDialog: item,
382          fontSize: this.fontSize,
383          itemSymbolModifier: this.getToolBarSymbolModifier(index),
384        }),
385        maskColor: Color.Transparent,
386        isModal: true,
387        customStyle: true,
388      }))
389      buttonGestureModifier.fontSize = this.fontSize;
390    }
391    return buttonGestureModifier;
392  }
393
394  refreshData() {
395    this.menuContent = [];
396    for (let i = 0; i < this.toolBarList.length; i++) {
397      if (i >= this.moreIndex && this.toolBarList.length > TOOLBAR_MAX_LENGTH) {
398        this.menuContent[i - this.moreIndex] = {
399          value: this.toolBarList[i].content,
400          action: this.toolBarList[i].action as () => void,
401          enabled: this.toolBarList[i].state !== ItemState.DISABLE,
402        }
403      } else {
404        this.menuContent = [];
405      }
406      this.toolBarItemBackground[i] = this.toolBarItemBackground[i] ?? Color.Transparent;
407    }
408    return true;
409  }
410
411  onMeasureSize(selfLayoutInfo: GeometryInfo, children: Measurable[], constraint: ConstraintSizeOptions): SizeResult {
412    this.fontSize = this.getFontSizeScale();
413    let sizeResult: SizeResult = { height: 0, width: 0 };
414    children.forEach((child) => {
415      let childMeasureResult: MeasureResult = child.measure(constraint);
416      sizeResult.width = childMeasureResult.width;
417      sizeResult.height = childMeasureResult.height;
418    });
419    return sizeResult;
420  }
421
422  aboutToAppear() {
423    this.refreshData();
424    try {
425      this.isFollowSystem = this.getUIContext()?.isFollowingSystemFontScale();
426      this.maxFontSizeScale = this.getUIContext()?.getMaxFontScale();
427    } catch (err) {
428      let code: number = (err as BusinessError)?.code;
429      let message: string = (err as BusinessError)?.message;
430      hilog.error(0x3900, 'Ace', `Faild to toolBar getMaxFontScale, code: ${code}, message: ${message}`);
431    }
432  }
433
434  build() {
435    Column() {
436      Tabs({ controller: this.controller }) {
437      }
438      .visibility(Visibility.None)
439      Divider()
440        .width('100%').height(1)
441        .attributeModifier(this.dividerModifier)
442      Row() {
443        ForEach(this.toolBarList, (item: ToolBarOption, index: number) => {
444          if (this.toolBarList.length <= TOOLBAR_MAX_LENGTH || index < this.moreIndex) {
445            Row() {
446              this.TabBuilder(index);
447            }
448            .height('100%')
449            .flexShrink(1)
450          }
451        })
452        if (this.refreshData() && this.toolBarList.length > TOOLBAR_MAX_LENGTH) {
453          Row() {
454            this.MoreTabBuilder(this.moreIndex);
455          }
456          .height('100%')
457          .flexShrink(1)
458        }
459      }
460      .justifyContent(FlexAlign.Center)
461      .constraintSize({
462        minHeight: this.toLengthString(this.toolBarModifier.heightValue),
463        maxHeight: this.toLengthString(this.toolBarModifier.heightValue),
464      })
465      .width('100%')
466      .height(this.toLengthString(this.toolBarModifier.heightValue))
467      .padding({
468        start: this.toolBarList.length < TOOLBAR_MAX_LENGTH ?
469        this.toolBarModifier.paddingValue : LengthMetrics.resource($r('sys.float.padding_level0')),
470        end: this.toolBarList.length < TOOLBAR_MAX_LENGTH ?
471        this.toolBarModifier.paddingValue : LengthMetrics.resource($r('sys.float.padding_level0')),
472      })
473    }
474    .attributeModifier(this.toolBarModifier)
475  }
476}
477
478/**
479 * ToolBarDialog
480 *
481 * @since 2024-07-23
482 */
483@CustomDialog
484struct ToolBarDialog {
485  itemDialog: ToolBarOption = {
486    icon: undefined,
487    content: '',
488  };
489  itemSymbolModifier?: SymbolGlyphModifier;
490  mainWindowStage: window.Window | undefined = undefined;
491  controller?: CustomDialogController
492  screenWidth: number = 640;
493  verticalScreenLines: number = 6;
494  horizontalsScreenLines: number = 1;
495  cancel: () => void = () => {
496  }
497  confirm: () => void = () => {
498  }
499  @StorageLink('mainWindow') mainWindow: Promise<window.Window> | undefined = undefined;
500  @Prop fontSize: number = 1;
501  @State maxLines: number = 1;
502  @StorageProp('windowStandardHeight') windowStandardHeight: number = 0;
503  @State symbolEffect: SymbolEffect = new SymbolEffect();
504
505  build() {
506    if (this.itemDialog.content) {
507      Column() {
508        if (this.itemDialog.toolBarSymbolOptions?.normal ||
509          this.itemDialog.toolBarSymbolOptions?.activated) {
510          SymbolGlyph()
511            .attributeModifier(this.itemSymbolModifier)
512            .symbolEffect(this.symbolEffect, false)
513            .fontColor([$r('sys.color.icon_primary')])
514            .fontSize(DIALOG_IMAGE_SIZE)
515            .margin({
516              top: $r('sys.float.padding_level24'),
517              bottom: $r('sys.float.padding_level8'),
518            })
519        } else {
520          Image(this.itemDialog.icon)
521            .width(DIALOG_IMAGE_SIZE)
522            .height(DIALOG_IMAGE_SIZE)
523            .margin({
524              top: $r('sys.float.padding_level24'),
525              bottom: $r('sys.float.padding_level8'),
526            })
527            .fillColor($r('sys.color.icon_primary'))
528        }
529        Column() {
530          Text(this.itemDialog.content)
531            .fontSize(TEXT_TOOLBAR_DIALOG)
532            .textOverflow({ overflow: TextOverflow.Ellipsis })
533            .maxLines(this.maxLines)
534            .width('100%')
535            .textAlign(TextAlign.Center)
536            .fontColor($r('sys.color.font_primary'))
537        }
538        .width('100%')
539        .padding({
540          left: $r('sys.float.padding_level4'),
541          right: $r('sys.float.padding_level4'),
542          bottom: $r('sys.float.padding_level12'),
543        })
544      }
545      .width(this.fontSize === MAX_FONT_SIZE ? MAX_DIALOG : MIN_DIALOG)
546      .constraintSize({ minHeight: this.fontSize === MAX_FONT_SIZE ? MAX_DIALOG : MIN_DIALOG })
547      .backgroundBlurStyle(BlurStyle.COMPONENT_ULTRA_THICK)
548      .shadow(ShadowStyle.OUTER_DEFAULT_LG)
549      .borderRadius(($r('sys.float.corner_radius_level10')))
550    } else {
551      Column() {
552        if (this.itemDialog.toolBarSymbolOptions?.normal ||
553          this.itemDialog.toolBarSymbolOptions?.activated) {
554          SymbolGlyph()
555            .attributeModifier(this.itemSymbolModifier)
556            .symbolEffect(this.symbolEffect, false)
557            .fontColor([$r('sys.color.icon_primary')])
558            .fontSize(DIALOG_IMAGE_SIZE)
559        } else {
560          Image(this.itemDialog.icon)
561            .width(DIALOG_IMAGE_SIZE)
562            .height(DIALOG_IMAGE_SIZE)
563            .fillColor($r('sys.color.icon_primary'))
564        }
565      }
566      .width(this.fontSize === MAX_FONT_SIZE ? MAX_DIALOG : MIN_DIALOG)
567      .constraintSize({ minHeight: this.fontSize === MAX_FONT_SIZE ? MAX_DIALOG : MIN_DIALOG })
568      .backgroundBlurStyle(BlurStyle.COMPONENT_ULTRA_THICK)
569      .shadow(ShadowStyle.OUTER_DEFAULT_LG)
570      .borderRadius(($r('sys.float.corner_radius_level10')))
571      .justifyContent(FlexAlign.Center)
572    }
573  }
574
575  async aboutToAppear(): Promise<void> {
576    let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
577    this.mainWindowStage = context.windowStage.getMainWindowSync();
578    let properties: window.WindowProperties = this.mainWindowStage.getWindowProperties();
579    let rect = properties.windowRect;
580    if (px2vp(rect.height) > this.screenWidth) {
581      this.maxLines = this.verticalScreenLines;
582    } else {
583      this.maxLines = this.horizontalsScreenLines;
584    }
585  }
586}