Files
bsky-social-app/src/view/com/pager/PagerWithHeader.tsx
T
2026-03-04 05:43:20 -08:00

417 lines
12 KiB
TypeScript

import {type JSX, memo, useCallback, useEffect, useRef, useState} from 'react'
import {
type LayoutChangeEvent,
type NativeScrollEvent,
type ScrollView,
StyleSheet,
View,
} from 'react-native'
import Animated, {
type AnimatedRef,
runOnUI,
scrollTo,
type SharedValue,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import {ScrollProvider} from '#/lib/ScrollContext'
import {
Pager,
type PagerRef,
type RenderTabBarFnProps,
} from '#/view/com/pager/Pager'
import {useTheme} from '#/alf'
import {IS_IOS} from '#/env'
import {type ListMethods} from '../util/List'
import {PagerHeaderProvider} from './PagerHeaderContext'
import {TabBar} from './TabBar'
export interface PagerWithHeaderChildParams {
headerHeight: number
isFocused: boolean
scrollElRef: React.MutableRefObject<ListMethods | ScrollView | null>
}
export interface PagerWithHeaderProps {
ref?: React.Ref<PagerRef>
testID?: string
children:
| (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
| ((props: PagerWithHeaderChildParams) => JSX.Element)
items: string[]
isHeaderReady: boolean
renderHeader?: ({
setMinimumHeight,
}: {
setMinimumHeight: (height: number) => void
}) => JSX.Element
initialPage?: number
onPageSelected?: (index: number) => void
onCurrentPageSelected?: (index: number) => void
allowHeaderOverScroll?: boolean
}
export function PagerWithHeader({
ref,
children,
testID,
items,
isHeaderReady,
renderHeader,
initialPage,
onPageSelected,
onCurrentPageSelected,
allowHeaderOverScroll,
}: PagerWithHeaderProps) {
const [currentPage, setCurrentPage] = useState(0)
const [tabBarHeight, setTabBarHeight] = useState(0)
const [headerOnlyHeight, setHeaderOnlyHeight] = useState(0)
const scrollY = useSharedValue(0)
const headerHeight = headerOnlyHeight + tabBarHeight
// capture the header bar sizing
const onTabBarLayout = useCallback((evt: LayoutChangeEvent) => {
const height = evt.nativeEvent.layout.height
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setTabBarHeight(Math.round(height * 2) / 2)
}
}, [])
const onHeaderOnlyLayout = useCallback((height: number) => {
if (height > 0) {
// The rounding is necessary to prevent jumps on iOS
setHeaderOnlyHeight(Math.round(height * 2) / 2)
}
}, [])
const renderTabBar = useCallback(
(props: RenderTabBarFnProps) => {
return (
<PagerHeaderProvider scrollY={scrollY} headerHeight={headerOnlyHeight}>
<PagerTabBar
headerOnlyHeight={headerOnlyHeight}
items={items}
isHeaderReady={isHeaderReady}
renderHeader={renderHeader}
currentPage={currentPage}
onCurrentPageSelected={onCurrentPageSelected}
onTabBarLayout={onTabBarLayout}
onHeaderOnlyLayout={onHeaderOnlyLayout}
onSelect={props.onSelect}
scrollY={scrollY}
testID={testID}
allowHeaderOverScroll={allowHeaderOverScroll}
dragProgress={props.dragProgress}
dragState={props.dragState}
/>
</PagerHeaderProvider>
)
},
[
headerOnlyHeight,
items,
isHeaderReady,
renderHeader,
currentPage,
onCurrentPageSelected,
onTabBarLayout,
onHeaderOnlyLayout,
scrollY,
testID,
allowHeaderOverScroll,
],
)
const scrollRefs = useSharedValue<Array<AnimatedRef<any> | null>>([])
const registerRef = useCallback(
(scrollRef: AnimatedRef<any> | null, atIndex: number) => {
scrollRefs.modify(refs => {
'worklet'
refs[atIndex] = scrollRef
return refs
})
},
[scrollRefs],
)
const lastForcedScrollY = useSharedValue(0)
const adjustScrollForOtherPages = useCallback(
(scrollState: 'idle' | 'dragging' | 'settling') => {
'worklet'
if (scrollState !== 'dragging') return
const currentScrollY = scrollY.get()
const forcedScrollY = Math.min(currentScrollY, headerOnlyHeight)
if (lastForcedScrollY.get() !== forcedScrollY) {
lastForcedScrollY.set(forcedScrollY)
const refs = scrollRefs.get()
for (let i = 0; i < refs.length; i++) {
const scollRef = refs[i]
if (i !== currentPage && scollRef != null) {
scrollTo(scollRef, 0, forcedScrollY, false)
}
}
}
},
[currentPage, headerOnlyHeight, lastForcedScrollY, scrollRefs, scrollY],
)
// HACK: onScroll reports a bogus value of exactly -headerHeight on initial
// load. We filter it out, but only until the first real scroll event has
// been accepted. After that we stop filtering so that legitimate
// scroll-to-top events (which also target -headerHeight) are not rejected.
// profiles with very large headers do scroll to -headerHeight normally,
// so we need to adjust the heuristic. - sfp
const hasReceivedScroll = useSharedValue(false)
const onScrollWorklet = useCallback(
(e: NativeScrollEvent) => {
'worklet'
const nextScrollY = e.contentOffset.y
if (!hasReceivedScroll.get()) {
const isPossiblyInvalid =
headerHeight > 0 && Math.round(nextScrollY * 2) / 2 === -headerHeight
if (isPossiblyInvalid) {
return
}
hasReceivedScroll.set(true)
}
scrollY.set(nextScrollY)
},
[scrollY, headerHeight, hasReceivedScroll],
)
const onPageSelectedInner = useCallback(
(index: number) => {
setCurrentPage(index)
onPageSelected?.(index)
},
[onPageSelected, setCurrentPage],
)
const onTabPressed = useCallback(() => {
runOnUI(adjustScrollForOtherPages)('dragging')
}, [adjustScrollForOtherPages])
return (
<Pager
ref={ref}
testID={testID}
initialPage={initialPage}
onTabPressed={onTabPressed}
onPageSelected={onPageSelectedInner}
renderTabBar={renderTabBar}
onPageScrollStateChanged={adjustScrollForOtherPages}>
{toArray(children)
.filter(Boolean)
.map((child, i) => {
const isReady =
isHeaderReady && headerOnlyHeight > 0 && tabBarHeight > 0
return (
<View key={i} collapsable={false}>
<PagerItem
headerHeight={headerHeight}
index={i}
isReady={isReady}
isFocused={i === currentPage}
onScrollWorklet={i === currentPage ? onScrollWorklet : noop}
registerRef={registerRef}
renderTab={child}
/>
</View>
)
})}
</Pager>
)
}
let PagerTabBar = ({
currentPage,
headerOnlyHeight,
isHeaderReady,
items,
scrollY,
testID,
renderHeader,
onHeaderOnlyLayout,
onTabBarLayout,
onCurrentPageSelected,
onSelect,
allowHeaderOverScroll,
dragProgress,
dragState,
}: {
currentPage: number
headerOnlyHeight: number
isHeaderReady: boolean
items: string[]
testID?: string
scrollY: SharedValue<number>
renderHeader?: ({
setMinimumHeight,
}: {
setMinimumHeight: (height: number) => void
}) => JSX.Element
onHeaderOnlyLayout: (height: number) => void
onTabBarLayout: (e: LayoutChangeEvent) => void
onCurrentPageSelected?: (index: number) => void
onSelect?: (index: number) => void
allowHeaderOverScroll?: boolean
dragProgress: SharedValue<number>
dragState: SharedValue<'idle' | 'dragging' | 'settling'>
}): React.ReactNode => {
const t = useTheme()
const [minimumHeaderHeight, setMinimumHeaderHeight] = useState(0)
const headerTransform = useAnimatedStyle(() => {
const translateY =
Math.min(
scrollY.get(),
Math.max(headerOnlyHeight - minimumHeaderHeight, 0),
) * -1
return {
transform: [
{
translateY: allowHeaderOverScroll
? translateY
: Math.min(translateY, 0),
},
],
}
})
const pendingHeaderHeightForWhenSentinelReady = useRef<number | undefined>(
undefined,
)
const sentinelHasRenderedRef = useRef(false)
return (
<Animated.View
pointerEvents={IS_IOS ? 'auto' : 'box-none'}
style={[styles.tabBarMobile, headerTransform, t.atoms.bg]}>
<View
pointerEvents={IS_IOS ? 'auto' : 'box-none'}
collapsable={false}
onLayout={(e: LayoutChangeEvent) => {
// we want to measure this view's height to get the header height.
// however, we risk doing it too early if the header hasn't rendered yet.
// therefore, we use a sentinel view and wait for *that* to layout before
// we set the header height, using the last measured height from this
// onLayout. after the sentinel has rendered for the first time, we can
// just straightforwardly set the header height here directly -sfn
const height = e.nativeEvent.layout.height
// note: sentinel only renders after `isHeaderReady` has turned `true`
if (sentinelHasRenderedRef.current) {
onHeaderOnlyLayout(height)
} else {
pendingHeaderHeightForWhenSentinelReady.current = height
}
}}>
{renderHeader?.({setMinimumHeight: setMinimumHeaderHeight})}
{
// It wouldn't be enough to place `onLayout` on the parent node because
// this would risk measuring before `isHeaderReady` has turned `true`.
// Instead, we'll render a brand node conditionally and get fresh layout.
isHeaderReady && (
<View
testID="layout-sentinel"
collapsable={false}
// It wouldn't be enough to do this in a `ref` of an effect because,
// even if `isHeaderReady` might have turned `true`, the associated
// layout might not have been performed yet on the native side.
onLayout={() => {
if (!sentinelHasRenderedRef.current) {
sentinelHasRenderedRef.current = true
const height = pendingHeaderHeightForWhenSentinelReady.current
if (height !== undefined) {
onHeaderOnlyLayout(height)
}
}
}}
/>
)
}
</View>
<View
onLayout={onTabBarLayout}
style={{
// Render it immediately to measure it early since its size doesn't depend on the content.
// However, keep it invisible until the header above stabilizes in order to prevent jumps.
opacity: isHeaderReady ? 1 : 0,
pointerEvents: isHeaderReady ? 'auto' : 'none',
}}>
<TabBar
testID={testID}
items={items}
selectedPage={currentPage}
onSelect={onSelect}
onPressSelected={onCurrentPageSelected}
dragProgress={dragProgress}
dragState={dragState}
/>
</View>
</Animated.View>
)
}
PagerTabBar = memo(PagerTabBar)
function PagerItem({
headerHeight,
index,
isReady,
isFocused,
onScrollWorklet,
renderTab,
registerRef,
}: {
headerHeight: number
index: number
isFocused: boolean
isReady: boolean
registerRef: (scrollRef: AnimatedRef<any> | null, atIndex: number) => void
onScrollWorklet: (e: NativeScrollEvent) => void
renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null
}) {
const scrollElRef = useAnimatedRef()
useEffect(() => {
registerRef(scrollElRef, index)
return () => {
registerRef(null, index)
}
}, [scrollElRef, registerRef, index])
if (!isReady || renderTab == null) {
return null
}
return (
<ScrollProvider onScroll={onScrollWorklet}>
{renderTab({
headerHeight,
isFocused,
scrollElRef: scrollElRef as React.MutableRefObject<
ListMethods | ScrollView | null
>,
})}
</ScrollProvider>
)
}
const styles = StyleSheet.create({
tabBarMobile: {
position: 'absolute',
zIndex: 1,
top: 0,
left: 0,
width: '100%',
},
})
function noop() {
'worklet'
}
function toArray<T>(v: T | T[]): T[] {
if (Array.isArray(v)) {
return v
}
return [v]
}