diff --git a/example/app/countries-with-headers-sticky/index.tsx b/example/app/countries-with-headers-sticky/index.tsx index 16203c1d..8e561f21 100644 --- a/example/app/countries-with-headers-sticky/index.tsx +++ b/example/app/countries-with-headers-sticky/index.tsx @@ -155,11 +155,30 @@ 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 +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, diff --git a/src/components/Container.tsx b/src/components/Container.tsx index 64afcf58..335344cb 100644 --- a/src/components/Container.tsx +++ b/src/components/Container.tsx @@ -32,15 +32,18 @@ export const Container = typedMemo(function Container({ 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(null); @@ -185,7 +188,9 @@ export const Container = typedMemo(function Container({ onLayout={onLayout} refView={ref} stickyHeaderConfig={stickyHeaderConfig} + stickyNextPosition={isSticky ? stickyNextPosition : undefined} stickyOffset={isSticky ? stickyOffset : undefined} + stickySize={isSticky ? stickySize : undefined} style={style} > diff --git a/src/components/PositionView.tsx b/src/components/PositionView.tsx index bfb74dca..9ac6928e 100644 --- a/src/components/PositionView.tsx +++ b/src/components/PositionView.tsx @@ -81,6 +81,8 @@ const PositionViewSticky = typedMemo(function PositionViewSticky({ refView, animatedScrollY, stickyOffset, + stickyNextPosition, + stickySize, index, stickyHeaderConfig, children, @@ -92,6 +94,8 @@ const PositionViewSticky = typedMemo(function PositionViewSticky({ refView: React.RefObject; animatedScrollY?: Animated.Value; stickyOffset?: number; + stickyNextPosition?: number; + stickySize?: number; onLayout: (event: LayoutChangeEvent) => void; index: number; children: React.ReactNode; @@ -99,22 +103,82 @@ const PositionViewSticky = typedMemo(function PositionViewSticky({ }) { 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) { diff --git a/src/core/calculateItemsInView.ts b/src/core/calculateItemsInView.ts index 61e711fe..a127105d 100644 --- a/src/core/calculateItemsInView.ts +++ b/src/core/calculateItemsInView.ts @@ -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; } @@ -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); @@ -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}`); + 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 @@ -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); } diff --git a/src/state/state.tsx b/src/state/state.tsx index bd8d5f64..79ef7a5a 100644 --- a/src/state/state.tsx +++ b/src/state/state.tsx @@ -28,6 +28,8 @@ export type ListenerType = | `containerColumn${number}` | `containerSticky${number}` | `containerStickyOffset${number}` + | `containerStickyNextPosition${number}` + | `containerStickySize${number}` | "containersDidLayout" | "extraData" | "numColumns" @@ -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 {