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