Add web sticky header support

This commit is contained in:
Eric Bailey
2025-09-24 10:52:07 -05:00
parent 4b19e3794e
commit e44a022226
3 changed files with 103 additions and 88 deletions
+19 -58
View File
@@ -1,9 +1,8 @@
import {forwardRef, useMemo, useState} from 'react'
import {forwardRef, useMemo} from 'react'
import {
type FlatList,
type FlatListProps,
RefreshControl,
View,
type ViewToken,
} from 'react-native'
import Animated, {
@@ -15,9 +14,9 @@ import Animated, {
import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
import {useDedupe} from '#/lib/hooks/useDedupe'
import {isIOS, isNative, isWeb} from '#/platform/detection'
import {isIOS, isNative} from '#/platform/detection'
import {useLightbox} from '#/state/lightbox'
import {atoms as a, platform, useTheme} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {useListScrollContext} from '#/components/List/ListScrollProvider'
export {
@@ -86,19 +85,10 @@ type ListProps<Item extends {key: string}> = Omit<
*/
didScrollDownThreshold?: number
onScrolledDownChange?: (isScrolledDown: boolean) => void
StickyHeaderComponent?: FlatListProps<Item>['ListHeaderComponent']
stickyHeaderOffset?: number
}
export const List = forwardRef(function List<Item extends {key: string}>(
{
stickyHeaderIndices,
ListHeaderComponent,
StickyHeaderComponent,
stickyHeaderOffset,
...props
}: ListProps<Item>,
{...props}: ListProps<Item>,
ref: React.Ref<FlatList<Item>>,
) {
const t = useTheme()
@@ -177,50 +167,19 @@ export const List = forwardRef(function List<Item extends {key: string}>(
)
}
const header = useMemo(() => {
if (isWeb) return {}
if (StickyHeaderComponent) {
if (ListHeaderComponent) {
throw new Error(
`List: cannot use both StickyHeaderComponent and ListHeaderComponent`,
)
}
return {
ListHeaderComponent: platform({
default: StickyHeaderComponent,
// web: () => (
// <View style={[a.fixed]}>
// <StickyHeaderComponent />
// </View>
// )
}),
stickyHeaderIndices: [0],
}
}
return {ListHeaderComponent, stickyHeaderIndices}
}, [ListHeaderComponent, StickyHeaderComponent, stickyHeaderIndices])
const [stickyHeaderHeight, setStickyHeaderHeight] =
useState(stickyHeaderOffset)
const headerOffset = isWeb ? stickyHeaderHeight : (props.headerOffset ?? 0)
/**
* Web only, provides a handle on the resulting `List` DOM element, which we
* then use to apply sticky styles to individual list item wrappers.
*/
const dataSet = props.stickyHeaderIndices?.reduce((ds, i) => {
return {...ds, [`sticky-header-index-${i}`]: 1}
}, {})
return (
<>
{isWeb && StickyHeaderComponent && (
<View
style={[a.fixed, a.w_full, a.z_10]}
onLayout={e => {
setStickyHeaderHeight(e.nativeEvent.layout.height)
}}>
<StickyHeaderComponent />
</View>
)}
<Animated.FlatList
// @ts-ignore
dataSet={dataSet}
ref={ref}
keyExtractor={props.keyExtractor || (i => i.key)}
viewabilityConfig={viewabilityConfig}
@@ -239,7 +198,7 @@ export const List = forwardRef(function List<Item extends {key: string}>(
showsVerticalScrollIndicator
indicatorStyle={t.name === 'light' ? 'black' : 'white'}
scrollIndicatorInsets={{
top: headerOffset ?? 0,
top: props.headerOffset ?? 0,
bottom: props.footerOffset ?? 0,
/**
* May fix a bug where the scroll indicator is in the middle of the screen
@@ -250,14 +209,16 @@ export const List = forwardRef(function List<Item extends {key: string}>(
/**
* Native only. On web, we use padding on `style` instead.
*/
contentOffset={headerOffset ? {x: 0, y: headerOffset * -1} : undefined}
contentOffset={
props.headerOffset ? {x: 0, y: props.headerOffset * -1} : undefined
}
scrollsToTop={!activeLightbox}
refreshControl={refreshControl}
{...(props as FlatListPropsWithLayout<Item>)}
{...header}
style={[
a.h_full,
{
paddingTop: headerOffset,
paddingTop: props.headerOffset,
paddingBottom: props.footerOffset,
transform: 'unset',
},
+22
View File
@@ -389,3 +389,25 @@ input[type='range'][orient='vertical']::-moz-range-thumb {
opacity: 0;
}
}
/*
* Sticky headers for `List` component. Add more indices as needed.
*
* Note — :nth-child is 1-indexed
*/
*[data-sticky-header-index-0] > div > div:nth-child(1),
*[data-sticky-header-index-1] > div > div:nth-child(2),
*[data-sticky-header-index-2] > div > div:nth-child(3),
*[data-sticky-header-index-3] > div > div:nth-child(4),
*[data-sticky-header-index-4] > div > div:nth-child(5),
*[data-sticky-header-index-5] > div > div:nth-child(6),
*[data-sticky-header-index-6] > div > div:nth-child(7),
*[data-sticky-header-index-7] > div > div:nth-child(8),
*[data-sticky-header-index-8] > div > div:nth-child(9),
*[data-sticky-header-index-9] > div > div:nth-child(10) {
position: sticky;
top: 0;
left: 0;
right: 0;
z-index: 10;
}
+62 -30
View File
@@ -1,7 +1,6 @@
import {useState} from 'react'
import {View} from 'react-native'
import {runOnJS} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {ScrollProvider} from '#/lib/ScrollContext'
import {List as OldList} from '#/view/com/util/List'
@@ -12,18 +11,28 @@ import {List, ListScrollProvider, useListScrollHandler} from '#/components/List'
import {Text} from '#/components/Typography'
export function StorybookLists() {
return <Inner />
return (
<Layout.Screen>
<Inner />
</Layout.Screen>
)
}
const items = Array.from({length: 100}).map((_, i) => ({
key: `item-${i + 1}`,
title: `Item ${i + 1}`,
}))
type Item = {
key: string
title: string
}
type Item =
| {
key: string
type: 'item'
title: string
}
| {
key: string
type: 'header'
title: string
}
| {
key: string
type: 'spacer'
}
const log = (msg: any) => console.log(msg)
@@ -41,13 +50,29 @@ function Header() {
export function Inner() {
const t = useTheme()
const insets = useSafeAreaInsets()
const [old, setOld] = useState<boolean>(false)
const onScrollWorklet = useListScrollHandler(e => {
'worklet'
runOnJS(log)(`scroll ${e.contentOffset.y}`)
}, [])
const items: Item[] = Array.from({length: 100}).map((_, i) => ({
key: `item-${i + 1}`,
type: 'item' as const,
title: `Item ${i + 1}`,
}))
items.unshift({
key: 'header',
type: 'header' as const,
title: 'Header',
})
items.unshift({
key: 'spacer',
type: 'spacer' as const,
})
return (
<View style={[]}>
<View
@@ -67,27 +92,34 @@ export function Inner() {
<ListScrollProvider onScroll={onScrollWorklet}>
<List<Item>
data={items}
headerOffset={insets.top}
stickyHeaderOffset={52}
StickyHeaderComponent={Header}
onScrolledDownChange={scrolledDown => {
console.log(`Scrolled down: ${scrolledDown}`)
}}
renderItem={({item, index}) => (
<Layout.Center>
<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]}
stickyHeaderIndices={[1]}
renderItem={({item, index}) => {
if (item.type === 'header') {
return <Header />
}
if (item.type === 'spacer') {
return <View style={[t.atoms.bg_contrast_50, {height: 100}]} />
}
return (
<Layout.Center>
<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.h_full_vh]}
/>
</ListScrollProvider>
) : (