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]);