From e44a022226be2894958e3a4f37a165b5f5bb2eec Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 24 Sep 2025 10:52:07 -0500 Subject: [PATCH] Add web sticky header support --- src/components/List/index.tsx | 77 +++++-------------- src/style.css | 22 ++++++ src/view/screens/StorybookLists/index.tsx | 92 +++++++++++++++-------- 3 files changed, 103 insertions(+), 88 deletions(-) diff --git a/src/components/List/index.tsx b/src/components/List/index.tsx index a53b06e7a3..803ca6be85 100644 --- a/src/components/List/index.tsx +++ b/src/components/List/index.tsx @@ -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 = Omit< */ didScrollDownThreshold?: number onScrolledDownChange?: (isScrolledDown: boolean) => void - - StickyHeaderComponent?: FlatListProps['ListHeaderComponent'] - stickyHeaderOffset?: number } export const List = forwardRef(function List( - { - stickyHeaderIndices, - ListHeaderComponent, - StickyHeaderComponent, - stickyHeaderOffset, - ...props - }: ListProps, + {...props}: ListProps, ref: React.Ref>, ) { const t = useTheme() @@ -177,50 +167,19 @@ export const List = forwardRef(function List( ) } - 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: () => ( - // - // - // - // ) - }), - 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 && ( - { - setStickyHeaderHeight(e.nativeEvent.layout.height) - }}> - - - )} - i.key)} viewabilityConfig={viewabilityConfig} @@ -239,7 +198,7 @@ export const List = forwardRef(function List( 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( /** * 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)} - {...header} style={[ + a.h_full, { - paddingTop: headerOffset, + paddingTop: props.headerOffset, paddingBottom: props.footerOffset, transform: 'unset', }, diff --git a/src/style.css b/src/style.css index 4c5677fbf3..ef531300b6 100644 --- a/src/style.css +++ b/src/style.css @@ -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; +} diff --git a/src/view/screens/StorybookLists/index.tsx b/src/view/screens/StorybookLists/index.tsx index 720d383aa1..fb12c7abc8 100644 --- a/src/view/screens/StorybookLists/index.tsx +++ b/src/view/screens/StorybookLists/index.tsx @@ -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 + return ( + + + + ) } -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(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 ( data={items} - headerOffset={insets.top} - stickyHeaderOffset={52} - StickyHeaderComponent={Header} onScrolledDownChange={scrolledDown => { console.log(`Scrolled down: ${scrolledDown}`) }} - renderItem={({item, index}) => ( - - - {item.title} - - - )} - style={[a.debug]} + stickyHeaderIndices={[1]} + renderItem={({item, index}) => { + if (item.type === 'header') { + return
+ } + if (item.type === 'spacer') { + return + } + + return ( + + + {item.title} + + + ) + }} + style={[a.h_full_vh]} /> ) : (