UNPKG

@shopify/flash-list

Version:

FlashList is a more performant FlatList replacement

202 lines 9.51 kB
import { PlatformConfig } from "../../native/config/PlatformHelper"; import { ConsecutiveNumbers } from "./ConsecutiveNumbers"; export class RVEngagedIndicesTrackerImpl { constructor() { // Current scroll position of the list this.scrollOffset = 0; // Distance to pre-render items before and after the visible viewport (in pixels) this.drawDistance = PlatformConfig.defaultDrawDistance; // Whether to use offset projection to predict the next scroll offset this.enableOffsetProjection = true; // Average render time of the list this.averageRenderTime = 16; // Internal override to disable offset projection this.forceDisableOffsetProjection = false; // Currently rendered item indices (including buffer items) this.engagedIndices = ConsecutiveNumbers.EMPTY; // Buffer distribution multipliers for scroll direction optimization this.smallMultiplier = 0.3; // Used for buffer in the opposite direction of scroll this.largeMultiplier = 0.7; // Used for buffer in the direction of scroll // Circular buffer to track recent scroll velocities for direction detection this.velocityHistory = [0, 0, 0, -0.1, -0.1]; this.velocityIndex = 0; } /** * Updates scroll position and determines which items should be rendered. * Implements a smart buffer system that: * 1. Calculates the visible viewport * 2. Determines optimal buffer distribution based on scroll direction * 3. Adjusts buffer sizes at list boundaries * 4. Returns new indices that need to be rendered */ updateScrollOffset(offset, velocity, layoutManager) { // Update current scroll position this.scrollOffset = offset; // STEP 1: Determine the currently visible viewport const windowSize = layoutManager.getWindowsSize(); const isHorizontal = layoutManager.isHorizontal(); // Update velocity history if (velocity) { this.updateVelocityHistory(isHorizontal ? velocity.x : velocity.y); } // Determine scroll direction to optimize buffer distribution const isScrollingBackward = this.isScrollingBackward(); const viewportStart = this.enableOffsetProjection && !this.forceDisableOffsetProjection ? this.getProjectedScrollOffset(offset, this.averageRenderTime) : offset; // console.log("timeMs", this.averageRenderTime, offset, viewportStart); const viewportSize = isHorizontal ? windowSize.width : windowSize.height; const viewportEnd = viewportStart + viewportSize; // STEP 2: Determine buffer size and distribution // The total extra space where items will be pre-rendered const totalBuffer = this.drawDistance * 2; // Distribute more buffer in the direction of scrolling // When scrolling forward: more buffer after viewport // When scrolling backward: more buffer before viewport const beforeRatio = isScrollingBackward ? this.largeMultiplier : this.smallMultiplier; const afterRatio = isScrollingBackward ? this.smallMultiplier : this.largeMultiplier; const bufferBefore = Math.ceil(totalBuffer * beforeRatio); const bufferAfter = Math.ceil(totalBuffer * afterRatio); // STEP 3: Calculate the extended viewport (visible area + buffers) // The start position with buffer (never less than 0) let extendedStart = Math.max(0, viewportStart - bufferBefore); // If we couldn't apply full buffer at start, calculate how much was unused const unusedStartBuffer = Math.max(0, bufferBefore - viewportStart); // Add any unused start buffer to the end buffer let extendedEnd = viewportEnd + bufferAfter + unusedStartBuffer; // STEP 4: Handle end boundary adjustments // Get the total content size to check for end boundary const layoutSize = layoutManager.getLayoutSize(); const maxPosition = isHorizontal ? layoutSize.width : layoutSize.height; // If we hit the end boundary, redistribute unused buffer to the start if (extendedEnd > maxPosition) { // Calculate unused end buffer and apply it to the start if possible const unusedEndBuffer = extendedEnd - maxPosition; extendedEnd = maxPosition; // Try to extend start position further with the unused end buffer extendedStart = Math.max(0, extendedStart - unusedEndBuffer); } // STEP 5: Get and return the new engaged indices const newEngagedIndices = layoutManager.getVisibleLayouts(extendedStart, extendedEnd); // console.log( // "newEngagedIndices", // newEngagedIndices, // this.scrollOffset, // viewportStart // ); // Only return new indices if they've changed const oldEngagedIndices = this.engagedIndices; this.engagedIndices = newEngagedIndices; return newEngagedIndices.equals(oldEngagedIndices) ? undefined : newEngagedIndices; } /** * Updates the velocity history with a new velocity value. * @param velocity - Current scroll velocity component (x or y) */ updateVelocityHistory(velocity) { this.velocityHistory[this.velocityIndex] = velocity; this.velocityIndex = (this.velocityIndex + 1) % this.velocityHistory.length; } /** * Determines scroll direction by analyzing recent velocity history. * Uses a majority voting system on the last 5 velocity values. * @returns true if scrolling backward (negative direction), false otherwise */ isScrollingBackward() { // should decide based on whether we have more positive or negative values, use for loop let positiveCount = 0; let negativeCount = 0; // eslint-disable-next-line @typescript-eslint/prefer-for-of for (let i = 0; i < this.velocityHistory.length; i++) { if (this.velocityHistory[i] > 0) { positiveCount++; } else if (this.velocityHistory[i] < 0) { negativeCount++; } } return positiveCount < negativeCount; } /** * Calculates the median velocity based on velocity history * Medina works better agains outliers * @returns Median velocity over the recent history */ getMedianVelocity() { // Make a copy of velocity history and sort it const sortedVelocities = [...this.velocityHistory].sort((valueA, valueB) => valueA - valueB); const length = sortedVelocities.length; // If length is odd, return the middle element if (length % 2 === 1) { return sortedVelocities[Math.floor(length / 2)]; } // If length is even, return the average of the two middle elements const midIndex = length / 2; return (sortedVelocities[midIndex - 1] + sortedVelocities[midIndex]) / 2; } /** * Projects the next scroll offset based on median velocity * @param timeMs Time in milliseconds to predict ahead * @returns Projected scroll offset */ getProjectedScrollOffset(offset, timeMs) { const medianVelocity = this.getMedianVelocity(); // Convert time from ms to seconds for velocity calculation // Predict next position: current position + (velocity * time) return offset + medianVelocity * timeMs; } /** * Calculates which items are currently visible in the viewport. * Unlike getEngagedIndices, this doesn't include buffer items. * @param layoutManager - Layout manager to fetch item positions * @returns Indices of items currently visible in the viewport */ computeVisibleIndices(layoutManager) { const windowSize = layoutManager.getWindowsSize(); const isHorizontal = layoutManager.isHorizontal(); // Calculate viewport boundaries const viewportStart = this.scrollOffset; const viewportSize = isHorizontal ? windowSize.width : windowSize.height; const viewportEnd = viewportStart + viewportSize; // Get indices of items currently visible in the viewport const newVisibleIndices = layoutManager.getVisibleLayouts(viewportStart, viewportEnd); return newVisibleIndices; } /** * Returns the currently engaged (rendered) indices. * This includes both visible items and buffer items. * @returns The last computed set of engaged indices */ getEngagedIndices() { return this.engagedIndices; } setScrollDirection(scrollDirection) { if (scrollDirection === "forward") { this.velocityHistory = [0, 0, 0, 0.1, 0.1]; this.velocityIndex = 0; } else { this.velocityHistory = [0, 0, 0, -0.1, -0.1]; this.velocityIndex = 0; } } /** * Resets the velocity history based on the current scroll direction. * This ensures that the velocity history is always in sync with the current scroll direction. */ resetVelocityHistory() { if (this.isScrollingBackward()) { this.setScrollDirection("backward"); } else { this.setScrollDirection("forward"); } } } //# sourceMappingURL=EngagedIndicesTracker.js.map