Add web sticky header support
This commit is contained in:
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user