diff --git a/src/components/List/index.tsx b/src/components/List/index.tsx index c46d8e5cb6..a53b06e7a3 100644 --- a/src/components/List/index.tsx +++ b/src/components/List/index.tsx @@ -1,8 +1,9 @@ -import {forwardRef, useMemo} from 'react' +import {forwardRef, useMemo, useState} from 'react' import { type FlatList, type FlatListProps, RefreshControl, + View, type ViewToken, } from 'react-native' import Animated, { @@ -14,9 +15,9 @@ import Animated, { import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video' 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 {atoms as a, useTheme, web} from '#/alf' +import {atoms as a, platform, useTheme} from '#/alf' import {useListScrollContext} from '#/components/List/ListScrollProvider' export { @@ -85,10 +86,19 @@ type ListProps = Omit< */ didScrollDownThreshold?: number onScrolledDownChange?: (isScrolledDown: boolean) => void + + StickyHeaderComponent?: FlatListProps['ListHeaderComponent'] + stickyHeaderOffset?: number } export const List = forwardRef(function List( - props: ListProps, + { + stickyHeaderIndices, + ListHeaderComponent, + StickyHeaderComponent, + stickyHeaderOffset, + ...props + }: ListProps, ref: React.Ref>, ) { const t = useTheme() @@ -167,58 +177,95 @@ export const List = forwardRef(function List( ) } - return ( - 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: 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 + const header = useMemo(() => { + if (isWeb) return {} + + if (StickyHeaderComponent) { + if (ListHeaderComponent) { + throw new Error( + `List: cannot use both StickyHeaderComponent and ListHeaderComponent`, + ) } - scrollsToTop={!activeLightbox} - refreshControl={refreshControl} - {...(props as FlatListPropsWithLayout)} - style={[ - { - paddingTop: props.headerOffset, - paddingBottom: props.footerOffset, - }, - web([ - /* - * On web, the List should always fill its container, otherwise - * `onScroll` will not work due to the entire page scrolling. + + 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) + + return ( + <> + {isWeb && StickyHeaderComponent && ( + { + setStickyHeaderHeight(e.nativeEvent.layout.height) + }}> + + + )} + + 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, - ]), - ]} - onScroll={onScroll} - /> + right: 1, + }} + /** + * 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)} + {...header} + style={[ + { + paddingTop: headerOffset, + paddingBottom: props.footerOffset, + transform: 'unset', + }, + props.style, + ]} + onScroll={onScroll} + /> + ) }) as ( props: ListProps & {ref?: React.Ref>}, diff --git a/src/view/screens/StorybookLists/index.tsx b/src/view/screens/StorybookLists/index.tsx index 099cd46b0b..720d383aa1 100644 --- a/src/view/screens/StorybookLists/index.tsx +++ b/src/view/screens/StorybookLists/index.tsx @@ -1,30 +1,18 @@ 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' -import {atoms as a} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {Button, ButtonText} from '#/components/Button' import * as Layout from '#/components/Layout' import {List, ListScrollProvider, useListScrollHandler} from '#/components/List' import {Text} from '#/components/Typography' export function StorybookLists() { - return ( - - - - - Storybook - - - - - - - - ) + return } const items = Array.from({length: 100}).map((_, i) => ({ @@ -39,7 +27,21 @@ type Item = { const log = (msg: any) => console.log(msg) +function Header() { + return ( + + + + Storybook + + + + ) +} + export function Inner() { + const t = useTheme() + const insets = useSafeAreaInsets() const [old, setOld] = useState(false) const onScrollWorklet = useListScrollHandler(e => { 'worklet' @@ -47,7 +49,7 @@ export function Inner() { }, []) return ( - +