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'
17
18export declare type TabTitleBarMenuItem = {
19  value: ResourceStr
20  isEnabled: boolean
21  action?: () => void
22}
23
24export declare type TabTitleBarTabItem = {
25  title: ResourceStr
26  icon?: ResourceStr
27}
28
29const PUBLIC_MORE = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMAAAADACAYAAABS3GwHAAAAIGNIUk0AAHomAACAhAAA+' +
30  'gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAAEZ0FNQQAAsY58+1GTAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAAO' +
31  'xAAADsQBlSsOGwAABEZJREFUeNrt3D1rFFEUBuAxhmAhFlYpUohYiYWFRcAmKAhWK2pjo1iKf8BCMIKFf8BarCyMhVj4VZhGSKEg2FqJyCKWIhY' +
32  'WnstMINgYsh+cmfs88BICydxw7jmzu2HvNg0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBN+3r6dx+LXIqsRpa7FF8j48hm5Fn3Peo9mAEYRd' +
33  'YjJ3f582Vj7nZfUe/eDsCRyMPI2h5/fyNyI/JDT6v3Tvt7sBllE15ETkxwjeORi5G3ke/6W737MgBnI68jh6ZwrcORq5HnhkC9+zAA5YXXy8jBK' +
34  'V5zKXIu8jjyS7+rd+YBeNVtyrSVO9PRyBM9r94LSTfjWuTUDK9/eYIXeENUbb0zDsBi5PYc1rmj79U74wCszuih+F/ljrSi/+uud8YBGA10rayq' +
35  'rnfGAVgb6FpZVV3vjAOwPNC1sqq63hkHYGWga2VVdb0XKt/8Rf1fd70zDsB4jmt5u3Tl9a59AMb6v+56ZxyArYGulVXV9c44ABtzXOup/q+73hk' +
36  'H4N2cHio/Rj7r/7rrnXEAfkfuz2Gddb2v3ln/DfpgxneLzaY9xE3l9c46AH8iVyI/Z3Dt8nB/Xc+rd5H5QMy3yJemPVs6zY0edc9HUe/0Z4I/dQ' +
37  '/N5Vjd0oTXKp9QcKFpD2qj3r0YgO1NeRM507TH6/bifeR85IMeV++d+vTBWOV9JDcjt5rdv6uw3M3uRR7pa/Xu+wBsOxA53bTnTP/3UX1b3fNQ1' +
38  'BsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKqyr6d/97HIpchqZLlL8TUyjmxGnnXfo96DGYBRZD1ycpc/Xzbm' +
39  'bvcV9e7tAByJPIys7fH3NyI3Ij/0tHrvtL8Hm1E24UXkxATXOB65GHkb+a6/1bsvA3A28jpyaArXOhy5GnluCNS7DwNQXni9jByc4jWXIucijyO' +
40  '/9Lt6Zx6AV92mTFu5Mx2NPNHz6r2QdDOuRU7N8PqXJ3iBN0TV1jvjACxGbs9hnTv6Xr0zDsDqjB6K/1XuSCv6v+56ZxyA0UDXyqrqemccgLWBrp' +
41  'VV1fXOOADLA10rq6rrnXEAVga6VlZV13uh8s1f1P911zvjAIznuJa3S1de79oHYKz/6653xgHYGuhaWVVd74wDsDHHtZ7q/7rrnXEA3s3pofJj5' +
42  'LP+r7veGQfgd+T+HNZZ1/vqnfXfoA9mfLfYbNpD3FRe76wD8CdyJfJzBtcuD/fX9bx6F5kPxHyLfGnas6XT3OhR93wU9U5/JvhT99BcjtUtTXit' +
43  '8gkFF5r2oDbq3YsB2N6UN5EzTXu8bi/eR85HPuhx9d6pTx+MVd5HcjNyq9n9uwrL3exe5JG+Vu++D8C2A5HTTXvO9H8f1bfVPQ9FvQEAAAAAAAA' +
44  'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgCn7C9HjBtwWfXpKAAAAAElFTkSuQmCC'
45
46@Component
47export struct TabTitleBar {
48  tabItems: Array<TabTitleBarTabItem>
49  menuItems: Array<TabTitleBarMenuItem>
50  @BuilderParam swiperContent: () => void
51
52  @State tabWidth: number = 0
53  @State currentIndex: number = 0
54
55  static readonly totalHeight = 56
56  static readonly correctionOffset = -40.0
57  static readonly gradientMaskWidth = 24
58  private menuSectionWidth = 0
59
60  private scroller: Scroller = new Scroller()
61  private swiperController: SwiperController = new SwiperController()
62  private settings: RenderingContextSettings = new RenderingContextSettings(true)
63  private leftContext2D: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
64  private rightContext2D: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
65
66  @Builder GradientMask(context2D: CanvasRenderingContext2D, x0: number, y0: number, x1: number, y1: number) {
67    Column() {
68      Canvas(context2D)
69        .width(TabTitleBar.gradientMaskWidth)
70        .height(TabTitleBar.totalHeight)
71        .onReady(() => {
72          var grad = context2D.createLinearGradient(x0, y0, x1, y1)
73          grad.addColorStop(0.0, '#ffffffff')
74          grad.addColorStop(1, '#00ffffff')
75          context2D.fillStyle = grad
76          context2D.fillRect(0, 0, TabTitleBar.gradientMaskWidth, TabTitleBar.totalHeight)
77        })
78    }
79    .width(TabTitleBar.gradientMaskWidth)
80    .height(TabTitleBar.totalHeight)
81  }
82
83  build() {
84    Column() {
85      Flex({
86        justifyContent: FlexAlign.SpaceBetween,
87        alignItems: ItemAlign.Stretch
88      }) {
89        Stack({ alignContent: Alignment.End }) {
90          Stack({ alignContent: Alignment.Start }) {
91            Column() {
92              List({ initialIndex: 0, scroller: this.scroller, space: 0 }) {
93                ForEach(this.tabItems, (tabItem, index?: number) => {
94                  ListItem() {
95                    TabContentItem({
96                      item: tabItem,
97                      index: index,
98                      maxIndex: this.tabItems.length - 1,
99                      currentIndex: this.currentIndex,
100                      onCustomClick: () => this.currentIndex = index
101                    })
102                  }
103                })
104              }
105              .width('100%')
106              .height(TabTitleBar.totalHeight)
107              .constraintSize({ maxWidth: this.tabWidth })
108              .edgeEffect(EdgeEffect.Spring)
109              .listDirection(Axis.Horizontal)
110              .scrollBar(BarState.Off)
111            }
112            this.GradientMask(this.leftContext2D, 0, TabTitleBar.totalHeight / 2,
113              TabTitleBar.gradientMaskWidth, TabTitleBar.totalHeight / 2)
114          }
115          this.GradientMask(this.rightContext2D, TabTitleBar.gradientMaskWidth,
116            TabTitleBar.totalHeight / 2, 0, TabTitleBar.totalHeight / 2)
117        }
118
119        if (this.menuItems !== undefined && this.menuItems.length > 0) {
120          CollapsibleMenuSection({ menuItems: this.menuItems })
121          .height(TabTitleBar.totalHeight)
122          .onAreaChange((_oldValue: Area, newValue: Area) => {
123            this.menuSectionWidth = Number(newValue.width)
124          })
125        }
126      }
127      .backgroundColor($r('sys.color.ohos_id_color_background'))
128      .margin({ right: $r('sys.float.ohos_id_max_padding_end') })
129      .onAreaChange((_oldValue: Area, newValue: Area) => {
130        this.tabWidth = Number(newValue.width) - this.menuSectionWidth
131      })
132
133      Column() {
134        Swiper(this.swiperController) { this.swiperContent() }
135        .index(this.currentIndex)
136        .itemSpace(0)
137        .indicator(false)
138        .width('100%')
139        .height('100%')
140        .curve(Curve.Friction)
141        .onChange((index) => {
142          this.currentIndex = index
143          this.scroller.scrollToIndex(this.currentIndex)
144          this.scroller.scrollBy(TabTitleBar.correctionOffset, 0)
145        })
146        .onAppear(() => {
147          this.scroller.scrollToIndex(this.currentIndex)
148          this.scroller.scrollBy(TabTitleBar.correctionOffset, 0)
149        })
150      }
151    }
152  }
153}
154
155@Component
156struct CollapsibleMenuSection {
157  menuItems: Array<TabTitleBarMenuItem>
158
159  static readonly maxCountOfVisibleItems = 1
160  private static readonly focusPadding = 4
161  private static readonly marginsNum = 2
162
163  @State isPopupShown: boolean = false
164
165  @State isMoreIconOnFocus: boolean = false
166  @State isMoreIconOnHover: boolean = false
167  @State isMoreIconOnClick: boolean = false
168
169  getMoreIconFgColor() {
170    return this.isMoreIconOnClick
171      ? $r('sys.color.ohos_id_color_titlebar_icon_pressed')
172      : $r('sys.color.ohos_id_color_titlebar_icon')
173  }
174
175  getMoreIconBgColor() {
176    if (this.isMoreIconOnClick) {
177      return $r('sys.color.ohos_id_color_click_effect')
178    } else if (this.isMoreIconOnHover) {
179      return $r('sys.color.ohos_id_color_hover')
180    } else {
181      return Color.Transparent
182    }
183  }
184
185  build() {
186    Column() {
187      Row() {
188        if (this.menuItems.length <= CollapsibleMenuSection.maxCountOfVisibleItems) {
189          ForEach(this.menuItems, (item) => {
190            ImageMenuItem({ item: item })
191          })
192        } else {
193          ForEach(this.menuItems.slice(0, CollapsibleMenuSection.maxCountOfVisibleItems - 1), (item) => {
194            ImageMenuItem({ item: item })
195          })
196
197          Row() {
198            Image(PUBLIC_MORE)
199              .width(ImageMenuItem.imageSize)
200              .height(ImageMenuItem.imageSize)
201              .focusable(true)
202          }
203          .width(ImageMenuItem.imageHotZoneWidth)
204          .height(ImageMenuItem.imageHotZoneWidth)
205          .borderRadius(ImageMenuItem.buttonBorderRadius)
206          .foregroundColor(this.getMoreIconFgColor())
207          .backgroundColor(this.getMoreIconBgColor())
208          .justifyContent(FlexAlign.Center)
209          .border(this.isMoreIconOnFocus ?
210            { width: ImageMenuItem.focusBorderWidth,
211              color: $r('sys.color.ohos_id_color_emphasize'),
212              style: BorderStyle.Solid
213            } : { width: 0 })
214          .onFocus(() => this.isMoreIconOnFocus = true)
215          .onBlur(() => this.isMoreIconOnFocus = false)
216          .onHover((isOn) => this.isMoreIconOnHover = isOn)
217          .onKeyEvent((event) => {
218            if (event.keyCode !== KeyCode.KEYCODE_ENTER && event.keyCode !== KeyCode.KEYCODE_SPACE) {
219              return
220            }
221            if (event.type === KeyType.Down) {
222              this.isMoreIconOnClick = true
223            }
224            if (event.type === KeyType.Up) {
225              this.isMoreIconOnClick = false
226            }
227          })
228          .onTouch((event) => {
229            if (event.type === TouchType.Down) {
230              this.isMoreIconOnClick = true
231            }
232            if (event.type === TouchType.Up) {
233              this.isMoreIconOnClick = false
234            }
235          })
236          .onClick(() => this.isPopupShown = true)
237          .bindPopup(this.isPopupShown, {
238            builder: this.popupBuilder,
239            placement: Placement.Bottom,
240            popupColor: Color.White,
241            enableArrow: false,
242            onStateChange: (e) => this.isPopupShown = e.isVisible
243          })
244        }
245      }
246    }
247    .height('100%')
248    // .margin({ right: $r('sys.float.ohos_id_default_padding_end') })
249    .justifyContent(FlexAlign.Center)
250  }
251
252  @Builder popupBuilder() {
253    Column() {
254      ForEach(this.menuItems.slice(CollapsibleMenuSection.maxCountOfVisibleItems - 1, this.menuItems.length), (item, _index?) => {
255        ImageMenuItem({ item: item })
256      })
257    }
258    .width(ImageMenuItem.imageHotZoneWidth + CollapsibleMenuSection.focusPadding * CollapsibleMenuSection.marginsNum)
259    .margin({ top: CollapsibleMenuSection.focusPadding, bottom: CollapsibleMenuSection.focusPadding })
260  }
261}
262
263@Component
264struct TabContentItem {
265  item: TabTitleBarTabItem
266  index: number
267  maxIndex: number
268  onCustomClick?: () => void
269
270  @Prop currentIndex: number
271
272  @State isOnFocus: boolean = false
273  @State isOnHover: boolean = false
274  @State isOnClick: boolean = false
275
276  static readonly imageSize = 24
277  static readonly imageHotZoneWidth = 48
278  static readonly imageMagnificationFactor = 1.4
279  static readonly buttonBorderRadius = 8
280  static readonly focusBorderWidth = 2
281
282  getBgColor() {
283    if (this.isOnClick) {
284      return $r('sys.color.ohos_id_color_click_effect')
285    } else if (this.isOnHover) {
286      return $r('sys.color.ohos_id_color_hover')
287    } else {
288      return Color.Transparent
289    }
290  }
291
292  build() {
293    Row() {
294      Column() {
295        if (this.item.icon === undefined) {
296          Text(this.item.title)
297            .fontSize(this.index === this.currentIndex
298              ? $r('sys.float.ohos_id_text_size_headline7')
299              : $r('sys.float.ohos_id_text_size_headline9'))
300            .fontColor(this.index === this.currentIndex
301              ? $r('sys.color.ohos_id_color_titlebar_text')
302              : $r('sys.color.ohos_id_color_titlebar_text_off'))
303            .fontWeight(FontWeight.Medium)
304            .focusable(true)
305            .padding({ top: this.index === this.currentIndex ? 6 : 10, left: 8, bottom: 2, right: 8 })
306            .onFocus(() => this.isOnFocus = true)
307            .onBlur(() => this.isOnFocus = false)
308            .onHover((isOn) => this.isOnHover = isOn)
309            .onKeyEvent((event) => {
310              if (event.keyCode !== KeyCode.KEYCODE_ENTER && event.keyCode !== KeyCode.KEYCODE_SPACE) {
311                return
312              }
313              if (event.type === KeyType.Down) {
314                this.isOnClick = true
315              }
316              if (event.type === KeyType.Up) {
317                this.isOnClick = false
318              }
319            })
320            .onTouch((event) => {
321              if (event.type === TouchType.Down) {
322                this.isOnClick = true
323              }
324              if (event.type === TouchType.Up) {
325                this.isOnClick = false
326              }
327            })
328            .onClick(() => this.onCustomClick && this.onCustomClick())
329        } else {
330          Row() {
331            Image(this.item.icon)
332              .alt(this.item.title)
333              .height(TabContentItem.imageSize)
334              .focusable(true)
335              .scale({
336                x: this.index === this.currentIndex ? TabContentItem.imageMagnificationFactor : 1,
337                y: this.index === this.currentIndex ? TabContentItem.imageMagnificationFactor : 1
338              })
339          }
340          .width(TabContentItem.imageHotZoneWidth)
341          .height(TabContentItem.imageHotZoneWidth)
342          .justifyContent(FlexAlign.Center)
343          .onFocus(() => this.isOnFocus = true)
344          .onBlur(() => this.isOnFocus = false)
345          .onHover((isOn) => this.isOnHover = isOn)
346          .onKeyEvent((event) => {
347            if (event.keyCode !== KeyCode.KEYCODE_ENTER && event.keyCode !== KeyCode.KEYCODE_SPACE) {
348              return
349            }
350            if (event.type === KeyType.Down) {
351              this.isOnClick = true
352            }
353            if (event.type === KeyType.Up) {
354              this.isOnClick = false
355            }
356          })
357          .onTouch((event) => {
358            if (event.type === TouchType.Down) {
359              this.isOnClick = true
360            }
361            if (event.type === TouchType.Up) {
362              this.isOnClick = false
363            }
364          })
365          .onClick(() => this.onCustomClick && this.onCustomClick())
366        }
367      }
368      .justifyContent(FlexAlign.Center)
369    }
370    .height(TabTitleBar.totalHeight)
371    .alignItems(VerticalAlign.Center)
372    .justifyContent(FlexAlign.Center)
373    .margin({
374      left: this.index === 0 ? 16 : 0,
375      right: this.index === this.maxIndex ? 12 : 0
376    })  // sys.float.ohos_id_max_padding_start - 8
377    .borderRadius(TabContentItem.buttonBorderRadius)
378    .backgroundColor(this.getBgColor())
379    .border(this.isOnFocus ?
380      { width: TabContentItem.focusBorderWidth,
381        color: $r('sys.color.ohos_id_color_emphasize'),
382        style: BorderStyle.Solid
383      } : { width: 0 })
384  }
385}
386
387@Component
388struct ImageMenuItem {
389  item: TabTitleBarMenuItem
390
391  static readonly imageSize = 24
392  static readonly imageHotZoneWidth = 48
393  static readonly buttonBorderRadius = 8
394  static readonly focusBorderWidth = 2
395  static readonly disabledImageOpacity = 0.4
396
397  @State isOnFocus: boolean = false
398  @State isOnHover: boolean = false
399  @State isOnClick: boolean = false
400
401  getFgColor() {
402    return this.isOnClick
403      ? $r('sys.color.ohos_id_color_titlebar_icon_pressed')
404      : $r('sys.color.ohos_id_color_titlebar_icon')
405  }
406
407  getBgColor() {
408    if (this.isOnClick) {
409      return $r('sys.color.ohos_id_color_click_effect')
410    } else if (this.isOnHover) {
411      return $r('sys.color.ohos_id_color_hover')
412    } else {
413      return Color.Transparent
414    }
415  }
416
417  build() {
418    Row() {
419      Image(this.item.value)
420        .width(ImageMenuItem.imageSize)
421        .height(ImageMenuItem.imageSize)
422        .focusable(this.item.isEnabled)
423    }
424    .width(ImageMenuItem.imageHotZoneWidth)
425    .height(ImageMenuItem.imageHotZoneWidth)
426    .borderRadius(ImageMenuItem.buttonBorderRadius)
427    .foregroundColor(this.getFgColor())
428    .backgroundColor(this.getBgColor())
429    .justifyContent(FlexAlign.Center)
430    .opacity(this.item.isEnabled ? 1 : ImageMenuItem.disabledImageOpacity)
431    .border(this.isOnFocus ?
432      { width: ImageMenuItem.focusBorderWidth,
433        color: $r('sys.color.ohos_id_color_emphasize'),
434        style: BorderStyle.Solid
435      } : { width: 0 })
436    .onFocus(() => {
437      if (!this.item.isEnabled) {
438        return
439      }
440      this.isOnFocus = true
441    })
442    .onBlur(() => this.isOnFocus = false)
443    .onHover((isOn) => {
444      if (!this.item.isEnabled) {
445        return
446      }
447      this.isOnHover = isOn
448    })
449    .onKeyEvent((event) => {
450      if (!this.item.isEnabled) {
451        return
452      }
453      if (event.keyCode !== KeyCode.KEYCODE_ENTER && event.keyCode !== KeyCode.KEYCODE_SPACE) {
454        return
455      }
456      if (event.type === KeyType.Down) {
457        this.isOnClick = true
458      }
459      if (event.type === KeyType.Up) {
460        this.isOnClick = false
461      }
462    })
463    .onTouch((event) => {
464      if (!this.item.isEnabled) {
465        return
466      }
467      if (event.type === TouchType.Down) {
468        this.isOnClick = true
469      }
470      if (event.type === TouchType.Up) {
471        this.isOnClick = false
472      }
473    })
474    .onClick(() => this.item.isEnabled && this.item.action && this.item.action())
475  }
476}
477
478export default { TabTitleBar }