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 }