Skip to content
Open
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
35 changes: 30 additions & 5 deletions example/app/countries-with-headers-sticky/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,30 @@ const BigCountryItem = ({ item, onPress, isSelected }: BigCountryItemProps) => (
</Pressable>
);

const HeaderItem = ({ item }: { item: Header }) => (
<View style={styles.header}>
<Text style={styles.headerText}>{item.letter}</Text>
</View>
);
const getHeaderHeight = (letter: string): number => {
// Vary header heights to test sticky push behavior
const letterCode = letter.charCodeAt(0) - 65; // A=0, B=1, etc.
if (letterCode % 3 === 0) return 80; // A, D, G, J, M, P, S, V, Y - tall
if (letterCode % 3 === 1) return 50; // B, E, H, K, N, Q, T, W, Z - medium
return 35; // C, F, I, L, O, R, U, X - short
};

const HeaderItem = ({ item }: { item: Header }) => {
const height = getHeaderHeight(item.letter);
return (
<View style={[styles.header, { minHeight: height }]}>
<Text style={styles.headerText}>{item.letter}</Text>
{height >= 80 && (
<Text style={styles.headerSubtext}>
Countries starting with {item.letter}
</Text>
)}
{height >= 50 && height < 80 && (
<Text style={styles.headerSubtext}>Section {item.letter}</Text>
)}
</View>
);
};

const App = () => {
const [selectedId, setSelectedId] = useState<string>();
Expand Down Expand Up @@ -299,6 +318,12 @@ const styles = StyleSheet.create({
paddingHorizontal: 16,
paddingVertical: 12,
},
headerSubtext: {
color: "#1976d2",
fontSize: 12,
marginTop: 4,
opacity: 0.7,
},
headerText: {
color: "#1976d2",
fontSize: 18,
Expand Down
23 changes: 14 additions & 9 deletions src/components/Container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,18 @@ export const Container = typedMemo(function Container<ItemT>({
const ctx = useStateContext();
const { columnWrapperStyle, animatedScrollY } = ctx;

const [column = 0, data, itemKey, numColumns, extraData, isSticky, stickyOffset] = useArr$([
`containerColumn${id}`,
`containerItemData${id}`,
`containerItemKey${id}`,
"numColumns",
"extraData",
`containerSticky${id}`,
`containerStickyOffset${id}`,
]);
const [column = 0, data, itemKey, numColumns, extraData, isSticky, stickyOffset, stickyNextPosition, stickySize] =
useArr$([
`containerColumn${id}`,
`containerItemData${id}`,
`containerItemKey${id}`,
"numColumns",
"extraData",
`containerSticky${id}`,
`containerStickyOffset${id}`,
`containerStickyNextPosition${id}`,
`containerStickySize${id}`,
]);

const refLastSize = useRef<{ width: number; height: number }>();
const ref = useRef<View>(null);
Expand Down Expand Up @@ -185,7 +188,9 @@ export const Container = typedMemo(function Container<ItemT>({
onLayout={onLayout}
refView={ref}
stickyHeaderConfig={stickyHeaderConfig}
stickyNextPosition={isSticky ? stickyNextPosition : undefined}
stickyOffset={isSticky ? stickyOffset : undefined}
stickySize={isSticky ? stickySize : undefined}
style={style}
>
<ContextContainer.Provider value={contextValue}>
Expand Down
74 changes: 69 additions & 5 deletions src/components/PositionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ const PositionViewSticky = typedMemo(function PositionViewSticky({
refView,
animatedScrollY,
stickyOffset,
stickyNextPosition,
stickySize,
index,
stickyHeaderConfig,
children,
Expand All @@ -92,29 +94,91 @@ const PositionViewSticky = typedMemo(function PositionViewSticky({
refView: React.RefObject<View>;
animatedScrollY?: Animated.Value;
stickyOffset?: number;
stickyNextPosition?: number;
stickySize?: number;
onLayout: (event: LayoutChangeEvent) => void;
index: number;
children: React.ReactNode;
stickyHeaderConfig?: StickyHeaderConfig;
}) {
const [position = POSITION_OUT_OF_VIEW, headerSize] = useArr$([`containerPosition${id}`, "headerSize"]);

// Calculate transform based on sticky state
// Calculate transform based on sticky state with push behavior
const transform = React.useMemo(() => {
if (animatedScrollY && stickyOffset !== undefined) {
const stickyOffset = stickyHeaderConfig?.offset ?? 0;
// Don't apply sticky transform if position is not yet set (out of view)
// This prevents items from briefly appearing at the top when first allocated
if (position === POSITION_OUT_OF_VIEW) {
return horizontal
? [{ translateX: POSITION_OUT_OF_VIEW }]
: [{ translateY: POSITION_OUT_OF_VIEW }];
}

const configOffset = stickyHeaderConfig?.offset ?? 0;
const currentStickySize = stickySize ?? 0;

// The stick point is when the item should start sticking
// Item sticks when scroll reaches: position + headerSize - configOffset
const stickPoint = position + headerSize - configOffset;

if (stickyNextPosition !== undefined && currentStickySize > 0) {
// Push behavior: when next sticky arrives, push current one up
// The next sticky will stick when scroll reaches: stickyNextPosition + headerSize - configOffset
// Push starts when next sticky's top would touch current sticky's bottom
// This happens when: scroll = stickyNextPosition + headerSize - configOffset - currentStickySize
const pushStartScroll = stickyNextPosition + headerSize - configOffset - currentStickySize;
const pushEndScroll = stickyNextPosition + headerSize - configOffset;

// During the "stuck" phase (stickPoint to pushStartScroll), translateY increases with scroll
// to keep the item at a fixed visual position. The relationship is:
// translateY = position + (scroll - stickPoint) = scroll - headerSize + configOffset
//
// At pushStartScroll: translateY = pushStartScroll - headerSize + configOffset
// = stickyNextPosition - currentStickySize
//
// During push (pushStartScroll to pushEndScroll), translateY stays constant
// while scroll increases, causing the item to visually move up.
const translateYAtPushStart = stickyNextPosition - currentStickySize;

const stickyPosition = animatedScrollY.interpolate({
extrapolate: "clamp",
inputRange: [
stickPoint, // Start sticking
pushStartScroll, // Start being pushed
pushEndScroll, // Fully pushed off
],
outputRange: [
position, // At natural position (translateY = position)
translateYAtPushStart, // At push start (still at top, about to be pushed)
translateYAtPushStart, // At push end (same translateY, but visually pushed up by scroll)
],
});

return horizontal ? [{ translateX: stickyPosition }] : [{ translateY: stickyPosition }];
}

// No next sticky or size not known yet - use simple sticky without push
const stickyPosition = animatedScrollY.interpolate({
extrapolateLeft: "clamp",
extrapolateRight: "extend",
inputRange: [position + headerSize - stickyOffset, position + 5000 + headerSize - stickyOffset],
inputRange: [stickPoint, stickPoint + 5000],
outputRange: [position, position + 5000],
});

return horizontal ? [{ translateX: stickyPosition }] : [{ translateY: stickyPosition }];
}
}, [animatedScrollY, headerSize, horizontal, stickyOffset, position, stickyHeaderConfig?.offset]);
}, [
animatedScrollY,
headerSize,
horizontal,
stickyOffset,
position,
stickyHeaderConfig?.offset,
stickyNextPosition,
stickySize,
]);

const viewStyle = React.useMemo(() => [style, { zIndex: index + 1000 }, { transform }], [style, transform]);
const viewStyle = React.useMemo(() => [style, { zIndex: index + 1000 }, { transform }], [style, transform, index]);

const renderStickyHeaderBackdrop = React.useMemo(() => {
if (!stickyHeaderConfig?.backdropComponent) {
Expand Down
31 changes: 31 additions & 0 deletions src/core/calculateItemsInView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ function handleStickyRecycling(
state.stickyContainerPool.delete(containerIndex);
set$(ctx, `containerSticky${containerIndex}`, false);
set$(ctx, `containerStickyOffset${containerIndex}`, undefined);
set$(ctx, `containerStickyNextPosition${containerIndex}`, undefined);
set$(ctx, `containerStickySize${containerIndex}`, undefined);
continue;
}

Expand Down Expand Up @@ -441,6 +443,7 @@ export function calculateItemsInView(
const topPadding = (peek$(ctx, "stylePaddingTop") || 0) + (peek$(ctx, "headerSize") || 0);
set$(ctx, `containerStickyOffset${containerIndex}`, topPadding);
// Add container to sticky pool
// Note: stickyNextPosition and stickySize are set by the update loop below
state.stickyContainerPool.add(containerIndex);
} else {
set$(ctx, `containerSticky${containerIndex}`, false);
Expand All @@ -465,6 +468,32 @@ export function calculateItemsInView(
// Handle sticky container recycling
if (stickyIndicesArr.length > 0) {
handleStickyRecycling(ctx, state, stickyIndicesArr, scroll, scrollBuffer, currentStickyIdx, pendingRemoval);

// Update sticky next positions for all sticky containers (for push behavior)
for (const containerIndex of state.stickyContainerPool) {
const itemKey = peek$(ctx, `containerItemKey${containerIndex}`);
const itemIndex = itemKey ? indexByKey.get(itemKey) : undefined;
if (itemIndex === undefined) continue;

const currentStickyArrayIdx = stickyIndicesArr.indexOf(itemIndex);
if (currentStickyArrayIdx >= 0 && currentStickyArrayIdx < stickyIndicesArr.length - 1) {
const nextStickyDataIndex = stickyIndicesArr[currentStickyArrayIdx + 1];
const nextStickyId = idCache[nextStickyDataIndex] ?? getId(state, nextStickyDataIndex);
const nextStickyPos = nextStickyId ? positions.get(nextStickyId) : undefined;
const prevNextPos = peek$(ctx, `containerStickyNextPosition${containerIndex}`);
Comment on lines +479 to +483

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset stale next sticky position for terminal sticky headers

This branch only updates containerStickyNextPosition when a following sticky index exists, so containers that become the last sticky header keep their old next value. After sticky-index/data changes (for example a list going from [0,10,20] to [0,10]), PositionViewSticky still sees a next position and applies push behavior, which can incorrectly push the final sticky header off-screen even though there is no successor. Clearing containerStickyNextPosition in the non-successor case avoids this stale state.

Useful? React with 👍 / 👎.

if (nextStickyPos !== prevNextPos) {
set$(ctx, `containerStickyNextPosition${containerIndex}`, nextStickyPos);
}
}

// Update sticky size in case it changed
const currentId = idCache[itemIndex] ?? getId(state, itemIndex);
const currentSize = sizes.get(currentId) ?? getItemSize(state, currentId, itemIndex, data[itemIndex]);
const prevSize = peek$(ctx, `containerStickySize${containerIndex}`);
if (currentSize !== prevSize) {
set$(ctx, `containerStickySize${containerIndex}`, currentSize);
}
}
}

// Update top positions of all containers
Expand All @@ -485,6 +514,8 @@ export function calculateItemsInView(
if (state.stickyContainerPool.has(i)) {
set$(ctx, `containerSticky${i}`, false);
set$(ctx, `containerStickyOffset${i}`, undefined);
set$(ctx, `containerStickyNextPosition${i}`, undefined);
set$(ctx, `containerStickySize${i}`, undefined);
// Remove container from sticky pool
state.stickyContainerPool.delete(i);
}
Expand Down
6 changes: 6 additions & 0 deletions src/state/state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export type ListenerType =
| `containerColumn${number}`
| `containerSticky${number}`
| `containerStickyOffset${number}`
| `containerStickyNextPosition${number}`
| `containerStickySize${number}`
| "containersDidLayout"
| "extraData"
| "numColumns"
Expand Down Expand Up @@ -79,6 +81,10 @@ export type ListenerTypeValueMap = {
[K in ListenerType as K extends `containerSticky${number}` ? K : never]: boolean;
} & {
[K in ListenerType as K extends `containerStickyOffset${number}` ? K : never]: number;
} & {
[K in ListenerType as K extends `containerStickyNextPosition${number}` ? K : never]: number | undefined;
} & {
[K in ListenerType as K extends `containerStickySize${number}` ? K : never]: number;
};

export interface StateContext {
Expand Down