1/* 2 * Copyright (c) 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 */ 15import { SymbolGlyphModifier } from '@ohos.arkui.modifier'; 16import { Chip, ChipSize, ChipSymbolGlyphOptions } from '@ohos.arkui.advanced.Chip'; 17 18interface ChipGroupTheme { 19 itemStyle: ChipGroupStyleTheme; 20 chipGroupSpace: ChipGroupSpaceOptions; 21 chipGroupPadding?: ChipGroupPaddingOptions 22} 23 24const noop = (selectedIndexes: Array<number>) => { 25} 26const colorStops: ([string, number])[] = [['rgba(0, 0, 0, 1)', 0], ['rgba(0, 0, 0, 0)', 1]] 27const defaultTheme: ChipGroupTheme = { 28 itemStyle: { 29 size: ChipSize.NORMAL, 30 backgroundColor: $r('sys.color.ohos_id_color_button_normal'), 31 fontColor: $r('sys.color.ohos_id_color_text_primary'), 32 selectedFontColor: $r('sys.color.ohos_id_color_text_primary_contrary'), 33 selectedBackgroundColor: $r('sys.color.ohos_id_color_emphasize'), 34 fillColor: $r('sys.color.ohos_id_color_secondary'), 35 selectedFillColor: $r('sys.color.ohos_id_color_text_primary_contrary'), 36 }, 37 chipGroupSpace: { itemSpace: 8, startSpace: 16, endSpace: 16 }, 38 chipGroupPadding: { top: 14, bottom: 14 } 39} 40 41const iconGroupSuffixTheme: IconGroupSuffixTheme = { 42 backgroundColor: $r('sys.color.ohos_id_color_button_normal'), 43 borderRadius: $r('sys.float.ohos_id_corner_radius_tips_instant_tip'), 44 smallIconSize: 16, 45 normalIconSize: 24, 46 smallBackgroundSize: 28, 47 normalBackgroundSize: 36, 48 marginLeft: 8, 49 marginRight: 16, 50 fillColor: $r('sys.color.ohos_id_color_primary'), 51 defaultEffect: -1 52} 53 54enum ChipGroupHeight { 55 NORMAL = 36, 56 SMALL = 28, 57} 58 59interface IconOptions { 60 src: ResourceStr; 61 size?: SizeOptions; 62} 63 64interface ChipGroupPaddingOptions { 65 top: Length; 66 bottom: Length; 67} 68 69interface ChipGroupStyleTheme { 70 size: ChipSize | SizeOptions; 71 backgroundColor: ResourceColor; 72 fontColor: ResourceColor; 73 selectedFontColor: ResourceColor; 74 selectedBackgroundColor: ResourceColor; 75 fillColor: ResourceColor; 76 selectedFillColor: ResourceColor; 77} 78 79interface LabelOptions { 80 text: string; 81} 82 83export interface ChipGroupItemOptions { 84 prefixIcon?: IconOptions; 85 prefixSymbol?: ChipSymbolGlyphOptions; 86 label: LabelOptions; 87 suffixIcon?: IconOptions; 88 suffixSymbol?: ChipSymbolGlyphOptions; 89 allowClose?: boolean; 90} 91 92export interface ChipItemStyle { 93 size?: ChipSize | SizeOptions; 94 backgroundColor?: ResourceColor; 95 fontColor?: ResourceColor; 96 selectedFontColor?: ResourceColor; 97 selectedBackgroundColor?: ResourceColor; 98} 99 100interface ChipGroupSpaceOptions { 101 itemSpace?: number | string; 102 startSpace?: Length; 103 endSpace?: Length; 104} 105 106export interface IconItemOptions { 107 icon: IconOptions; 108 action: Callback<void>; 109} 110 111interface IconGroupSuffixTheme { 112 smallIconSize: number; 113 normalIconSize: number; 114 backgroundColor: ResourceColor; 115 smallBackgroundSize: number; 116 normalBackgroundSize: number; 117 borderRadius: Dimension; 118 marginLeft: number; 119 marginRight: number; 120 fillColor: ResourceColor; 121 defaultEffect: number; 122} 123 124function parseDimension<T>( 125 uiContext: UIContext, 126 value: Dimension | Length | undefined, 127 isValid: Callback<string, boolean>, 128 defaultValue: T 129): T { 130 if (value === void (0) || value === null) { 131 return defaultValue; 132 } 133 const resourceManager = uiContext.getHostContext()?.resourceManager; 134 if (typeof value === "object") { 135 let temp: Resource = value as Resource; 136 if (temp.type === 10002 || temp.type === 10007) { 137 if (resourceManager.getNumber(temp.id) >= 0) { 138 return value as T; 139 } 140 } else if (temp.type === 10003) { 141 if (isValidDimensionString(resourceManager.getStringSync(temp.id))) { 142 return value as T; 143 } 144 } 145 } else if (typeof value === "number") { 146 if (value >= 0) { 147 return value as T; 148 } 149 } else if (typeof value === "string") { 150 if (isValid(value)) { 151 return value as T; 152 } 153 } 154 return defaultValue; 155} 156 157function isValidString(dimension: string, regex: RegExp): boolean { 158 const matches = dimension.match(regex); 159 if (!matches || matches.length < 3) { 160 return false; 161 } 162 const value = Number.parseFloat(matches[1]); 163 return value >= 0; 164} 165 166function isValidDimensionString(dimension: string): boolean { 167 return isValidString(dimension, new RegExp("(-?\\d+(?:\\.\\d+)?)_?(fp|vp|px|lpx|%)?$", "i")); 168} 169 170function isValidDimensionNoPercentageString(dimension: string): boolean { 171 return isValidString(dimension, new RegExp("(-?\\d+(?:\\.\\d+)?)_?(fp|vp|px|lpx)?$", "i")); 172} 173 174@Component 175export struct IconGroupSuffix { 176 @Consume chipSize: ChipSize | SizeOptions; 177 @Prop items: Array<IconItemOptions | SymbolGlyphModifier> = []; 178 symbolEffect: SymbolEffect = new SymbolEffect(); 179 180 private getBackgroundSize(): number { 181 if (this.chipSize === ChipSize.SMALL) { 182 return iconGroupSuffixTheme.smallBackgroundSize; 183 } else { 184 return iconGroupSuffixTheme.normalBackgroundSize; 185 } 186 } 187 188 private getIconSize(val?: Length): Length { 189 if (val === undefined) { 190 return this.chipSize === ChipSize.SMALL ? 191 iconGroupSuffixTheme.smallIconSize : 192 iconGroupSuffixTheme.normalIconSize; 193 } 194 let value: Length; 195 if (this.chipSize === ChipSize.SMALL) { 196 value = parseDimension(this.getUIContext(), val, isValidDimensionString, iconGroupSuffixTheme.smallIconSize); 197 } else { 198 value = parseDimension(this.getUIContext(), val, isValidDimensionString, iconGroupSuffixTheme.normalIconSize); 199 } 200 return value; 201 } 202 203 build() { 204 Row({ space: 8 }) { 205 ForEach(this.items || [], (suffixItem: IconItemOptions | SymbolGlyphModifier) => { 206 Button() { 207 if (suffixItem instanceof SymbolGlyphModifier) { 208 SymbolGlyph() 209 .fontColor([iconGroupSuffixTheme.fillColor]) 210 .fontSize(this.getIconSize()) 211 .attributeModifier(suffixItem) 212 .focusable(true) 213 .effectStrategy(SymbolEffectStrategy.NONE) 214 .symbolEffect(this.symbolEffect, false) 215 .symbolEffect(this.symbolEffect, iconGroupSuffixTheme.defaultEffect) 216 } else { 217 Image(suffixItem.icon.src) 218 .fillColor(iconGroupSuffixTheme.fillColor) 219 .size({ 220 width: this.getIconSize(suffixItem.icon?.size?.width), 221 height: this.getIconSize(suffixItem.icon?.size?.height) 222 }) 223 .focusable(true) 224 } 225 } 226 .size({ 227 width: this.getBackgroundSize(), 228 height: this.getBackgroundSize() 229 }) 230 .backgroundColor(iconGroupSuffixTheme.backgroundColor) 231 .borderRadius(iconGroupSuffixTheme.borderRadius) 232 .onClick(() => { 233 if (!(suffixItem instanceof SymbolGlyphModifier)) { 234 suffixItem.action(); 235 } 236 }) 237 .borderRadius(iconGroupSuffixTheme.borderRadius) 238 }) 239 } 240 } 241} 242 243@Component 244export struct ChipGroup { 245 @Prop @Watch('onItemsChange') items: ChipGroupItemOptions[] = []; 246 @Prop @Watch('itemStyleOnChange') itemStyle: ChipItemStyle = defaultTheme.itemStyle; 247 @Provide chipSize: ChipSize | SizeOptions = defaultTheme.itemStyle.size; 248 @Prop selectedIndexes: Array<number> = [0]; 249 @Prop @Watch('onMultipleChange') multiple: boolean = false; 250 @Prop chipGroupSpace: ChipGroupSpaceOptions = defaultTheme.chipGroupSpace; 251 @BuilderParam suffix?: Callback<void>; 252 public onChange: Callback<Array<number>> = noop; 253 private scroller: Scroller = new Scroller(); 254 @State isReachEnd: boolean = this.scroller.isAtEnd(); 255 @Prop chipGroupPadding: ChipGroupPaddingOptions = defaultTheme.chipGroupPadding; 256 @State isRefresh: boolean = true; 257 258 onItemsChange() { 259 this.isRefresh = !this.isRefresh; 260 } 261 262 onMultipleChange() { 263 this.selectedIndexes = this.getSelectedIndexes(); 264 } 265 266 itemStyleOnChange() { 267 this.chipSize = this.getChipSize(); 268 } 269 270 aboutToAppear() { 271 this.itemStyleOnChange(); 272 } 273 274 private getChipSize(): ChipSize | SizeOptions { 275 if (this.itemStyle && this.itemStyle.size) { 276 if (typeof this.itemStyle.size === 'object') { 277 if ( !this.itemStyle.size.width || !this.itemStyle.size.height || !this.itemStyle.size) { 278 return defaultTheme.itemStyle.size; 279 } 280 } 281 return this.itemStyle.size; 282 } 283 return defaultTheme.itemStyle.size; 284 } 285 286 private getFontColor(): ResourceColor { 287 if (this.itemStyle && this.itemStyle.fontColor) { 288 if (typeof this.itemStyle.fontColor === 'object') { 289 let temp: Resource = this.itemStyle.fontColor as Resource; 290 if (temp == undefined || temp == null) { 291 return defaultTheme.itemStyle.fontColor; 292 } 293 if (temp.type === 10001) { 294 return this.itemStyle.fontColor; 295 } 296 return defaultTheme.itemStyle.fontColor; 297 } 298 return this.itemStyle.fontColor; 299 } 300 return defaultTheme.itemStyle.fontColor; 301 } 302 303 private getSelectedFontColor(): ResourceColor { 304 if (this.itemStyle && this.itemStyle.selectedFontColor) { 305 if (typeof this.itemStyle.selectedFontColor === 'object') { 306 let temp: Resource = this.itemStyle.selectedFontColor as Resource; 307 if (temp == undefined || temp == null) { 308 return defaultTheme.itemStyle.selectedFontColor; 309 } 310 if (temp.type === 10001) { 311 return this.itemStyle.selectedFontColor; 312 } 313 return defaultTheme.itemStyle.selectedFontColor; 314 } 315 return this.itemStyle.selectedFontColor; 316 } 317 return defaultTheme.itemStyle.selectedFontColor; 318 } 319 320 private getFillColor(): ResourceColor { 321 if (this.itemStyle && this.itemStyle.fontColor) { 322 return this.itemStyle.fontColor; 323 } 324 return defaultTheme.itemStyle.fillColor; 325 } 326 327 private getSelectedFillColor(): ResourceColor { 328 if (this.itemStyle && this.itemStyle.selectedFontColor) { 329 return this.itemStyle.selectedFontColor; 330 } 331 return defaultTheme.itemStyle.selectedFillColor; 332 } 333 334 private getBackgroundColor(): ResourceColor { 335 if (this.itemStyle && this.itemStyle.backgroundColor) { 336 if (typeof this.itemStyle.backgroundColor === 'object') { 337 let temp: Resource = this.itemStyle.backgroundColor as Resource; 338 if (temp == undefined || temp == null) { 339 return defaultTheme.itemStyle.backgroundColor; 340 } 341 if (temp.type === 10001) { 342 return this.itemStyle.backgroundColor; 343 } 344 return defaultTheme.itemStyle.backgroundColor; 345 } 346 return this.itemStyle.backgroundColor; 347 } 348 return defaultTheme.itemStyle.backgroundColor; 349 } 350 351 private getSelectedBackgroundColor(): ResourceColor { 352 if (this.itemStyle && this.itemStyle.selectedBackgroundColor) { 353 if (typeof this.itemStyle.selectedBackgroundColor === 'object') { 354 let temp: Resource = this.itemStyle.selectedBackgroundColor as Resource; 355 if (temp == undefined || temp == null) { 356 return defaultTheme.itemStyle.selectedBackgroundColor; 357 } 358 if (temp.type === 10001) { 359 return this.itemStyle.selectedBackgroundColor; 360 } 361 return defaultTheme.itemStyle.selectedBackgroundColor; 362 } 363 return this.itemStyle.selectedBackgroundColor; 364 } 365 return defaultTheme.itemStyle.selectedBackgroundColor; 366 } 367 368 private getSelectedIndexes(): Array<number> { 369 let temp: number[] = []; 370 temp = (this.selectedIndexes ?? [0]).filter( 371 (element, index, array) => { 372 return ( 373 element >= 0 && 374 element % 1 == 0 && 375 element != null && 376 element != undefined && 377 array.indexOf(element) === index && 378 element < (this.items || []).length); 379 }); 380 if (temp.length == 0) { 381 temp = [0]; 382 } 383 return temp; 384 } 385 386 private isMultiple(): boolean { 387 return this.multiple ?? false; 388 } 389 390 private getChipGroupItemSpace() { 391 if (this.chipGroupSpace == undefined) { 392 return defaultTheme.chipGroupSpace.itemSpace 393 } 394 return parseDimension( 395 this.getUIContext(), 396 this.chipGroupSpace.itemSpace, 397 isValidDimensionNoPercentageString, 398 defaultTheme.chipGroupSpace.itemSpace 399 ); 400 } 401 402 private getChipGroupStartSpace() { 403 if (this.chipGroupSpace == undefined) { 404 return defaultTheme.chipGroupSpace.startSpace 405 } 406 return parseDimension( 407 this.getUIContext(), 408 this.chipGroupSpace.startSpace, 409 isValidDimensionNoPercentageString, 410 defaultTheme.chipGroupSpace.startSpace 411 ); 412 } 413 414 private getChipGroupEndSpace() { 415 if (this.chipGroupSpace == undefined) { 416 return defaultTheme.chipGroupSpace.endSpace 417 } 418 return parseDimension( 419 this.getUIContext(), 420 this.chipGroupSpace.endSpace, 421 isValidDimensionNoPercentageString, 422 defaultTheme.chipGroupSpace.endSpace 423 ); 424 } 425 426 private getOnChange(): (selectedIndexes: Array<number>) => void { 427 return this.onChange ?? noop; 428 } 429 430 private isSelected(itemIndex: number): boolean { 431 if (!this.isMultiple()) { 432 return itemIndex == this.getSelectedIndexes()[0]; 433 } else { 434 return this.getSelectedIndexes().some((element, index, array) => { 435 return (element == itemIndex); 436 }) 437 } 438 } 439 440 private getChipGroupHeight() { 441 if (typeof this.chipSize === 'string') { 442 if (this.chipSize === ChipSize.NORMAL) { 443 return ChipGroupHeight.NORMAL; 444 } else { 445 return ChipGroupHeight.SMALL; 446 } 447 } else if (typeof this.chipSize === 'object') { 448 return this.chipSize.height as number 449 } else { 450 return ChipGroupHeight.NORMAL 451 } 452 } 453 454 private getPaddingTop() { 455 if (!this.chipGroupPadding || !this.chipGroupPadding.top) { 456 return defaultTheme.chipGroupPadding.top 457 } 458 return parseDimension( 459 this.getUIContext(), 460 this.chipGroupPadding.top, 461 isValidDimensionNoPercentageString, 462 defaultTheme.chipGroupPadding.top 463 ); 464 } 465 466 private getPaddingBottom() { 467 if (!this.chipGroupPadding || !this.chipGroupPadding.bottom) { 468 return defaultTheme.chipGroupPadding.bottom 469 } 470 return parseDimension( 471 this.getUIContext(), 472 this.chipGroupPadding.bottom, 473 isValidDimensionNoPercentageString, 474 defaultTheme.chipGroupPadding.bottom 475 ); 476 } 477 478 build() { 479 Row() { 480 Stack() { 481 Scroll(this.scroller) { 482 Row({ space: this.getChipGroupItemSpace() }) { 483 ForEach(this.items || [], (chipItem: ChipGroupItemOptions, index) => { 484 if (chipItem) { 485 Chip({ 486 prefixIcon: { 487 src: chipItem.prefixIcon?.src ?? "", 488 size: chipItem.prefixIcon?.size ?? undefined, 489 fillColor: this.getFillColor(), 490 activatedFillColor: this.getSelectedFillColor() 491 }, 492 prefixSymbol: chipItem?.prefixSymbol, 493 label: { 494 text: chipItem?.label?.text ?? " ", 495 fontColor: this.getFontColor(), 496 activatedFontColor: this.getSelectedFontColor(), 497 }, 498 suffixIcon: { 499 src: chipItem.suffixIcon?.src ?? "", 500 size: chipItem.suffixIcon?.size ?? undefined, 501 fillColor: this.getFillColor(), 502 activatedFillColor: this.getSelectedFillColor() 503 }, 504 suffixSymbol: chipItem?.suffixSymbol, 505 allowClose: chipItem.allowClose ?? false, 506 enabled: true, 507 activated: this.isSelected(index), 508 backgroundColor: this.getBackgroundColor(), 509 size: this.getChipSize(), 510 activatedBackgroundColor: this.getSelectedBackgroundColor(), 511 onClicked: () => { 512 if (this.isSelected(index)) { 513 if (!(!this.isMultiple())) { 514 if (this.getSelectedIndexes().length > 1) { 515 this.selectedIndexes.splice(this.selectedIndexes.indexOf(index), 1); 516 } 517 } 518 } else { 519 if (!this.selectedIndexes || this.selectedIndexes.length === 0) { 520 this.selectedIndexes = this.getSelectedIndexes(); 521 } 522 if (!this.isMultiple()) { 523 this.selectedIndexes = []; 524 } 525 this.selectedIndexes.push(index); 526 } 527 this.getOnChange()(this.getSelectedIndexes()); 528 } 529 }) 530 } 531 }, () => { 532 return JSON.stringify(this.isRefresh); 533 }); 534 } 535 .padding({ left: this.getChipGroupStartSpace(), 536 right: this.getChipGroupEndSpace() }) 537 .constraintSize({ minWidth: '100%' }) 538 } 539 .scrollable(ScrollDirection.Horizontal) 540 .scrollBar(BarState.Off) 541 .align(Alignment.Start) 542 .width('100%') 543 .clip(false) 544 .onScroll(() => { 545 this.isReachEnd = this.scroller.isAtEnd(); 546 }) 547 548 if (this.suffix && !this.isReachEnd) { 549 Stack() 550 .width(iconGroupSuffixTheme.normalBackgroundSize) 551 .height(this.getChipGroupHeight()) 552 .linearGradient({ angle: 90, colors: colorStops }) 553 .blendMode(BlendMode.DST_IN, BlendApplyType.OFFSCREEN) 554 .hitTestBehavior(HitTestMode.None) 555 } 556 } 557 .height(this.getChipGroupHeight() + (this.getPaddingTop() as number) + (this.getPaddingBottom() as number)) 558 .layoutWeight(1) 559 .blendMode(BlendMode.SRC_OVER, BlendApplyType.OFFSCREEN) 560 .alignContent(Alignment.End) 561 562 if (this.suffix) { 563 Row() { 564 this.suffix(); 565 }.padding({ left: iconGroupSuffixTheme.marginLeft, 566 right: iconGroupSuffixTheme.marginRight 567 }) 568 } 569 } 570 .align(Alignment.End) 571 .width("100%") 572 .height(this.getChipGroupHeight() + (this.getPaddingTop() as number) + (this.getPaddingBottom() as number)) 573 .padding({ top: this.getPaddingTop(), bottom: this.getPaddingBottom() }) 574 } 575}