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
29 changes: 24 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,24 @@ 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 +312,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 @@ -36,15 +36,18 @@ export const Container = typedMemo(function Container<ItemT>({
const { columnWrapperStyle, animatedScrollY } = ctx;
const stickyPositionComponentInternal = ctx.state.props.stickyPositionComponentInternal;

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

const itemLayoutRef = useRef<{
horizontal: boolean;
Expand Down Expand Up @@ -255,6 +258,8 @@ export const Container = typedMemo(function Container<ItemT>({
onLayout={onLayout}
refView={ref as React.RefObject<any>}
stickyHeaderConfig={stickyHeaderConfig}
stickyNextPosition={isSticky ? stickyNextPosition : undefined}
stickySize={isSticky ? stickySize : undefined}
style={style as any}
>
<ContextContainer.Provider value={contextValue}>
Expand Down
73 changes: 67 additions & 6 deletions src/components/PositionView.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ const PositionViewSticky = typedMemo(function PositionViewSticky({
style,
refView,
animatedScrollY,
stickyNextPosition,
stickySize,
index,
stickyHeaderConfig,
children,
Expand All @@ -91,6 +93,8 @@ const PositionViewSticky = typedMemo(function PositionViewSticky({
style: StyleProp<ViewStyle>;
refView: React.RefObject<View>;
animatedScrollY?: Animated.Value;
stickyNextPosition?: number;
stickySize?: number;
onLayout: (event: LayoutChangeEvent) => void;
index: number;
stickyHeaderConfig?: StickyHeaderConfig;
Expand All @@ -102,23 +106,80 @@ const PositionViewSticky = typedMemo(function PositionViewSticky({
"stylePaddingTop",
]);

// Calculate transform based on sticky state
// Calculate transform based on sticky state with push behavior
const transform = React.useMemo(() => {
if (animatedScrollY) {
const stickyConfigOffset = stickyHeaderConfig?.offset ?? 0;
const stickyStart = position + headerSize + stylePaddingTop - stickyConfigOffset;
// 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 + stylePaddingTop - configOffset
const stickPoint = position + headerSize + stylePaddingTop - configOffset;

if (stickyNextPosition !== undefined && currentStickySize > 0) {
// Push behavior: when next sticky arrives, push current one up
// Push starts when next sticky's top would touch current sticky's bottom
// This happens when: scroll = stickyNextPosition + headerSize + stylePaddingTop - configOffset - currentStickySize
const pushStartScroll =
stickyNextPosition + headerSize + stylePaddingTop - configOffset - currentStickySize;
const pushEndScroll = stickyNextPosition + headerSize + stylePaddingTop - 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 - stylePaddingTop + configOffset
//
// At pushStartScroll: translateY = pushStartScroll - headerSize - stylePaddingTop + 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: [stickyStart, stickyStart + 5000],
inputRange: [stickPoint, stickPoint + 5000],
outputRange: [position, position + 5000],
});

return horizontal ? [{ translateX: stickyPosition }] : [{ translateY: stickyPosition }];
}
}, [animatedScrollY, headerSize, horizontal, position, stylePaddingTop, stickyHeaderConfig?.offset]);
}, [
animatedScrollY,
headerSize,
horizontal,
position,
stylePaddingTop,
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
2 changes: 2 additions & 0 deletions src/components/PositionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export const PositionViewSticky = typedMemo(function PositionViewSticky({
index: number;
animatedScrollY?: unknown;
stickyHeaderConfig?: StickyHeaderConfig;
stickyNextPosition?: number;
stickySize?: number;
onLayout?: unknown;
children: React.ReactNode;
}) {
Expand Down
30 changes: 30 additions & 0 deletions src/core/calculateItemsInView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ function handleStickyRecycling(
if (arrayIdx === -1) {
state.stickyContainerPool.delete(containerIndex);
set$(ctx, `containerSticky${containerIndex}`, false);
set$(ctx, `containerStickyNextPosition${containerIndex}`, undefined);
set$(ctx, `containerStickySize${containerIndex}`, undefined);
continue;
}

Expand Down Expand Up @@ -561,6 +563,32 @@ export function calculateItemsInView(
pendingRemoval,
alwaysRenderSet,
);

// 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}`);
if (nextStickyPos !== prevNextPos) {
set$(ctx, `containerStickyNextPosition${containerIndex}`, nextStickyPos);
Comment on lines +574 to +580

Choose a reason for hiding this comment

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

P1 Badge Reset next-sticky position when no successor exists

This branch only updates containerStickyNextPosition when the current sticky item has a following sticky index, but it never clears the previous value when the container is reused for the last sticky item. In calculateItemsInView, a recycled sticky container can therefore keep a stale next-position from an earlier header, and PositionViewSticky/ReanimatedPositionViewSticky will still enable push behavior and push the final sticky header offscreen incorrectly. Add an else path to explicitly set containerStickyNextPosition to undefined when there is no next sticky item.

Useful? React with 👍 / 👎.

}
}

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

let didChangePositions = false;
Expand All @@ -581,6 +609,8 @@ export function calculateItemsInView(
// Clear sticky state if this was a sticky container
if (state.stickyContainerPool.has(i)) {
set$(ctx, `containerSticky${i}`, false);
set$(ctx, `containerStickyNextPosition${i}`, undefined);
set$(ctx, `containerStickySize${i}`, undefined);
// Remove container from sticky pool
state.stickyContainerPool.delete(i);
}
Expand Down
67 changes: 58 additions & 9 deletions src/integrations/reanimated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ interface ReanimatedPositionViewStickyProps {
index: number;
stickyHeaderConfig?: StickyHeaderConfig;
stickyScrollOffset: Reanimated.SharedValue<number>;
stickyNextPosition?: number;
stickySize?: number;
children: React.ReactNode;
}

Expand Down Expand Up @@ -99,23 +101,68 @@ const StickyOverlay = typedMemo(function StickyOverlayComponent({ stickyHeaderCo
const ReanimatedPositionViewSticky = typedMemo(function ReanimatedPositionViewStickyComponent(
props: ReanimatedPositionViewStickyProps,
) {
const { id, horizontal, style, refView, stickyScrollOffset, stickyHeaderConfig, index, children, ...rest } = props;
const {
id,
horizontal,
style,
refView,
stickyScrollOffset,
stickyHeaderConfig,
stickyNextPosition,
stickySize,
index,
children,
...rest
} = props;
const [position = POSITION_OUT_OF_VIEW, headerSize = 0, stylePaddingTop = 0] = useArr$([
`containerPosition${id}`,
"headerSize",
"stylePaddingTop",
]);

const stickyOffset = stickyHeaderConfig?.offset ?? 0;
const stickyStart = position + headerSize + stylePaddingTop - stickyOffset;
const configOffset = stickyHeaderConfig?.offset ?? 0;
const stickPoint = position + headerSize + stylePaddingTop - configOffset;
const currentStickySize = stickySize ?? 0;

const transformStyle = useAnimatedStyle(() => {
const delta = Math.max(0, stickyScrollOffset.value - stickyStart);
// Calculate push behavior parameters
const hasPushBehavior = stickyNextPosition !== undefined && currentStickySize > 0;
const pushStartScroll = hasPushBehavior
? stickyNextPosition + headerSize + stylePaddingTop - configOffset - currentStickySize
: 0;
const translateYAtPushStart = hasPushBehavior ? stickyNextPosition - currentStickySize : 0;

return horizontal
? { transform: [{ translateX: position + delta }] }
: { transform: [{ translateY: position + delta }] };
}, [horizontal, position, stickyStart]);
const transformStyle = useAnimatedStyle(() => {
"worklet";
// Don't apply sticky transform if position is not yet set
if (position === POSITION_OUT_OF_VIEW) {
return horizontal
? { transform: [{ translateX: POSITION_OUT_OF_VIEW }] }
: { transform: [{ translateY: POSITION_OUT_OF_VIEW }] };
}

const scroll = stickyScrollOffset.value;

let translateY: number;

if (hasPushBehavior) {
if (scroll <= stickPoint) {
// Before sticking - natural position
translateY = position;
} else if (scroll <= pushStartScroll) {
// Stuck at top - translateY increases with scroll
translateY = position + (scroll - stickPoint);
} else {
// Being pushed - translateY stays constant
translateY = translateYAtPushStart;
}
} else {
// Simple sticky without push
const delta = Math.max(0, scroll - stickPoint);
translateY = position + delta;
}

return horizontal ? { transform: [{ translateX: translateY }] } : { transform: [{ translateY }] };
}, [horizontal, position, stickPoint, hasPushBehavior, pushStartScroll, translateYAtPushStart]);

const viewStyle = React.useMemo(
() => [style, { zIndex: index + 1000 }, transformStyle],
Expand All @@ -138,6 +185,8 @@ interface StickyPositionComponentInternalProps {
onLayout: (event: LayoutChangeEvent) => void;
index: number;
stickyHeaderConfig?: StickyHeaderConfig;
stickyNextPosition?: number;
stickySize?: number;
children: React.ReactNode;
}

Expand Down
31 changes: 30 additions & 1 deletion src/state/state.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ export type ListenerType =
| `containerItemData${number}`
| `containerItemKey${number}`
| `containerPosition${number}`
| `containerSticky${number}`;
| `containerSticky${number}`
| `containerStickyNextPosition${number}`
| `containerStickySize${number}`;

export type LegendListListenerType = Extract<
ListenerType,
Expand Down Expand Up @@ -100,6 +102,10 @@ export type ListenerTypeValueMap = {
[K in ListenerType as K extends `containerSpan${number}` ? K : never]: number;
} & {
[K in ListenerType as K extends `containerSticky${number}` ? K : never]: boolean;
} & {
[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 Expand Up @@ -363,6 +369,29 @@ export function useArr$<
ListenerTypeValueMap[T7],
ListenerTypeValueMap[T8],
];
export function useArr$<
T1 extends ListenerType,
T2 extends ListenerType,
T3 extends ListenerType,
T4 extends ListenerType,
T5 extends ListenerType,
T6 extends ListenerType,
T7 extends ListenerType,
T8 extends ListenerType,
T9 extends ListenerType,
>(
signalNames: [T1, T2, T3, T4, T5, T6, T7, T8, T9],
): [
ListenerTypeValueMap[T1],
ListenerTypeValueMap[T2],
ListenerTypeValueMap[T3],
ListenerTypeValueMap[T4],
ListenerTypeValueMap[T5],
ListenerTypeValueMap[T6],
ListenerTypeValueMap[T7],
ListenerTypeValueMap[T8],
ListenerTypeValueMap[T9],
];
export function useArr$<T extends ListenerType>(signalNames: T[]): ListenerTypeValueMap[T][] {
const ctx = React.useContext(ContextState)!;
const { subscribe, get } = React.useMemo(() => createSelectorFunctionsArr(ctx, signalNames), [ctx, signalNames]);
Expand Down