• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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}