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}