@@ -6,70 +6,111 @@ import {
66 type ListRenderItem ,
77 type ListRenderItemInfo ,
88} from '@shopify/flash-list' ;
9- import { isValidElement , useEffect , useRef , useState } from 'react' ;
10- import { View } from 'react-native' ;
11- import { Post } from './Post' ;
12-
13- // Initial page size for the web list. FlashList virtualises on native;
14- // the web fallback (`WebFeedViewer`) renders every item synchronously,
15- // so a long feed blows up the first paint. We cap the initial render
16- // and grow the visible window when the sentinel comes into view.
9+ import {
10+ cloneElement ,
11+ isValidElement ,
12+ useEffect ,
13+ useRef ,
14+ useState ,
15+ } from 'react' ;
16+ import { LayoutChangeEvent , StyleSheet , View } from 'react-native' ;
17+ import Animated , {
18+ useAnimatedScrollHandler ,
19+ useAnimatedStyle ,
20+ useSharedValue ,
21+ } from 'react-native-reanimated' ;
22+
23+ // A reanimated-compatible FlashList.
24+ const AnimatedFlashList = Animated . createAnimatedComponent ( FlashList ) ;
25+
1726const WEB_INITIAL_VISIBLE = 12 ;
1827const WEB_PAGE_SIZE = 12 ;
1928
2029export type { FlashListProps , ListRenderItem , ListRenderItemInfo } ;
2130
22- // `renderItem` is required by FlashList but FeedViewer defaults it to a
23- // `Post` renderer for `PostData`, so consumers can omit it.
24- export type FeedViewerProps < T = PostData > = Omit <
25- FlashListProps < T > ,
26- 'renderItem'
27- > & {
28- renderItem ?: FlashListProps < T > [ 'renderItem' ] ;
29- } ;
30-
31- const defaultRenderItem : ListRenderItem < PostData > = ( { item } ) => (
32- < Post post = { item } />
33- ) ;
34-
35- const defaultKeyExtractor = ( item : PostData , index : number ) =>
36- `${ item . id } :${ index } ` ;
37-
38- export function FeedViewer < T extends PostData = PostData > (
39- props : FeedViewerProps < T > ,
40- ) {
41- const renderItem =
42- ( props . renderItem as ListRenderItem < T > ) ??
43- ( defaultRenderItem as unknown as ListRenderItem < T > ) ;
44- const keyExtractor =
45- props . keyExtractor ??
46- ( defaultKeyExtractor as unknown as FlashListProps < T > [ 'keyExtractor' ] ) ;
31+ export type ListProps < T > = FlashListProps < T > ;
4732
33+ export function List < T extends PostData = PostData > ( props : ListProps < T > ) {
4834 if ( isWeb ) {
49- return (
50- < WebFeedViewer < T >
51- { ...props }
52- renderItem = { renderItem }
53- keyExtractor = { keyExtractor }
54- />
55- ) ;
35+ return < WebFeedViewer < T > { ...props } /> ;
5636 }
5737
38+ return < NativeList < T > { ...props } /> ;
39+ }
40+
41+ function NativeList < T > ( {
42+ ListHeaderComponent,
43+ contentContainerStyle,
44+ refreshControl,
45+ onScroll : _ignoredOnScroll ,
46+ ...rest
47+ } : FlashListProps < T > ) {
48+ const lastScrollY = useSharedValue ( 0 ) ;
49+ const headerTranslate = useSharedValue ( 0 ) ;
50+ const headerHeightShared = useSharedValue ( 0 ) ;
51+ const [ headerHeight , setHeaderHeight ] = useState ( 0 ) ;
52+
53+ const onScroll = useAnimatedScrollHandler ( ( event ) => {
54+ const currentY = event . contentOffset . y ;
55+ const h = headerHeightShared . value ;
56+
57+ const delta = currentY - lastScrollY . value ;
58+ const next = headerTranslate . value - delta ;
59+ headerTranslate . value = Math . min ( 0 , Math . max ( - h , next ) ) ;
60+ lastScrollY . value = currentY ;
61+ } ) ;
62+
63+ const headerAnimatedStyle = useAnimatedStyle ( ( ) => ( {
64+ transform : [ { translateY : headerTranslate . value } ] ,
65+ } ) ) ;
66+
67+ const onHeaderLayout = ( event : LayoutChangeEvent ) => {
68+ const next = event . nativeEvent . layout . height ;
69+ headerHeightShared . value = next ;
70+ if ( next !== headerHeight ) setHeaderHeight ( next ) ;
71+ } ;
72+
73+ const renderedHeader = renderNode ( ListHeaderComponent ) ;
74+
75+ // Show below the sticky header
76+ const adjustedRefreshControl = (
77+ isValidElement ( refreshControl )
78+ ? cloneElement (
79+ refreshControl as React . ReactElement < { progressViewOffset ?: number } > ,
80+ {
81+ progressViewOffset : headerHeight ,
82+ } ,
83+ )
84+ : refreshControl
85+ ) as FlashListProps < T > [ 'refreshControl' ] ;
86+
5887 return (
59- < FlashList < T >
60- { ...props }
61- renderItem = { renderItem }
62- keyExtractor = { keyExtractor }
63- />
88+ < View style = { styles . container } >
89+ { renderedHeader ? (
90+ < Animated . View
91+ onLayout = { onHeaderLayout }
92+ style = { [ styles . stickyHeader , headerAnimatedStyle ] }
93+ >
94+ { renderedHeader }
95+ </ Animated . View >
96+ ) : null }
97+ < AnimatedFlashList
98+ { ...( rest as FlashListProps < unknown > ) }
99+ refreshControl = { adjustedRefreshControl }
100+ onScroll = { onScroll }
101+ scrollEventThrottle = { 16 }
102+ contentContainerStyle = { {
103+ paddingTop : headerHeight ,
104+ ...( typeof contentContainerStyle === 'object' &&
105+ contentContainerStyle !== null
106+ ? contentContainerStyle
107+ : { } ) ,
108+ } }
109+ />
110+ </ View >
64111 ) ;
65112}
66113
67- // Renders the FlashList contract inline so the page-level scroll on web
68- // is preserved (the Stack content area already has `overflow: auto`).
69- // Only the props the feed actually uses are honored. Caps the first
70- // paint at `WEB_INITIAL_VISIBLE` items and expands the window in
71- // `WEB_PAGE_SIZE` chunks when the sentinel scrolls into view, so a
72- // long feed doesn't block navigation.
73114function WebFeedViewer < T > ( {
74115 data,
75116 renderItem,
@@ -155,3 +196,16 @@ function renderNode(node: ReactNodeOrComponent) {
155196 const Component = node as React . ComponentType ;
156197 return < Component /> ;
157198}
199+
200+ const styles = StyleSheet . create ( {
201+ container : {
202+ flex : 1 ,
203+ } ,
204+ stickyHeader : {
205+ position : 'absolute' ,
206+ top : 0 ,
207+ left : 0 ,
208+ right : 0 ,
209+ zIndex : 1 ,
210+ } ,
211+ } ) ;
0 commit comments