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 { KeyCode } from '@ohos.multimodalInput.keyCode'
17import window from '@ohos.window'
18import common from '@ohos.app.ability.common'
19import { BusinessError } from '@kit.BasicServicesKit'
20import { hilog } from '@kit.PerformanceAnalysisKit'
21
22export interface SelectTitleBarMenuItem {
23  value: ResourceStr;
24  isEnabled?: boolean;
25  action?: () => void;
26  label?: ResourceStr;
27}
28
29const PUBLIC_MORE: Resource = $r('sys.media.ohos_ic_public_more');
30
31const PUBLIC_BACK: Resource = $r('sys.media.ohos_ic_back');
32
33const TEXT_EDITABLE_DIALOG = '18.3fp'
34const IMAGE_SIZE = '64vp'
35const MAX_DIALOG = '256vp'
36const MIN_DIALOG = '216vp'
37
38
39@Component
40export struct SelectTitleBar {
41  @State selected: number = 0
42
43  options: Array<SelectOption> = [];
44  menuItems: Array<SelectTitleBarMenuItem> = [];
45
46  subtitle: ResourceStr = '';
47  badgeValue: number = 0;
48  hidesBackButton: boolean = false;
49
50  onSelected: ((index: number) => void) = () => {};
51
52  private static readonly badgeSize = 16;
53  private static readonly totalHeight = 56;
54  private static readonly leftPadding = 24;
55  private static readonly leftPaddingWithBack = 12;
56  private static readonly rightPadding = 24;
57  private static readonly badgePadding = 16;
58  private static readonly subtitleLeftPadding = 4;
59  private static instanceCount = 0;
60
61  @State selectMaxWidth: number = 0;
62  @State fontSize: number = 1;
63
64  build() {
65    Flex({
66      justifyContent: FlexAlign.SpaceBetween,
67      alignItems: ItemAlign.Stretch
68    }) {
69      Row() {
70        if (!this.hidesBackButton) {
71          ImageMenuItem({ item: {
72            value: PUBLIC_BACK,
73            isEnabled: true,
74            action: () => this.getUIContext()?.getRouter()?.back()
75          }, index: -1 });
76        }
77
78        Column() {
79          if (this.badgeValue) {
80            Badge({
81              count: this.badgeValue,
82              position: BadgePosition.Right,
83              style: {
84                badgeSize: SelectTitleBar.badgeSize,
85                badgeColor: $r('sys.color.ohos_id_color_emphasize'),
86                borderColor: $r('sys.color.ohos_id_color_emphasize'),
87                borderWidth: 0
88              }
89            }) {
90              Row() {
91                Select(this.options)
92                  .selected(this.selected)
93                  .value(this.selected < this.options.length ? this.options[this.selected].value.toString() : '')
94                  .font({ size: this.hidesBackButton && !this.subtitle
95                    ? $r('sys.float.ohos_id_text_size_headline7')
96                    : $r('sys.float.ohos_id_text_size_headline8') })
97                  .fontColor($r('sys.color.ohos_id_color_titlebar_text'))
98                  .backgroundColor(Color.Transparent)
99                  .onSelect(this.onSelected)
100                  .constraintSize({ maxWidth: this.selectMaxWidth })
101                  .offset({ x: -4 });
102              }
103              .justifyContent(FlexAlign.Start)
104              .margin({ right: $r('sys.float.ohos_id_elements_margin_horizontal_l') });
105            }
106          } else {
107            Row() {
108              Select(this.options)
109                .selected(this.selected)
110                .value(this.selected < this.options.length ? this.options[this.selected].value.toString() : '')
111                .font({ size: this.hidesBackButton && !this.subtitle
112                  ? $r('sys.float.ohos_id_text_size_headline7')
113                  : $r('sys.float.ohos_id_text_size_headline8') })
114                .fontColor($r('sys.color.ohos_id_color_titlebar_text'))
115                .backgroundColor(Color.Transparent)
116                .onSelect(this.onSelected)
117                .constraintSize({ maxWidth: this.selectMaxWidth })
118                .offset({ x: -4 });
119            }
120            .justifyContent(FlexAlign.Start);
121          }
122          if (this.subtitle !== undefined) {
123            Row() {
124              Text(this.subtitle)
125                .fontSize($r('sys.float.ohos_id_text_size_over_line'))
126                .fontColor($r('sys.color.ohos_id_color_titlebar_subtitle_text'))
127                .maxLines(1)
128                .textOverflow({ overflow: TextOverflow.Ellipsis })
129                .constraintSize({ maxWidth: this.selectMaxWidth })
130                .offset({ y: -4 });
131            }
132            .justifyContent(FlexAlign.Start)
133            .margin({ left: SelectTitleBar.subtitleLeftPadding });
134          }
135        }
136        .justifyContent(FlexAlign.Start)
137        .alignItems(HorizontalAlign.Start)
138        .constraintSize({ maxWidth: this.selectMaxWidth });
139      }
140      .margin({ left: this.hidesBackButton ? $r('sys.float.ohos_id_max_padding_start') :
141      $r('sys.float.ohos_id_default_padding_start') });
142
143      if (this.menuItems !== undefined && this.menuItems.length > 0) {
144        CollapsibleMenuSection({ menuItems: this.menuItems, index: 1 + SelectTitleBar.instanceCount++ });
145      }
146    }
147    .width('100%')
148    .height(SelectTitleBar.totalHeight)
149    .backgroundColor($r('sys.color.ohos_id_color_background'))
150    .onAreaChange((_oldValue: Area, newValue: Area) => {
151      let newWidth = Number(newValue.width);
152      if (!this.hidesBackButton) {
153        newWidth -= ImageMenuItem.imageHotZoneWidth;
154        newWidth += SelectTitleBar.leftPadding;
155        newWidth -= SelectTitleBar.leftPaddingWithBack;
156      }
157      if (this.menuItems !== undefined) {
158        let menusLength = this.menuItems.length;
159        if (menusLength >= CollapsibleMenuSection.maxCountOfVisibleItems) {
160          newWidth -= ImageMenuItem.imageHotZoneWidth * CollapsibleMenuSection.maxCountOfVisibleItems;
161        } else if (menusLength > 0) {
162          newWidth -= ImageMenuItem.imageHotZoneWidth * menusLength;
163        }
164      }
165      if (this.badgeValue) {
166        this.selectMaxWidth = newWidth - SelectTitleBar.badgeSize - SelectTitleBar.leftPadding -
167        SelectTitleBar.rightPadding - SelectTitleBar.badgePadding;
168      } else {
169        this.selectMaxWidth = newWidth - SelectTitleBar.leftPadding - SelectTitleBar.rightPadding;
170      }
171    })
172  }
173}
174
175@Component
176struct CollapsibleMenuSection {
177  menuItems: Array<SelectTitleBarMenuItem> = [];
178  item: SelectTitleBarMenuItem = {
179    value: PUBLIC_MORE,
180    label: $r('sys.string.ohos_toolbar_more'),
181  } as SelectTitleBarMenuItem;
182  index: number = 0;
183  longPressTime: number = 500;
184  minFontSize: number = 1.75;
185  isFollowingSystemFontScale: boolean = false;
186  maxFontScale: number = 1;
187  systemFontScale?: number = 1;
188
189  static readonly maxCountOfVisibleItems = 3
190  private static readonly focusPadding = 4
191  private static readonly marginsNum = 2
192  private firstFocusableIndex = -1
193
194  @State isPopupShown: boolean = false
195
196  @State isMoreIconOnFocus: boolean = false
197  @State isMoreIconOnHover: boolean = false
198  @State isMoreIconOnClick: boolean = false
199  @State fontSize: number = 1
200
201  dialogController: CustomDialogController | null = new CustomDialogController({
202    builder: SelectTitleBarDialog({
203      cancel: () => {
204      },
205      confirm: () => {
206      },
207      selectTitleDialog: this.item,
208      selectTitleBarDialog: this.item.label ? this.item.label : '',
209      fontSize: this.fontSize,
210    }),
211    maskColor: Color.Transparent,
212    isModal: true,
213    customStyle: true,
214  })
215
216  getMoreIconFgColor() {
217    return this.isMoreIconOnClick
218      ? $r('sys.color.ohos_id_color_titlebar_icon_pressed')
219      : $r('sys.color.ohos_id_color_titlebar_icon')
220  }
221
222  getMoreIconBgColor() {
223    if (this.isMoreIconOnClick) {
224      return $r('sys.color.ohos_id_color_click_effect')
225    } else if (this.isMoreIconOnHover) {
226      return $r('sys.color.ohos_id_color_hover')
227    } else {
228      return Color.Transparent
229    }
230  }
231
232  aboutToAppear() {
233    try {
234      let uiContent: UIContext = this.getUIContext();
235      this.isFollowingSystemFontScale = uiContent.isFollowingSystemFontScale();
236      this.maxFontScale = uiContent.getMaxFontScale();
237    } catch (exception) {
238      let code: number = (exception as BusinessError).code;
239      let message: string = (exception as BusinessError).message;
240      hilog.error(0x3900, 'Ace', `Faild to decideFontScale,cause, code: ${code}, message: ${message}`);
241    }
242    this.menuItems.forEach((item, index) => {
243      if (item.isEnabled && this.firstFocusableIndex == -1 &&
244        index > CollapsibleMenuSection.maxCountOfVisibleItems - 2) {
245        this.firstFocusableIndex = this.index * 1000 + index + 1
246      }
247    })
248  }
249
250  decideFontScale(): number {
251    let uiContent: UIContext = this.getUIContext();
252    this.systemFontScale = (uiContent.getHostContext() as common.UIAbilityContext)?.config?.fontSizeScale ?? 1;
253    if (!this.isFollowingSystemFontScale) {
254      return 1;
255    }
256    return Math.min(this.systemFontScale, this.maxFontScale);
257  }
258
259  build() {
260    Column() {
261      Row() {
262        if (this.menuItems.length <= CollapsibleMenuSection.maxCountOfVisibleItems) {
263          ForEach(this.menuItems, (item: SelectTitleBarMenuItem, index) => {
264            ImageMenuItem({ item: item, index: this.index * 1000 + index + 1 })
265          })
266        } else {
267          ForEach(this.menuItems.slice(0, CollapsibleMenuSection.maxCountOfVisibleItems - 1),
268            (item: SelectTitleBarMenuItem, index) => {
269              ImageMenuItem({ item: item, index: this.index * 1000 + index + 1 })
270            })
271
272          Row() {
273            Image(PUBLIC_MORE)
274              .width(ImageMenuItem.imageSize)
275              .height(ImageMenuItem.imageSize)
276              .focusable(true)
277              .draggable(false)
278              .fillColor($r('sys.color.icon_primary'))
279          }
280          .width(ImageMenuItem.imageHotZoneWidth)
281          .height(ImageMenuItem.imageHotZoneWidth)
282          .borderRadius(ImageMenuItem.buttonBorderRadius)
283          .foregroundColor(this.getMoreIconFgColor())
284          .backgroundColor(this.getMoreIconBgColor())
285          .justifyContent(FlexAlign.Center)
286          .stateStyles({
287            focused: {
288              .border({
289                radius: $r('sys.float.ohos_id_corner_radius_clicked'),
290                width: ImageMenuItem.focusBorderWidth,
291                color: $r('sys.color.ohos_id_color_focused_outline'),
292                style: BorderStyle.Solid
293              })
294            },
295            normal: {
296              .border({
297                radius: $r('sys.float.ohos_id_corner_radius_clicked'),
298                width: 0
299              })
300            }
301          })
302          .onFocus(() => this.isMoreIconOnFocus = true)
303          .onBlur(() => this.isMoreIconOnFocus = false)
304          .onHover((isOn) => this.isMoreIconOnHover = isOn)
305          .onKeyEvent((event) => {
306            if (event.keyCode !== KeyCode.KEYCODE_ENTER && event.keyCode !== KeyCode.KEYCODE_SPACE) {
307              return
308            }
309            if (event.type === KeyType.Down) {
310              this.isMoreIconOnClick = true
311            }
312            if (event.type === KeyType.Up) {
313              this.isMoreIconOnClick = false
314            }
315          })
316          .onTouch((event) => {
317            if (event.type === TouchType.Down) {
318              this.isMoreIconOnClick = true
319            }
320            if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
321              this.isMoreIconOnClick = false
322              if (this.fontSize >= this.minFontSize) {
323                this.dialogController?.close()
324              }
325            }
326          })
327          .onClick(() => this.isPopupShown = true)
328          .gesture(
329            LongPressGesture({ repeat: false, duration: this.longPressTime })
330              .onAction((event: GestureEvent) => {
331                this.fontSize = this.decideFontScale();
332                if (event) {
333                  if (this.fontSize >= this.minFontSize) {
334                    this.dialogController?.open()
335                  }
336                }
337              }))
338          .bindPopup(this.isPopupShown, {
339            builder: this.popupBuilder,
340            placement: Placement.Bottom,
341            popupColor: Color.White,
342            enableArrow: false,
343            onStateChange: (e) => {
344              this.isPopupShown = e.isVisible
345              if (!e.isVisible) {
346                this.isMoreIconOnClick = false
347              }
348            }
349          })
350        }
351      }
352    }
353    .height('100%')
354    .margin({ right: $r('sys.float.ohos_id_default_padding_end') })
355    .justifyContent(FlexAlign.Center)
356  }
357
358  @Builder
359  popupBuilder() {
360    Column() {
361      ForEach(this.menuItems.slice(CollapsibleMenuSection.maxCountOfVisibleItems - 1, this.menuItems.length),
362        (item: SelectTitleBarMenuItem, index) => {
363          ImageMenuItem({ item: item, index: this.index * 1000 +
364          CollapsibleMenuSection.maxCountOfVisibleItems + index, isPopup: true })
365        })
366    }
367    .width(ImageMenuItem.imageHotZoneWidth + CollapsibleMenuSection.focusPadding * CollapsibleMenuSection.marginsNum)
368    .margin({ top: CollapsibleMenuSection.focusPadding, bottom: CollapsibleMenuSection.focusPadding })
369    .onAppear(() => {
370      focusControl.requestFocus(ImageMenuItem.focusablePrefix + this.firstFocusableIndex)
371    })
372  }
373}
374
375@Component
376struct ImageMenuItem {
377  item: SelectTitleBarMenuItem = {} as SelectTitleBarMenuItem;
378  index: number = 0;
379  longPressTime: number = 500;
380  minFontSize: number = 1.75;
381  isFollowingSystemFontScale: boolean = false;
382  maxFontScale: number = 1;
383  systemFontScale?: number = 1;
384  isPopup: boolean = false;
385
386  static readonly imageSize = 24
387  static readonly imageHotZoneWidth = 48
388  static readonly buttonBorderRadius = 8
389  static readonly focusBorderWidth = 2
390  static readonly disabledImageOpacity = 0.4
391  static readonly focusablePrefix = 'Id-SelectTitleBar-ImageMenuItem-';
392
393  @State isOnFocus: boolean = false
394  @State isOnHover: boolean = false
395  @State isOnClick: boolean = false
396  @Prop fontSize: number = 1
397
398  dialogController: CustomDialogController | null = new CustomDialogController({
399    builder: SelectTitleBarDialog({
400      cancel: () => {
401      },
402      confirm: () => {
403      },
404      selectTitleDialog: this.item,
405      selectTitleBarDialog: this.item.label ? this.item.label : this.textDialog(),
406      fontSize: this.fontSize,
407    }),
408    maskColor: Color.Transparent,
409    isModal: true,
410    customStyle: true,
411  })
412
413  private textDialog(): ResourceStr {
414    if (this.item.value === PUBLIC_MORE) {
415      return $r('sys.string.ohos_toolbar_more');
416    } else if (this.item.value === PUBLIC_BACK) {
417      return $r('sys.string.icon_back');
418    } else {
419      return this.item.label ? this.item.label : '';
420    }
421  }
422
423  getFgColor() {
424    return this.isOnClick
425      ? $r('sys.color.ohos_id_color_titlebar_icon_pressed')
426      : $r('sys.color.ohos_id_color_titlebar_icon')
427  }
428
429  getBgColor() {
430    if (this.isOnClick) {
431      return $r('sys.color.ohos_id_color_click_effect')
432    } else if (this.isOnHover) {
433      return $r('sys.color.ohos_id_color_hover')
434    } else {
435      return Color.Transparent
436    }
437  }
438
439  aboutToAppear(): void {
440    try {
441      let uiContent: UIContext = this.getUIContext();
442      this.isFollowingSystemFontScale = uiContent.isFollowingSystemFontScale();
443      this.maxFontScale = uiContent.getMaxFontScale();
444    } catch (exception) {
445      let code: number = (exception as BusinessError).code;
446      let message: string = (exception as BusinessError).message;
447      hilog.error(0x3900, 'Ace', `Faild to decideFontScale,cause, code: ${code}, message: ${message}`);
448    }
449  }
450
451  decideFontScale(): number {
452    let uiContent: UIContext = this.getUIContext();
453    this.systemFontScale = (uiContent.getHostContext() as common.UIAbilityContext)?.config?.fontSizeScale ?? 1;
454    if (!this.isFollowingSystemFontScale) {
455      return 1;
456    }
457    return Math.min(this.systemFontScale, this.maxFontScale);
458  }
459
460  build() {
461    Row() {
462      Image(this.item.value)
463        .draggable(false)
464        .width(ImageMenuItem.imageSize)
465        .height(ImageMenuItem.imageSize)
466        .focusable(this.item.isEnabled)
467        .key(ImageMenuItem.focusablePrefix + this.index)
468        .fillColor($r('sys.color.icon_primary'))
469    }
470    .width(ImageMenuItem.imageHotZoneWidth)
471    .height(ImageMenuItem.imageHotZoneWidth)
472    .borderRadius(ImageMenuItem.buttonBorderRadius)
473    .foregroundColor(this.getFgColor())
474    .backgroundColor(this.getBgColor())
475    .justifyContent(FlexAlign.Center)
476    .opacity(this.item.isEnabled ? 1 : ImageMenuItem.disabledImageOpacity)
477    .stateStyles({
478      focused: {
479        .border({
480          radius: $r('sys.float.ohos_id_corner_radius_clicked'),
481          width: ImageMenuItem.focusBorderWidth,
482          color: $r('sys.color.ohos_id_color_focused_outline'),
483          style: BorderStyle.Solid
484        })
485      },
486      normal: {
487        .border({
488          radius: $r('sys.float.ohos_id_corner_radius_clicked'),
489          width: 0
490        })
491      }
492    })
493    .onFocus(() => {
494      if (!this.item.isEnabled) {
495        return
496      }
497      this.isOnFocus = true
498    })
499    .onBlur(() => this.isOnFocus = false)
500    .onHover((isOn) => {
501      if (!this.item.isEnabled) {
502        return
503      }
504      this.isOnHover = isOn
505    })
506    .onKeyEvent((event) => {
507      if (!this.item.isEnabled) {
508        return
509      }
510      if (event.keyCode !== KeyCode.KEYCODE_ENTER && event.keyCode !== KeyCode.KEYCODE_SPACE) {
511        return
512      }
513      if (event.type === KeyType.Down) {
514        this.isOnClick = true
515      }
516      if (event.type === KeyType.Up) {
517        this.isOnClick = false
518      }
519    })
520    .onTouch((event) => {
521      if (!this.item.isEnabled) {
522        return
523      }
524      if (event.type === TouchType.Down) {
525        this.isOnClick = true
526      }
527      if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
528        this.isOnClick = false
529        if (this.fontSize >= this.minFontSize && this.isPopup === false) {
530          this.dialogController?.close()
531        }
532      }
533    })
534    .onClick(() => this.item.isEnabled && this.item.action && this.item.action())
535    .gesture(
536      LongPressGesture({ repeat: false, duration: this.longPressTime })
537        .onAction((event: GestureEvent) => {
538          this.fontSize = this.decideFontScale();
539          if (event) {
540            if (this.fontSize >= this.minFontSize && this.isPopup === false) {
541              this.dialogController?.open()
542            }
543          }
544        }))
545  }
546}
547
548/**
549 *  SelectTitleBarDialog
550 */
551@CustomDialog
552struct SelectTitleBarDialog {
553  selectTitleDialog: SelectTitleBarMenuItem = {} as SelectTitleBarMenuItem;
554  callbackId: number | undefined = undefined;
555  selectTitleBarDialog?: ResourceStr = '';
556  mainWindowStage: window.Window | undefined = undefined;
557  controller?: CustomDialogController
558  minFontSize: number = 1.75;
559  maxFontSize: number = 3.2;
560  screenWidth: number = 640;
561  verticalScreenLines: number = 6;
562  horizontalsScreenLines: number = 1;
563  @StorageLink('mainWindow') mainWindow: Promise<window.Window> | undefined = undefined;
564  @State fontSize: number = 1;
565  @State maxLines: number = 1;
566  @StorageProp('windowStandardHeight') windowStandardHeight: number = 0;
567  cancel: () => void = () => {
568  }
569  confirm: () => void = () => {
570  }
571
572  build() {
573    if (this.selectTitleBarDialog) {
574      Column() {
575        Image(this.selectTitleDialog.value)
576          .width(IMAGE_SIZE)
577          .height(IMAGE_SIZE)
578          .margin({
579            top: $r('sys.float.padding_level24'),
580            bottom: $r('sys.float.padding_level8'),
581          })
582          .fillColor($r('sys.color.icon_primary'))
583        Column() {
584          Text(this.selectTitleBarDialog)
585            .fontSize(TEXT_EDITABLE_DIALOG)
586            .textOverflow({ overflow: TextOverflow.Ellipsis })
587            .maxLines(this.maxLines)
588            .width('100%')
589            .textAlign(TextAlign.Center)
590            .fontColor($r('sys.color.font_primary'))
591        }
592        .width('100%')
593        .padding({
594          left: $r('sys.float.padding_level4'),
595          right: $r('sys.float.padding_level4'),
596          bottom: $r('sys.float.padding_level12'),
597        })
598      }
599      .width(this.fontSize === this.maxFontSize ? MAX_DIALOG : MIN_DIALOG)
600      .constraintSize({ minHeight: this.fontSize === this.maxFontSize ? MAX_DIALOG : MIN_DIALOG })
601      .backgroundBlurStyle(BlurStyle.COMPONENT_ULTRA_THICK)
602      .shadow(ShadowStyle.OUTER_DEFAULT_LG)
603      .borderRadius($r('sys.float.corner_radius_level10'))
604    } else {
605      Column() {
606        Image(this.selectTitleDialog.value)
607          .width(IMAGE_SIZE)
608          .height(IMAGE_SIZE)
609          .fillColor($r('sys.color.icon_primary'))
610      }
611      .width(this.fontSize === this.maxFontSize ? MAX_DIALOG : MIN_DIALOG)
612      .constraintSize({ minHeight: this.fontSize === this.maxFontSize ? MAX_DIALOG : MIN_DIALOG })
613      .backgroundBlurStyle(BlurStyle.COMPONENT_ULTRA_THICK)
614      .shadow(ShadowStyle.OUTER_DEFAULT_LG)
615      .borderRadius($r('sys.float.corner_radius_level10'))
616      .justifyContent(FlexAlign.Center)
617    }
618  }
619
620  async aboutToAppear(): Promise<void> {
621    let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
622    this.mainWindowStage = context.windowStage.getMainWindowSync();
623    let properties: window.WindowProperties = this.mainWindowStage.getWindowProperties();
624    let rect = properties.windowRect;
625    if (px2vp(rect.height) > this.screenWidth) {
626      this.maxLines = this.verticalScreenLines;
627    } else {
628      this.maxLines = this.horizontalsScreenLines;
629    }
630  }
631}