Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

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

## [1.7.6] - 2025-03-19

Expand Down
91 changes: 91 additions & 0 deletions documentation/docs/fundamentals/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,97 @@ Multiple columns can only be rendered with `horizontal={false}` and will zig-zag

`numColumns?: number;`

### `stickyHeaderConfig`

Configuration object for sticky header behavior and appearance. All properties are optional.

```tsx
stickyHeaderConfig?: {
useNativeDriver?: boolean;
offset?: number;
backdropComponent?: React.ComponentType<any> | React.ReactElement | null;
hideRelatedCell?: boolean;
};
```

#### `useNativeDriver`

If true, the sticky headers will use native driver for animations. Default is `true`.

```tsx
useNativeDriver?: boolean;
```

#### `offset`

Offset from the top of the list where sticky headers should stick.
This is useful when you have a fixed header or navigation bar at the top of your screen
and want sticky headers to appear below it instead of at the very top.
Default is `0`.

```tsx
offset?: number;
```

#### `backdropComponent`

Component to render behind sticky headers (e.g., a backdrop or blur effect).
Renders in front of the scroll view content but behind the sticky header itself.
Useful for creating visual separation or effects like backgrounds with blur.

```tsx
backdropComponent?: React.ComponentType<any> | React.ReactElement | null;
```

#### `hideRelatedCell`

When a sticky header is displayed, the cell associated with it is hidden.
Default is `false`.

```tsx
hideRelatedCell?: boolean;
```

**Example:**

```jsx
<FlashList
data={sectionData}
stickyHeaderIndices={[0, 10, 20]}
stickyHeaderConfig={{
useNativeDriver: true,
offset: 50, // Headers stick 50px from top
backdropComponent: <BlurView style={StyleSheet.absoluteFill} />,
hideRelatedCell: true,
}}
renderItem={({ item }) => <ListItem item={item} />}
/>
```

### `onChangeStickyIndex`

Callback invoked when the currently displayed sticky header changes as you scroll.
Receives the current sticky header index and the previous sticky header index.
This is useful for tracking which header is currently stuck at the top while scrolling.
The index refers to the position of the item in your data array that's being used as a sticky header.

```tsx
onChangeStickyIndex?: (current: number, previous: number) => void;
```

Example:

```jsx
<FlashList
data={sectionData}
stickyHeaderIndices={[0, 10, 20]}
onChangeStickyIndex={(current, previous) => {
console.log(`Sticky header changed from ${previous} to ${current}`);
}}
renderItem={({ item }) => <ListItem item={item} />}
/>
```

### `onBlankArea`

```tsx
Expand Down
1 change: 1 addition & 0 deletions fixture/react-native/src/ExamplesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const ExamplesScreen = () => {
};

const data: ExampleItem[] = [
{ title: "Sticky Header Example", destination: "StickyHeaderExample" },
{ title: "Horizontal List", destination: "HorizontalList" },
{ title: "Carousel", destination: "Carousel" },
{ title: "Grid", destination: "Grid" },
Expand Down
6 changes: 6 additions & 0 deletions fixture/react-native/src/NavigationTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import ShowcaseApp from "./ShowcaseApp";
import LotOfItems from "./lot-of-items/LotOfItems";
import ManualBenchmarkExample from "./ManualBenchmarkExample";
import ManualFlatListBenchmarkExample from "./ManualFlatListBenchmarkExample";
import { StickyHeaderExample } from "./StickyHeaderExample";

const Stack = createStackNavigator<RootStackParamList>();

Expand Down Expand Up @@ -106,6 +107,11 @@ const NavigationTree = () => {
component={LotOfItems}
options={{ title: "Lot of Items" }}
/>
<Stack.Screen
name="StickyHeaderExample"
component={StickyHeaderExample}
options={{ title: "Sticky Headers" }}
/>
</Stack.Group>
<Stack.Screen name="Masonry" component={Masonry} />
<Stack.Screen name="ComplexMasonry" component={ComplexMasonry} />
Expand Down
170 changes: 170 additions & 0 deletions fixture/react-native/src/StickyHeaderExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import React, {
forwardRef,
ForwardedRef,
useState,
useCallback,
useMemo,
} from "react";
import { Text, View, Switch, StyleSheet } from "react-native";
import { FlashList, type FlashListRef } from "@shopify/flash-list";

// Define our data structure
type Item =
| {
type: "basic";
title: string;
}
| {
type: "header";
title: string;
};

const data: Item[] = Array.from({ length: 300 }, (_, index) =>
index % 20 === 0
? {
type: "header",
title: `Header ${index / 20 + 1}`,
}
: {
type: "basic",
title: `Item ${index - Math.floor(index / 20) + 1}`,
}
);

const headerIndices = data
.map((item, index) => (item.type === "header" ? index : null))
.filter((item) => item !== null) as number[];

interface ToggleProps {
label: string;
value: boolean;
onChange: (value: boolean) => void;
}
const Toggle = ({ label, value, onChange }: ToggleProps) => {
return (
<View style={styles.toggleContainer}>
<Text>{label}</Text>
<Switch value={value} onValueChange={onChange} />
</View>
);
};

const ItemSeparator = () => {
return <View style={styles.itemSeparator} />;
};

// Create our masonry component
export const StickyHeaderExample = forwardRef(
(_: unknown, ref: ForwardedRef<unknown>) => {
const [stickyHeadersEnabled, setStickyHeadersEnabled] = useState(false);
const [withStickyHeaderOffset, setWithStickyHeaderOffset] = useState(false);
const [withStickyHeaderBackground, setWithStickyHeaderBackground] =
useState(false);

// Memoize the renderItem function
const renderItem = useCallback(
({ item }: { item: Item }) => (
<View style={item.type === "header" ? styles.headerItem : styles.item}>
<Text>{item.title}</Text>
</View>
),
[]
);

const stickyHeaderConfig = useMemo(
() => ({
offset: withStickyHeaderOffset ? 44 : 0,
backdropComponent: withStickyHeaderBackground ? (
<View style={styles.stickyHeaderBackdropContainer}>
<View style={styles.stickyHeaderBackground} />
</View>
) : undefined,
}),
[withStickyHeaderOffset, withStickyHeaderBackground]
);

return (
<View
style={styles.container}
key={`${stickyHeadersEnabled}-${withStickyHeaderOffset}-${withStickyHeaderBackground}`}
>
<View>
<Toggle
label="Enable Sticky Headers"
value={stickyHeadersEnabled}
onChange={setStickyHeadersEnabled}
/>
<Toggle
label="Sticky Header Offset"
value={withStickyHeaderOffset}
onChange={setWithStickyHeaderOffset}
/>
<Toggle
label="Sticky Header Background"
value={withStickyHeaderBackground}
onChange={setWithStickyHeaderBackground}
/>
</View>
<View style={styles.listContainer}>
<FlashList
ref={ref as React.RefObject<FlashListRef<Item>>}
renderItem={renderItem}
alwaysBounceVertical
data={data}
stickyHeaderIndices={
stickyHeadersEnabled ? headerIndices : undefined
}
stickyHeaderConfig={stickyHeaderConfig}
ItemSeparatorComponent={ItemSeparator}
/>
</View>
</View>
);
}
);
StickyHeaderExample.displayName = "StickyHeaderExample";

const styles = StyleSheet.create({
container: {
flex: 1,
},
toggleContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 16,
marginVertical: 8,
},
listContainer: {
borderRadius: 20,
backgroundColor: "#C0C0CC",
margin: 16,
overflow: "hidden",
flex: 1,
},
itemSeparator: {
height: 1,
backgroundColor: "#CDCDCD",
},
stickyHeaderBackdropContainer: {
position: "absolute",
width: "100%",
inset: 0,
},
stickyHeaderBackground: {
height: 44,
backgroundColor: "#40C4FF4C",
},
headerItem: {
height: 44,
backgroundColor: "#FFAB40AA",
paddingHorizontal: 16,
justifyContent: "center",
},
item: {
height: 44,
backgroundColor: "#E0E0E0",
paddingHorizontal: 16,
justifyContent: "center",
},
});
1 change: 1 addition & 0 deletions fixture/react-native/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ export type RootStackParamList = {
LotOfItems: undefined;
ManualBenchmarkExample: undefined;
ManualFlatListBenchmarkExample: undefined;
StickyHeaderExample: undefined;
};
43 changes: 43 additions & 0 deletions src/FlashListProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,4 +365,47 @@ export interface FlashListProps<TItem>
* Doing set state inside the callback can lead to infinite loops. Make sure FlashList's props are memoized.
*/
onCommitLayoutEffect?: () => void;

/**
* Callback invoked when the currently displayed sticky header changes.
* Receives the current sticky header index and the previous sticky header index.
* This is useful for tracking which header is currently stuck at the top while scrolling.
* The index refers to the position of the item in your data array that's being used as a sticky header.
*/
onChangeStickyIndex?: (current: number, previous: number) => void;

stickyHeaderConfig?:
| {
/**
* If true, the sticky headers will use native driver for animations.
* @default true
*/
useNativeDriver?: boolean;

/**
* Offset from the top of the list where sticky headers should stick.
* This is useful when you have a fixed header or navigation bar at the top of your screen
* and want sticky headers to appear below it instead of at the very top.
* @default 0
*/
offset?: number;

/**
* Component to render behind sticky headers (e.g., a backdrop or blur effect).
* Renders in front of the scroll view content but behind the sticky header itself.
* Useful for creating visual separation or effects like backgrounds with blur.
*/
backdropComponent?:
| React.ComponentType<any>
| React.ReactElement
| null
| undefined;

/**
* When a sticky header is displayed, the cell associated with it is hidden.
* @default false
*/
hideRelatedCell?: boolean;
}
| undefined;
}
Loading