[Nicer Tabs] Fork TabBar, simplify Pager (#6762)

* Fork TabBar.web.tsx

* Trim dead code from both forks

* Remove onPageSelecting event

It's difficult to tell what exactly it's supposed to represent, and in practice it's not really used aside from logging. Let's rip it out for now to keep other changes simpler.

* Remove early onPageSelected call

It was added to try to do some work eagerly when we're sure which way the scroll is snapping. This is not necessarily a good idea though. It schedules a potentially expensive re-render right during the deceleration animation, which is not great. Whatever we're optimizing there, we should optimize smarter (e.g. prewarm just the network call). The other thing it used to help with is triggering the pager header autoscroll earlier. But we're going to rewrite that part differently anyway so that's not relevant either.

* Prune more dead code from the native version

We'll have to revisit this when adding tablet support but for now I'd prefer to remove a codepath that is not being tested or ever run.

* Use regular ScrollView on native

The Draggable thing was needed for web-only behavior so we can drop it in the native fork.
This commit is contained in:
dan
2024-12-03 01:12:58 +00:00
committed by GitHub
parent 996871d88b
commit 5a313c2d10
8 changed files with 216 additions and 199 deletions
-6
View File
@@ -80,12 +80,6 @@ export type LogEvents = {
feedUrl: string
feedType: string
index: number
reason:
| 'focus'
| 'tabbar-click'
| 'pager-swipe'
| 'desktop-sidebar-click'
| 'starter-pack-initial-feed'
}
'feed:endReached': {
feedUrl: string
+5 -65
View File
@@ -1,21 +1,16 @@
import React, {forwardRef} from 'react'
import {View} from 'react-native'
import PagerView, {
PagerViewOnPageScrollEvent,
PagerViewOnPageSelectedEvent,
PageScrollStateChangedNativeEvent,
} from 'react-native-pager-view'
import {LogEvents} from '#/lib/statsig/events'
import {atoms as a, native} from '#/alf'
export type PageSelectedEvent = PagerViewOnPageSelectedEvent
export interface PagerRef {
setPage: (
index: number,
reason: LogEvents['home:feedDisplayed']['reason'],
) => void
setPage: (index: number) => void
}
export interface RenderTabBarFnProps {
@@ -29,10 +24,6 @@ interface Props {
initialPage?: number
renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void
onPageSelecting?: (
index: number,
reason: LogEvents['home:feedDisplayed']['reason'],
) => void
onPageScrollStateChanged?: (
scrollState: 'idle' | 'dragging' | 'settling',
) => void
@@ -46,24 +37,16 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
renderTabBar,
onPageScrollStateChanged,
onPageSelected,
onPageSelecting,
testID,
}: React.PropsWithChildren<Props>,
ref,
) {
const [selectedPage, setSelectedPage] = React.useState(0)
const lastOffset = React.useRef(0)
const lastDirection = React.useRef(0)
const scrollState = React.useRef('')
const pagerView = React.useRef<PagerView>(null)
React.useImperativeHandle(ref, () => ({
setPage: (
index: number,
reason: LogEvents['home:feedDisplayed']['reason'],
) => {
setPage: (index: number) => {
pagerView.current?.setPage(index)
onPageSelecting?.(index, reason)
},
}))
@@ -75,60 +58,18 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
[setSelectedPage, onPageSelected],
)
const onPageScroll = React.useCallback(
(e: PagerViewOnPageScrollEvent) => {
const {position, offset} = e.nativeEvent
if (offset === 0) {
// offset hits 0 in some awkward spots so we ignore it
return
}
// NOTE
// we want to call `onPageSelecting` as soon as the scroll-gesture
// enters the "settling" phase, which means the user has released it
// we can't infer directionality from the scroll information, so we
// track the offset changes. if the offset delta is consistent with
// the existing direction during the settling phase, we can say for
// certain where it's going and can fire
// -prf
if (scrollState.current === 'settling') {
if (lastDirection.current === -1 && offset < lastOffset.current) {
onPageSelecting?.(position, 'pager-swipe')
setSelectedPage(position)
lastDirection.current = 0
} else if (
lastDirection.current === 1 &&
offset > lastOffset.current
) {
onPageSelecting?.(position + 1, 'pager-swipe')
setSelectedPage(position + 1)
lastDirection.current = 0
}
} else {
if (offset < lastOffset.current) {
lastDirection.current = -1
} else if (offset > lastOffset.current) {
lastDirection.current = 1
}
}
lastOffset.current = offset
},
[lastOffset, lastDirection, onPageSelecting],
)
const handlePageScrollStateChanged = React.useCallback(
(e: PageScrollStateChangedNativeEvent) => {
scrollState.current = e.nativeEvent.pageScrollState
onPageScrollStateChanged?.(e.nativeEvent.pageScrollState)
},
[scrollState, onPageScrollStateChanged],
[onPageScrollStateChanged],
)
const onTabBarSelect = React.useCallback(
(index: number) => {
pagerView.current?.setPage(index)
onPageSelecting?.(index, 'tabbar-click')
},
[pagerView, onPageSelecting],
[pagerView],
)
return (
@@ -142,8 +83,7 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
style={[a.flex_1]}
initialPage={initialPage}
onPageScrollStateChanged={handlePageScrollStateChanged}
onPageSelected={onPageSelectedInner}
onPageScroll={onPageScroll}>
onPageSelected={onPageSelectedInner}>
{children}
</PagerView>
</View>
+5 -15
View File
@@ -2,7 +2,6 @@ import React from 'react'
import {View} from 'react-native'
import {flushSync} from 'react-dom'
import {LogEvents} from '#/lib/statsig/events'
import {s} from '#/lib/styles'
export interface RenderTabBarFnProps {
@@ -16,10 +15,6 @@ interface Props {
initialPage?: number
renderTabBar: RenderTabBarFn
onPageSelected?: (index: number) => void
onPageSelecting?: (
index: number,
reason: LogEvents['home:feedDisplayed']['reason'],
) => void
}
export const Pager = React.forwardRef(function PagerImpl(
{
@@ -27,7 +22,6 @@ export const Pager = React.forwardRef(function PagerImpl(
initialPage = 0,
renderTabBar,
onPageSelected,
onPageSelecting,
}: React.PropsWithChildren<Props>,
ref,
) {
@@ -36,16 +30,13 @@ export const Pager = React.forwardRef(function PagerImpl(
const anchorRef = React.useRef(null)
React.useImperativeHandle(ref, () => ({
setPage: (
index: number,
reason: LogEvents['home:feedDisplayed']['reason'],
) => {
onTabBarSelect(index, reason)
setPage: (index: number) => {
onTabBarSelect(index)
},
}))
const onTabBarSelect = React.useCallback(
(index: number, reason: LogEvents['home:feedDisplayed']['reason']) => {
(index: number) => {
const scrollY = window.scrollY
// We want to determine if the tabbar is already "sticking" at the top (in which
// case we should preserve and restore scroll), or if it is somewhere below in the
@@ -64,7 +55,6 @@ export const Pager = React.forwardRef(function PagerImpl(
flushSync(() => {
setSelectedPage(index)
onPageSelected?.(index)
onPageSelecting?.(index, reason)
})
if (isSticking) {
const restoredScrollY = scrollYs.current[index]
@@ -75,7 +65,7 @@ export const Pager = React.forwardRef(function PagerImpl(
}
}
},
[selectedPage, setSelectedPage, onPageSelected, onPageSelecting],
[selectedPage, setSelectedPage, onPageSelected],
)
return (
@@ -83,7 +73,7 @@ export const Pager = React.forwardRef(function PagerImpl(
{renderTabBar({
selectedPage,
tabBarAnchor: <View ref={anchorRef} />,
onSelect: e => onTabBarSelect(e, 'tabbar-click'),
onSelect: e => onTabBarSelect(e),
})}
{React.Children.map(children, (child, i) => (
<View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
-5
View File
@@ -182,17 +182,12 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
[onPageSelected, setCurrentPage],
)
const onPageSelecting = React.useCallback((index: number) => {
setCurrentPage(index)
}, [])
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onPageSelected={onPageSelectedInner}
onPageSelecting={onPageSelecting}
renderTabBar={renderTabBar}>
{toArray(children)
.filter(Boolean)
@@ -75,17 +75,12 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
[onPageSelected, setCurrentPage],
)
const onPageSelecting = React.useCallback((index: number) => {
setCurrentPage(index)
}, [])
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onPageSelected={onPageSelectedInner}
onPageSelecting={onPageSelecting}
renderTabBar={renderTabBar}>
{toArray(children)
.filter(Boolean)
+11 -91
View File
@@ -2,11 +2,8 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'
import {LayoutChangeEvent, ScrollView, StyleSheet, View} from 'react-native'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {isNative} from '#/platform/detection'
import {PressableWithHover} from '../util/PressableWithHover'
import {Text} from '../util/text/Text'
import {DraggableScrollView} from './DraggableScrollView'
export interface TabBarProps {
testID?: string
@@ -31,68 +28,20 @@ export function TabBar({
}: TabBarProps) {
const pal = usePalette('default')
const scrollElRef = useRef<ScrollView>(null)
const itemRefs = useRef<Array<Element>>([])
const [itemXs, setItemXs] = useState<number[]>([])
const indicatorStyle = useMemo(
() => ({borderBottomColor: indicatorColor || pal.colors.link}),
[indicatorColor, pal],
)
const {isDesktop, isTablet} = useWebMediaQueries()
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
useEffect(() => {
if (isNative) {
// On native, the primary interaction is swiping.
// We adjust the scroll little by little on every tab change.
// Scroll into view but keep the end of the previous item visible.
let x = itemXs[selectedPage] || 0
x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH)
scrollElRef.current?.scrollTo({x})
} else {
// On the web, the primary interaction is tapping.
// Scrolling under tap feels disorienting so only adjust the scroll offset
// when tapping on an item out of view--and we adjust by almost an entire page.
const parent = scrollElRef?.current?.getScrollableNode?.()
if (!parent) {
return
}
const parentRect = parent.getBoundingClientRect()
if (!parentRect) {
return
}
const {
left: parentLeft,
right: parentRight,
width: parentWidth,
} = parentRect
const child = itemRefs.current[selectedPage]
if (!child) {
return
}
const childRect = child.getBoundingClientRect?.()
if (!childRect) {
return
}
const {left: childLeft, right: childRight, width: childWidth} = childRect
let dx = 0
if (childRight >= parentRight) {
dx += childRight - parentRight
dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
} else if (childLeft <= parentLeft) {
dx -= parentLeft - childLeft
dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
}
let x = parent.scrollLeft + dx
x = Math.max(0, x)
x = Math.min(x, parent.scrollWidth - parentWidth)
if (dx !== 0) {
parent.scroll({
left: x,
behavior: 'smooth',
})
}
}
}, [scrollElRef, itemXs, selectedPage, styles])
// On native, the primary interaction is swiping.
// We adjust the scroll little by little on every tab change.
// Scroll into view but keep the end of the previous item visible.
let x = itemXs[selectedPage] || 0
x = Math.max(0, x - OFFSCREEN_ITEM_WIDTH)
scrollElRef.current?.scrollTo({x})
}, [scrollElRef, itemXs, selectedPage])
const onPressItem = useCallback(
(index: number) => {
@@ -122,7 +71,7 @@ export function TabBar({
testID={testID}
style={[pal.view, styles.outer]}
accessibilityRole="tablist">
<DraggableScrollView
<ScrollView
testID={`${testID}-selector`}
horizontal={true}
showsHorizontalScrollIndicator={false}
@@ -134,7 +83,6 @@ export function TabBar({
<PressableWithHover
testID={`${testID}-selector-${i}`}
key={`${item}-${i}`}
ref={node => (itemRefs.current[i] = node as any)}
onLayout={e => onItemLayout(e, i)}
style={styles.item}
hoverStyle={pal.viewLight}
@@ -143,7 +91,7 @@ export function TabBar({
<View style={[styles.itemInner, selected && indicatorStyle]}>
<Text
emoji
type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'}
type="lg-bold"
testID={testID ? `${testID}-${item}` : undefined}
style={[
selected ? pal.text : pal.textLight,
@@ -155,41 +103,13 @@ export function TabBar({
</PressableWithHover>
)
})}
</DraggableScrollView>
</ScrollView>
<View style={[pal.border, styles.outerBottomBorder]} />
</View>
)
}
const desktopStyles = StyleSheet.create({
outer: {
flexDirection: 'row',
width: 598,
},
contentContainer: {
paddingHorizontal: 0,
backgroundColor: 'transparent',
},
item: {
paddingTop: 14,
paddingHorizontal: 14,
justifyContent: 'center',
},
itemInner: {
paddingBottom: 12,
borderBottomWidth: 3,
borderBottomColor: 'transparent',
},
outerBottomBorder: {
position: 'absolute',
left: 0,
right: 0,
top: '100%',
borderBottomWidth: StyleSheet.hairlineWidth,
},
})
const mobileStyles = StyleSheet.create({
const styles = StyleSheet.create({
outer: {
flexDirection: 'row',
},
+192
View File
@@ -0,0 +1,192 @@
import {useCallback, useEffect, useMemo, useRef} from 'react'
import {ScrollView, StyleSheet, View} from 'react-native'
import {usePalette} from '#/lib/hooks/usePalette'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {PressableWithHover} from '../util/PressableWithHover'
import {Text} from '../util/text/Text'
import {DraggableScrollView} from './DraggableScrollView'
export interface TabBarProps {
testID?: string
selectedPage: number
items: string[]
indicatorColor?: string
onSelect?: (index: number) => void
onPressSelected?: (index: number) => void
}
// How much of the previous/next item we're showing
// to give the user a hint there's more to scroll.
const OFFSCREEN_ITEM_WIDTH = 20
export function TabBar({
testID,
selectedPage,
items,
indicatorColor,
onSelect,
onPressSelected,
}: TabBarProps) {
const pal = usePalette('default')
const scrollElRef = useRef<ScrollView>(null)
const itemRefs = useRef<Array<Element>>([])
const indicatorStyle = useMemo(
() => ({borderBottomColor: indicatorColor || pal.colors.link}),
[indicatorColor, pal],
)
const {isDesktop, isTablet} = useWebMediaQueries()
const styles = isDesktop || isTablet ? desktopStyles : mobileStyles
useEffect(() => {
// On the web, the primary interaction is tapping.
// Scrolling under tap feels disorienting so only adjust the scroll offset
// when tapping on an item out of view--and we adjust by almost an entire page.
const parent = scrollElRef?.current?.getScrollableNode?.()
if (!parent) {
return
}
const parentRect = parent.getBoundingClientRect()
if (!parentRect) {
return
}
const {
left: parentLeft,
right: parentRight,
width: parentWidth,
} = parentRect
const child = itemRefs.current[selectedPage]
if (!child) {
return
}
const childRect = child.getBoundingClientRect?.()
if (!childRect) {
return
}
const {left: childLeft, right: childRight, width: childWidth} = childRect
let dx = 0
if (childRight >= parentRight) {
dx += childRight - parentRight
dx += parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
} else if (childLeft <= parentLeft) {
dx -= parentLeft - childLeft
dx -= parentWidth - childWidth - OFFSCREEN_ITEM_WIDTH
}
let x = parent.scrollLeft + dx
x = Math.max(0, x)
x = Math.min(x, parent.scrollWidth - parentWidth)
if (dx !== 0) {
parent.scroll({
left: x,
behavior: 'smooth',
})
}
}, [scrollElRef, selectedPage, styles])
const onPressItem = useCallback(
(index: number) => {
onSelect?.(index)
if (index === selectedPage) {
onPressSelected?.(index)
}
},
[onSelect, selectedPage, onPressSelected],
)
return (
<View
testID={testID}
style={[pal.view, styles.outer]}
accessibilityRole="tablist">
<DraggableScrollView
testID={`${testID}-selector`}
horizontal={true}
showsHorizontalScrollIndicator={false}
ref={scrollElRef}
contentContainerStyle={styles.contentContainer}>
{items.map((item, i) => {
const selected = i === selectedPage
return (
<PressableWithHover
testID={`${testID}-selector-${i}`}
key={`${item}-${i}`}
ref={node => (itemRefs.current[i] = node as any)}
style={styles.item}
hoverStyle={pal.viewLight}
onPress={() => onPressItem(i)}
accessibilityRole="tab">
<View style={[styles.itemInner, selected && indicatorStyle]}>
<Text
emoji
type={isDesktop || isTablet ? 'xl-bold' : 'lg-bold'}
testID={testID ? `${testID}-${item}` : undefined}
style={[
selected ? pal.text : pal.textLight,
{lineHeight: 20},
]}>
{item}
</Text>
</View>
</PressableWithHover>
)
})}
</DraggableScrollView>
<View style={[pal.border, styles.outerBottomBorder]} />
</View>
)
}
const desktopStyles = StyleSheet.create({
outer: {
flexDirection: 'row',
width: 598,
},
contentContainer: {
paddingHorizontal: 0,
backgroundColor: 'transparent',
},
item: {
paddingTop: 14,
paddingHorizontal: 14,
justifyContent: 'center',
},
itemInner: {
paddingBottom: 12,
borderBottomWidth: 3,
borderBottomColor: 'transparent',
},
outerBottomBorder: {
position: 'absolute',
left: 0,
right: 0,
top: '100%',
borderBottomWidth: StyleSheet.hairlineWidth,
},
})
const mobileStyles = StyleSheet.create({
outer: {
flexDirection: 'row',
},
contentContainer: {
backgroundColor: 'transparent',
paddingHorizontal: 6,
},
item: {
paddingTop: 10,
paddingHorizontal: 10,
justifyContent: 'center',
},
itemInner: {
paddingBottom: 10,
borderBottomWidth: 3,
borderBottomColor: 'transparent',
},
outerBottomBorder: {
position: 'absolute',
left: 0,
right: 0,
top: '100%',
borderBottomWidth: StyleSheet.hairlineWidth,
},
})
+3 -12
View File
@@ -11,7 +11,7 @@ import {
HomeTabNavigatorParams,
NativeStackScreenProps,
} from '#/lib/routes/types'
import {logEvent, LogEvents} from '#/lib/statsig/statsig'
import {logEvent} from '#/lib/statsig/statsig'
import {isWeb} from '#/platform/detection'
import {emitSoftReset} from '#/state/events'
import {SavedFeedSourceInfo, usePinnedFeedsInfos} from '#/state/queries/feed'
@@ -121,7 +121,7 @@ function HomeScreenReady({
// This is supposed to only happen on the web when you use the right nav.
if (selectedIndex !== lastPagerReportedIndexRef.current) {
lastPagerReportedIndexRef.current = selectedIndex
pagerRef.current?.setPage(selectedIndex, 'desktop-sidebar-click')
pagerRef.current?.setPage(selectedIndex)
}
}, [selectedIndex])
@@ -158,21 +158,13 @@ function HomeScreenReady({
const feed = allFeeds[index]
setSelectedFeed(feed)
lastPagerReportedIndexRef.current = index
},
[setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds],
)
const onPageSelecting = React.useCallback(
(index: number, reason: LogEvents['home:feedDisplayed']['reason']) => {
const feed = allFeeds[index]
logEvent('home:feedDisplayed', {
index,
feedType: feed.split('|')[0],
feedUrl: feed,
reason,
})
},
[allFeeds],
[setDrawerSwipeDisabled, setSelectedFeed, setMinimalShellMode, allFeeds],
)
const onPressSelected = React.useCallback(() => {
@@ -228,7 +220,6 @@ function HomeScreenReady({
ref={pagerRef}
testID="homeScreen"
initialPage={selectedIndex}
onPageSelecting={onPageSelecting}
onPageSelected={onPageSelected}
onPageScrollStateChanged={onPageScrollStateChanged}
renderTabBar={renderTabBar}>