diff --git a/example/app/countries-with-headers-sticky/index.tsx b/example/app/countries-with-headers-sticky/index.tsx
index bfda6d09..2ff75b7f 100644
--- a/example/app/countries-with-headers-sticky/index.tsx
+++ b/example/app/countries-with-headers-sticky/index.tsx
@@ -155,11 +155,24 @@ const BigCountryItem = ({ item, onPress, isSelected }: BigCountryItemProps) => (
);
-const HeaderItem = ({ item }: { item: Header }) => (
-
- {item.letter}
-
-);
+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 (
+
+ {item.letter}
+ {height >= 80 && Countries starting with {item.letter}}
+ {height >= 50 && height < 80 && Section {item.letter}}
+
+ );
+};
const App = () => {
const [selectedId, setSelectedId] = useState();
@@ -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,
diff --git a/src/components/Container.tsx b/src/components/Container.tsx
index ef42c42b..2d5b5a44 100644
--- a/src/components/Container.tsx
+++ b/src/components/Container.tsx
@@ -36,15 +36,18 @@ export const Container = typedMemo(function Container({
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;
@@ -255,6 +258,8 @@ export const Container = typedMemo(function Container({
onLayout={onLayout}
refView={ref as React.RefObject}
stickyHeaderConfig={stickyHeaderConfig}
+ stickyNextPosition={isSticky ? stickyNextPosition : undefined}
+ stickySize={isSticky ? stickySize : undefined}
style={style as any}
>
diff --git a/src/components/PositionView.native.tsx b/src/components/PositionView.native.tsx
index 7e6f8b25..cc20881d 100644
--- a/src/components/PositionView.native.tsx
+++ b/src/components/PositionView.native.tsx
@@ -81,6 +81,8 @@ const PositionViewSticky = typedMemo(function PositionViewSticky({
style,
refView,
animatedScrollY,
+ stickyNextPosition,
+ stickySize,
index,
stickyHeaderConfig,
children,
@@ -91,6 +93,8 @@ const PositionViewSticky = typedMemo(function PositionViewSticky({
style: StyleProp;
refView: React.RefObject;
animatedScrollY?: Animated.Value;
+ stickyNextPosition?: number;
+ stickySize?: number;
onLayout: (event: LayoutChangeEvent) => void;
index: number;
stickyHeaderConfig?: StickyHeaderConfig;
@@ -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) {
diff --git a/src/components/PositionView.tsx b/src/components/PositionView.tsx
index d59c1100..d639b46f 100644
--- a/src/components/PositionView.tsx
+++ b/src/components/PositionView.tsx
@@ -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;
}) {
diff --git a/src/core/calculateItemsInView.ts b/src/core/calculateItemsInView.ts
index d1a3db0a..ac0c3dad 100644
--- a/src/core/calculateItemsInView.ts
+++ b/src/core/calculateItemsInView.ts
@@ -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;
}
@@ -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);
+ }
+ }
+
+ // 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;
@@ -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);
}
diff --git a/src/integrations/reanimated.tsx b/src/integrations/reanimated.tsx
index 6fbc67a9..79684e1e 100644
--- a/src/integrations/reanimated.tsx
+++ b/src/integrations/reanimated.tsx
@@ -71,6 +71,8 @@ interface ReanimatedPositionViewStickyProps {
index: number;
stickyHeaderConfig?: StickyHeaderConfig;
stickyScrollOffset: Reanimated.SharedValue;
+ stickyNextPosition?: number;
+ stickySize?: number;
children: React.ReactNode;
}
@@ -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],
@@ -138,6 +185,8 @@ interface StickyPositionComponentInternalProps {
onLayout: (event: LayoutChangeEvent) => void;
index: number;
stickyHeaderConfig?: StickyHeaderConfig;
+ stickyNextPosition?: number;
+ stickySize?: number;
children: React.ReactNode;
}
diff --git a/src/state/state.tsx b/src/state/state.tsx
index 967f7f99..eb421ce4 100644
--- a/src/state/state.tsx
+++ b/src/state/state.tsx
@@ -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,
@@ -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 {
@@ -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$(signalNames: T[]): ListenerTypeValueMap[T][] {
const ctx = React.useContext(ContextState)!;
const { subscribe, get } = React.useMemo(() => createSelectorFunctionsArr(ctx, signalNames), [ctx, signalNames]);