forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback} from 'react'
2import {
3 type LayoutChangeEvent,
4 ScrollView,
5 StyleSheet,
6 View,
7} from 'react-native'
8import Animated, {
9 interpolate,
10 runOnJS,
11 runOnUI,
12 scrollTo,
13 type SharedValue,
14 useAnimatedReaction,
15 useAnimatedRef,
16 useAnimatedStyle,
17 useSharedValue,
18} from 'react-native-reanimated'
19
20import {PressableWithHover} from '#/view/com/util/PressableWithHover'
21import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
22import {atoms as a, useTheme} from '#/alf'
23import {Text} from '#/components/Typography'
24
25export interface TabBarProps {
26 testID?: string
27 selectedPage: number
28 items: string[]
29 onSelect?: (index: number) => void
30 onPressSelected?: (index: number) => void
31 dragProgress: SharedValue<number>
32 dragState: SharedValue<'idle' | 'dragging' | 'settling'>
33 transparent?: boolean
34}
35
36const ITEM_PADDING = 10
37const CONTENT_PADDING = 6
38// How much of the previous/next item we're requiring
39// when deciding whether to scroll into view on tap.
40const OFFSCREEN_ITEM_WIDTH = 20
41
42export function TabBar({
43 testID,
44 selectedPage,
45 items,
46 onSelect,
47 onPressSelected,
48 dragProgress,
49 dragState,
50 transparent,
51}: TabBarProps) {
52 const t = useTheme()
53 const scrollElRef = useAnimatedRef<ScrollView>()
54 const syncScrollState = useSharedValue<'synced' | 'unsynced' | 'needs-sync'>(
55 'synced',
56 )
57 const didInitialScroll = useSharedValue(false)
58 const contentSize = useSharedValue(0)
59 const containerSize = useSharedValue(0)
60 const scrollX = useSharedValue(0)
61 const layouts = useSharedValue<{x: number; width: number}[]>([])
62 const textLayouts = useSharedValue<{width: number}[]>([])
63 const itemsLength = items.length
64
65 const scrollToOffsetJS = useCallback(
66 (x: number) => {
67 scrollElRef.current?.scrollTo({
68 x,
69 y: 0,
70 animated: true,
71 })
72 },
73 [scrollElRef],
74 )
75
76 const indexToOffset = useCallback(
77 (index: number) => {
78 'worklet'
79 const layout = layouts.get()[index]
80 const availableSize = containerSize.get() - 2 * CONTENT_PADDING
81 if (!layout) {
82 // Should not happen, but fall back to equal sizes.
83 const offsetPerPage = contentSize.get() - availableSize
84 return (index / (itemsLength - 1)) * offsetPerPage
85 }
86 const freeSpace = availableSize - layout.width
87 const accumulatingOffset = interpolate(
88 index,
89 // Gradually shift every next item to the left so that the first item
90 // is positioned like "left: 0" but the last item is like "right: 0".
91 [0, itemsLength - 1],
92 [0, freeSpace],
93 'clamp',
94 )
95 return layout.x - accumulatingOffset
96 },
97 [itemsLength, contentSize, containerSize, layouts],
98 )
99
100 const progressToOffset = useCallback(
101 (progress: number) => {
102 'worklet'
103 return interpolate(
104 progress,
105 [Math.floor(progress), Math.ceil(progress)],
106 [
107 indexToOffset(Math.floor(progress)),
108 indexToOffset(Math.ceil(progress)),
109 ],
110 'clamp',
111 )
112 },
113 [indexToOffset],
114 )
115
116 // When we know the entire layout for the first time, scroll selection into view.
117 useAnimatedReaction(
118 () => layouts.get().length,
119 (nextLayoutsLength, prevLayoutsLength) => {
120 if (nextLayoutsLength !== prevLayoutsLength) {
121 if (
122 nextLayoutsLength === itemsLength &&
123 didInitialScroll.get() === false
124 ) {
125 didInitialScroll.set(true)
126 const progress = dragProgress.get()
127 const offset = progressToOffset(progress)
128 // It's unclear why we need to go back to JS here. It seems iOS-specific.
129 runOnJS(scrollToOffsetJS)(offset)
130 }
131 }
132 },
133 )
134
135 // When you swipe the pager, the tabbar should scroll automatically
136 // as you're dragging the page and then even during deceleration.
137 useAnimatedReaction(
138 () => dragProgress.get(),
139 (nextProgress, prevProgress) => {
140 if (
141 nextProgress !== prevProgress &&
142 dragState.value !== 'idle' &&
143 // This is only OK to do when we're 100% sure we're synced.
144 // Otherwise, there would be a jump at the beginning of the swipe.
145 syncScrollState.get() === 'synced'
146 ) {
147 const offset = progressToOffset(nextProgress)
148 scrollTo(scrollElRef, offset, 0, false)
149 }
150 },
151 )
152
153 // If the syncing is currently off but you've just finished swiping,
154 // it's an opportunity to resync. It won't feel disruptive because
155 // you're not directly interacting with the tabbar at the moment.
156 useAnimatedReaction(
157 () => dragState.value,
158 (nextDragState, prevDragState) => {
159 if (
160 nextDragState !== prevDragState &&
161 nextDragState === 'idle' &&
162 (syncScrollState.get() === 'unsynced' ||
163 syncScrollState.get() === 'needs-sync')
164 ) {
165 const progress = dragProgress.get()
166 const offset = progressToOffset(progress)
167 scrollTo(scrollElRef, offset, 0, true)
168 syncScrollState.set('synced')
169 }
170 },
171 )
172
173 // When you press on the item, we'll scroll into view -- unless you previously
174 // have scrolled the tabbar manually, in which case it'll re-sync on next press.
175 const onPressUIThread = useCallback(
176 (index: number) => {
177 'worklet'
178 const itemLayout = layouts.get()[index]
179 if (!itemLayout) {
180 // Should not happen.
181 return
182 }
183 const leftEdge = itemLayout.x - OFFSCREEN_ITEM_WIDTH
184 const rightEdge = itemLayout.x + itemLayout.width + OFFSCREEN_ITEM_WIDTH
185 const scrollLeft = scrollX.get()
186 const scrollRight = scrollLeft + containerSize.get()
187 const scrollIntoView = leftEdge < scrollLeft || rightEdge > scrollRight
188 if (
189 syncScrollState.get() === 'synced' ||
190 syncScrollState.get() === 'needs-sync' ||
191 scrollIntoView
192 ) {
193 const offset = progressToOffset(index)
194 scrollTo(scrollElRef, offset, 0, true)
195 syncScrollState.set('synced')
196 } else {
197 // The item is already in view so it's disruptive to
198 // scroll right now. Do it on the next opportunity.
199 syncScrollState.set('needs-sync')
200 }
201 },
202 [
203 syncScrollState,
204 scrollElRef,
205 scrollX,
206 progressToOffset,
207 containerSize,
208 layouts,
209 ],
210 )
211
212 const onItemLayout = useCallback(
213 (i: number, layout: {x: number; width: number}) => {
214 'worklet'
215 layouts.modify(ls => {
216 ls[i] = layout
217 return ls
218 })
219 },
220 [layouts],
221 )
222
223 const onTextLayout = useCallback(
224 (i: number, layout: {width: number}) => {
225 'worklet'
226 textLayouts.modify(ls => {
227 ls[i] = layout
228 return ls
229 })
230 },
231 [textLayouts],
232 )
233
234 const indicatorStyle = useAnimatedStyle(() => {
235 if (!_WORKLET) {
236 return {opacity: 0}
237 }
238 const layoutsValue = layouts.get()
239 const textLayoutsValue = textLayouts.get()
240 if (
241 layoutsValue.length !== itemsLength ||
242 textLayoutsValue.length !== itemsLength
243 ) {
244 return {
245 opacity: 0,
246 }
247 }
248
249 function getScaleX(index: number) {
250 const textWidth = textLayoutsValue[index].width
251 const itemWidth = layoutsValue[index].width
252 const minIndicatorWidth = 45
253 const maxIndicatorWidth = itemWidth - 2 * CONTENT_PADDING
254 const indicatorWidth = Math.min(
255 Math.max(minIndicatorWidth, textWidth),
256 maxIndicatorWidth,
257 )
258 return indicatorWidth / contentSize.get()
259 }
260
261 if (textLayoutsValue.length === 1) {
262 return {
263 opacity: 1,
264 transform: [
265 {
266 scaleX: getScaleX(0),
267 },
268 ],
269 }
270 }
271 return {
272 opacity: 1,
273 transform: [
274 {
275 translateX: interpolate(
276 dragProgress.get(),
277 layoutsValue.map((l, i) => {
278 'worklet'
279 return i
280 }),
281 layoutsValue.map(l => {
282 'worklet'
283 return l.x + l.width / 2 - contentSize.get() / 2
284 }),
285 ),
286 },
287 {
288 scaleX: interpolate(
289 dragProgress.get(),
290 textLayoutsValue.map((l, i) => {
291 'worklet'
292 return i
293 }),
294 textLayoutsValue.map((l, i) => {
295 'worklet'
296 return getScaleX(i)
297 }),
298 ),
299 },
300 ],
301 }
302 })
303
304 const onPressItem = useCallback(
305 (index: number) => {
306 runOnUI(onPressUIThread)(index)
307 onSelect?.(index)
308 if (index === selectedPage) {
309 onPressSelected?.(index)
310 }
311 },
312 [onSelect, selectedPage, onPressSelected, onPressUIThread],
313 )
314
315 return (
316 <View
317 testID={testID}
318 style={[!transparent && t.atoms.bg, a.flex_row]}
319 accessibilityRole="tablist">
320 <BlockDrawerGesture>
321 <ScrollView
322 testID={`${testID}-selector`}
323 horizontal={true}
324 showsHorizontalScrollIndicator={false}
325 ref={scrollElRef}
326 contentContainerStyle={styles.contentContainer}
327 onLayout={e => {
328 containerSize.set(e.nativeEvent.layout.width)
329 }}
330 onScrollBeginDrag={() => {
331 // Remember that you've manually messed with the tabbar scroll.
332 // This will disable auto-adjustment until after next pager swipe or item tap.
333 syncScrollState.set('unsynced')
334 }}
335 onScroll={e => {
336 scrollX.value = Math.round(e.nativeEvent.contentOffset.x)
337 }}>
338 <Animated.View
339 onLayout={e => {
340 contentSize.set(e.nativeEvent.layout.width)
341 }}
342 style={{flexDirection: 'row', flexGrow: 1}}>
343 {items.map((item, i) => {
344 return (
345 <TabBarItem
346 key={i}
347 index={i}
348 testID={testID}
349 dragProgress={dragProgress}
350 item={item}
351 onPressItem={onPressItem}
352 onItemLayout={onItemLayout}
353 onTextLayout={onTextLayout}
354 />
355 )
356 })}
357 <Animated.View
358 style={[
359 indicatorStyle,
360 {
361 position: 'absolute',
362 left: 0,
363 bottom: 0,
364 right: 0,
365 borderBottomWidth: 2,
366 borderColor: t.palette.primary_500,
367 },
368 ]}
369 />
370 </Animated.View>
371 </ScrollView>
372 </BlockDrawerGesture>
373 <View style={[t.atoms.border_contrast_low, styles.outerBottomBorder]} />
374 </View>
375 )
376}
377
378function TabBarItem({
379 index,
380 testID,
381 dragProgress,
382 item,
383 onPressItem,
384 onItemLayout,
385 onTextLayout,
386}: {
387 index: number
388 testID: string | undefined
389 dragProgress: SharedValue<number>
390 item: string
391 onPressItem: (index: number) => void
392 onItemLayout: (index: number, layout: {x: number; width: number}) => void
393 onTextLayout: (index: number, layout: {width: number}) => void
394}) {
395 const t = useTheme()
396 const style = useAnimatedStyle(() => {
397 if (!_WORKLET) {
398 return {opacity: 0.7}
399 }
400 return {
401 opacity: interpolate(
402 dragProgress.get(),
403 [index - 1, index, index + 1],
404 [0.7, 1, 0.7],
405 'clamp',
406 ),
407 }
408 })
409
410 const handleLayout = useCallback(
411 (e: LayoutChangeEvent) => {
412 runOnUI(onItemLayout)(index, e.nativeEvent.layout)
413 },
414 [index, onItemLayout],
415 )
416
417 const handleTextLayout = useCallback(
418 (e: LayoutChangeEvent) => {
419 runOnUI(onTextLayout)(index, e.nativeEvent.layout)
420 },
421 [index, onTextLayout],
422 )
423
424 return (
425 <View onLayout={handleLayout} style={{flexGrow: 1}}>
426 <PressableWithHover
427 testID={`${testID}-selector-${index}`}
428 style={styles.item}
429 hoverStyle={t.atoms.bg_contrast_25}
430 onPress={() => onPressItem(index)}
431 accessibilityRole="tab">
432 <Animated.View style={[style, styles.itemInner]}>
433 <Text
434 emoji
435 testID={testID ? `${testID}-${item}` : undefined}
436 style={[styles.itemText, t.atoms.text, a.text_md, a.font_semi_bold]}
437 onLayout={handleTextLayout}>
438 {item}
439 </Text>
440 </Animated.View>
441 </PressableWithHover>
442 </View>
443 )
444}
445
446const styles = StyleSheet.create({
447 contentContainer: {
448 flexGrow: 1,
449 backgroundColor: 'transparent',
450 paddingHorizontal: CONTENT_PADDING,
451 },
452 item: {
453 flexGrow: 1,
454 paddingTop: 10,
455 paddingHorizontal: ITEM_PADDING,
456 justifyContent: 'center',
457 },
458 itemInner: {
459 alignItems: 'center',
460 flexGrow: 1,
461 paddingBottom: 10,
462 borderBottomWidth: 3,
463 borderBottomColor: 'transparent',
464 },
465 itemText: {
466 lineHeight: 20,
467 textAlign: 'center',
468 },
469 outerBottomBorder: {
470 position: 'absolute',
471 left: 0,
472 right: 0,
473 top: '100%',
474 borderBottomWidth: StyleSheet.hairlineWidth,
475 },
476})