UNPKG

@shopify/flash-list

Version:

FlashList is a more performant FlatList replacement

526 lines 28.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.useRecyclerViewController = useRecyclerViewController; var tslib_1 = require("tslib"); var react_1 = require("react"); var react_native_1 = require("react-native"); var adjustOffsetForRTL_1 = require("../utils/adjustOffsetForRTL"); var PlatformHelper_1 = require("../../native/config/PlatformHelper"); var WarningMessages_1 = require("../../errors/WarningMessages"); var useUnmountFlag_1 = require("./useUnmountFlag"); var useUnmountAwareCallbacks_1 = require("./useUnmountAwareCallbacks"); /** * Comprehensive hook that manages RecyclerView scrolling behavior and provides * imperative methods for controlling the RecyclerView. * * This hook combines content offset management and scroll handling functionality: * 1. Provides imperative methods for scrolling and measurement * 2. Handles initial scroll position when the list first loads * 3. Maintains visible content position during updates * 4. Manages scroll anchors for chat-like applications * * @param recyclerViewManager - The RecyclerViewManager instance that handles core functionality * @param ref - The ref to expose the imperative methods * @param scrollViewRef - Reference to the scrollable container component * @param scrollAnchorRef - Reference to the scroll anchor component * @param props - The RecyclerViewProps containing configuration */ function useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef) { var _this = this; var isUnmounted = (0, useUnmountFlag_1.useUnmountFlag)(); var _a = tslib_1.__read((0, react_1.useState)(0), 2), _ = _a[0], setRenderId = _a[1]; var pauseOffsetCorrection = (0, react_1.useRef)(false); var initialScrollCompletedRef = (0, react_1.useRef)(false); var lastDataLengthRef = (0, react_1.useRef)(recyclerViewManager.getDataLength()); var setTimeout = (0, useUnmountAwareCallbacks_1.useUnmountAwareTimeout)().setTimeout; // Track the first visible item for maintaining scroll position var firstVisibleItemKey = (0, react_1.useRef)(undefined); var firstVisibleItemLayout = (0, react_1.useRef)(undefined); // Queue to store callbacks that should be executed after scroll offset updates var pendingScrollCallbacks = (0, react_1.useRef)([]); // Handle initial scroll position when the list first loads // useOnLoad(recyclerViewManager, () => { // }); /** * Updates the scroll offset and calls the provided callback * after the update has been applied and the component has re-rendered. * * @param offset - The new scroll offset to apply * @param callback - Optional callback to execute after the update is applied */ var updateScrollOffsetWithCallback = (0, react_1.useCallback)(function (offset, callback) { // Attempt to update the scroll offset in the RecyclerViewManager // This returns undefined if no update is needed if (recyclerViewManager.updateScrollOffset(offset) !== undefined) { // It will be executed after the next render pendingScrollCallbacks.current.push(callback); // Trigger a re-render to apply the scroll offset update setRenderId(function (prev) { return prev + 1; }); } else { // No update needed, execute callback immediately callback(); } }, [recyclerViewManager]); var computeFirstVisibleIndexForOffsetCorrection = (0, react_1.useCallback)(function () { if (recyclerViewManager.getIsFirstLayoutComplete() && recyclerViewManager.hasStableDataKeys() && recyclerViewManager.getDataLength() > 0 && recyclerViewManager.shouldMaintainVisibleContentPosition()) { // Update the tracked first visible item var firstVisibleIndex = Math.max(0, recyclerViewManager.computeVisibleIndices().startIndex); if (firstVisibleIndex !== undefined && firstVisibleIndex >= 0) { firstVisibleItemKey.current = recyclerViewManager.getDataKey(firstVisibleIndex); firstVisibleItemLayout.current = tslib_1.__assign({}, recyclerViewManager.getLayout(firstVisibleIndex)); } } }, [recyclerViewManager]); /** * Maintains the visible content position when the list updates. * This is particularly useful for chat applications where we want to keep * the user's current view position when new messages are added. */ var applyOffsetCorrection = (0, react_1.useCallback)(function () { var _a, _b, _c; var _d = recyclerViewManager.props, horizontal = _d.horizontal, data = _d.data; // Execute all pending callbacks from previous scroll offset updates // This ensures any scroll operations that were waiting for render are completed var callbacks = pendingScrollCallbacks.current; pendingScrollCallbacks.current = []; callbacks.forEach(function (callback) { return callback(); }); var currentDataLength = recyclerViewManager.getDataLength(); if (recyclerViewManager.getIsFirstLayoutComplete() && recyclerViewManager.hasStableDataKeys() && currentDataLength > 0 && recyclerViewManager.shouldMaintainVisibleContentPosition()) { var hasDataChanged = currentDataLength !== lastDataLengthRef.current; // If we have a tracked first visible item, maintain its position if (firstVisibleItemKey.current) { var currentIndexOfFirstVisibleItem = (_a = recyclerViewManager .getEngagedIndices() .findValue(function (index) { return recyclerViewManager.getDataKey(index) === firstVisibleItemKey.current; })) !== null && _a !== void 0 ? _a : (hasDataChanged ? data === null || data === void 0 ? void 0 : data.findIndex(function (item, index) { return recyclerViewManager.getDataKey(index) === firstVisibleItemKey.current; }) : undefined); if (currentIndexOfFirstVisibleItem !== undefined && currentIndexOfFirstVisibleItem >= 0) { // Calculate the difference in position and apply the offset var diff = horizontal ? recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).x - firstVisibleItemLayout.current.x : recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).y - firstVisibleItemLayout.current.y; firstVisibleItemLayout.current = tslib_1.__assign({}, recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem)); if (diff !== 0 && !pauseOffsetCorrection.current && !recyclerViewManager.animationOptimizationsEnabled) { // console.log("diff", diff, firstVisibleItemKey.current); if (PlatformHelper_1.PlatformConfig.supportsOffsetCorrection) { // console.log("scrollBy", diff); (_b = scrollAnchorRef.current) === null || _b === void 0 ? void 0 : _b.scrollBy(diff); } else { var scrollToParams = horizontal ? { x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, animated: false, } : { y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff, animated: false, }; (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams); } if (hasDataChanged) { updateScrollOffsetWithCallback(recyclerViewManager.getAbsoluteLastScrollOffset() + diff, function () { }); recyclerViewManager.ignoreScrollEvents = true; setTimeout(function () { recyclerViewManager.ignoreScrollEvents = false; }, 100); } } } } computeFirstVisibleIndexForOffsetCorrection(); } lastDataLengthRef.current = recyclerViewManager.getDataLength(); }, [ recyclerViewManager, scrollAnchorRef, scrollViewRef, setTimeout, updateScrollOffsetWithCallback, computeFirstVisibleIndexForOffsetCorrection, ]); var handlerMethods = (0, react_1.useMemo)(function () { return { get props() { return recyclerViewManager.props; }, /** * Scrolls the list to a specific offset position. * Handles RTL layouts and first item offset adjustments. */ scrollToOffset: function (_a) { var offset = _a.offset, animated = _a.animated, _b = _a.skipFirstItemOffset, skipFirstItemOffset = _b === void 0 ? true : _b; var horizontal = recyclerViewManager.props.horizontal; if (scrollViewRef.current) { // Adjust offset for RTL layouts in horizontal mode if (react_native_1.I18nManager.isRTL && horizontal) { // eslint-disable-next-line no-param-reassign offset = (0, adjustOffsetForRTL_1.adjustOffsetForRTL)(offset, recyclerViewManager.getChildContainerDimensions().width, recyclerViewManager.getWindowSize().width) + (skipFirstItemOffset ? recyclerViewManager.firstItemOffset : -recyclerViewManager.firstItemOffset); } // Calculate the final offset including first item offset if needed var adjustedOffset = offset + (skipFirstItemOffset ? 0 : recyclerViewManager.firstItemOffset); var scrollTo_1 = horizontal ? { x: adjustedOffset, y: 0 } : { x: 0, y: adjustedOffset }; scrollViewRef.current.scrollTo(tslib_1.__assign(tslib_1.__assign({}, scrollTo_1), { animated: animated })); } }, clearLayoutCacheOnUpdate: function () { recyclerViewManager.markLayoutManagerDirty(); }, // Expose native scroll view methods flashScrollIndicators: function () { scrollViewRef.current.flashScrollIndicators(); }, getNativeScrollRef: function () { return scrollViewRef.current; }, getScrollResponder: function () { return scrollViewRef.current.getScrollResponder(); }, getScrollableNode: function () { return scrollViewRef.current.getScrollableNode(); }, /** * Scrolls to the end of the list. */ scrollToEnd: function () { var args_1 = []; for (var _i = 0; _i < arguments.length; _i++) { args_1[_i] = arguments[_i]; } return tslib_1.__awaiter(_this, tslib_1.__spreadArray([], tslib_1.__read(args_1), false), void 0, function (_a) { var data, lastIndex; var _b = _a === void 0 ? {} : _a, animated = _b.animated; return tslib_1.__generator(this, function (_c) { switch (_c.label) { case 0: data = recyclerViewManager.props.data; if (!(data && data.length > 0)) return [3 /*break*/, 2]; lastIndex = data.length - 1; if (!!recyclerViewManager.getEngagedIndices().includes(lastIndex)) return [3 /*break*/, 2]; return [4 /*yield*/, handlerMethods.scrollToIndex({ index: lastIndex, animated: animated, })]; case 1: _c.sent(); _c.label = 2; case 2: setTimeout(function () { scrollViewRef.current.scrollToEnd({ animated: animated }); }, 0); return [2 /*return*/]; } }); }); }, /** * Scrolls to the beginning of the list. */ scrollToTop: function (_a) { var _b = _a === void 0 ? {} : _a, animated = _b.animated; handlerMethods.scrollToOffset({ offset: 0, animated: animated, }); }, /** * Scrolls to a specific index in the list. * Supports viewPosition and viewOffset for precise positioning. * Returns a Promise that resolves when the scroll is complete. */ scrollToIndex: function (_a) { var index = _a.index, animated = _a.animated, viewPosition = _a.viewPosition, viewOffset = _a.viewOffset; return new Promise(function (resolve) { var horizontal = recyclerViewManager.props.horizontal; if (scrollViewRef.current && index >= 0 && index < recyclerViewManager.getDataLength()) { // Pause the scroll offset adjustments pauseOffsetCorrection.current = true; recyclerViewManager.setOffsetProjectionEnabled(false); var getFinalOffset_1 = function () { var layout = recyclerViewManager.getLayout(index); var offset = horizontal ? layout.x : layout.y; var finalOffset = offset; // take viewPosition etc into account if (viewPosition !== undefined || viewOffset !== undefined) { var containerSize = horizontal ? recyclerViewManager.getWindowSize().width : recyclerViewManager.getWindowSize().height; var itemSize = horizontal ? layout.width : layout.height; if (viewPosition !== undefined) { // viewPosition: 0 = top, 0.5 = center, 1 = bottom finalOffset = offset - (containerSize - itemSize) * viewPosition; } if (viewOffset !== undefined) { finalOffset += viewOffset; } } return finalOffset + recyclerViewManager.firstItemOffset; }; var lastAbsoluteScrollOffset_1 = recyclerViewManager.getAbsoluteLastScrollOffset(); var bufferForScroll = horizontal ? recyclerViewManager.getWindowSize().width : recyclerViewManager.getWindowSize().height; var bufferForCompute_1 = bufferForScroll * 2; var getStartScrollOffset_1 = function () { var lastScrollOffset = lastAbsoluteScrollOffset_1; var finalOffset = getFinalOffset_1(); if (finalOffset > lastScrollOffset) { lastScrollOffset = Math.max(finalOffset - bufferForCompute_1, lastScrollOffset); recyclerViewManager.setScrollDirection("forward"); } else { lastScrollOffset = Math.min(finalOffset + bufferForCompute_1, lastScrollOffset); recyclerViewManager.setScrollDirection("backward"); } return lastScrollOffset; }; var initialTargetOffset_1 = getFinalOffset_1(); var initialStartScrollOffset_1 = getStartScrollOffset_1(); var finalOffset_1 = initialTargetOffset_1; var startScrollOffset_1 = initialStartScrollOffset_1; var steps_1 = 5; /** * Recursively performs the scroll animation steps. * This function replaces the async/await loop with callback-based execution. * * @param currentStep - The current step in the animation (0 to steps-1) */ var performScrollStep_1 = function (currentStep) { // Check if component is unmounted or we've completed all steps if (isUnmounted.current) { resolve(); return; } else if (currentStep >= steps_1) { // All steps completed, perform final scroll finishScrollToIndex_1(); return; } // Calculate the offset for this step // For animated scrolls: interpolate from finalOffset to startScrollOffset // For non-animated: interpolate from startScrollOffset to finalOffset var nextOffset = animated ? finalOffset_1 + (startScrollOffset_1 - finalOffset_1) * (currentStep / (steps_1 - 1)) : startScrollOffset_1 + (finalOffset_1 - startScrollOffset_1) * (currentStep / (steps_1 - 1)); // Update scroll offset with a callback to continue to the next step updateScrollOffsetWithCallback(nextOffset, function () { // Check if the index is still valid after the update if (index >= recyclerViewManager.getDataLength()) { // Index out of bounds, scroll to end instead handlerMethods.scrollToEnd({ animated: animated }); resolve(); // Resolve the promise as we're done return; } // Check if the target position has changed significantly var newFinalOffset = getFinalOffset_1(); if ((newFinalOffset < initialTargetOffset_1 && newFinalOffset < initialStartScrollOffset_1) || (newFinalOffset > initialTargetOffset_1 && newFinalOffset > initialStartScrollOffset_1)) { // Target has moved, recalculate and restart from beginning finalOffset_1 = newFinalOffset; startScrollOffset_1 = getStartScrollOffset_1(); initialTargetOffset_1 = newFinalOffset; initialStartScrollOffset_1 = startScrollOffset_1; performScrollStep_1(0); // Restart from step 0 } else { // Continue to next step performScrollStep_1(currentStep + 1); } }); }; /** * Completes the scroll to index operation by performing the final scroll * and re-enabling offset correction after a delay. */ var finishScrollToIndex_1 = function () { finalOffset_1 = getFinalOffset_1(); var maxOffset = recyclerViewManager.getMaxScrollOffset(); if (finalOffset_1 > maxOffset) { finalOffset_1 = maxOffset; } if (animated) { // For animated scrolls, first jump to the start position // We don't need to add firstItemOffset here as it's already added handlerMethods.scrollToOffset({ offset: startScrollOffset_1, animated: false, skipFirstItemOffset: true, }); } // Perform the final scroll to the target position handlerMethods.scrollToOffset({ offset: finalOffset_1, animated: animated, skipFirstItemOffset: true, }); // Re-enable offset correction after a delay // Longer delay for animated scrolls to allow animation to complete setTimeout(function () { pauseOffsetCorrection.current = false; recyclerViewManager.setOffsetProjectionEnabled(true); resolve(); // Resolve the promise after re-enabling corrections }, animated ? 300 : 200); }; // Start the scroll animation process performScrollStep_1(0); } else { // Invalid parameters, resolve immediately resolve(); } }); }, /** * Scrolls to a specific item in the list. * Finds the item's index and uses scrollToIndex internally. */ scrollToItem: function (_a) { var item = _a.item, animated = _a.animated, viewPosition = _a.viewPosition, viewOffset = _a.viewOffset; var data = recyclerViewManager.props.data; if (scrollViewRef.current && data) { // Find the index of the item in the data array var index = data.findIndex(function (dataItem) { return dataItem === item; }); if (index >= 0) { handlerMethods.scrollToIndex({ index: index, animated: animated, viewPosition: viewPosition, viewOffset: viewOffset, }); } } }, // Utility methods for measuring header height / top padding getFirstItemOffset: function () { return recyclerViewManager.firstItemOffset; }, getWindowSize: function () { return recyclerViewManager.getWindowSize(); }, getLayout: function (index) { return recyclerViewManager.tryGetLayout(index); }, getAbsoluteLastScrollOffset: function () { return recyclerViewManager.getAbsoluteLastScrollOffset(); }, getChildContainerDimensions: function () { return recyclerViewManager.getChildContainerDimensions(); }, recordInteraction: function () { recyclerViewManager.recordInteraction(); }, computeVisibleIndices: function () { return recyclerViewManager.computeVisibleIndices(); }, getFirstVisibleIndex: function () { return recyclerViewManager.computeVisibleIndices().startIndex; }, recomputeViewableItems: function () { recyclerViewManager.recomputeViewableItems(); }, /** * Disables item recycling in preparation for layout animations. */ prepareForLayoutAnimationRender: function () { if (!recyclerViewManager.props.keyExtractor) { console.warn(WarningMessages_1.WarningMessages.keyExtractorNotDefinedForAnimation); } recyclerViewManager.animationOptimizationsEnabled = true; }, }; }, [ recyclerViewManager, scrollViewRef, setTimeout, isUnmounted, updateScrollOffsetWithCallback, ]); var applyInitialScrollIndex = (0, react_1.useCallback)(function () { var _a, _b; var _c = recyclerViewManager.props, horizontal = _c.horizontal, data = _c.data; var initialScrollIndex = (_a = recyclerViewManager.getInitialScrollIndex()) !== null && _a !== void 0 ? _a : -1; var dataLength = (_b = data === null || data === void 0 ? void 0 : data.length) !== null && _b !== void 0 ? _b : 0; if (initialScrollIndex >= 0 && initialScrollIndex < dataLength && !initialScrollCompletedRef.current && recyclerViewManager.getIsFirstLayoutComplete()) { // Use setTimeout to ensure that we keep trying to scroll on first few renders setTimeout(function () { initialScrollCompletedRef.current = true; pauseOffsetCorrection.current = false; }, 100); pauseOffsetCorrection.current = true; var offset_1 = horizontal ? recyclerViewManager.getLayout(initialScrollIndex).x : recyclerViewManager.getLayout(initialScrollIndex).y; handlerMethods.scrollToOffset({ offset: offset_1, animated: false, skipFirstItemOffset: false, }); setTimeout(function () { handlerMethods.scrollToOffset({ offset: offset_1, animated: false, skipFirstItemOffset: false, }); }, 0); } }, [handlerMethods, recyclerViewManager, setTimeout]); // Expose imperative methods through the ref (0, react_1.useImperativeHandle)(ref, function () { var imperativeApi = tslib_1.__assign(tslib_1.__assign({}, scrollViewRef.current), handlerMethods); // Without this the props getter from handlerMethods is evaluated during spread and // future updates to props are not reflected in the ref Object.defineProperty(imperativeApi, "props", { get: function () { return recyclerViewManager.props; }, enumerable: true, configurable: true, }); return imperativeApi; }, [handlerMethods, scrollViewRef, recyclerViewManager]); return { applyOffsetCorrection: applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection: computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex: applyInitialScrollIndex, handlerMethods: handlerMethods, }; } //# sourceMappingURL=useRecyclerViewController.js.map