Skip to content

Commit 0cf8775

Browse files
authored
Add stickyHeaderOffset and StickyHeaderBackgroundComponent; implement offset spacer, background layer, and header hiding; update docs/changelog and add StickyHeader example in RN fixture (#1953)
1 parent 0e8e860 commit 0cf8775

File tree

13 files changed

+1090
-17
lines changed

13 files changed

+1090
-17
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
99

1010
- Introduce optional view offset param for initial scroll position
1111
- https://github.com/Shopify/flash-list/pull/1870
12+
- Add sticky header offset and sticky header backgrounds
13+
- https://github.com/Shopify/flash-list/pull/1953
1214

1315
## [1.7.6] - 2025-03-19
1416

documentation/docs/fundamentals/usage.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,97 @@ Multiple columns can only be rendered with `horizontal={false}` and will zig-zag
301301

302302
`numColumns?: number;`
303303

304+
### `stickyHeaderConfig`
305+
306+
Configuration object for sticky header behavior and appearance. All properties are optional.
307+
308+
```tsx
309+
stickyHeaderConfig?: {
310+
useNativeDriver?: boolean;
311+
offset?: number;
312+
backdropComponent?: React.ComponentType<any> | React.ReactElement | null;
313+
hideRelatedCell?: boolean;
314+
};
315+
```
316+
317+
#### `useNativeDriver`
318+
319+
If true, the sticky headers will use native driver for animations. Default is `true`.
320+
321+
```tsx
322+
useNativeDriver?: boolean;
323+
```
324+
325+
#### `offset`
326+
327+
Offset from the top of the list where sticky headers should stick.
328+
This is useful when you have a fixed header or navigation bar at the top of your screen
329+
and want sticky headers to appear below it instead of at the very top.
330+
Default is `0`.
331+
332+
```tsx
333+
offset?: number;
334+
```
335+
336+
#### `backdropComponent`
337+
338+
Component to render behind sticky headers (e.g., a backdrop or blur effect).
339+
Renders in front of the scroll view content but behind the sticky header itself.
340+
Useful for creating visual separation or effects like backgrounds with blur.
341+
342+
```tsx
343+
backdropComponent?: React.ComponentType<any> | React.ReactElement | null;
344+
```
345+
346+
#### `hideRelatedCell`
347+
348+
When a sticky header is displayed, the cell associated with it is hidden.
349+
Default is `false`.
350+
351+
```tsx
352+
hideRelatedCell?: boolean;
353+
```
354+
355+
**Example:**
356+
357+
```jsx
358+
<FlashList
359+
data={sectionData}
360+
stickyHeaderIndices={[0, 10, 20]}
361+
stickyHeaderConfig={{
362+
useNativeDriver: true,
363+
offset: 50, // Headers stick 50px from top
364+
backdropComponent: <BlurView style={StyleSheet.absoluteFill} />,
365+
hideRelatedCell: true,
366+
}}
367+
renderItem={({ item }) => <ListItem item={item} />}
368+
/>
369+
```
370+
371+
### `onChangeStickyIndex`
372+
373+
Callback invoked when the currently displayed sticky header changes as you scroll.
374+
Receives the current sticky header index and the previous sticky header index.
375+
This is useful for tracking which header is currently stuck at the top while scrolling.
376+
The index refers to the position of the item in your data array that's being used as a sticky header.
377+
378+
```tsx
379+
onChangeStickyIndex?: (current: number, previous: number) => void;
380+
```
381+
382+
Example:
383+
384+
```jsx
385+
<FlashList
386+
data={sectionData}
387+
stickyHeaderIndices={[0, 10, 20]}
388+
onChangeStickyIndex={(current, previous) => {
389+
console.log(`Sticky header changed from ${previous} to ${current}`);
390+
}}
391+
renderItem={({ item }) => <ListItem item={item} />}
392+
/>
393+
```
394+
304395
### `onBlankArea`
305396

306397
```tsx

fixture/react-native/src/ExamplesScreen.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const ExamplesScreen = () => {
2727
};
2828

2929
const data: ExampleItem[] = [
30+
{ title: "Sticky Header Example", destination: "StickyHeaderExample" },
3031
{ title: "Horizontal List", destination: "HorizontalList" },
3132
{ title: "Carousel", destination: "Carousel" },
3233
{ title: "Grid", destination: "Grid" },

fixture/react-native/src/NavigationTree.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import ShowcaseApp from "./ShowcaseApp";
3131
import LotOfItems from "./lot-of-items/LotOfItems";
3232
import ManualBenchmarkExample from "./ManualBenchmarkExample";
3333
import ManualFlatListBenchmarkExample from "./ManualFlatListBenchmarkExample";
34+
import { StickyHeaderExample } from "./StickyHeaderExample";
3435

3536
const Stack = createStackNavigator<RootStackParamList>();
3637

@@ -106,6 +107,11 @@ const NavigationTree = () => {
106107
component={LotOfItems}
107108
options={{ title: "Lot of Items" }}
108109
/>
110+
<Stack.Screen
111+
name="StickyHeaderExample"
112+
component={StickyHeaderExample}
113+
options={{ title: "Sticky Headers" }}
114+
/>
109115
</Stack.Group>
110116
<Stack.Screen name="Masonry" component={Masonry} />
111117
<Stack.Screen name="ComplexMasonry" component={ComplexMasonry} />
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import React, {
2+
forwardRef,
3+
ForwardedRef,
4+
useState,
5+
useCallback,
6+
useMemo,
7+
} from "react";
8+
import { Text, View, Switch, StyleSheet } from "react-native";
9+
import { FlashList, type FlashListRef } from "@shopify/flash-list";
10+
11+
// Define our data structure
12+
type Item =
13+
| {
14+
type: "basic";
15+
title: string;
16+
}
17+
| {
18+
type: "header";
19+
title: string;
20+
};
21+
22+
const data: Item[] = Array.from({ length: 300 }, (_, index) =>
23+
index % 20 === 0
24+
? {
25+
type: "header",
26+
title: `Header ${index / 20 + 1}`,
27+
}
28+
: {
29+
type: "basic",
30+
title: `Item ${index - Math.floor(index / 20) + 1}`,
31+
}
32+
);
33+
34+
const headerIndices = data
35+
.map((item, index) => (item.type === "header" ? index : null))
36+
.filter((item) => item !== null) as number[];
37+
38+
interface ToggleProps {
39+
label: string;
40+
value: boolean;
41+
onChange: (value: boolean) => void;
42+
}
43+
const Toggle = ({ label, value, onChange }: ToggleProps) => {
44+
return (
45+
<View style={styles.toggleContainer}>
46+
<Text>{label}</Text>
47+
<Switch value={value} onValueChange={onChange} />
48+
</View>
49+
);
50+
};
51+
52+
const ItemSeparator = () => {
53+
return <View style={styles.itemSeparator} />;
54+
};
55+
56+
// Create our masonry component
57+
export const StickyHeaderExample = forwardRef(
58+
(_: unknown, ref: ForwardedRef<unknown>) => {
59+
const [stickyHeadersEnabled, setStickyHeadersEnabled] = useState(false);
60+
const [withStickyHeaderOffset, setWithStickyHeaderOffset] = useState(false);
61+
const [withStickyHeaderBackground, setWithStickyHeaderBackground] =
62+
useState(false);
63+
64+
// Memoize the renderItem function
65+
const renderItem = useCallback(
66+
({ item }: { item: Item }) => (
67+
<View style={item.type === "header" ? styles.headerItem : styles.item}>
68+
<Text>{item.title}</Text>
69+
</View>
70+
),
71+
[]
72+
);
73+
74+
const stickyHeaderConfig = useMemo(
75+
() => ({
76+
offset: withStickyHeaderOffset ? 44 : 0,
77+
backdropComponent: withStickyHeaderBackground ? (
78+
<View style={styles.stickyHeaderBackdropContainer}>
79+
<View style={styles.stickyHeaderBackground} />
80+
</View>
81+
) : undefined,
82+
}),
83+
[withStickyHeaderOffset, withStickyHeaderBackground]
84+
);
85+
86+
return (
87+
<View
88+
style={styles.container}
89+
key={`${stickyHeadersEnabled}-${withStickyHeaderOffset}-${withStickyHeaderBackground}`}
90+
>
91+
<View>
92+
<Toggle
93+
label="Enable Sticky Headers"
94+
value={stickyHeadersEnabled}
95+
onChange={setStickyHeadersEnabled}
96+
/>
97+
<Toggle
98+
label="Sticky Header Offset"
99+
value={withStickyHeaderOffset}
100+
onChange={setWithStickyHeaderOffset}
101+
/>
102+
<Toggle
103+
label="Sticky Header Background"
104+
value={withStickyHeaderBackground}
105+
onChange={setWithStickyHeaderBackground}
106+
/>
107+
</View>
108+
<View style={styles.listContainer}>
109+
<FlashList
110+
ref={ref as React.RefObject<FlashListRef<Item>>}
111+
renderItem={renderItem}
112+
alwaysBounceVertical
113+
data={data}
114+
stickyHeaderIndices={
115+
stickyHeadersEnabled ? headerIndices : undefined
116+
}
117+
stickyHeaderConfig={stickyHeaderConfig}
118+
ItemSeparatorComponent={ItemSeparator}
119+
/>
120+
</View>
121+
</View>
122+
);
123+
}
124+
);
125+
StickyHeaderExample.displayName = "StickyHeaderExample";
126+
127+
const styles = StyleSheet.create({
128+
container: {
129+
flex: 1,
130+
},
131+
toggleContainer: {
132+
flexDirection: "row",
133+
justifyContent: "space-between",
134+
alignItems: "center",
135+
paddingHorizontal: 16,
136+
marginVertical: 8,
137+
},
138+
listContainer: {
139+
borderRadius: 20,
140+
backgroundColor: "#C0C0CC",
141+
margin: 16,
142+
overflow: "hidden",
143+
flex: 1,
144+
},
145+
itemSeparator: {
146+
height: 1,
147+
backgroundColor: "#CDCDCD",
148+
},
149+
stickyHeaderBackdropContainer: {
150+
position: "absolute",
151+
width: "100%",
152+
inset: 0,
153+
},
154+
stickyHeaderBackground: {
155+
height: 44,
156+
backgroundColor: "#40C4FF4C",
157+
},
158+
headerItem: {
159+
height: 44,
160+
backgroundColor: "#FFAB40AA",
161+
paddingHorizontal: 16,
162+
justifyContent: "center",
163+
},
164+
item: {
165+
height: 44,
166+
backgroundColor: "#E0E0E0",
167+
paddingHorizontal: 16,
168+
justifyContent: "center",
169+
},
170+
});

fixture/react-native/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ export type RootStackParamList = {
3535
LotOfItems: undefined;
3636
ManualBenchmarkExample: undefined;
3737
ManualFlatListBenchmarkExample: undefined;
38+
StickyHeaderExample: undefined;
3839
};

src/FlashListProps.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,4 +365,47 @@ export interface FlashListProps<TItem>
365365
* Doing set state inside the callback can lead to infinite loops. Make sure FlashList's props are memoized.
366366
*/
367367
onCommitLayoutEffect?: () => void;
368+
369+
/**
370+
* Callback invoked when the currently displayed sticky header changes.
371+
* Receives the current sticky header index and the previous sticky header index.
372+
* This is useful for tracking which header is currently stuck at the top while scrolling.
373+
* The index refers to the position of the item in your data array that's being used as a sticky header.
374+
*/
375+
onChangeStickyIndex?: (current: number, previous: number) => void;
376+
377+
stickyHeaderConfig?:
378+
| {
379+
/**
380+
* If true, the sticky headers will use native driver for animations.
381+
* @default true
382+
*/
383+
useNativeDriver?: boolean;
384+
385+
/**
386+
* Offset from the top of the list where sticky headers should stick.
387+
* This is useful when you have a fixed header or navigation bar at the top of your screen
388+
* and want sticky headers to appear below it instead of at the very top.
389+
* @default 0
390+
*/
391+
offset?: number;
392+
393+
/**
394+
* Component to render behind sticky headers (e.g., a backdrop or blur effect).
395+
* Renders in front of the scroll view content but behind the sticky header itself.
396+
* Useful for creating visual separation or effects like backgrounds with blur.
397+
*/
398+
backdropComponent?:
399+
| React.ComponentType<any>
400+
| React.ReactElement
401+
| null
402+
| undefined;
403+
404+
/**
405+
* When a sticky header is displayed, the cell associated with it is hidden.
406+
* @default false
407+
*/
408+
hideRelatedCell?: boolean;
409+
}
410+
| undefined;
368411
}

0 commit comments

Comments
 (0)