Skip to content

Commit

Permalink
Merge pull request #11 from azimgd/optimize-state
Browse files Browse the repository at this point in the history
Performance enhancements
  • Loading branch information
azimgd authored Nov 27, 2024
2 parents 3b14bed + 32acb42 commit e42ddd8
Show file tree
Hide file tree
Showing 36 changed files with 2,952 additions and 1,005 deletions.
21 changes: 15 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,34 @@ ShadowList is a new alternative to FlatList for React Native, created to address
It invokes Yoga for precise layout measurements of Shadow Nodes and constructs a Fenwick Tree with layout metrics for efficient offset calculations. By virtualizing children and rendering only items within the visible area, ShadowList ensures optimal performance. It's built on Fabric and works with React Native version 0.75 and newer.

## Out of box comparison to FlatList
| Feature | ShadowList | FlatList |
| Feature | ShadowList | FlatList / FlashList |
|----------------------------------|--------------|------------|
| 60 FPS Scrolling |||
| No Estimated Size required |||
| No Content Flashing |||
| No Sidebar Indicator Jump |||
| Fast initialScrollIndex |||
| Native Bidirectional List |||
| Instant initialScrollIndex |||
| Instant initialScrollIndex |||
| Nested ShadowList (ScrollView) |||
| Natively Inverted List Support |||
| Smooth Scrolling |||

## Scroll Performance
| Number of Items | ShadowList | FlatList | FlashList |
|------------------|----------------------------|----------------------|----------------------|
| 100 (text only) | **156mb memory - 60fps** | 195mb (38-43fps) | ~~180mb (56fps)~~* |
| 1000 (text only) | **187mb memory - 60fps** | 200mb (33-38fps) | ~~180mb (56fps)~~* |
| 100 (text only) | **~~156mb memory~~ - 60fps** | ~~195mb~~ (38-43fps) | ~~180mb (56fps)~~* |
| 1000 (text only) | **~~187mb memory~~ - 60fps** | ~~200mb~~ (33-38fps) | ~~180mb (56fps)~~* |

> **FlashList is unreliable and completely breaks when scrolling, resulting in unrealistic metrics.*
> Given measurements show memory usage and FPS on fully loaded content, see demo [here](https://github.com/azimgd/shadowlist/issues/1) and implementation details [here](https://github.com/azimgd/shadowlist/blob/main/example/src/App.tsx).
## Note on Performance Considerations

ShadowList initiates ShadowNode creation for each child. This process can be slower when rendering a large number of items at once, which may impact performance compared to purely JS-based solutions. However, once the children are measured, it performs real-time virtualization ensuring smooth, flicker-free scrolling.

One temporary way to mitigate this is by implementing list pagination until the [following problem is addressed](https://github.com/reactwg/react-native-new-architecture/discussions/223).

## Installation
- CLI: Add the package to your project via `yarn add shadowlist` and run `pod install` in the `ios` directory.
- Expo: Add the package to your project via `npx expo install shadowlist` and run `npx expo prebuild` in the root directory.
Expand All @@ -31,9 +40,9 @@ It invokes Yoga for precise layout measurements of Shadow Nodes and constructs a
## Usage

```js
import {SLContainer} from 'shadowlist';
import {Shadowlist} from 'shadowlist';

<SLContainer
<Shadowlist
contentContainerStyle={styles.container}
ref={shadowListContainerRef}
data={data}
Expand Down
159 changes: 105 additions & 54 deletions android/src/main/java/com/shadowlist/SLContainer.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.shadowlist;

import android.content.Context;
import android.os.Handler;
import android.widget.HorizontalScrollView;
import android.widget.ScrollView;
import android.view.View;
Expand All @@ -9,7 +10,6 @@
import androidx.annotation.Nullable;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;

import com.facebook.react.bridge.WritableNativeMap;
import com.facebook.react.common.mapbuffer.MapBuffer;
import com.facebook.react.uimanager.PixelUtil;
import com.facebook.react.uimanager.StateWrapper;
Expand All @@ -24,6 +24,10 @@ public class SLContainer extends ReactViewGroup {
private ReactViewGroup mScrollContent;
private SLScrollable mScrollable;
private SLContainerChildrenManager mContainerChildrenManager;
private SLFenwickTree mChildrenMeasurements;
private SLContainerManager.OnStartReachedHandler mOnStartReachedHandler;
private SLContainerManager.OnEndReachedHandler mOnEndReachedHandler;
private SLContainerManager.OnVisibleChangeHandler mOnVisibleChangeHandler;

private @Nullable StateWrapper mStateWrapper = null;

Expand All @@ -50,33 +54,10 @@ private void init(Context context) {
SwipeRefreshLayout.OnRefreshListener refreshListener = () -> {
};
OnScrollChangeListener scrollListenerVertical = (v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
/**
* Disable optimization when nearing the end of the list to ensure scrollPosition remains in sync.
* Required to prevent content shifts when adding items to the list.
*/
if (
false &&
mScrollable.shouldNotifyStart(new float[]{scrollX, scrollY}) == 0 &&
mScrollable.shouldNotifyEnd(new float[]{scrollX, scrollY}) == 0 &&
!mScrollable.shouldUpdate(new float[]{scrollX, scrollY})
) {
return;
}

WritableNativeMap mapBuffer = new WritableNativeMap();
mapBuffer.putDouble("scrollPositionTop", PixelUtil.toDIPFromPixel(scrollY));
mapBuffer.putDouble("scrollPositionLeft", PixelUtil.toDIPFromPixel(scrollX));
mStateWrapper.updateState(mapBuffer);
this.updateVirtualization();
};
OnScrollChangeListener scrollListenerHorizontal = (v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
if (!mScrollable.shouldUpdate(new float[]{scrollX, scrollY})) {
return;
}

WritableNativeMap mapBuffer = new WritableNativeMap();
mapBuffer.putDouble("scrollPositionTop", PixelUtil.toDIPFromPixel(scrollY));
mapBuffer.putDouble("scrollPositionLeft", PixelUtil.toDIPFromPixel(scrollX));
mStateWrapper.updateState(mapBuffer);
this.updateVirtualization();
};
mScrollContainerVertical.setOnScrollChangeListener(scrollListenerVertical);
mScrollContainerVertical.setVerticalScrollBarEnabled(true);
Expand All @@ -86,6 +67,45 @@ private void init(Context context) {
mScrollContainerRefreshHorizontal.setOnRefreshListener(refreshListener);
}

public void updateVirtualization() {
if (mStateWrapper == null) return;

MapBuffer stateMapBuffer = mStateWrapper.getStateDataMapBuffer();

float[] scrollPosition = new float[]{
PixelUtil.toDIPFromPixel(this.mScrollContainerVertical.getScrollX()),
PixelUtil.toDIPFromPixel(this.mScrollContainerVertical.getScrollY())};

int visibleStartIndex = mChildrenMeasurements.adjustVisibleStartIndex(
mChildrenMeasurements.lowerBound(mScrollable.getVisibleStartOffset(scrollPosition)),
stateMapBuffer.getInt(SLContainerManager.SLCONTAINER_STATE_CHILDREN_MEASUREMENTS_TREE_SIZE)
);
int visibleEndIndex = mChildrenMeasurements.adjustVisibleEndIndex(
mChildrenMeasurements.lowerBound(mScrollable.getVisibleEndOffset(scrollPosition)),
stateMapBuffer.getInt(SLContainerManager.SLCONTAINER_STATE_CHILDREN_MEASUREMENTS_TREE_SIZE)
);
mContainerChildrenManager.mount(
visibleStartIndex,
visibleEndIndex
);

updateObservers(scrollPosition, visibleStartIndex, visibleEndIndex);
}

public void updateObservers(float[] scrollPosition, int visibleStartIndex, int visibleEndIndex) {
mOnVisibleChangeHandler.onVisibleChange(this, visibleStartIndex, visibleEndIndex);

int distanceFromStart = mScrollable.shouldNotifyStart(scrollPosition);
if (distanceFromStart > 0) {
mOnStartReachedHandler.onStartReached(this, distanceFromStart);
}

int distanceFromEnd = mScrollable.shouldNotifyEnd(scrollPosition);
if (distanceFromEnd > 0) {
mOnEndReachedHandler.onEndReached(this, distanceFromEnd);
}
}

public void setScrollContainerHorizontal() {
if (mOrientation) return;
mOrientation = true;
Expand Down Expand Up @@ -129,6 +149,11 @@ public void addView(View child, int index) {
mContainerChildrenManager.mountChildComponentView(child, ((SLElement)child).getUniqueId());
}

@Override
public void removeView(View child) {
mContainerChildrenManager.mountChildComponentView(child, ((SLElement)child).getUniqueId());
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), MeasureSpec.getSize(heightMeasureSpec));
Expand All @@ -146,6 +171,26 @@ public void setStateWrapper(
SLContainerManager.OnVisibleChangeHandler onVisibleChangeHandler) {
MapBuffer stateMapBuffer = stateWrapper.getStateDataMapBuffer();

float[] childrenMeasurements = new float[stateMapBuffer.getInt(SLContainerManager.SLCONTAINER_STATE_CHILDREN_MEASUREMENTS_TREE_SIZE)];
for (int i = 0; i < stateMapBuffer.getInt(SLContainerManager.SLCONTAINER_STATE_CHILDREN_MEASUREMENTS_TREE_SIZE); i++) {
childrenMeasurements[i] = (float) stateMapBuffer.getMapBuffer(SLContainerManager.SLCONTAINER_STATE_CHILDREN_MEASUREMENTS_TREE).getDouble(i);
}

mChildrenMeasurements = new SLFenwickTree(childrenMeasurements);

mScrollable.updateState(
stateMapBuffer.getBoolean(SLContainerManager.SLCONTAINER_STATE_HORIZONTAL),
false,
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_CONTAINER_WIDTH),
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_CONTAINER_HEIGHT),
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_CONTENT_WIDTH),
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_CONTENT_HEIGHT)
);

float[] scrollPosition = new float[]{
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_POSITION_LEFT) + PixelUtil.toDIPFromPixel(mScrollContainerVertical.getScrollX()),
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_POSITION_TOP) + PixelUtil.toDIPFromPixel(mScrollContainerVertical.getScrollY())};

this.setScrollContainerLayout(
(int)stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_CONTAINER_WIDTH),
(int)stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_CONTAINER_HEIGHT)
Expand All @@ -161,43 +206,49 @@ public void setStateWrapper(
(int)stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_POSITION_TOP)
);

mScrollable.updateState(
stateMapBuffer.getBoolean(SLContainerManager.SLCONTAINER_STATE_HORIZONTAL),
false,
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_VISIBLE_START_TRIGGER),
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_VISIBLE_END_TRIGGER),
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_CONTAINER_WIDTH),
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_CONTAINER_HEIGHT),
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_CONTENT_WIDTH),
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_CONTENT_HEIGHT)
int visibleStartIndex = mChildrenMeasurements.adjustVisibleStartIndex(
mChildrenMeasurements.lowerBound(mScrollable.getVisibleStartOffset(scrollPosition)),
stateMapBuffer.getInt(SLContainerManager.SLCONTAINER_STATE_CHILDREN_MEASUREMENTS_TREE_SIZE)
);
int visibleEndIndex = mChildrenMeasurements.adjustVisibleEndIndex(
mChildrenMeasurements.lowerBound(mScrollable.getVisibleEndOffset(scrollPosition)),
stateMapBuffer.getInt(SLContainerManager.SLCONTAINER_STATE_CHILDREN_MEASUREMENTS_TREE_SIZE)
);

int visibleStartIndex = stateMapBuffer.getInt(SLContainerManager.SLCONTAINER_STATE_VISIBLE_START_INDEX);
int visibleEndIndex = stateMapBuffer.getInt(SLContainerManager.SLCONTAINER_STATE_VISIBLE_END_INDEX);

mContainerChildrenManager.mount(
visibleStartIndex,
visibleEndIndex,
stateMapBuffer.getString(SLContainerManager.SLCONTAINER_STATE_FIRST_CHILD_UNIQUE_ID),
stateMapBuffer.getString(SLContainerManager.SLCONTAINER_STATE_LAST_CHILD_UNIQUE_ID)
visibleEndIndex
);

float[] scrollPosition = new float[]{
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_POSITION_TOP),
(float) stateMapBuffer.getDouble(SLContainerManager.SLCONTAINER_STATE_SCROLL_POSITION_TOP)};
mOnStartReachedHandler = onStartReachedHandler;
mOnEndReachedHandler = onEndReachedHandler;
mOnVisibleChangeHandler = onVisibleChangeHandler;
mStateWrapper = stateWrapper;

onVisibleChangeHandler.onVisibleChange(this, visibleStartIndex, visibleEndIndex);
mScrollContainerVertical.scrollTo((int)PixelUtil.toPixelFromDIP(scrollPosition[0]), (int)PixelUtil.toPixelFromDIP(scrollPosition[1]));

int distanceFromStart = mScrollable.shouldNotifyStart(scrollPosition);
if (distanceFromStart > 0) {
onStartReachedHandler.onStartReached(this, distanceFromStart);
}
Handler handler = new Handler();
handler.postDelayed(new Runnable() {
@Override
public void run() {
updateVirtualization();
}
}, 16);
}

int distanceFromEnd = mScrollable.shouldNotifyEnd(scrollPosition);
if (distanceFromEnd > 0) {
onEndReachedHandler.onEndReached(this, distanceFromEnd);
}
public void scrollToIndex(int index, boolean animated) {
MapBuffer stateMapBuffer = mStateWrapper.getStateDataMapBuffer();
int headerFooter = 1;
int offset = mChildrenMeasurements.adjustVisibleStartIndex(
(int) mChildrenMeasurements.sum(index + headerFooter),
stateMapBuffer.getInt(SLContainerManager.SLCONTAINER_STATE_CHILDREN_MEASUREMENTS_TREE_SIZE)
);
float[] scrollPosition = mScrollable.getScrollPositionFromOffset(offset);
mScrollContainerVertical.scrollTo((int)PixelUtil.toPixelFromDIP(scrollPosition[0]), (int)PixelUtil.toPixelFromDIP(scrollPosition[1]));
}

mStateWrapper = stateWrapper;
public void scrollToOffset(int offset, boolean animated) {
float[] scrollPosition = mScrollable.getScrollPositionFromOffset(offset);
mScrollContainerVertical.scrollTo((int)PixelUtil.toPixelFromDIP(scrollPosition[0]), (int)PixelUtil.toPixelFromDIP(scrollPosition[1]));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,9 @@ public void unmountChildComponentView(View childComponentView, String uniqueId)
mChildrenPool.remove(uniqueId);
}

public void mount(int visibleStartIndex, int visibleEndIndex, String firstChildUniquId, String lastChildUniqueId) {
public void mount(int visibleStartIndex, int visibleEndIndex) {
List<String> mounted = new ArrayList<>();

if (!mChildrenPool.containsKey(firstChildUniquId) || !mChildrenPool.containsKey(lastChildUniqueId)) {
return;
}

for (Map.Entry<String, View> entry : mChildrenPool.entrySet()) {
SLElement childComponentView = (SLElement) entry.getValue();

Expand Down
12 changes: 4 additions & 8 deletions android/src/main/java/com/shadowlist/SLContainerManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,8 @@
public class SLContainerManager extends ViewGroupManager<SLContainer>
implements SLContainerManagerInterface<SLContainer> {

public static final short SLCONTAINER_STATE_VISIBLE_START_INDEX = 0;
public static final short SLCONTAINER_STATE_VISIBLE_END_INDEX = 1;
public static final short SLCONTAINER_STATE_VISIBLE_START_TRIGGER = 2;
public static final short SLCONTAINER_STATE_VISIBLE_END_TRIGGER = 3;
public static final short SLCONTAINER_STATE_CHILDREN_MEASUREMENTS_TREE = 0;
public static final short SLCONTAINER_STATE_CHILDREN_MEASUREMENTS_TREE_SIZE = 1;
public static final short SLCONTAINER_STATE_SCROLL_POSITION_LEFT = 4;
public static final short SLCONTAINER_STATE_SCROLL_POSITION_TOP = 5;
public static final short SLCONTAINER_STATE_SCROLL_CONTENT_WIDTH = 6;
Expand All @@ -37,8 +35,6 @@ public class SLContainerManager extends ViewGroupManager<SLContainer>
public static final short SLCONTAINER_STATE_SCROLL_CONTAINER_HEIGHT = 9;
public static final short SLCONTAINER_STATE_HORIZONTAL = 10;
public static final short SLCONTAINER_STATE_INITIAL_NUM_TO_RENDER = 11;
public static final short SLCONTAINER_STATE_FIRST_CHILD_UNIQUE_ID = 12;
public static final short SLCONTAINER_STATE_LAST_CHILD_UNIQUE_ID = 13;

private final ViewManagerDelegate<SLContainer> mDelegate;
private OnVisibleChangeHandler mVisibleChangeHandler = null;
Expand Down Expand Up @@ -184,12 +180,12 @@ public void receiveCommand(@NonNull SLContainer view, String commandId, @Nullabl

@Override
public void scrollToIndex(SLContainer view, int index, boolean animated) {

view.scrollToIndex(index, animated);
}

@Override
public void scrollToOffset(SLContainer view, int offset, boolean animated) {

view.scrollToOffset(offset, animated);
}

public static final String NAME = "SLContainer";
Expand Down
41 changes: 41 additions & 0 deletions android/src/main/java/com/shadowlist/SLFenwickTree.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.shadowlist;

public class SLFenwickTree {
static {
System.loadLibrary("react_codegen_SLContainerSpec");
}

private long mNativePtr;

public SLFenwickTree(float[] childrenMeasurements) {
mNativePtr = nativeInit(childrenMeasurements);
}

private native long nativeInit(float[] childrenMeasurements);
private native void nativeDestroy(long nativePtr);
private native float nativeSum(long nativePtr, int index);
private native int nativeLowerBound(long nativePtr, float offset);
private native int nativeAdjustVisibleStartIndex(long nativePtr, int visibleStartIndex, int childrenMeasurementsTreeSize);
private native int nativeAdjustVisibleEndIndex(long nativePtr, int visibleEndIndex, int childrenMeasurementsTreeSize);

public float sum(int index) {
return nativeSum(mNativePtr, index);
}

public int lowerBound(float offset) {
return nativeLowerBound(mNativePtr, offset);
}

public int adjustVisibleStartIndex(int visibleStartIndex, int childrenMeasurementsTreeSize) {
return nativeAdjustVisibleStartIndex(mNativePtr, visibleStartIndex, childrenMeasurementsTreeSize);
}

public int adjustVisibleEndIndex(int visibleStartIndex, int childrenMeasurementsTreeSize) {
return nativeAdjustVisibleEndIndex(mNativePtr, visibleStartIndex, childrenMeasurementsTreeSize);
}

public void destroy() {
nativeDestroy(mNativePtr);
mNativePtr = 0;
}
}
Loading

0 comments on commit e42ddd8

Please sign in to comment.