about summary refs log tree commit diff
path: root/src/view/com/pager/TabBar.tsx
blob: 04803fa9bfbbeb6b620909bc8fc9cb5b9001f2ba (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
import {useCallback} from 'react'
import {
  type LayoutChangeEvent,
  ScrollView,
  StyleSheet,
  View,
} from 'react-native'
import Animated, {
  interpolate,
  runOnJS,
  runOnUI,
  scrollTo,
  type SharedValue,
  useAnimatedReaction,
  useAnimatedRef,
  useAnimatedStyle,
  useSharedValue,
} from 'react-native-reanimated'

import {PressableWithHover} from '#/view/com/util/PressableWithHover'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useTheme} from '#/alf'
import {Text} from '#/components/Typography'

export interface TabBarProps {
  testID?: string
  selectedPage: number
  items: string[]
  onSelect?: (index: number) => void
  onPressSelected?: (index: number) => void
  dragProgress: SharedValue<number>
  dragState: SharedValue<'idle' | 'dragging' | 'settling'>
}

const ITEM_PADDING = 10
const CONTENT_PADDING = 6
// How much of the previous/next item we're requiring
// when deciding whether to scroll into view on tap.
const OFFSCREEN_ITEM_WIDTH = 20

export function TabBar({
  testID,
  selectedPage,
  items,
  onSelect,
  onPressSelected,
  dragProgress,
  dragState,
}: TabBarProps) {
  const t = useTheme()
  const scrollElRef = useAnimatedRef<ScrollView>()
  const syncScrollState = useSharedValue<'synced' | 'unsynced' | 'needs-sync'>(
    'synced',
  )
  const didInitialScroll = useSharedValue(false)
  const contentSize = useSharedValue(0)
  const containerSize = useSharedValue(0)
  const scrollX = useSharedValue(0)
  const layouts = useSharedValue<{x: number; width: number}[]>([])
  const textLayouts = useSharedValue<{width: number}[]>([])
  const itemsLength = items.length

  const scrollToOffsetJS = useCallback(
    (x: number) => {
      scrollElRef.current?.scrollTo({
        x,
        y: 0,
        animated: true,
      })
    },
    [scrollElRef],
  )

  const indexToOffset = useCallback(
    (index: number) => {
      'worklet'
      const layout = layouts.get()[index]
      const availableSize = containerSize.get() - 2 * CONTENT_PADDING
      if (!layout) {
        // Should not happen, but fall back to equal sizes.
        const offsetPerPage = contentSize.get() - availableSize
        return (index / (itemsLength - 1)) * offsetPerPage
      }
      const freeSpace = availableSize - layout.width
      const accumulatingOffset = interpolate(
        index,
        // Gradually shift every next item to the left so that the first item
        // is positioned like "left: 0" but the last item is like "right: 0".
        [0, itemsLength - 1],
        [0, freeSpace],
        'clamp',
      )
      return layout.x - accumulatingOffset
    },
    [itemsLength, contentSize, containerSize, layouts],
  )

  const progressToOffset = useCallback(
    (progress: number) => {
      'worklet'
      return interpolate(
        progress,
        [Math.floor(progress), Math.ceil(progress)],
        [
          indexToOffset(Math.floor(progress)),
          indexToOffset(Math.ceil(progress)),
        ],
        'clamp',
      )
    },
    [indexToOffset],
  )

  // When we know the entire layout for the first time, scroll selection into view.
  useAnimatedReaction(
    () => layouts.get().length,
    (nextLayoutsLength, prevLayoutsLength) => {
      if (nextLayoutsLength !== prevLayoutsLength) {
        if (
          nextLayoutsLength === itemsLength &&
          didInitialScroll.get() === false
        ) {
          didInitialScroll.set(true)
          const progress = dragProgress.get()
          const offset = progressToOffset(progress)
          // It's unclear why we need to go back to JS here. It seems iOS-specific.
          runOnJS(scrollToOffsetJS)(offset)
        }
      }
    },
  )

  // When you swipe the pager, the tabbar should scroll automatically
  // as you're dragging the page and then even during deceleration.
  useAnimatedReaction(
    () => dragProgress.get(),
    (nextProgress, prevProgress) => {
      if (
        nextProgress !== prevProgress &&
        dragState.value !== 'idle' &&
        // This is only OK to do when we're 100% sure we're synced.
        // Otherwise, there would be a jump at the beginning of the swipe.
        syncScrollState.get() === 'synced'
      ) {
        const offset = progressToOffset(nextProgress)
        scrollTo(scrollElRef, offset, 0, false)
      }
    },
  )

  // If the syncing is currently off but you've just finished swiping,
  // it's an opportunity to resync. It won't feel disruptive because
  // you're not directly interacting with the tabbar at the moment.
  useAnimatedReaction(
    () => dragState.value,
    (nextDragState, prevDragState) => {
      if (
        nextDragState !== prevDragState &&
        nextDragState === 'idle' &&
        (syncScrollState.get() === 'unsynced' ||
          syncScrollState.get() === 'needs-sync')
      ) {
        const progress = dragProgress.get()
        const offset = progressToOffset(progress)
        scrollTo(scrollElRef, offset, 0, true)
        syncScrollState.set('synced')
      }
    },
  )

  // When you press on the item, we'll scroll into view -- unless you previously
  // have scrolled the tabbar manually, in which case it'll re-sync on next press.
  const onPressUIThread = useCallback(
    (index: number) => {
      'worklet'
      const itemLayout = layouts.get()[index]
      if (!itemLayout) {
        // Should not happen.
        return
      }
      const leftEdge = itemLayout.x - OFFSCREEN_ITEM_WIDTH
      const rightEdge = itemLayout.x + itemLayout.width + OFFSCREEN_ITEM_WIDTH
      const scrollLeft = scrollX.get()
      const scrollRight = scrollLeft + containerSize.get()
      const scrollIntoView = leftEdge < scrollLeft || rightEdge > scrollRight
      if (
        syncScrollState.get() === 'synced' ||
        syncScrollState.get() === 'needs-sync' ||
        scrollIntoView
      ) {
        const offset = progressToOffset(index)
        scrollTo(scrollElRef, offset, 0, true)
        syncScrollState.set('synced')
      } else {
        // The item is already in view so it's disruptive to
        // scroll right now. Do it on the next opportunity.
        syncScrollState.set('needs-sync')
      }
    },
    [
      syncScrollState,
      scrollElRef,
      scrollX,
      progressToOffset,
      containerSize,
      layouts,
    ],
  )

  const onItemLayout = useCallback(
    (i: number, layout: {x: number; width: number}) => {
      'worklet'
      layouts.modify(ls => {
        ls[i] = layout
        return ls
      })
    },
    [layouts],
  )

  const onTextLayout = useCallback(
    (i: number, layout: {width: number}) => {
      'worklet'
      textLayouts.modify(ls => {
        ls[i] = layout
        return ls
      })
    },
    [textLayouts],
  )

  const indicatorStyle = useAnimatedStyle(() => {
    if (!_WORKLET) {
      return {opacity: 0}
    }
    const layoutsValue = layouts.get()
    const textLayoutsValue = textLayouts.get()
    if (
      layoutsValue.length !== itemsLength ||
      textLayoutsValue.length !== itemsLength
    ) {
      return {
        opacity: 0,
      }
    }

    function getScaleX(index: number) {
      const textWidth = textLayoutsValue[index].width
      const itemWidth = layoutsValue[index].width
      const minIndicatorWidth = 45
      const maxIndicatorWidth = itemWidth - 2 * CONTENT_PADDING
      const indicatorWidth = Math.min(
        Math.max(minIndicatorWidth, textWidth),
        maxIndicatorWidth,
      )
      return indicatorWidth / contentSize.get()
    }

    if (textLayoutsValue.length === 1) {
      return {
        opacity: 1,
        transform: [
          {
            scaleX: getScaleX(0),
          },
        ],
      }
    }
    return {
      opacity: 1,
      transform: [
        {
          translateX: interpolate(
            dragProgress.get(),
            layoutsValue.map((l, i) => {
              'worklet'
              return i
            }),
            layoutsValue.map(l => {
              'worklet'
              return l.x + l.width / 2 - contentSize.get() / 2
            }),
          ),
        },
        {
          scaleX: interpolate(
            dragProgress.get(),
            textLayoutsValue.map((l, i) => {
              'worklet'
              return i
            }),
            textLayoutsValue.map((l, i) => {
              'worklet'
              return getScaleX(i)
            }),
          ),
        },
      ],
    }
  })

  const onPressItem = useCallback(
    (index: number) => {
      runOnUI(onPressUIThread)(index)
      onSelect?.(index)
      if (index === selectedPage) {
        onPressSelected?.(index)
      }
    },
    [onSelect, selectedPage, onPressSelected, onPressUIThread],
  )

  return (
    <View
      testID={testID}
      style={[t.atoms.bg, a.flex_row]}
      accessibilityRole="tablist">
      <BlockDrawerGesture>
        <ScrollView
          testID={`${testID}-selector`}
          horizontal={true}
          showsHorizontalScrollIndicator={false}
          ref={scrollElRef}
          contentContainerStyle={styles.contentContainer}
          onLayout={e => {
            containerSize.set(e.nativeEvent.layout.width)
          }}
          onScrollBeginDrag={() => {
            // Remember that you've manually messed with the tabbar scroll.
            // This will disable auto-adjustment until after next pager swipe or item tap.
            syncScrollState.set('unsynced')
          }}
          onScroll={e => {
            scrollX.value = Math.round(e.nativeEvent.contentOffset.x)
          }}>
          <Animated.View
            onLayout={e => {
              contentSize.set(e.nativeEvent.layout.width)
            }}
            style={{flexDirection: 'row', flexGrow: 1}}>
            {items.map((item, i) => {
              return (
                <TabBarItem
                  key={i}
                  index={i}
                  testID={testID}
                  dragProgress={dragProgress}
                  item={item}
                  onPressItem={onPressItem}
                  onItemLayout={onItemLayout}
                  onTextLayout={onTextLayout}
                />
              )
            })}
            <Animated.View
              style={[
                indicatorStyle,
                {
                  position: 'absolute',
                  left: 0,
                  bottom: 0,
                  right: 0,
                  borderBottomWidth: 2,
                  borderColor: t.palette.primary_500,
                },
              ]}
            />
          </Animated.View>
        </ScrollView>
      </BlockDrawerGesture>
      <View style={[t.atoms.border_contrast_low, styles.outerBottomBorder]} />
    </View>
  )
}

function TabBarItem({
  index,
  testID,
  dragProgress,
  item,
  onPressItem,
  onItemLayout,
  onTextLayout,
}: {
  index: number
  testID: string | undefined
  dragProgress: SharedValue<number>
  item: string
  onPressItem: (index: number) => void
  onItemLayout: (index: number, layout: {x: number; width: number}) => void
  onTextLayout: (index: number, layout: {width: number}) => void
}) {
  const t = useTheme()
  const style = useAnimatedStyle(() => {
    if (!_WORKLET) {
      return {opacity: 0.7}
    }
    return {
      opacity: interpolate(
        dragProgress.get(),
        [index - 1, index, index + 1],
        [0.7, 1, 0.7],
        'clamp',
      ),
    }
  })

  const handleLayout = useCallback(
    (e: LayoutChangeEvent) => {
      runOnUI(onItemLayout)(index, e.nativeEvent.layout)
    },
    [index, onItemLayout],
  )

  const handleTextLayout = useCallback(
    (e: LayoutChangeEvent) => {
      runOnUI(onTextLayout)(index, e.nativeEvent.layout)
    },
    [index, onTextLayout],
  )

  return (
    <View onLayout={handleLayout} style={{flexGrow: 1}}>
      <PressableWithHover
        testID={`${testID}-selector-${index}`}
        style={styles.item}
        hoverStyle={t.atoms.bg_contrast_25}
        onPress={() => onPressItem(index)}
        accessibilityRole="tab">
        <Animated.View style={[style, styles.itemInner]}>
          <Text
            emoji
            testID={testID ? `${testID}-${item}` : undefined}
            style={[styles.itemText, t.atoms.text, a.text_md, a.font_bold]}
            onLayout={handleTextLayout}>
            {item}
          </Text>
        </Animated.View>
      </PressableWithHover>
    </View>
  )
}

const styles = StyleSheet.create({
  contentContainer: {
    flexGrow: 1,
    backgroundColor: 'transparent',
    paddingHorizontal: CONTENT_PADDING,
  },
  item: {
    flexGrow: 1,
    paddingTop: 10,
    paddingHorizontal: ITEM_PADDING,
    justifyContent: 'center',
  },
  itemInner: {
    alignItems: 'center',
    flexGrow: 1,
    paddingBottom: 10,
    borderBottomWidth: 3,
    borderBottomColor: 'transparent',
  },
  itemText: {
    lineHeight: 20,
    textAlign: 'center',
  },
  outerBottomBorder: {
    position: 'absolute',
    left: 0,
    right: 0,
    top: '100%',
    borderBottomWidth: StyleSheet.hairlineWidth,
  },
})