Skip to content

Commit

Permalink
[Nicer Tabs] New native pager (#6868)
Browse files Browse the repository at this point in the history
* Remove tab bar autoscroll

This will be replaced by a different mechanism.

* Track pager drag gesture in a worklet

* Track pager state change in a worklet

* Track offset relative to current page

* Sync scroll to swipe

* Extract TabBarItem

* Sync scroll to swipe properly

* Implement all interactions

* Clarify more hacks

* Simplify the implementation

I was trying to be too smart and this was causing the current page event to lag behind if you continuously drag. Better to let the library do its job.

* Interpolate the indicator

* Fix an infinite swipe loop

* Add TODO

* Animate header color

* Respect initial page

* Keep layouts in a shared value

* Fix profile and types

* Fast path for initial styles

* Scroll to initial

* Factor out a helper

* Fix positioning

* Scroll into view on tap if needed

* Divide free space proportionally

* Scroll into view more aggressively

* Fix corner case

* Ignore spurious event on iOS

* Simplify the condition

Due to RN onLayout event ordering, we know that by now we'll have container and content sizes already.

* Change boolean state to enum

* Better syncing heuristic

* Rm extra return
  • Loading branch information
gaearon authored Dec 3, 2024
1 parent 5a313c2 commit cd81111
Show file tree
Hide file tree
Showing 6 changed files with 443 additions and 85 deletions.
5 changes: 2 additions & 3 deletions src/view/com/home/HomeHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from 'react'
import {useNavigation} from '@react-navigation/native'

import {usePalette} from '#/lib/hooks/usePalette'
import {NavigationProp} from '#/lib/routes/types'
import {FeedSourceInfo} from '#/state/queries/feed'
import {useSession} from '#/state/session'
Expand All @@ -19,7 +18,6 @@ export function HomeHeader(
const {feeds} = props
const {hasSession} = useSession()
const navigation = useNavigation<NavigationProp>()
const pal = usePalette('default')

const hasPinnedCustom = React.useMemo<boolean>(() => {
if (!hasSession) return false
Expand Down Expand Up @@ -61,7 +59,8 @@ export function HomeHeader(
onSelect={onSelect}
testID={props.testID}
items={items}
indicatorColor={pal.colors.link}
dragProgress={props.dragProgress}
dragState={props.dragState}
/>
</HomeHeaderLayout>
)
Expand Down
115 changes: 95 additions & 20 deletions src/view/com/pager/Pager.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import React, {forwardRef} from 'react'
import {View} from 'react-native'
import PagerView, {
PagerViewOnPageScrollEventData,
PagerViewOnPageSelectedEvent,
PageScrollStateChangedNativeEvent,
PagerViewOnPageSelectedEventData,
PageScrollStateChangedNativeEventData,
} from 'react-native-pager-view'
import Animated, {
runOnJS,
SharedValue,
useEvent,
useHandler,
useSharedValue,
} from 'react-native-reanimated'

import {atoms as a, native} from '#/alf'

Expand All @@ -17,6 +26,8 @@ export interface RenderTabBarFnProps {
selectedPage: number
onSelect?: (index: number) => void
tabBarAnchor?: JSX.Element | null | undefined // Ignored on native.
dragProgress: SharedValue<number> // Ignored on web.
dragState: SharedValue<'idle' | 'dragging' | 'settling'> // Ignored on web.
}
export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element

Expand All @@ -29,19 +40,22 @@ interface Props {
) => void
testID?: string
}

const AnimatedPagerView = Animated.createAnimatedComponent(PagerView)

export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
function PagerImpl(
{
children,
initialPage = 0,
renderTabBar,
onPageScrollStateChanged,
onPageSelected,
onPageScrollStateChanged: parentOnPageScrollStateChanged,
onPageSelected: parentOnPageSelected,
testID,
}: React.PropsWithChildren<Props>,
ref,
) {
const [selectedPage, setSelectedPage] = React.useState(0)
const [selectedPage, setSelectedPage] = React.useState(initialPage)
const pagerView = React.useRef<PagerView>(null)

React.useImperativeHandle(ref, () => ({
Expand All @@ -50,19 +64,12 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
},
}))

const onPageSelectedInner = React.useCallback(
(e: PageSelectedEvent) => {
setSelectedPage(e.nativeEvent.position)
onPageSelected?.(e.nativeEvent.position)
},
[setSelectedPage, onPageSelected],
)

const handlePageScrollStateChanged = React.useCallback(
(e: PageScrollStateChangedNativeEvent) => {
onPageScrollStateChanged?.(e.nativeEvent.pageScrollState)
const onPageSelectedJSThread = React.useCallback(
(nextPosition: number) => {
setSelectedPage(nextPosition)
parentOnPageSelected?.(nextPosition)
},
[onPageScrollStateChanged],
[setSelectedPage, parentOnPageSelected],
)

const onTabBarSelect = React.useCallback(
Expand All @@ -72,21 +79,89 @@ export const Pager = forwardRef<PagerRef, React.PropsWithChildren<Props>>(
[pagerView],
)

const dragState = useSharedValue<'idle' | 'settling' | 'dragging'>('idle')
const dragProgress = useSharedValue(selectedPage)
const didInit = useSharedValue(false)
const handlePageScroll = usePagerHandlers(
{
onPageScroll(e: PagerViewOnPageScrollEventData) {
'worklet'
if (didInit.get() === false) {
// On iOS, there's a spurious scroll event with 0 position
// even if a different page was supplied as the initial page.
// Ignore it and wait for the first confirmed selection instead.
return
}
dragProgress.set(e.offset + e.position)
},
onPageScrollStateChanged(e: PageScrollStateChangedNativeEventData) {
'worklet'
if (dragState.get() === 'idle' && e.pageScrollState === 'settling') {
// This is a programmatic scroll on Android.
// Stay "idle" to match iOS and avoid confusing downstream code.
return
}
dragState.set(e.pageScrollState)
parentOnPageScrollStateChanged?.(e.pageScrollState)
},
onPageSelected(e: PagerViewOnPageSelectedEventData) {
'worklet'
didInit.set(true)
runOnJS(onPageSelectedJSThread)(e.position)
},
},
[parentOnPageScrollStateChanged],
)

return (
<View testID={testID} style={[a.flex_1, native(a.overflow_hidden)]}>
{renderTabBar({
selectedPage,
onSelect: onTabBarSelect,
dragProgress,
dragState,
})}
<PagerView
<AnimatedPagerView
ref={pagerView}
style={[a.flex_1]}
initialPage={initialPage}
onPageScrollStateChanged={handlePageScrollStateChanged}
onPageSelected={onPageSelectedInner}>
onPageScroll={handlePageScroll}>
{children}
</PagerView>
</AnimatedPagerView>
</View>
)
},
)

function usePagerHandlers(
handlers: {
onPageScroll: (e: PagerViewOnPageScrollEventData) => void
onPageScrollStateChanged: (e: PageScrollStateChangedNativeEventData) => void
onPageSelected: (e: PagerViewOnPageSelectedEventData) => void
},
dependencies: unknown[],
) {
const {doDependenciesDiffer} = useHandler(handlers as any, dependencies)
const subscribeForEvents = [
'onPageScroll',
'onPageScrollStateChanged',
'onPageSelected',
]
return useEvent(
event => {
'worklet'
const {onPageScroll, onPageScrollStateChanged, onPageSelected} = handlers
if (event.eventName.endsWith('onPageScroll')) {
onPageScroll(event as any as PagerViewOnPageScrollEventData)
} else if (event.eventName.endsWith('onPageScrollStateChanged')) {
onPageScrollStateChanged(
event as any as PageScrollStateChangedNativeEventData,
)
} else if (event.eventName.endsWith('onPageSelected')) {
onPageSelected(event as any as PagerViewOnPageSelectedEventData)
}
},
subscribeForEvents,
doDependenciesDiffer,
)
}
8 changes: 8 additions & 0 deletions src/view/com/pager/PagerWithHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
scrollY={scrollY}
testID={testID}
allowHeaderOverScroll={allowHeaderOverScroll}
dragProgress={props.dragProgress}
dragState={props.dragState}
/>
</PagerHeaderProvider>
)
Expand Down Expand Up @@ -226,6 +228,8 @@ let PagerTabBar = ({
onCurrentPageSelected,
onSelect,
allowHeaderOverScroll,
dragProgress,
dragState,
}: {
currentPage: number
headerOnlyHeight: number
Expand All @@ -239,6 +243,8 @@ let PagerTabBar = ({
onCurrentPageSelected?: (index: number) => void
onSelect?: (index: number) => void
allowHeaderOverScroll?: boolean
dragProgress: SharedValue<number>
dragState: SharedValue<'idle' | 'dragging' | 'settling'>
}): React.ReactNode => {
const headerTransform = useAnimatedStyle(() => {
const translateY = Math.min(scrollY.get(), headerOnlyHeight) * -1
Expand Down Expand Up @@ -297,6 +303,8 @@ let PagerTabBar = ({
selectedPage={currentPage}
onSelect={onSelect}
onPressSelected={onCurrentPageSelected}
dragProgress={dragProgress}
dragState={dragState}
/>
</View>
</Animated.View>
Expand Down
2 changes: 2 additions & 0 deletions src/view/com/pager/PagerWithHeader.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ let PagerTabBar = ({
selectedPage={currentPage}
onSelect={onSelect}
onPressSelected={onCurrentPageSelected}
dragProgress={undefined as any /* native-only */}
dragState={undefined as any /* native-only */}
/>
</View>
</>
Expand Down
Loading

0 comments on commit cd81111

Please sign in to comment.