import { interpolate } from "react-native-reanimated"; import type { KeyboardLiftBehavior } from "./types"; const AT_END_THRESHOLD = 20; /** * Map the current keyboard height to an effective height that accounts for a * fixed offset (e.g. Bottom safe-area or tab-bar height).. * * @param height - Current keyboard height. * @param targetKeyboardHeight - Full target keyboard height (captured on keyboard open). * @param offset - Fixed distance between the scroll-view bottom and the screen bottom. * @returns Effective height after subtracting the offset proportionally. * @example * ```ts * getEffectiveHeight(300, 300, 50); // 250 * getEffectiveHeight(150, 300, 50); // 125 * ``` */ export const getEffectiveHeight = ( height: number, targetKeyboardHeight: number, offset: number, ): number => { "worklet"; if (offset === 0 || targetKeyboardHeight === 0) { return height; } return interpolate( height, [0, targetKeyboardHeight], [0, Math.max(targetKeyboardHeight - offset, 0)], ); }; /** * Check whether the scroll view is at the end of its content. * * For non-inverted lists the "end" is the bottom of the content. * For inverted lists the "end" is the top (scroll offset near 0), * because that is where the latest messages are displayed. * * @param scrollOffset - Current vertical scroll offset. * @param layoutHeight - Visible height of the scroll view. * @param contentHeight - Total height of the scrollable content. * @param inverted - Whether the list is inverted. * @returns `true` if the scroll position is within the threshold of the content end. * @example * ```ts * const atEnd = isScrollAtEnd(100, 800, 920); // true (100 + 800 >= 920 - 20) * const atEndInverted = isScrollAtEnd(5, 800, 2000, true); // true (5 <= 20) * ``` */ export const isScrollAtEnd = ( scrollOffset: number, layoutHeight: number, contentHeight: number, inverted: boolean = false, ): boolean => { "worklet"; if (inverted) { return scrollOffset <= AT_END_THRESHOLD; } return scrollOffset + layoutHeight >= contentHeight - AT_END_THRESHOLD; }; /** * Decide whether content should be shifted based on the keyboard lift behavior. * * @param behavior - The configured keyboard lift behavior. * @param isAtEnd - Whether the scroll view is currently at the end. * @returns `true` if content should be shifted. * @example * ```ts * shouldShiftContent("always", false); // true * shouldShiftContent("whenAtEnd", false); // false * ``` */ export const shouldShiftContent = ( behavior: KeyboardLiftBehavior, isAtEnd: boolean, ): boolean => { "worklet"; switch (behavior) { case "always": return true; case "never": return false; case "whenAtEnd": return isAtEnd; case "persistent": return true; } }; /** * Compute the fraction of minimum padding space currently visible in the viewport (0–1). * * The minimum padding space lives in the scroll view's contentInset, NOT in the * content itself. So `contentHeight` (from onContentSizeChange / scroll * events) does **not** include it. The visible portion is how far the * viewport extends past the content boundary into the inset area. * * For non-inverted lists the padding is in contentInset.bottom. * For inverted lists the padding is in contentInset.top (negative scroll). * * @param scrollOffset - Current vertical scroll offset. * @param layoutHeight - Visible height of the scroll view. * @param contentHeight - Height of the scroll content (excludes insets). * @param blankSpace - Size of the minimum padding inset area. * @param inverted - Whether the list is inverted. * @returns A value between 0 (padding fully off-screen) and 1 (padding fully visible). * @example * ```ts * // Non-inverted: contentHeight=1500, layout=800, blankSpace=300 * getVisibleMinimumPaddingFraction(1500, 800, 1500, 300, false); // 1 (at end, viewport past content) * getVisibleMinimumPaddingFraction(850, 800, 1500, 300, false); // 0.5 (half padding visible) * getVisibleMinimumPaddingFraction(700, 800, 1500, 300, false); // 0 (padding off-screen) * ``` */ export const getVisibleMinimumPaddingFraction = ( scrollOffset: number, layoutHeight: number, contentHeight: number, blankSpace: number, inverted: boolean, ): number => { "worklet"; if (blankSpace <= 0) { return 0; } if (inverted) { // Minimum padding is in contentInset.top; visible when scroll < 0 return Math.max(0, Math.min(1, -scrollOffset / blankSpace)); } // Minimum padding is in contentInset.bottom; visible when viewport extends past content const pastContentEnd = scrollOffset + layoutHeight - contentHeight; return Math.max(0, Math.min(1, pastContentEnd / blankSpace)); }; /** * Compute how much of the minimum content padding absorbs the keyboard + extraContentPadding. * * @param blankSpace - Minimum inset floor. * @param extraContentPadding - Extra content padding from external elements. * @returns The portion of blankSpace that absorbs keyboard displacement. * @example * ```ts * getMinimumPaddingAbsorbed(500, 20); // 480 * getMinimumPaddingAbsorbed(0, 20); // 0 * ``` */ export const getMinimumPaddingAbsorbed = ( blankSpace: number, extraContentPadding: number, ): number => { "worklet"; return Math.max(0, blankSpace - extraContentPadding); }; /** * Compute the effective scroll displacement after minimum padding absorption. * * @param rawEffective - Raw effective keyboard height. * @param minimumPaddingAbsorbed - Amount absorbed by minimum content padding. * @returns The scroll displacement after subtracting the absorbed portion. * @example * ```ts * getScrollEffective(300, 200); // 100 * getScrollEffective(300, 400); // 0 * ``` */ export const getScrollEffective = ( rawEffective: number, minimumPaddingAbsorbed: number, ): number => { "worklet"; return Math.max(0, rawEffective - minimumPaddingAbsorbed); }; /** * Compute the clamped scroll target for non-inverted lists. * * @param offsetBeforeScroll - Scroll position before keyboard appeared. * @param keyboardHeight - Current keyboard height (used for scroll displacement). * @param contentHeight - Total height of the scrollable content. * @param layoutHeight - Visible height of the scroll view. * @param totalPaddingForMaxScroll - Total padding to use for maxScroll calculation. When provided, used instead of keyboardHeight for the scrollable range. Defaults to keyboardHeight. * @returns Clamped scroll target between 0 and maxScroll. * @example * ```ts * clampedScrollTarget(100, 300, 1000, 800); // 400 * clampedScrollTarget(100, 100, 1000, 800, 500); // 200, maxScroll uses 500 * ``` */ export const clampedScrollTarget = ( offsetBeforeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, totalPaddingForMaxScroll?: number, ): number => { "worklet"; const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight; const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0); return Math.min(Math.max(offsetBeforeScroll + keyboardHeight, 0), maxScroll); }; /** * Compute contentOffset.y for iOS lists. * * @param relativeScroll - Scroll position relative to current inset. * @param keyboardHeight - Target keyboard height (used for scroll displacement). * @param contentHeight - Total height of the scrollable content. * @param layoutHeight - Visible height of the scroll view. * @param inverted - Whether the list is inverted. * @param totalPaddingForMaxScroll - Total padding to use for maxScroll calculation. When provided, used instead of keyboardHeight for the scrollable range. Defaults to keyboardHeight. * @returns The absolute contentOffset.y to set. * @example * ```ts * computeIOSContentOffset(100, 300, 1000, 800, false); // 400 * ``` */ export const computeIOSContentOffset = ( relativeScroll: number, keyboardHeight: number, contentHeight: number, layoutHeight: number, inverted: boolean, totalPaddingForMaxScroll?: number, ): number => { "worklet"; const paddingForMax = totalPaddingForMaxScroll !== undefined ? totalPaddingForMaxScroll : keyboardHeight; if (inverted) { const maxScroll = Math.max(contentHeight - layoutHeight, 0); return Math.max( Math.min(relativeScroll - keyboardHeight, maxScroll), -paddingForMax, ); } const maxScroll = Math.max(contentHeight - layoutHeight + paddingForMax, 0); return Math.min(Math.max(keyboardHeight + relativeScroll, 0), maxScroll); };