Checkpoint external sticky header

This commit is contained in:
Eric Bailey
2025-09-24 09:48:51 -05:00
parent e7a4686bc4
commit 4b19e3794e
2 changed files with 136 additions and 76 deletions
+101 -54
View File
@@ -1,8 +1,9 @@
import {forwardRef, useMemo} from 'react' import {forwardRef, useMemo, useState} from 'react'
import { import {
type FlatList, type FlatList,
type FlatListProps, type FlatListProps,
RefreshControl, RefreshControl,
View,
type ViewToken, type ViewToken,
} from 'react-native' } from 'react-native'
import Animated, { import Animated, {
@@ -14,9 +15,9 @@ import Animated, {
import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video' import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
import {useDedupe} from '#/lib/hooks/useDedupe' import {useDedupe} from '#/lib/hooks/useDedupe'
import {isIOS, isNative} from '#/platform/detection' import {isIOS, isNative, isWeb} from '#/platform/detection'
import {useLightbox} from '#/state/lightbox' import {useLightbox} from '#/state/lightbox'
import {atoms as a, useTheme, web} from '#/alf' import {atoms as a, platform, useTheme} from '#/alf'
import {useListScrollContext} from '#/components/List/ListScrollProvider' import {useListScrollContext} from '#/components/List/ListScrollProvider'
export { export {
@@ -85,10 +86,19 @@ type ListProps<Item extends {key: string}> = Omit<
*/ */
didScrollDownThreshold?: number didScrollDownThreshold?: number
onScrolledDownChange?: (isScrolledDown: boolean) => void onScrolledDownChange?: (isScrolledDown: boolean) => void
StickyHeaderComponent?: FlatListProps<Item>['ListHeaderComponent']
stickyHeaderOffset?: number
} }
export const List = forwardRef(function List<Item extends {key: string}>( export const List = forwardRef(function List<Item extends {key: string}>(
props: ListProps<Item>, {
stickyHeaderIndices,
ListHeaderComponent,
StickyHeaderComponent,
stickyHeaderOffset,
...props
}: ListProps<Item>,
ref: React.Ref<FlatList<Item>>, ref: React.Ref<FlatList<Item>>,
) { ) {
const t = useTheme() const t = useTheme()
@@ -167,58 +177,95 @@ export const List = forwardRef(function List<Item extends {key: string}>(
) )
} }
return ( const header = useMemo(() => {
<Animated.FlatList if (isWeb) return {}
ref={ref}
keyExtractor={props.keyExtractor || (i => i.key)} if (StickyHeaderComponent) {
viewabilityConfig={viewabilityConfig} if (ListHeaderComponent) {
onViewableItemsChanged={onViewableItemsChanged} throw new Error(
/** `List: cannot use both StickyHeaderComponent and ListHeaderComponent`,
* iOS automatically adds in the safe area to the scroll indicator )
* insets, even though the overwhelming majority of our ScrollViews do
* not stretch from edge to edge.
* @see https://github.com/bluesky-social/social-app/pull/7131
*/
automaticallyAdjustsScrollIndicatorInsets={false}
/**
* For better UX, we default to true, but it can be disabled if needed.
* @see https://github.com/bluesky-social/social-app/pull/8529
*/
showsVerticalScrollIndicator
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
scrollIndicatorInsets={{
top: props.headerOffset ?? 0,
bottom: props.footerOffset ?? 0,
/**
* May fix a bug where the scroll indicator is in the middle of the screen
* @see https://github.com/facebook/react-native/issues/26610
*/
right: 1,
}}
/**
* Native only. On web, we use padding on `style` instead.
*/
contentOffset={
props.headerOffset ? {x: 0, y: props.headerOffset * -1} : undefined
} }
scrollsToTop={!activeLightbox}
refreshControl={refreshControl} return {
{...(props as FlatListPropsWithLayout<Item>)} ListHeaderComponent: platform({
style={[ default: StickyHeaderComponent,
{ // web: () => (
paddingTop: props.headerOffset, // <View style={[a.fixed]}>
paddingBottom: props.footerOffset, // <StickyHeaderComponent />
}, // </View>
web([ // )
/* }),
* On web, the List should always fill its container, otherwise stickyHeaderIndices: [0],
* `onScroll` will not work due to the entire page scrolling. }
}
return {ListHeaderComponent, stickyHeaderIndices}
}, [ListHeaderComponent, StickyHeaderComponent, stickyHeaderIndices])
const [stickyHeaderHeight, setStickyHeaderHeight] =
useState(stickyHeaderOffset)
const headerOffset = isWeb ? stickyHeaderHeight : (props.headerOffset ?? 0)
return (
<>
{isWeb && StickyHeaderComponent && (
<View
style={[a.fixed, a.w_full, a.z_10]}
onLayout={e => {
setStickyHeaderHeight(e.nativeEvent.layout.height)
}}>
<StickyHeaderComponent />
</View>
)}
<Animated.FlatList
ref={ref}
keyExtractor={props.keyExtractor || (i => i.key)}
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={onViewableItemsChanged}
/**
* iOS automatically adds in the safe area to the scroll indicator
* insets, even though the overwhelming majority of our ScrollViews do
* not stretch from edge to edge.
* @see https://github.com/bluesky-social/social-app/pull/7131
*/
automaticallyAdjustsScrollIndicatorInsets={false}
/**
* For better UX, we default to true, but it can be disabled if needed.
* @see https://github.com/bluesky-social/social-app/pull/8529
*/
showsVerticalScrollIndicator
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
scrollIndicatorInsets={{
top: headerOffset ?? 0,
bottom: props.footerOffset ?? 0,
/**
* May fix a bug where the scroll indicator is in the middle of the screen
* @see https://github.com/facebook/react-native/issues/26610
*/ */
a.h_full, right: 1,
]), }}
]} /**
onScroll={onScroll} * Native only. On web, we use padding on `style` instead.
/> */
contentOffset={headerOffset ? {x: 0, y: headerOffset * -1} : undefined}
scrollsToTop={!activeLightbox}
refreshControl={refreshControl}
{...(props as FlatListPropsWithLayout<Item>)}
{...header}
style={[
{
paddingTop: headerOffset,
paddingBottom: props.footerOffset,
transform: 'unset',
},
props.style,
]}
onScroll={onScroll}
/>
</>
) )
}) as <Item extends {key: string}>( }) as <Item extends {key: string}>(
props: ListProps<Item> & {ref?: React.Ref<FlatList<Item>>}, props: ListProps<Item> & {ref?: React.Ref<FlatList<Item>>},
+35 -22
View File
@@ -1,30 +1,18 @@
import {useState} from 'react' import {useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {runOnJS} from 'react-native-reanimated' import {runOnJS} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {ScrollProvider} from '#/lib/ScrollContext' import {ScrollProvider} from '#/lib/ScrollContext'
import {List as OldList} from '#/view/com/util/List' import {List as OldList} from '#/view/com/util/List'
import {atoms as a} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {List, ListScrollProvider, useListScrollHandler} from '#/components/List' import {List, ListScrollProvider, useListScrollHandler} from '#/components/List'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export function StorybookLists() { export function StorybookLists() {
return ( return <Inner />
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>Storybook</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content keyboardShouldPersistTaps="handled">
<Inner />
</Layout.Content>
</Layout.Screen>
)
} }
const items = Array.from({length: 100}).map((_, i) => ({ const items = Array.from({length: 100}).map((_, i) => ({
@@ -39,7 +27,21 @@ type Item = {
const log = (msg: any) => console.log(msg) const log = (msg: any) => console.log(msg)
function Header() {
return (
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>Storybook</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
)
}
export function Inner() { export function Inner() {
const t = useTheme()
const insets = useSafeAreaInsets()
const [old, setOld] = useState<boolean>(false) const [old, setOld] = useState<boolean>(false)
const onScrollWorklet = useListScrollHandler(e => { const onScrollWorklet = useListScrollHandler(e => {
'worklet' 'worklet'
@@ -47,7 +49,7 @@ export function Inner() {
}, []) }, [])
return ( return (
<View style={[a.h_full_vh]}> <View style={[]}>
<View <View
style={[a.fixed, a.w_full, a.py_sm, a.px_xl, a.align_center, a.z_10]}> style={[a.fixed, a.w_full, a.py_sm, a.px_xl, a.align_center, a.z_10]}>
<Button <Button
@@ -65,15 +67,25 @@ export function Inner() {
<ListScrollProvider onScroll={onScrollWorklet}> <ListScrollProvider onScroll={onScrollWorklet}>
<List<Item> <List<Item>
data={items} data={items}
headerOffset={100} headerOffset={insets.top}
footerOffset={100} stickyHeaderOffset={52}
StickyHeaderComponent={Header}
onScrolledDownChange={scrolledDown => { onScrolledDownChange={scrolledDown => {
console.log(`Scrolled down: ${scrolledDown}`) console.log(`Scrolled down: ${scrolledDown}`)
}} }}
renderItem={({item}) => ( renderItem={({item, index}) => (
<View style={[a.p_md, a.border_b]}> <Layout.Center>
<Text>{item.title}</Text> <View
</View> style={[
a.px_md,
a.align_center,
a.justify_center,
{height: 100},
index % 2 === 0 ? t.atoms.bg_contrast_25 : t.atoms.bg,
]}>
<Text>{item.title}</Text>
</View>
</Layout.Center>
)} )}
style={[a.debug]} style={[a.debug]}
/> />
@@ -93,6 +105,7 @@ export function Inner() {
</View> </View>
)} )}
style={[a.debug]} style={[a.debug]}
desktopFixedHeight
/> />
</ScrollProvider> </ScrollProvider>
)} )}