diff --git a/CHANGELOG.md b/CHANGELOG.md index ec254b79b..559c21a97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). - Update types to match `react-native@0.72` view types. - https://github.com/Shopify/flash-list/pull/890 +- Add support for `experimentalMaintainTopContentPosition` + - https://github.com/Shopify/flash-list/issues/547 ## [1.5.0] - 2023-07-12 diff --git a/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutShadow.kt b/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutShadow.kt index 6b31d4b15..9cc30f38d 100644 --- a/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutShadow.kt +++ b/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutShadow.kt @@ -1,5 +1,7 @@ package com.shopify.reactnative.flash_list +import android.widget.ScrollView + class AutoLayoutShadow { var horizontal: Boolean = false var scrollOffset: Int = 0 @@ -11,17 +13,25 @@ class AutoLayoutShadow { var blankOffsetAtEnd = 0 // Tracks blank area from the bottom var lastMaxBoundOverall = 0 // Tracks where the last pixel is drawn in the overall + var maintainTopContentPosition = false private var lastMaxBound = 0 // Tracks where the last pixel is drawn in the visible window private var lastMinBound = 0 // Tracks where first pixel is drawn in the visible window + private var anchorStableId = "" + private var anchorOffset = 0 + /** Checks for overlaps or gaps between adjacent items and then applies a correction (Only Grid layouts with varying spans) * Performance: RecyclerListView renders very small number of views and this is not going to trigger multiple layouts on Android side. Not expecting any major perf issue. */ - fun clearGapsAndOverlaps(sortedItems: Array) { + fun clearGapsAndOverlaps(sortedItems: Array, scrollView: ScrollView) { var maxBound = 0 var minBound = Int.MAX_VALUE var maxBoundNeighbour = 0 + lastMaxBoundOverall = 0 + var nextAnchorStableId = "" + var nextAnchorOffset = 0 + for (i in 0 until sortedItems.size - 1) { val cell = sortedItems[i] val neighbour = sortedItems[i + 1] @@ -74,9 +84,31 @@ class AutoLayoutShadow { } } } + val isAnchorFound = nextAnchorStableId == "" || neighbour.stableId == anchorStableId + + if(isAnchorFound) { + nextAnchorOffset = neighbour.top + nextAnchorStableId = neighbour.stableId + } lastMaxBoundOverall = kotlin.math.max(lastMaxBoundOverall, if (horizontal) cell.right else cell.bottom) lastMaxBoundOverall = kotlin.math.max(lastMaxBoundOverall, if (horizontal) neighbour.right else neighbour.bottom) } + + if(maintainTopContentPosition) { + for (cell in sortedItems) { + val minValue = cell.top + if (cell.stableId == anchorStableId) { + if (minValue != anchorOffset) { + val diff = minValue - anchorOffset + (scrollView as DoubleSidedScrollView).setShiftOffset(diff.toDouble()) + break + } + } + } + } + + anchorStableId = nextAnchorStableId + anchorOffset = nextAnchorOffset lastMaxBound = maxBoundNeighbour lastMinBound = minBound } diff --git a/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutView.kt b/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutView.kt index 6b78bd93e..6f454c980 100644 --- a/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutView.kt +++ b/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutView.kt @@ -2,8 +2,6 @@ package com.shopify.reactnative.flash_list import android.content.Context import android.graphics.Canvas -import android.util.DisplayMetrics -import android.util.Log import android.view.View import android.view.ViewGroup import android.widget.HorizontalScrollView @@ -21,8 +19,19 @@ class AutoLayoutView(context: Context) : ReactViewGroup(context) { val alShadow = AutoLayoutShadow() var enableInstrumentation = false var disableAutoLayout = false + var pixelDensity = 1.0 + var experimentalScrollPositionManagement: Boolean = false + set(value: Boolean) { + alShadow.maintainTopContentPosition = value + field = value + } - var pixelDensity = 1.0; + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + if(experimentalScrollPositionManagement) { + fixLayout() + } + super.onLayout(changed, left, top, right, bottom) + } /** Overriding draw instead of onLayout. RecyclerListView uses absolute positions for each and every item which means that changes in child layouts may not trigger onLayout on this container. The same layout * can still cause views to overlap. Therefore, it makes sense to override draw to do correction. */ @@ -66,7 +75,10 @@ class AutoLayoutView(context: Context) : ReactViewGroup(context) { } positionSortedViews.sortBy { it.index } alShadow.offsetFromStart = if (alShadow.horizontal) left else top - alShadow.clearGapsAndOverlaps(positionSortedViews) + alShadow.clearGapsAndOverlaps( + positionSortedViews, + getParentScrollView() as ScrollView, + ) } } diff --git a/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutViewManager.kt b/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutViewManager.kt index b646f09c1..315187416 100644 --- a/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutViewManager.kt +++ b/android/src/main/kotlin/com/shopify/reactnative/flash_list/AutoLayoutViewManager.kt @@ -63,6 +63,11 @@ class AutoLayoutViewManager: ReactViewManager() { view.enableInstrumentation = enableInstrumentation } + @ReactProp(name = "experimentalScrollPositionManagement") + fun setExperimentalMaintainContentPosition(view: AutoLayoutView, isMaintaining: Boolean) { + view.experimentalScrollPositionManagement = isMaintaining + } + private fun convertToPixelLayout(dp: Double, density: Double): Int { return (dp * density).roundToInt() } diff --git a/android/src/main/kotlin/com/shopify/reactnative/flash_list/CellContainer.java b/android/src/main/kotlin/com/shopify/reactnative/flash_list/CellContainer.java index a212ea364..20ca199c6 100644 --- a/android/src/main/kotlin/com/shopify/reactnative/flash_list/CellContainer.java +++ b/android/src/main/kotlin/com/shopify/reactnative/flash_list/CellContainer.java @@ -3,6 +3,8 @@ public interface CellContainer { void setIndex(int value); int getIndex(); + void setStableId(String value); + String getStableId(); void setLeft(int value); int getLeft(); void setTop(int value); diff --git a/android/src/main/kotlin/com/shopify/reactnative/flash_list/CellContainerImpl.kt b/android/src/main/kotlin/com/shopify/reactnative/flash_list/CellContainerImpl.kt index 109eb9411..405810351 100644 --- a/android/src/main/kotlin/com/shopify/reactnative/flash_list/CellContainerImpl.kt +++ b/android/src/main/kotlin/com/shopify/reactnative/flash_list/CellContainerImpl.kt @@ -1,10 +1,13 @@ package com.shopify.reactnative.flash_list import android.content.Context +import android.view.View import com.facebook.react.views.view.ReactViewGroup class CellContainerImpl(context: Context) : ReactViewGroup(context), CellContainer { private var index = -1 + private var stableId = "" + override fun setIndex(value: Int) { index = value } @@ -13,4 +16,11 @@ class CellContainerImpl(context: Context) : ReactViewGroup(context), CellContain return index } + override fun setStableId(stableId: String) { + this.stableId = stableId + } + + override fun getStableId(): String { + return this.stableId + } } diff --git a/android/src/main/kotlin/com/shopify/reactnative/flash_list/CellContainerManager.kt b/android/src/main/kotlin/com/shopify/reactnative/flash_list/CellContainerManager.kt index 1434caaae..973d8f706 100644 --- a/android/src/main/kotlin/com/shopify/reactnative/flash_list/CellContainerManager.kt +++ b/android/src/main/kotlin/com/shopify/reactnative/flash_list/CellContainerManager.kt @@ -24,4 +24,9 @@ class CellContainerManager: ReactViewManager() { fun setIndex(view: CellContainerImpl, index: Int) { view.index = index } + + @ReactProp(name = "stableId") + fun setStableId(view: CellContainerImpl, stableId: String) { + view.stableId = stableId + } } diff --git a/android/src/main/kotlin/com/shopify/reactnative/flash_list/DoubleSidedScrollView.java b/android/src/main/kotlin/com/shopify/reactnative/flash_list/DoubleSidedScrollView.java new file mode 100644 index 000000000..3f7e828b8 --- /dev/null +++ b/android/src/main/kotlin/com/shopify/reactnative/flash_list/DoubleSidedScrollView.java @@ -0,0 +1,117 @@ +/** + * This file comes courtsey of steuerbot and their work on react-native-bidirectional-flatlist. Huge thanks for helping + * solve this problem with fling! + * */ + +package com.shopify.reactnative.flash_list; + +import android.content.Context; +import android.util.Log; +import android.view.View; +import android.widget.OverScroller; + +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; + +import com.facebook.common.logging.FLog; +import com.facebook.react.common.ReactConstants; +import com.facebook.react.views.scroll.ReactScrollView; + +import java.lang.reflect.Field; + +public class DoubleSidedScrollView extends ReactScrollView { + + private OverScroller mScroller; + private boolean mTriedToGetScroller; + protected double mShiftHeight = 0; + protected double mShiftOffset = 0; + + public DoubleSidedScrollView(Context context) { + super(context, null); + } + + public void setShiftHeight(double shiftHeight) { + mShiftHeight = shiftHeight; + Log.d("ScrollView", "set shiftHeight " + shiftHeight); + } + + public void setShiftOffset(double shiftOffset) { + mShiftOffset = shiftOffset; + adjustOverscroller(); + Log.d("ScrollView", "set shiftOffset " + shiftOffset); + } + + protected void adjustOverscroller() { + int scrollWindowHeight = getHeight() - getPaddingBottom() - getPaddingTop(); + if(mShiftOffset != 0) { + // correct + scrollTo(0, getScrollY() + (int) mShiftOffset); + if(getOverScrollerFromParent() != null && !getOverScrollerFromParent().isFinished()) { + + // get current directed velocity from scroller + int direction = getOverScrollerFromParent().getFinalY() - getOverScrollerFromParent().getStartY() > 0 ? 1 : -1; + float velocity = getOverScrollerFromParent().getCurrVelocity() * direction; + // stop and restart animation again + getOverScrollerFromParent().abortAnimation(); + mScroller.fling( + getScrollX(), // startX + getScrollY(), // startY + 0, // velocityX + (int)velocity, // velocityY + 0, // minX + 0, // maxX + 0, // minY + Integer.MAX_VALUE, // maxY + 0, // overX + scrollWindowHeight / 2 // overY + ); + ViewCompat.postInvalidateOnAnimation(this); + } + } + mShiftHeight = 0; + mShiftOffset = 0; + } + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) { + super.onLayoutChange(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom); + adjustOverscroller(); + } + + @Nullable + private OverScroller getOverScrollerFromParent() { + if(mTriedToGetScroller) { + return mScroller; + } + mTriedToGetScroller = true; + Field field = null; + try { + field = ReactScrollView.class.getDeclaredField("mScroller"); + field.setAccessible(true); + } catch (NoSuchFieldException e) { + FLog.w( + "ScrollView", + "Failed to get mScroller field for ScrollView! " + + "This app will exhibit the bounce-back scrolling bug :("); + } + + if(field != null) { + Object scrollerValue = null; + try { + scrollerValue = field.get(this); + if (scrollerValue instanceof OverScroller) { + mScroller = (OverScroller) scrollerValue; + } else { + FLog.w( + ReactConstants.TAG, + "Failed to cast mScroller field in ScrollView (probably due to OEM changes to AOSP)! " + + "This app will exhibit the bounce-back scrolling bug :("); + mScroller = null; + } + } catch (IllegalAccessException e) { + throw new RuntimeException("Failed to get mScroller from ScrollView!", e); + } + } + return mScroller; + } +} diff --git a/android/src/main/kotlin/com/shopify/reactnative/flash_list/DoubleSidedScrollViewManager.kt b/android/src/main/kotlin/com/shopify/reactnative/flash_list/DoubleSidedScrollViewManager.kt new file mode 100644 index 000000000..99ccc5d51 --- /dev/null +++ b/android/src/main/kotlin/com/shopify/reactnative/flash_list/DoubleSidedScrollViewManager.kt @@ -0,0 +1,29 @@ +package com.shopify.reactnative.flash_list + +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.views.view.ReactViewGroup +import com.facebook.react.views.view.ReactViewManager +import com.facebook.react.common.MapBuilder +import com.facebook.react.views.scroll.ReactScrollView +import com.facebook.react.views.scroll.ReactScrollViewManager +import kotlin.math.roundToInt + +/** ViewManager for AutoLayoutView - Container for all RecyclerListView children. Automatically removes all gaps and overlaps for GridLayouts with flexible spans. + * Note: This cannot work for masonry layouts i.e, pinterest like layout */ +@ReactModule(name = AutoLayoutViewManager.REACT_CLASS) +class DoubleSidedScrollViewManager: ReactScrollViewManager() { + + companion object { + const val REACT_CLASS = "DoubleSidedScrollView" + } + + override fun getName(): String { + return REACT_CLASS + } + + override fun createViewInstance(context: ThemedReactContext): ReactScrollView { + return DoubleSidedScrollView(context) + } +} diff --git a/android/src/main/kotlin/com/shopify/reactnative/flash_list/FlashListPackage.kt b/android/src/main/kotlin/com/shopify/reactnative/flash_list/FlashListPackage.kt index dc118ef42..a24d7c9e1 100644 --- a/android/src/main/kotlin/com/shopify/reactnative/flash_list/FlashListPackage.kt +++ b/android/src/main/kotlin/com/shopify/reactnative/flash_list/FlashListPackage.kt @@ -13,7 +13,8 @@ class ReactNativeFlashListPackage : ReactPackage { override fun createViewManagers(reactContext: ReactApplicationContext): List> { return listOf( AutoLayoutViewManager(), - CellContainerManager() + CellContainerManager(), + DoubleSidedScrollViewManager() ) } } diff --git a/fixture/ios/Podfile.lock b/fixture/ios/Podfile.lock index a437f1c4f..55810a803 100644 --- a/fixture/ios/Podfile.lock +++ b/fixture/ios/Podfile.lock @@ -597,11 +597,11 @@ SPEC CHECKSUMS: Flipper-RSocket: d9d9ade67cbecf6ac10730304bf5607266dd2541 FlipperKit: cbdee19bdd4e7f05472a66ce290f1b729ba3cb86 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 - glog: 85ecdd10ee8d8ec362ef519a6a45ff9aa27b2e85 + glog: 476ee3e89abb49e07f822b48323c51c57124b572 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c - RCT-Folly: 803a9cfd78114b2ec0f140cfa6fa2a6bafb2d685 + RCT-Folly: 4d8508a426467c48885f1151029bc15fa5d7b3b8 RCTRequired: 0f06b6068f530932d10e1a01a5352fad4eaacb74 RCTTypeSafety: b0ee81f10ef1b7d977605a2b266823dabd565e65 React: 3becd12bd51ea8a43bdde7e09d0f40fba7820e03 diff --git a/fixture/src/List.tsx b/fixture/src/List.tsx index 1ce8dd728..9283641a1 100644 --- a/fixture/src/List.tsx +++ b/fixture/src/List.tsx @@ -77,6 +77,7 @@ const List = () => { renderItem={renderItem} estimatedItemSize={100} data={data} + experimentalScrollPositionManagement /> ); }; diff --git a/ios/Sources/AutoLayoutView.swift b/ios/Sources/AutoLayoutView.swift index f18e92c3d..36958131e 100644 --- a/ios/Sources/AutoLayoutView.swift +++ b/ios/Sources/AutoLayoutView.swift @@ -32,7 +32,12 @@ import UIKit self.disableAutoLayout = disableAutoLayout } + @objc func setExperimentalScrollPositionManagement(_ experimentalScrollPositionManagement: Bool) { + self.experimentalScrollPositionManagement = experimentalScrollPositionManagement + } + private var horizontal = false + private var experimentalScrollPositionManagement = false private var scrollOffset: CGFloat = 0 private var windowSize: CGFloat = 0 private var renderAheadOffset: CGFloat = 0 @@ -46,9 +51,19 @@ import UIKit /// Tracks where first pixel is drawn in the visible window private var lastMinBound: CGFloat = 0 + /// State that informs us whether this is the first render + private var isInitialRender: Bool = true + + /// Id of the anchor element when using `maintainTopContentPosition` + private var anchorStableId: String = "" + + /// Offset of the anchor when using `maintainTopContentPosition` + private var anchorOffset: CGFloat = 0 + override func layoutSubviews() { fixLayout() super.layoutSubviews() + self.isInitialRender = false guard enableInstrumentation, let scrollView = getScrollView() else { return } @@ -81,6 +96,23 @@ import UIKit return sequence(first: self, next: { $0.superview }).first(where: { $0 is UIScrollView }) as? UIScrollView } + func getScrollViewOffset(for scrollView: UIScrollView?) -> CGFloat { + /// When using `maintainTopContentPosition` we can't use the offset provided by React + /// Native. Because its async, it is sometimes sent in too late for the position maintainence + /// calculation causing list jumps or sometimes wrong scroll positions altogether. Since this is still + /// experimental, the old scrollOffset is here to not regress previous functionality if the feature + /// doesn't work at scale. + /// + /// The goal is that we can remove this in the future and get the offset from only one place 🤞 + if let scrollView, experimentalScrollPositionManagement { + return horizontal ? + scrollView.contentOffset.x : + scrollView.contentOffset.y + } + + return scrollOffset + } + /// Sorts views by index and then invokes clearGaps which does the correction. /// Performance: Sort is needed. Given relatively low number of views in RecyclerListView render tree this should be a non issue. private func fixLayout() { @@ -104,16 +136,58 @@ import UIKit fixFooter() } + /// Finds the item with the first stable id and adjusts the scroll view offset based on how much + /// it moved when a new item is added. + private func adjustTopContentPosition( + cellContainers: [CellContainer], + scrollView: UIScrollView? + ) { + guard let scrollView = scrollView, !self.isInitialRender else { return } + + for cellContainer in cellContainers { + let minValue = horizontal ? + cellContainer.frame.minX : + cellContainer.frame.minY + + if cellContainer.stableId == anchorStableId { + if minValue != anchorOffset { + let diff = minValue - anchorOffset + + let currentOffset = horizontal + ? scrollView.contentOffset.x + : scrollView.contentOffset.y + + let scrollValue = diff + currentOffset + + scrollView.contentOffset = CGPoint( + x: horizontal ? scrollValue : 0, + y: horizontal ? 0 : scrollValue + ) + + // You only need to adjust the scroll view once. Break the + // loop after this + return + } + } + } + } + /// Checks for overlaps or gaps between adjacent items and then applies a correction. /// Performance: RecyclerListView renders very small number of views and this is not going to trigger multiple layouts on the iOS side. private func clearGaps(for cellContainers: [CellContainer]) { + let scrollView = getScrollView() var maxBound: CGFloat = 0 var minBound: CGFloat = CGFloat(Int.max) var maxBoundNextCell: CGFloat = 0 - let correctedScrollOffset = scrollOffset - (horizontal ? frame.minX : frame.minY) + let correctedScrollOffset = getScrollViewOffset(for: scrollView) + lastMaxBoundOverall = 0 + var nextAnchorStableId = "" + var nextAnchorOffset: CGFloat = 0 + cellContainers.indices.dropLast().forEach { index in let cellContainer = cellContainers[index] + let cellTop = cellContainer.frame.minY let cellBottom = cellContainer.frame.maxY let cellLeft = cellContainer.frame.minX @@ -185,11 +259,33 @@ import UIKit maxBoundNextCell = max(maxBound, nextCell.frame.maxY) } } + + let isAnchorFound = + nextAnchorStableId == "" || + nextCell.stableId == anchorStableId + + if experimentalScrollPositionManagement && isAnchorFound { + nextAnchorOffset = horizontal ? + nextCell.frame.minX : + nextCell.frame.minY + + nextAnchorStableId = nextCell.stableId + } + updateLastMaxBoundOverall(currentCell: cellContainer, nextCell: nextCell) } + if experimentalScrollPositionManagement { + adjustTopContentPosition( + cellContainers: cellContainers, + scrollView: scrollView + ) + } + lastMaxBound = maxBoundNextCell lastMinBound = minBound + anchorStableId = nextAnchorStableId + anchorOffset = nextAnchorOffset } private func updateLastMaxBoundOverall(currentCell: CellContainer, nextCell: CellContainer) { diff --git a/ios/Sources/AutoLayoutViewManager.m b/ios/Sources/AutoLayoutViewManager.m index 855ce72e5..7efb71f0b 100644 --- a/ios/Sources/AutoLayoutViewManager.m +++ b/ios/Sources/AutoLayoutViewManager.m @@ -10,5 +10,6 @@ @interface RCT_EXTERN_MODULE(AutoLayoutViewManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(enableInstrumentation, BOOL) RCT_EXPORT_VIEW_PROPERTY(disableAutoLayout, BOOL) RCT_EXPORT_VIEW_PROPERTY(onBlankAreaEvent, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(experimentalScrollPositionManagement, BOOL) @end diff --git a/ios/Sources/CellContainer.swift b/ios/Sources/CellContainer.swift index 7f09ce767..13e5e4f79 100644 --- a/ios/Sources/CellContainer.swift +++ b/ios/Sources/CellContainer.swift @@ -2,8 +2,13 @@ import Foundation @objc class CellContainer: UIView { var index: Int = -1 - + var stableId: String = "" + @objc func setIndex(_ index: Int) { self.index = index } + + @objc func setStableId(_ stableId: String) { + self.stableId = stableId + } } diff --git a/ios/Sources/CellContainerManager.m b/ios/Sources/CellContainerManager.m index 7bcad13fd..1defa7d6f 100644 --- a/ios/Sources/CellContainerManager.m +++ b/ios/Sources/CellContainerManager.m @@ -4,5 +4,6 @@ @interface RCT_EXTERN_MODULE(CellContainerManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(index, NSInteger) +RCT_EXPORT_VIEW_PROPERTY(stableId, NSString) @end diff --git a/src/BiDirectionalScrollView.tsx b/src/BiDirectionalScrollView.tsx new file mode 100644 index 000000000..73cbdbde3 --- /dev/null +++ b/src/BiDirectionalScrollView.tsx @@ -0,0 +1,175 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck + +/** + * This file comes courtsey of steuerbot and their work on react-native-bidirectional-flatlist. Huge thanks for helping + * solve this problem with fling! + * */ + +import React, { Component, forwardRef } from "react"; +import { + PixelRatio, + Platform, + ScrollView as ScrollViewRN, + ScrollViewProps, + StyleSheet, + View, +} from "react-native"; + +import { BidirectionalList } from "./BidirectionalList"; +import type { ShiftFunction } from "./types"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +const ScrollViewRNRaw: Component = ScrollViewRN.render().type; + +export class ScrollViewComponent extends ScrollViewRNRaw { + constructor(props: ScrollViewProps) { + super(props); + } + + shift: ShiftFunction = ({ + offset, + height, + }: { + offset: number; + height: number; + }) => { + this._scrollViewRef.setNativeProps({ + shiftOffset: PixelRatio.getPixelSizeForLayoutSize(offset), + shiftHeight: PixelRatio.getPixelSizeForLayoutSize(height), + }); + }; + + render() { + const NativeDirectionalScrollView = BidirectionalFlatlist; + const NativeDirectionalScrollContentView = View; + + const contentContainerStyle = [this.props.contentContainerStyle]; + + const contentSizeChangeProps = + this.props.onContentSizeChange == null + ? null + : { + onLayout: this._handleContentOnLayout, + }; + + const { stickyHeaderIndices } = this.props; + const children = this.props.children; + + const hasStickyHeaders = + Array.isArray(stickyHeaderIndices) && stickyHeaderIndices.length > 0; + + const contentContainer = ( + + {children} + + ); + + const alwaysBounceHorizontal = + this.props.alwaysBounceHorizontal === undefined + ? this.props.horizontal + : this.props.alwaysBounceHorizontal; + + const alwaysBounceVertical = + this.props.alwaysBounceVertical === undefined + ? !this.props.horizontal + : this.props.alwaysBounceVertical; + + const baseStyle = styles.baseVertical; + const props = { + ...this.props, + alwaysBounceHorizontal, + alwaysBounceVertical, + style: StyleSheet.compose(baseStyle, this.props.style), + // Override the onContentSizeChange from props, since this event can + // bubble up from TextInputs + onContentSizeChange: null, + onLayout: this._handleLayout, + onMomentumScrollBegin: this._handleMomentumScrollBegin, + onMomentumScrollEnd: this._handleMomentumScrollEnd, + onResponderGrant: this._handleResponderGrant, + onResponderReject: this._handleResponderReject, + onResponderRelease: this._handleResponderRelease, + onResponderTerminationRequest: this._handleResponderTerminationRequest, + onScrollBeginDrag: this._handleScrollBeginDrag, + onScrollEndDrag: this._handleScrollEndDrag, + onScrollShouldSetResponder: this._handleScrollShouldSetResponder, + onStartShouldSetResponder: this._handleStartShouldSetResponder, + onStartShouldSetResponderCapture: + this._handleStartShouldSetResponderCapture, + onTouchEnd: this._handleTouchEnd, + onTouchMove: this._handleTouchMove, + onTouchStart: this._handleTouchStart, + onTouchCancel: this._handleTouchCancel, + onScroll: this._handleScroll, + scrollEventThrottle: hasStickyHeaders + ? 1 + : this.props.scrollEventThrottle, + sendMomentumEvents: Boolean( + this.props.onMomentumScrollBegin || this.props.onMomentumScrollEnd + ), + // default to true + snapToStart: this.props.snapToStart !== false, + // default to true + snapToEnd: this.props.snapToEnd !== false, + // pagingEnabled is overridden by snapToInterval / snapToOffsets + pagingEnabled: Platform.select({ + // on iOS, pagingEnabled must be set to false to have snapToInterval / snapToOffsets work + ios: + this.props.pagingEnabled === true && + this.props.snapToInterval == null && + this.props.snapToOffsets == null, + // on Android, pagingEnabled must be set to true to have snapToInterval / snapToOffsets work + android: + this.props.pagingEnabled === true || + this.props.snapToInterval != null || + this.props.snapToOffsets != null, + }), + }; + + // const { decelerationRate } = this.props; + // if (decelerationRate != null) { + // props.decelerationRate = processDecelerationRate(decelerationRate); + // } + + return ( + + {contentContainer} + + ); + } +} + +const styles = StyleSheet.create({ + baseVertical: { + flexGrow: 1, + flexShrink: 1, + flexDirection: "column", + overflow: "scroll", + }, +}); + +export type ScrollViewType = typeof ScrollViewRN & { + shift: (options: { offset: number; height: number }) => void; +}; + +// eslint-disable-next-line react/display-name +export const BidirectionalScrollView: ScrollViewType = forwardRef< + ScrollViewType, + ScrollViewProps +>((props, ref) => { + return ; +}); diff --git a/src/BidirectionalList.ts b/src/BidirectionalList.ts new file mode 100644 index 000000000..e5bf36594 --- /dev/null +++ b/src/BidirectionalList.ts @@ -0,0 +1,31 @@ +/** + * This file comes courtsey of steuerbot and their work on react-native-bidirectional-flatlist. Huge thanks for helping + * solve this problem with fling! + * */ +import type { ReactNode } from "react"; +import { Platform, requireNativeComponent, ViewStyle } from "react-native"; + +const LINKING_ERROR = + `The package 'react-native-flashlist' doesn't seem to be linked. Make sure: \n\n${Platform.select( + { + ios: "- You have run 'pod install'\n", + default: "", + } + )}- You rebuilt the app after installing the package\n` + + `- You are not using Expo managed workflow\n`; + +interface BidirectionalListProps { + style: ViewStyle; + children: ReactNode; +} + +const ComponentName = "DoubleSidedScrollView"; + +const BidirectionalList = + requireNativeComponent(ComponentName); + +if (BidirectionalList === null) { + throw new Error(LINKING_ERROR); +} + +export { BidirectionalList }; diff --git a/src/FlashList.tsx b/src/FlashList.tsx index 4b1080c18..8fd8fcd45 100644 --- a/src/FlashList.tsx +++ b/src/FlashList.tsx @@ -31,6 +31,7 @@ import { RenderTargetOptions, } from "./FlashListProps"; import { + getBidirectionalScrollView, getCellContainerPlatformStyles, getFooterContainer, getItemAnimator, @@ -138,6 +139,19 @@ class FlashList extends React.PureComponent< if (Number(this.props.numColumns) > 1 && this.props.horizontal) { throw new CustomError(ExceptionList.columnsWhileHorizontalNotSupported); } + if ( + this.props.horizontal && + this.props.experimentalScrollPositionManagement + ) { + throw new CustomError(ExceptionList.horizontalMaintainScrollNotSupported); + } + + if ( + this.props.experimentalScrollPositionManagement && + this.props.renderScrollComponent + ) { + throw new CustomError(ExceptionList.customMaintainScrollNotSupported); + } // `createAnimatedComponent` always passes a blank style object. To avoid warning while using AnimatedFlashList we've modified the check // `style` prop can be an array. So we need to validate every object in array. Check: https://github.com/Shopify/flash-list/issues/651 @@ -173,9 +187,6 @@ class FlashList extends React.PureComponent< newState.numColumns, nextProps ); - // RLV retries to reposition the first visible item on layout provider change. - // It's not required in our case so we're disabling it - newState.layoutProvider.shouldRefreshWithAnchoring = false; } if (nextProps.data !== prevState.data) { newState.data = nextProps.data; @@ -190,6 +201,10 @@ class FlashList extends React.PureComponent< newState.extraData = { value: nextProps.extraData }; } newState.renderItem = nextProps.renderItem; + + // RLV retries to reposition the first visible item on layout provider change. + // It's not required in our case so we're disabling it + newState.layoutProvider.shouldRefreshWithAnchoring = false; return newState; } @@ -383,9 +398,10 @@ class FlashList extends React.PureComponent< windowCorrectionConfig={this.getUpdatedWindowCorrectionConfig()} itemAnimator={this.itemAnimator} suppressBoundedSizeException - externalScrollView={ - renderScrollComponent as RecyclerListViewProps["externalScrollView"] - } + externalScrollView={getBidirectionalScrollView( + Boolean(this.props.maintainVisibleContentPosition), + renderScrollComponent + )} /> ); @@ -467,6 +483,9 @@ class FlashList extends React.PureComponent< onBlankAreaEvent={this.props.onBlankArea} onLayout={this.updateDistanceFromWindow} disableAutoLayout={this.props.disableAutoLayout} + experimentalScrollPositionManagement={ + this.props.experimentalScrollPositionManagement + } > {children} @@ -502,6 +521,10 @@ class FlashList extends React.PureComponent< ...getCellContainerPlatformStyles(this.props.inverted!!, parentProps), }} index={parentProps.index} + stableId={ + /* Empty string is used so the list can still render without an extractor */ + this.props.keyExtractor?.(parentProps.data, parentProps.index) ?? "" + } > extends React.PureComponent< } } + public experimentalFindApproxFirstVisibleIndex() { + return this.rlvRef?.findApproxFirstVisibleIndex() ?? 0; + } + public scrollToItem(params: { animated?: boolean | null | undefined; item: any; diff --git a/src/FlashListProps.ts b/src/FlashListProps.ts index 75436a1e1..e08c0ae34 100644 --- a/src/FlashListProps.ts +++ b/src/FlashListProps.ts @@ -332,4 +332,13 @@ export interface FlashListProps extends ScrollViewProps { * `false` again. */ disableAutoLayout?: boolean; + + /** + * If enabled, FlashList will try and maintain the position of the list when items are added from the top. + * Additionally, it will fix scroll position when chainging scroll orientation on your device. + * This prop requires you define a `keyExtractor` function. The `keyExtractor` is used to compute the list + * top anchor. Without it, the list will fail to render. If in debug mode, you may see flashes if new data + * comes in quickly. If this happens, please confirm you see this in release mode before reporting an issue. + */ + experimentalScrollPositionManagement?: boolean; } diff --git a/src/errors/ExceptionList.ts b/src/errors/ExceptionList.ts index 05e3ceda8..a5c7626f2 100644 --- a/src/errors/ExceptionList.ts +++ b/src/errors/ExceptionList.ts @@ -24,5 +24,13 @@ const ExceptionList = { "optimizeItemArrangement has been enabled on `MasonryFlashList` but overrideItemLayout is not set.", type: "InvariantViolation", }, + horizontalMaintainScrollNotSupported: { + message: "Cannot Scroll Horizontally while maintaining content position", + type: "NotSupportedException", + }, + customMaintainScrollNotSupported: { + message: "Cannot maintain scroll position for a custom scroll view", + type: "NotSupportedException", + }, }; export default ExceptionList; diff --git a/src/native/auto-layout/AutoLayoutView.tsx b/src/native/auto-layout/AutoLayoutView.tsx index c040dfa1f..11398ffa7 100644 --- a/src/native/auto-layout/AutoLayoutView.tsx +++ b/src/native/auto-layout/AutoLayoutView.tsx @@ -29,6 +29,7 @@ export interface AutoLayoutViewProps { onBlankAreaEvent?: BlankAreaEventHandler; onLayout?: (event: LayoutChangeEvent) => void; disableAutoLayout?: boolean; + experimentalScrollPositionManagement?: boolean; } class AutoLayoutView extends React.Component { @@ -63,6 +64,9 @@ class AutoLayoutView extends React.Component { listeners.length !== 0 || Boolean(this.props.onBlankAreaEvent) } disableAutoLayout={this.props.disableAutoLayout} + experimentalScrollPositionManagement={Boolean( + this.props.experimentalScrollPositionManagement + )} > {this.props.children} diff --git a/src/native/auto-layout/AutoLayoutViewNativeComponentProps.ts b/src/native/auto-layout/AutoLayoutViewNativeComponentProps.ts index 008c8f660..3ba064f64 100644 --- a/src/native/auto-layout/AutoLayoutViewNativeComponentProps.ts +++ b/src/native/auto-layout/AutoLayoutViewNativeComponentProps.ts @@ -14,4 +14,5 @@ export interface AutoLayoutViewNativeComponentProps { onBlankAreaEvent: OnBlankAreaEventHandler; enableInstrumentation: boolean; disableAutoLayout?: boolean; + experimentalScrollPositionManagement?: boolean; } diff --git a/src/native/config/PlatformHelper.ts b/src/native/config/PlatformHelper.ts index b5a46b8e7..705cecf28 100644 --- a/src/native/config/PlatformHelper.ts +++ b/src/native/config/PlatformHelper.ts @@ -1,5 +1,9 @@ -import { BaseItemAnimator } from "recyclerlistview"; +import { Platform, ScrollViewProps } from "react-native"; +import { BaseItemAnimator, RecyclerListViewProps } from "recyclerlistview"; import { DefaultJSItemAnimator } from "recyclerlistview/dist/reactnative/platform/reactnative/itemanimators/defaultjsanimator/DefaultJSItemAnimator"; +import React from "react"; + +import { BidirectionalScrollView } from "../../BiDirectionalScrollView"; const PlatformConfig = { defaultDrawDistance: 250, @@ -21,9 +25,22 @@ const getFooterContainer = (): React.ComponentClass | undefined => { return undefined; }; +const getBidirectionalScrollView = ( + experimentalScrollPositionManagement: boolean, + renderScrollComponent: + | React.FC + | React.ComponentType + | undefined +) => { + return experimentalScrollPositionManagement && Platform.OS === "android" + ? (BidirectionalScrollView as unknown as RecyclerListViewProps["externalScrollView"]) + : (renderScrollComponent as unknown as RecyclerListViewProps["externalScrollView"]); +}; + export { PlatformConfig, getCellContainerPlatformStyles, getItemAnimator, getFooterContainer, + getBidirectionalScrollView, };