@shopify/flash-list
Version:
FlashList is a more performant FlatList replacement
202 lines • 9.51 kB
JavaScript
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