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 {
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<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}>(
props: ListProps<Item>,
{
stickyHeaderIndices,
ListHeaderComponent,
StickyHeaderComponent,
stickyHeaderOffset,
...props
}: ListProps<Item>,
ref: React.Ref<FlatList<Item>>,
) {
const t = useTheme()
@@ -167,58 +177,95 @@ export const List = forwardRef(function List<Item extends {key: string}>(
)
}
return (
<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: 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<Item>)}
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: () => (
// <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)
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,
]),
]}
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<Item>)}
{...header}
style={[
{
paddingTop: headerOffset,
paddingBottom: props.footerOffset,
transform: 'unset',
},
props.style,
]}
onScroll={onScroll}
/>
</>
)
}) as <Item extends {key: string}>(
props: ListProps<Item> & {ref?: React.Ref<FlatList<Item>>},
+35 -22
View File
@@ -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 (
<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>
)
return <Inner />
}
const items = Array.from({length: 100}).map((_, i) => ({
@@ -39,7 +27,21 @@ type Item = {
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() {
const t = useTheme()
const insets = useSafeAreaInsets()
const [old, setOld] = useState<boolean>(false)
const onScrollWorklet = useListScrollHandler(e => {
'worklet'
@@ -47,7 +49,7 @@ export function Inner() {
}, [])
return (
<View style={[a.h_full_vh]}>
<View style={[]}>
<View
style={[a.fixed, a.w_full, a.py_sm, a.px_xl, a.align_center, a.z_10]}>
<Button
@@ -65,15 +67,25 @@ export function Inner() {
<ListScrollProvider onScroll={onScrollWorklet}>
<List<Item>
data={items}
headerOffset={100}
footerOffset={100}
headerOffset={insets.top}
stickyHeaderOffset={52}
StickyHeaderComponent={Header}
onScrolledDownChange={scrolledDown => {
console.log(`Scrolled down: ${scrolledDown}`)
}}
renderItem={({item}) => (
<View style={[a.p_md, a.border_b]}>
<Text>{item.title}</Text>
</View>
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]}
/>
@@ -93,6 +105,7 @@ export function Inner() {
</View>
)}
style={[a.debug]}
desktopFixedHeight
/>
</ScrollProvider>
)}