From ad3b98ac78d46539bca739080848d628abb13e4e Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 10 Feb 2023 16:33:07 -0800 Subject: [PATCH 1/5] All bidirectional pagination work --- Libraries/Components/ScrollView/ScrollView.js | 2 - Libraries/Lists/FlatList.d.ts | 13 - Libraries/Lists/VirtualizedList.d.ts | 27 ++ Libraries/Lists/VirtualizedList.js | 235 ++++++++++++++---- Libraries/Lists/VirtualizedListProps.js | 23 +- .../Lists/__tests__/VirtualizedList-test.js | 162 ++++++++++++ .../ScrollView/RCTScrollViewComponentView.mm | 96 +++++++ .../react/animated/NativeAnimatedModule.java | 10 + .../react/bridge/UIManagerListener.java | 27 +- .../react/fabric/FabricUIManager.java | 14 ++ .../fabric/mounting/MountItemDispatcher.java | 6 + .../MaintainVisibleScrollPositionHelper.java | 205 +++++++++++++++ .../scroll/ReactHorizontalScrollView.java | 66 ++++- .../ReactHorizontalScrollViewManager.java | 10 + .../react/views/scroll/ReactScrollView.java | 34 ++- .../views/scroll/ReactScrollViewHelper.java | 4 + .../views/scroll/ReactScrollViewManager.java | 10 + .../components/scrollview/ScrollViewProps.cpp | 14 ++ .../components/scrollview/ScrollViewProps.h | 3 + .../components/scrollview/conversions.h | 35 +++ .../components/scrollview/primitives.h | 18 ++ .../renderer/debug/DebugStringConvertible.h | 9 + .../js/components/ListExampleShared.js | 48 +++- .../examples/FlatList/BaseFlatListExample.js | 10 +- .../js/examples/FlatList/FlatList-basic.js | 71 +++++- .../examples/FlatList/FlatList-multiColumn.js | 4 +- .../FlatList/FlatList-onStartReached.js | 56 +++++ .../examples/FlatList/FlatListExampleIndex.js | 2 + .../examples/ScrollView/ScrollViewExample.js | 25 +- .../SectionList/SectionList-scrollable.js | 4 +- 30 files changed, 1135 insertions(+), 108 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java create mode 100644 packages/rn-tester/js/examples/FlatList/FlatList-onStartReached.js diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index 0c2ecf2f1684..df7a1fa89866 100644 --- a/Libraries/Components/ScrollView/ScrollView.js +++ b/Libraries/Components/ScrollView/ScrollView.js @@ -278,8 +278,6 @@ type IOSProps = $ReadOnly<{| * Caveat 2: This simply uses `contentOffset` and `frame.origin` in native code to compute * visibility. Occlusion, transforms, and other complexity won't be taken into account as to * whether content is "visible" or not. - * - * @platform ios */ maintainVisibleContentPosition?: ?$ReadOnly<{| minIndexForVisible: number, diff --git a/Libraries/Lists/FlatList.d.ts b/Libraries/Lists/FlatList.d.ts index 3d9cf32840eb..ee910dcd6d35 100644 --- a/Libraries/Lists/FlatList.d.ts +++ b/Libraries/Lists/FlatList.d.ts @@ -104,19 +104,6 @@ export interface FlatListProps extends VirtualizedListProps { */ numColumns?: number | undefined; - /** - * Called once when the scroll position gets within onEndReachedThreshold of the rendered content. - */ - onEndReached?: ((info: {distanceFromEnd: number}) => void) | null | undefined; - - /** - * How far from the end (in units of visible length of the list) the bottom edge of the - * list must be from the end of the content to trigger the `onEndReached` callback. - * Thus a value of 0.5 will trigger `onEndReached` when the end of the content is - * within half the visible length of the list. - */ - onEndReachedThreshold?: number | null | undefined; - /** * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. * Make sure to also set the refreshing prop correctly. diff --git a/Libraries/Lists/VirtualizedList.d.ts b/Libraries/Lists/VirtualizedList.d.ts index eaef385999f5..8bd0de170c23 100644 --- a/Libraries/Lists/VirtualizedList.d.ts +++ b/Libraries/Lists/VirtualizedList.d.ts @@ -262,10 +262,37 @@ export interface VirtualizedListWithoutRenderItemProps */ maxToRenderPerBatch?: number | undefined; + /** + * Called once when the scroll position gets within within `onEndReachedThreshold` + * from the logical end of the list. + */ onEndReached?: ((info: {distanceFromEnd: number}) => void) | null | undefined; + /** + * How far from the end (in units of visible length of the list) the trailing edge of the + * list must be from the end of the content to trigger the `onEndReached` callback. + * Thus, a value of 0.5 will trigger `onEndReached` when the end of the content is + * within half the visible length of the list. + */ onEndReachedThreshold?: number | null | undefined; + /** + * Called once when the scroll position gets within within `onStartReachedThreshold` + * from the logical start of the list. + */ + onStartReached?: + | ((info: {distanceFromStart: number}) => void) + | null + | undefined; + + /** + * How far from the start (in units of visible length of the list) the leading edge of the + * list must be from the start of the content to trigger the `onStartReached` callback. + * Thus, a value of 0.5 will trigger `onStartReached` when the start of the content is + * within half the visible length of the list. + */ + onStartReachedThreshold?: number | null | undefined; + onLayout?: ((event: LayoutChangeEvent) => void) | undefined; /** diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index e948a85bde49..c37ae0f0b072 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -50,7 +50,7 @@ import * as React from 'react'; export type {RenderItemProps, RenderItemType, Separators}; -const ON_END_REACHED_EPSILON = 0.001; +const ON_EDGE_REACHED_EPSILON = 0.001; let _usedIndexForKey = false; let _keylessItemComponentName: string = ''; @@ -68,6 +68,11 @@ type ViewabilityHelperCallbackTuple = { type State = { renderMask: CellRenderMask, cellsAroundViewport: {first: number, last: number}, + // Used to track items added at the start of the list for maintainVisibleContentPosition. + firstItemKey: ?string, + // When using maintainVisibleContentPosition we need to adjust the window to make sure + // make sure that the visible elements are still rendered. + maintainVisibleContentPositionAdjustment: ?number, }; /** @@ -90,11 +95,21 @@ function maxToRenderPerBatchOrDefault(maxToRenderPerBatch: ?number) { return maxToRenderPerBatch ?? 10; } +// onStartReachedThresholdOrDefault(this.props.onStartReachedThreshold) +function onStartReachedThresholdOrDefault(onStartReachedThreshold: ?number) { + return onStartReachedThreshold ?? 2; +} + // onEndReachedThresholdOrDefault(this.props.onEndReachedThreshold) function onEndReachedThresholdOrDefault(onEndReachedThreshold: ?number) { return onEndReachedThreshold ?? 2; } +// getScrollingThreshold(visibleLength, onEndReachedThreshold) +function getScrollingThreshold(threshold: number, visibleLength: number) { + return (threshold * visibleLength) / 2; +} + // scrollEventThrottleOrDefault(this.props.scrollEventThrottle) function scrollEventThrottleOrDefault(scrollEventThrottle: ?number) { return scrollEventThrottle ?? 50; @@ -118,6 +133,47 @@ function findLastWhere( return null; } +function extractKey( + props: { + keyExtractor?: ?(item: Item, index: number) => string, + ... + }, + item: Item, + index: number, +): string { + if (props.keyExtractor != null) { + return props.keyExtractor(item, index); + } + + const key = defaultKeyExtractor(item, index); + if (key === String(index)) { + _usedIndexForKey = true; + if (item.type && item.type.displayName) { + _keylessItemComponentName = item.type.displayName; + } + } + return key; +} + +function findItemIndexWithKey(props: Props, key: string): ?number { + for (let ii = 0; ii < props.getItemCount(props.data); ii++) { + const item = props.getItem(props.data, ii); + const curKey = extractKey(props, item, ii); + if (curKey === key) { + return ii; + } + } + return null; +} + +function getItemKey(props: Props, index: number): ?string { + const item = props.getItem(props.data, index); + if (item == null) { + return null; + } + return extractKey(props, item, 0); +} + /** * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist) * and [``](https://reactnative.dev/docs/sectionlist) components, which are also better @@ -454,13 +510,20 @@ export default class VirtualizedList extends StateSafePureComponent< this.state = { cellsAroundViewport: initialRenderRegion, - renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion), + renderMask: VirtualizedList._createRenderMask( + props, + initialRenderRegion, + null, + ), + firstItemKey: getItemKey(this.props, 0), + maintainVisibleContentPositionAdjustment: null, }; } static _createRenderMask( props: Props, cellsAroundViewport: {first: number, last: number}, + maintainVisibleContentPositionAdjustment: ?number, additionalRegions?: ?$ReadOnlyArray<{first: number, last: number}>, ): CellRenderMask { const itemCount = props.getItemCount(props.data); @@ -497,6 +560,16 @@ export default class VirtualizedList extends StateSafePureComponent< renderMask, cellsAroundViewport.first, ); + + if (maintainVisibleContentPositionAdjustment != null) { + renderMask.addCells({ + first: + cellsAroundViewport.first + + maintainVisibleContentPositionAdjustment, + last: + cellsAroundViewport.last + maintainVisibleContentPositionAdjustment, + }); + } } return renderMask; @@ -664,6 +737,21 @@ export default class VirtualizedList extends StateSafePureComponent< return prevState; } + let maintainVisibleContentPositionAdjustment = + prevState.maintainVisibleContentPositionAdjustment; + const newFirstItemKey = getItemKey(newProps, 0); + if ( + newProps.maintainVisibleContentPosition != null && + maintainVisibleContentPositionAdjustment == null && + prevState.firstItemKey != null && + newFirstItemKey != null + ) { + maintainVisibleContentPositionAdjustment = + newFirstItemKey !== prevState.firstItemKey + ? findItemIndexWithKey(newProps, prevState.firstItemKey) + : null; + } + const constrainedCells = VirtualizedList._constrainToItemCount( prevState.cellsAroundViewport, newProps, @@ -671,7 +759,13 @@ export default class VirtualizedList extends StateSafePureComponent< return { cellsAroundViewport: constrainedCells, - renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), + renderMask: VirtualizedList._createRenderMask( + newProps, + constrainedCells, + maintainVisibleContentPositionAdjustment, + ), + firstItemKey: newFirstItemKey, + maintainVisibleContentPositionAdjustment, }; } @@ -702,7 +796,7 @@ export default class VirtualizedList extends StateSafePureComponent< last = Math.min(end, last); for (let ii = first; ii <= last; ii++) { const item = getItem(data, ii); - const key = this._keyExtractor(item, ii, this.props); + const key = extractKey(this.props, item, ii); this._indicesToKeys.set(ii, key); if (stickyIndicesFromProps.has(ii + stickyOffset)) { stickyHeaderIndices.push(cells.length); @@ -771,29 +865,6 @@ export default class VirtualizedList extends StateSafePureComponent< _getSpacerKey = (isVertical: boolean): string => isVertical ? 'height' : 'width'; - _keyExtractor( - item: Item, - index: number, - props: { - keyExtractor?: ?(item: Item, index: number) => string, - ... - }, - // $FlowFixMe[missing-local-annot] - ) { - if (props.keyExtractor != null) { - return props.keyExtractor(item, index); - } - - const key = defaultKeyExtractor(item, index); - if (key === String(index)) { - _usedIndexForKey = true; - if (item.type && item.type.displayName) { - _keylessItemComponentName = item.type.displayName; - } - } - return key; - } - render(): React.Node { if (__DEV__) { const flatStyles = flattenStyle(this.props.contentContainerStyle); @@ -1116,6 +1187,7 @@ export default class VirtualizedList extends StateSafePureComponent< zoomScale: 1, }; _scrollRef: ?React.ElementRef = null; + _sentStartForContentLength = 0; _sentEndForContentLength = 0; _totalCellLength = 0; _totalCellsMeasured = 0; @@ -1216,6 +1288,7 @@ export default class VirtualizedList extends StateSafePureComponent< const renderMask = VirtualizedList._createRenderMask( this.props, this.state.cellsAroundViewport, + this.state.maintainVisibleContentPositionAdjustment, this._getNonViewportRenderRegions(this.props), ); @@ -1303,7 +1376,7 @@ export default class VirtualizedList extends StateSafePureComponent< } this.props.onLayout && this.props.onLayout(e); this._scheduleCellsToRenderUpdate(); - this._maybeCallOnEndReached(); + this._maybeCallOnEdgeReached(); }; _onLayoutEmpty = (e: LayoutEvent) => { @@ -1412,35 +1485,81 @@ export default class VirtualizedList extends StateSafePureComponent< return !horizontalOrDefault(this.props.horizontal) ? metrics.y : metrics.x; } - _maybeCallOnEndReached() { - const {data, getItemCount, onEndReached, onEndReachedThreshold} = - this.props; + _maybeCallOnEdgeReached() { + const { + data, + getItemCount, + onStartReached, + onStartReachedThreshold, + onEndReached, + onEndReachedThreshold, + initialScrollIndex, + } = this.props; + if (this.state.maintainVisibleContentPositionAdjustment != null) { + return; + } const {contentLength, visibleLength, offset} = this._scrollMetrics; + let distanceFromStart = offset; let distanceFromEnd = contentLength - visibleLength - offset; - // Especially when oERT is zero it's necessary to 'floor' very small distanceFromEnd values to be 0 + // Especially when oERT is zero it's necessary to 'floor' very small distance values to be 0 // since debouncing causes us to not fire this event for every single "pixel" we scroll and can thus - // be at the "end" of the list with a distanceFromEnd approximating 0 but not quite there. - if (distanceFromEnd < ON_END_REACHED_EPSILON) { + // be at the edge of the list with a distance approximating 0 but not quite there. + if (distanceFromStart < ON_EDGE_REACHED_EPSILON) { + distanceFromStart = 0; + } + if (distanceFromEnd < ON_EDGE_REACHED_EPSILON) { distanceFromEnd = 0; } // TODO: T121172172 Look into why we're "defaulting" to a threshold of 2 when oERT is not present - const threshold = - onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2; + const startThreshold = + onStartReachedThresholdOrDefault(onStartReachedThreshold) * visibleLength; + const endThreshold = + onEndReachedThresholdOrDefault(onEndReachedThreshold) * visibleLength; + const isWithinStartThreshold = distanceFromStart <= startThreshold; + const isWithinEndThreshold = distanceFromEnd <= endThreshold; + + // First check if the user just scrolled within the end threshold + // and call onEndReached only once for a given content length, + // and only if onStartReached is not being executed if ( onEndReached && this.state.cellsAroundViewport.last === getItemCount(data) - 1 && - distanceFromEnd <= threshold && + isWithinEndThreshold && this._scrollMetrics.contentLength !== this._sentEndForContentLength ) { - // Only call onEndReached once for a given content length this._sentEndForContentLength = this._scrollMetrics.contentLength; onEndReached({distanceFromEnd}); - } else if (distanceFromEnd > threshold) { - // If the user scrolls away from the end and back again cause - // an onEndReached to be triggered again - this._sentEndForContentLength = 0; + } + + // Next check if the user just scrolled within the start threshold + // and call onStartReached only once for a given content length, + // and only if onEndReached is not being executed + else if ( + onStartReached && + this.state.cellsAroundViewport.first === 0 && + isWithinStartThreshold && + this._scrollMetrics.contentLength !== this._sentStartForContentLength && + // On initial mount when using initialScrollIndex the offset will be 0 initially + // and will trigger an unexpected onStartReached. To avoid this we can use + // timestamp to differentiate between the initial scroll metrics and when we actually + // received the first scroll event. + (!initialScrollIndex || this._scrollMetrics.timestamp !== 0) + ) { + this._sentStartForContentLength = this._scrollMetrics.contentLength; + onStartReached({distanceFromStart}); + } + + // If the user scrolls away from the start or end and back again, + // cause onStartReached or onEndReached to be triggered again + else { + this._sentStartForContentLength = isWithinStartThreshold + ? this._sentStartForContentLength + : 0; + this._sentEndForContentLength = isWithinEndThreshold + ? this._sentEndForContentLength + : 0; } } @@ -1465,7 +1584,7 @@ export default class VirtualizedList extends StateSafePureComponent< } this._scrollMetrics.contentLength = this._selectLength({height, width}); this._scheduleCellsToRenderUpdate(); - this._maybeCallOnEndReached(); + this._maybeCallOnEdgeReached(); }; /* Translates metrics from a scroll event in a parent VirtualizedList into @@ -1549,11 +1668,16 @@ export default class VirtualizedList extends StateSafePureComponent< visibleLength, zoomScale, }; + if (this.state.maintainVisibleContentPositionAdjustment != null) { + this.setState({ + maintainVisibleContentPositionAdjustment: null, + }); + } this._updateViewableItems(this.props, this.state.cellsAroundViewport); if (!this.props) { return; } - this._maybeCallOnEndReached(); + this._maybeCallOnEdgeReached(); if (velocity !== 0) { this._fillRateHelper.activate(); } @@ -1562,21 +1686,30 @@ export default class VirtualizedList extends StateSafePureComponent< }; _scheduleCellsToRenderUpdate() { + if (this.state.maintainVisibleContentPositionAdjustment != null) { + return; + } const {first, last} = this.state.cellsAroundViewport; const {offset, visibleLength, velocity} = this._scrollMetrics; const itemCount = this.props.getItemCount(this.props.data); let hiPri = false; + const onStartReachedThreshold = onStartReachedThresholdOrDefault( + this.props.onStartReachedThreshold, + ); const onEndReachedThreshold = onEndReachedThresholdOrDefault( this.props.onEndReachedThreshold, ); - const scrollingThreshold = (onEndReachedThreshold * visibleLength) / 2; // Mark as high priority if we're close to the start of the first item // But only if there are items before the first rendered item if (first > 0) { const distTop = offset - this.__getFrameMetricsApprox(first, this.props).offset; hiPri = - hiPri || distTop < 0 || (velocity < -2 && distTop < scrollingThreshold); + hiPri || + distTop < 0 || + (velocity < -2 && + distTop < + getScrollingThreshold(onStartReachedThreshold, visibleLength)); } // Mark as high priority if we're close to the end of the last item // But only if there are items after the last rendered item @@ -1587,7 +1720,9 @@ export default class VirtualizedList extends StateSafePureComponent< hiPri = hiPri || distBottom < 0 || - (velocity > 2 && distBottom < scrollingThreshold); + (velocity > 2 && + distBottom < + getScrollingThreshold(onEndReachedThreshold, visibleLength)); } // Only trigger high-priority updates if we've actually rendered cells, // and with that size estimate, accurately compute how many cells we should render. @@ -1660,6 +1795,7 @@ export default class VirtualizedList extends StateSafePureComponent< const renderMask = VirtualizedList._createRenderMask( props, cellsAroundViewport, + state.maintainVisibleContentPositionAdjustment, this._getNonViewportRenderRegions(props), ); @@ -1686,7 +1822,7 @@ export default class VirtualizedList extends StateSafePureComponent< return { index, item, - key: this._keyExtractor(item, index, props), + key: extractKey(props, item, index), isViewable, }; }; @@ -1753,7 +1889,8 @@ export default class VirtualizedList extends StateSafePureComponent< 'Tried to get frame for out of range index ' + index, ); const item = getItem(data, index); - const frame = item && this._frames[this._keyExtractor(item, index, props)]; + const frame = + item != null ? this._frames[extractKey(props, item, index)] : undefined; if (!frame || frame.index !== index) { if (getItemLayout) { /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment diff --git a/Libraries/Lists/VirtualizedListProps.js b/Libraries/Lists/VirtualizedListProps.js index 59e57f2bb6df..317afc9f70b5 100644 --- a/Libraries/Lists/VirtualizedListProps.js +++ b/Libraries/Lists/VirtualizedListProps.js @@ -170,18 +170,29 @@ type OptionalProps = {| */ maxToRenderPerBatch?: ?number, /** - * Called once when the scroll position gets within `onEndReachedThreshold` of the rendered - * content. + * Called once when the scroll position gets within within `onEndReachedThreshold` + * from the logical end of the list. */ onEndReached?: ?(info: {distanceFromEnd: number, ...}) => void, /** - * How far from the end (in units of visible length of the list) the bottom edge of the + * How far from the end (in units of visible length of the list) the trailing edge of the * list must be from the end of the content to trigger the `onEndReached` callback. - * Thus a value of 0.5 will trigger `onEndReached` when the end of the content is - * within half the visible length of the list. A value of 0 will not trigger until scrolling - * to the very end of the list. + * Thus, a value of 0.5 will trigger `onEndReached` when the end of the content is + * within half the visible length of the list. */ onEndReachedThreshold?: ?number, + /** + * Called once when the scroll position gets within within `onStartReachedThreshold` + * from the logical start of the list. + */ + onStartReached?: ?(info: {distanceFromStart: number, ...}) => void, + /** + * How far from the start (in units of visible length of the list) the leading edge of the + * list must be from the start of the content to trigger the `onStartReached` callback. + * Thus, a value of 0.5 will trigger `onStartReached` when the start of the content is + * within half the visible length of the list. + */ + onStartReachedThreshold?: ?number, /** * If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality. Make * sure to also set the `refreshing` prop correctly. diff --git a/Libraries/Lists/__tests__/VirtualizedList-test.js b/Libraries/Lists/__tests__/VirtualizedList-test.js index e98428a5347b..e05f68ee8708 100644 --- a/Libraries/Lists/__tests__/VirtualizedList-test.js +++ b/Libraries/Lists/__tests__/VirtualizedList-test.js @@ -382,6 +382,168 @@ describe('VirtualizedList', () => { expect(scrollRef.measureLayout).toBeInstanceOf(jest.fn().constructor); expect(scrollRef.measureInWindow).toBeInstanceOf(jest.fn().constructor); }); + + it('calls onStartReached when near the start', () => { + const ITEM_HEIGHT = 40; + const layout = {width: 300, height: 600}; + let data = Array(40) + .fill() + .map((_, index) => ({key: `key-${index}`})); + const onStartReached = jest.fn(); + const props = { + data, + initialNumToRender: 10, + onStartReachedThreshold: 1, + windowSize: 10, + renderItem: ({item}) => , + getItem: (items, index) => items[index], + getItemCount: items => items.length, + getItemLayout: (items, index) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }), + onStartReached, + initialScrollIndex: data.length - 1, + }; + + const component = ReactTestRenderer.create(); + + const instance = component.getInstance(); + + instance._onLayout({nativeEvent: {layout, zoomScale: 1}}); + instance._onContentSizeChange(300, data.length * ITEM_HEIGHT); + + // Make sure onStartReached is not called initially when initialScrollIndex is set. + performAllBatches(); + expect(onStartReached).not.toHaveBeenCalled(); + + // Scroll for a small amount and make sure onStartReached is not called. + instance._onScroll({ + timeStamp: 1000, + nativeEvent: { + contentOffset: {y: (data.length - 2) * ITEM_HEIGHT, x: 0}, + layoutMeasurement: layout, + contentSize: {...layout, height: data.length * ITEM_HEIGHT}, + zoomScale: 1, + contentInset: {right: 0, top: 0, left: 0, bottom: 0}, + }, + }); + performAllBatches(); + expect(onStartReached).not.toHaveBeenCalled(); + + // Scroll to start and make sure onStartReached is called. + instance._onScroll({ + timeStamp: 1000, + nativeEvent: { + contentOffset: {y: 0, x: 0}, + layoutMeasurement: layout, + contentSize: {...layout, height: data.length * ITEM_HEIGHT}, + zoomScale: 1, + contentInset: {right: 0, top: 0, left: 0, bottom: 0}, + }, + }); + performAllBatches(); + expect(onStartReached).toHaveBeenCalled(); + }); + + it('calls onStartReached initially', () => { + const ITEM_HEIGHT = 40; + const layout = {width: 300, height: 600}; + let data = Array(40) + .fill() + .map((_, index) => ({key: `key-${index}`})); + const onStartReached = jest.fn(); + const props = { + data, + initialNumToRender: 10, + onStartReachedThreshold: 1, + windowSize: 10, + renderItem: ({item}) => , + getItem: (items, index) => items[index], + getItemCount: items => items.length, + getItemLayout: (items, index) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }), + onStartReached, + }; + + const component = ReactTestRenderer.create(); + + const instance = component.getInstance(); + + instance._onLayout({nativeEvent: {layout, zoomScale: 1}}); + instance._onContentSizeChange(300, data.length * ITEM_HEIGHT); + + performAllBatches(); + expect(onStartReached).toHaveBeenCalled(); + }); + + it('calls onEndReached when near the end', () => { + const ITEM_HEIGHT = 40; + const layout = {width: 300, height: 600}; + let data = Array(40) + .fill() + .map((_, index) => ({key: `key-${index}`})); + const onEndReached = jest.fn(); + const props = { + data, + initialNumToRender: 10, + onEndReachedThreshold: 1, + windowSize: 10, + renderItem: ({item}) => , + getItem: (items, index) => items[index], + getItemCount: items => items.length, + getItemLayout: (items, index) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }), + onEndReached, + }; + + const component = ReactTestRenderer.create(); + + const instance = component.getInstance(); + + instance._onLayout({nativeEvent: {layout, zoomScale: 1}}); + instance._onContentSizeChange(300, data.length * ITEM_HEIGHT); + + // Make sure onEndReached is not called initially. + performAllBatches(); + expect(onEndReached).not.toHaveBeenCalled(); + + // Scroll for a small amount and make sure onEndReached is not called. + instance._onScroll({ + timeStamp: 1000, + nativeEvent: { + contentOffset: {y: ITEM_HEIGHT, x: 0}, + layoutMeasurement: layout, + contentSize: {...layout, height: data.length * ITEM_HEIGHT}, + zoomScale: 1, + contentInset: {right: 0, top: 0, left: 0, bottom: 0}, + }, + }); + performAllBatches(); + expect(onEndReached).not.toHaveBeenCalled(); + + // Scroll to end and make sure onEndReached is called. + instance._onScroll({ + timeStamp: 1000, + nativeEvent: { + contentOffset: {y: data.length * ITEM_HEIGHT, x: 0}, + layoutMeasurement: layout, + contentSize: {...layout, height: data.length * ITEM_HEIGHT}, + zoomScale: 1, + contentInset: {right: 0, top: 0, left: 0, bottom: 0}, + }, + }); + performAllBatches(); + expect(onEndReached).toHaveBeenCalled(); + }); + it('does not call onEndReached when onContentSizeChange happens after onLayout', () => { const ITEM_HEIGHT = 40; const layout = {width: 300, height: 600}; diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 2ebbb0e82ac9..a9638f650a91 100644 --- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -23,6 +23,7 @@ #import "RCTConversions.h" #import "RCTEnhancedScrollView.h" #import "RCTFabricComponentsPlugins.h" +#import "RCTPullToRefreshViewComponentView.h" using namespace facebook::react; @@ -100,6 +101,11 @@ @implementation RCTScrollViewComponentView { BOOL _shouldUpdateContentInsetAdjustmentBehavior; CGPoint _contentOffsetWhenClipped; + + __weak UIView *_contentView; + + CGRect _prevFirstVisibleFrame; + __weak UIView *_firstVisibleView; } + (RCTScrollViewComponentView *_Nullable)findScrollViewComponentViewForView:(UIView *)view @@ -149,10 +155,17 @@ - (void)dealloc #pragma mark - RCTMountingTransactionObserving +- (void)mountingTransactionWillMount:(const facebook::react::MountingTransaction &)transaction + withSurfaceTelemetry:(const facebook::react::SurfaceTelemetry &)surfaceTelemetry +{ + [self _prepareForMaintainVisibleScrollPosition]; +} + - (void)mountingTransactionDidMount:(MountingTransaction const &)transaction withSurfaceTelemetry:(facebook::react::SurfaceTelemetry const &)surfaceTelemetry { [self _remountChildren]; + [self _adjustForMaintainVisibleContentPosition]; } #pragma mark - RCTComponentViewProtocol @@ -337,11 +350,23 @@ - (void)_preserveContentOffsetIfNeededWithBlock:(void (^)())block - (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { [_containerView insertSubview:childComponentView atIndex:index]; + if ([childComponentView isKindOfClass:RCTPullToRefreshViewComponentView.class]) { + // Ignore the pull to refresh component. + } else { + RCTAssert(_contentView == nil, @"RCTScrollView may only contain a single subview."); + _contentView = childComponentView; + } } - (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { [childComponentView removeFromSuperview]; + if ([childComponentView isKindOfClass:RCTPullToRefreshViewComponentView.class]) { + // Ignore the pull to refresh component. + } else { + RCTAssert(_contentView == childComponentView, @"Attempted to remove non-existent subview"); + _contentView = nil; + } } /* @@ -404,6 +429,9 @@ - (void)prepareForRecycle CGRect oldFrame = self.frame; self.frame = CGRectZero; self.frame = oldFrame; + _contentView = nil; + _prevFirstVisibleFrame = CGRectZero; + _firstVisibleView = nil; [super prepareForRecycle]; } @@ -684,6 +712,74 @@ - (void)removeScrollListener:(NSObject *)scrollListener [self.scrollViewDelegateSplitter removeDelegate:scrollListener]; } +#pragma mark - Maintain visible content position + +- (void)_prepareForMaintainVisibleScrollPosition +{ + const auto &props = *std::static_pointer_cast(_props); + if (!props.maintainVisibleContentPosition) { + return; + } + + BOOL horizontal = _scrollView.contentSize.width > self.frame.size.width; + int minIdx = props.maintainVisibleContentPosition.value().minIndexForVisible; + for (NSUInteger ii = minIdx; ii < _contentView.subviews.count; ++ii) { + // Find the first entirely visible view. + UIView *subview = _contentView.subviews[ii]; + BOOL hasNewView = NO; + if (horizontal) { + hasNewView = subview.frame.origin.x > _scrollView.contentOffset.x; + } else { + hasNewView = subview.frame.origin.y > _scrollView.contentOffset.y; + } + if (hasNewView || ii == _contentView.subviews.count - 1) { + _prevFirstVisibleFrame = subview.frame; + _firstVisibleView = subview; + break; + } + } +} + +- (void)_adjustForMaintainVisibleContentPosition +{ + const auto &props = *std::static_pointer_cast(_props); + if (!props.maintainVisibleContentPosition) { + return; + } + + std::optional autoscrollThreshold = props.maintainVisibleContentPosition.value().autoscrollToTopThreshold; + BOOL horizontal = _scrollView.contentSize.width > self.frame.size.width; + // TODO: detect and handle/ignore re-ordering + if (horizontal) { + CGFloat deltaX = _firstVisibleView.frame.origin.x - _prevFirstVisibleFrame.origin.x; + if (ABS(deltaX) > 0.5) { + CGFloat x = _scrollView.contentOffset.x; + [self _forceDispatchNextScrollEvent]; + _scrollView.contentOffset = CGPointMake(_scrollView.contentOffset.x + deltaX, _scrollView.contentOffset.y); + if (autoscrollThreshold) { + // If the offset WAS within the threshold of the start, animate to the start. + if (x <= autoscrollThreshold.value()) { + [self scrollToOffset:CGPointMake(0, _scrollView.contentOffset.y) animated:YES]; + } + } + } + } else { + CGRect newFrame = _firstVisibleView.frame; + CGFloat deltaY = newFrame.origin.y - _prevFirstVisibleFrame.origin.y; + if (ABS(deltaY) > 0.5) { + CGFloat y = _scrollView.contentOffset.y; + [self _forceDispatchNextScrollEvent]; + _scrollView.contentOffset = CGPointMake(_scrollView.contentOffset.x, _scrollView.contentOffset.y + deltaY); + if (autoscrollThreshold) { + // If the offset WAS within the threshold of the start, animate to the start. + if (y <= autoscrollThreshold.value()) { + [self scrollToOffset:CGPointMake(_scrollView.contentOffset.x, 0) animated:YES]; + } + } + } + } +} + @end Class RCTScrollViewCls(void) diff --git a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.java b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.java index 0f52b73c6162..cf3ca7f04f72 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.java +++ b/ReactAndroid/src/main/java/com/facebook/react/animated/NativeAnimatedModule.java @@ -299,6 +299,16 @@ public void didScheduleMountItems(UIManager uiManager) { mCurrentFrameNumber++; } + @Override + public void willMountItems(UIManager uiManager) { + // noop + } + + @Override + public void didMountItems(UIManager uiManager) { + // noop + } + // For FabricUIManager only @Override @UiThread diff --git a/ReactAndroid/src/main/java/com/facebook/react/bridge/UIManagerListener.java b/ReactAndroid/src/main/java/com/facebook/react/bridge/UIManagerListener.java index 984bcf15848e..72837cbc2e6b 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/bridge/UIManagerListener.java +++ b/ReactAndroid/src/main/java/com/facebook/react/bridge/UIManagerListener.java @@ -12,10 +12,33 @@ public interface UIManagerListener { /** * Called right before view updates are dispatched at the end of a batch. This is useful if a * module needs to add UIBlocks to the queue before it is flushed. + * + * This is called by Paper only. */ void willDispatchViewUpdates(UIManager uiManager); - /* Called right after view updates are dispatched for a frame. */ + /** + * Called on UIThread right before view updates are executed. + * + * This is called by Fabric only. + */ + void willMountItems(UIManager uiManager); + /** + * Called on UIThread right after view updates are executed. + * + * This is called by Fabric only. + */ + void didMountItems(UIManager uiManager); + /** + * Called on UIThread right after view updates are dispatched for a frame. Note that this will be called + * for every frame even if there are no updates. + * + * This is called by Fabric only. + */ void didDispatchMountItems(UIManager uiManager); - /* Called right after scheduleMountItems is called in Fabric, after a new tree is committed. */ + /** + * Called right after scheduleMountItems is called in Fabric, after a new tree is committed. + * + * This is called by Fabric only. + */ void didScheduleMountItems(UIManager uiManager); } diff --git a/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java b/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java index e0947264fd95..f8f11e8578e2 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/fabric/FabricUIManager.java @@ -1202,6 +1202,20 @@ public Map getPerformanceCounters() { } private class MountItemDispatchListener implements MountItemDispatcher.ItemDispatchListener { + @Override + public void willMountItems() { + for (UIManagerListener listener : mListeners) { + listener.willMountItems(FabricUIManager.this); + } + } + + @Override + public void didMountItems() { + for (UIManagerListener listener : mListeners) { + listener.didMountItems(FabricUIManager.this); + } + } + @Override public void didDispatchMountItems() { for (UIManagerListener listener : mListeners) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.java b/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.java index a14cc858c5d3..5e92ccfa16b1 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.java +++ b/ReactAndroid/src/main/java/com/facebook/react/fabric/mounting/MountItemDispatcher.java @@ -193,6 +193,8 @@ private boolean dispatchMountItems() { return false; } + mItemDispatchListener.willMountItems(); + // As an optimization, execute all ViewCommands first // This should be: // 1) Performant: ViewCommands are often a replacement for SetNativeProps, which we've always @@ -240,6 +242,8 @@ private boolean dispatchMountItems() { } } + mItemDispatchListener.didMountItems(); + Systrace.endSection(Systrace.TRACE_TAG_REACT_JAVA_BRIDGE); } @@ -411,6 +415,8 @@ private static void printMountItem(MountItem mountItem, String prefix) { } public interface ItemDispatchListener { + void willMountItems(); + void didMountItems(); void didDispatchMountItems(); } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java new file mode 100644 index 000000000000..2c3d343e2474 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/MaintainVisibleScrollPositionHelper.java @@ -0,0 +1,205 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.scroll; + +import android.graphics.Rect; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import com.facebook.infer.annotation.Assertions; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.UIManager; +import com.facebook.react.bridge.UIManagerListener; +import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.uimanager.UIManagerHelper; +import com.facebook.react.uimanager.common.UIManagerType; +import com.facebook.react.uimanager.common.ViewUtil; +import com.facebook.react.views.view.ReactViewGroup; +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll; + +import java.lang.ref.WeakReference; + +/** + * Manage state for the maintainVisibleContentPosition prop. + * + * This uses UIManager to listen to updates and capture position of items before and after layout. + */ +public class MaintainVisibleScrollPositionHelper implements UIManagerListener { + private final ScrollViewT mScrollView; + private final boolean mHorizontal; + private @Nullable Config mConfig; + private @Nullable WeakReference mFirstVisibleView = null; + private @Nullable Rect mPrevFirstVisibleFrame = null; + private boolean mListening = false; + + public static class Config { + public final int minIndexForVisible; + public final @Nullable Integer autoScrollToTopThreshold; + + Config(int minIndexForVisible, @Nullable Integer autoScrollToTopThreshold) { + this.minIndexForVisible = minIndexForVisible; + this.autoScrollToTopThreshold = autoScrollToTopThreshold; + } + + static Config fromReadableMap(ReadableMap value) { + int minIndexForVisible = value.getInt("minIndexForVisible"); + Integer autoScrollToTopThreshold = + value.hasKey("autoscrollToTopThreshold") + ? value.getInt("autoscrollToTopThreshold") + : null; + return new Config(minIndexForVisible, autoScrollToTopThreshold); + } + } + + public MaintainVisibleScrollPositionHelper(ScrollViewT scrollView, boolean horizontal) { + mScrollView = scrollView; + mHorizontal = horizontal; + } + + public void setConfig(@Nullable Config config) { + mConfig = config; + } + + /** + * Start listening to view hierarchy updates. Should be called when this is created. + */ + public void start() { + if (mListening) { + return; + } + mListening = true; + getUIManagerModule().addUIManagerEventListener(this); + } + + /** + * Stop listening to view hierarchy updates. Should be called before this is destroyed. + */ + public void stop() { + if (!mListening) { + return; + } + mListening = false; + getUIManagerModule().removeUIManagerEventListener(this); + } + + /** + * Update the scroll position of the managed ScrollView. This should be called after layout + * has been updated. + */ + public void updateScrollPosition() { + // On Fabric this will be called internally in `didMountItems`. + if (ViewUtil.getUIManagerType(mScrollView.getId()) == UIManagerType.FABRIC) { + return; + } + updateScrollPositionInternal(); + } + + private void updateScrollPositionInternal() { + if (mConfig == null + || mFirstVisibleView == null + || mPrevFirstVisibleFrame == null) { + return; + } + + View firstVisibleView = mFirstVisibleView.get(); + Rect newFrame = new Rect(); + firstVisibleView.getHitRect(newFrame); + + if (mHorizontal) { + int deltaX = newFrame.left - mPrevFirstVisibleFrame.left; + if (deltaX != 0) { + int scrollX = mScrollView.getScrollX(); + mScrollView.scrollTo(scrollX + deltaX, mScrollView.getScrollY()); + mPrevFirstVisibleFrame = newFrame; + if (mConfig.autoScrollToTopThreshold != null && scrollX <= mConfig.autoScrollToTopThreshold) { + mScrollView.reactSmoothScrollTo(0, mScrollView.getScrollY()); + } + } + } else { + int deltaY = newFrame.top - mPrevFirstVisibleFrame.top; + if (deltaY != 0) { + int scrollY = mScrollView.getScrollY(); + mScrollView.scrollTo(mScrollView.getScrollX(), scrollY + deltaY); + mPrevFirstVisibleFrame = newFrame; + if (mConfig.autoScrollToTopThreshold != null && scrollY <= mConfig.autoScrollToTopThreshold) { + mScrollView.reactSmoothScrollTo(mScrollView.getScrollX(), 0); + } + } + } + } + + private @Nullable ReactViewGroup getContentView() { + return (ReactViewGroup) mScrollView.getChildAt(0); + } + + private UIManager getUIManagerModule() { + return Assertions.assertNotNull( + UIManagerHelper.getUIManager( + (ReactContext) mScrollView.getContext(), + ViewUtil.getUIManagerType(mScrollView.getId()))); + } + + private void computeTargetView() { + if (mConfig == null) { + return; + } + ReactViewGroup contentView = getContentView(); + if (contentView == null) { + return; + } + + int currentScroll = mHorizontal ? mScrollView.getScrollX() : mScrollView.getScrollY(); + for (int i = mConfig.minIndexForVisible; i < contentView.getChildCount(); i++) { + View child = contentView.getChildAt(i); + float position = mHorizontal ? child.getX() : child.getY(); + if (position > currentScroll || i == contentView.getChildCount() - 1) { + mFirstVisibleView = new WeakReference<>(child); + Rect frame = new Rect(); + child.getHitRect(frame); + mPrevFirstVisibleFrame = frame; + break; + } + } + } + + // UIManagerListener + + @Override + public void willDispatchViewUpdates(final UIManager uiManager) { + UiThreadUtil.runOnUiThread( + new Runnable() { + @Override + public void run() { + computeTargetView(); + } + }); + } + + @Override + public void willMountItems(UIManager uiManager) { + computeTargetView(); + } + + @Override + public void didMountItems(UIManager uiManager) { + updateScrollPositionInternal(); + } + + @Override + public void didDispatchMountItems(UIManager uiManager) { + // noop + } + + @Override + public void didScheduleMountItems(UIManager uiManager) { + // noop + } +} diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java index 3c962f03565a..3f38f42ff268 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java @@ -45,6 +45,7 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator; import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle; import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState; +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll; import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState; import com.facebook.react.views.view.ReactViewBackgroundManager; import java.lang.reflect.Field; @@ -54,11 +55,14 @@ /** Similar to {@link ReactScrollView} but only supports horizontal scrolling. */ public class ReactHorizontalScrollView extends HorizontalScrollView implements ReactClippingViewGroup, + ViewGroup.OnHierarchyChangeListener, + View.OnLayoutChangeListener, FabricViewStateManager.HasFabricViewStateManager, ReactOverflowViewWithInset, HasScrollState, HasFlingAnimator, - HasScrollEventThrottle { + HasScrollEventThrottle, + HasSmoothScroll { private static boolean DEBUG_MODE = false && ReactBuildConfig.DEBUG; private static String TAG = ReactHorizontalScrollView.class.getSimpleName(); @@ -107,6 +111,8 @@ public class ReactHorizontalScrollView extends HorizontalScrollView private PointerEvents mPointerEvents = PointerEvents.AUTO; private long mLastScrollDispatchTime = 0; private int mScrollEventThrottle = 0; + private @Nullable View mContentView; + private @Nullable MaintainVisibleScrollPositionHelper mMaintainVisibleContentPositionHelper; private final Rect mTempRect = new Rect(); @@ -127,6 +133,8 @@ public ReactHorizontalScrollView(Context context, @Nullable FpsListener fpsListe I18nUtil.getInstance().isRTL(context) ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR); + + setOnHierarchyChangeListener(this); } public boolean getScrollEnabled() { @@ -243,6 +251,19 @@ public void setOverflow(String overflow) { invalidate(); } + public void setMaintainVisibleContentPosition(@Nullable MaintainVisibleScrollPositionHelper.Config config) { + if (config != null && mMaintainVisibleContentPositionHelper == null) { + mMaintainVisibleContentPositionHelper = new MaintainVisibleScrollPositionHelper(this, true); + mMaintainVisibleContentPositionHelper.start(); + } else if (config == null && mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.stop(); + mMaintainVisibleContentPositionHelper = null; + } + if (mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.setConfig(config); + } + } + @Override public @Nullable String getOverflow() { return mOverflow; @@ -635,6 +656,17 @@ protected void onAttachedToWindow() { if (mRemoveClippedSubviews) { updateClippingRect(); } + if (mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.start(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.stop(); + } } @Override @@ -714,6 +746,18 @@ protected void onOverScrolled(int scrollX, int scrollY, boolean clampedX, boolea super.onOverScrolled(scrollX, scrollY, clampedX, clampedY); } + @Override + public void onChildViewAdded(View parent, View child) { + mContentView = child; + mContentView.addOnLayoutChangeListener(this); + } + + @Override + public void onChildViewRemoved(View parent, View child) { + mContentView.removeOnLayoutChangeListener(this); + mContentView = null; + } + private void enableFpsListener() { if (isScrollPerfLoggingEnabled()) { Assertions.assertNotNull(mFpsListener); @@ -1237,6 +1281,26 @@ private void setPendingContentOffsets(int x, int y) { } } + @Override + public void onLayoutChange( + View v, + int left, + int top, + int right, + int bottom, + int oldLeft, + int oldTop, + int oldRight, + int oldBottom) { + if (mContentView == null) { + return; + } + + if (mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.updateScrollPosition(); + } + } + @Override public FabricViewStateManager getFabricViewStateManager() { return mFabricViewStateManager; diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java index aada6a7c4079..4c29d92fc866 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.java @@ -328,6 +328,16 @@ public void setContentOffset(ReactHorizontalScrollView view, ReadableMap value) } } + @ReactProp(name = "maintainVisibleContentPosition") + public void setMaintainVisibleContentPosition(ReactHorizontalScrollView view, ReadableMap value) { + if (value != null) { + view.setMaintainVisibleContentPosition( + MaintainVisibleScrollPositionHelper.Config.fromReadableMap(value)); + } else { + view.setMaintainVisibleContentPosition(null); + } + } + @ReactProp(name = ViewProps.POINTER_EVENTS) public void setPointerEvents(ReactHorizontalScrollView view, @Nullable String pointerEventsStr) { view.setPointerEvents(PointerEvents.parsePointerEvents(pointerEventsStr)); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java index ae0a51274e71..128af8535ef7 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java @@ -45,8 +45,10 @@ import com.facebook.react.views.scroll.ReactScrollViewHelper.HasFlingAnimator; import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollEventThrottle; import com.facebook.react.views.scroll.ReactScrollViewHelper.HasScrollState; +import com.facebook.react.views.scroll.ReactScrollViewHelper.HasSmoothScroll; import com.facebook.react.views.scroll.ReactScrollViewHelper.ReactScrollViewScrollState; import com.facebook.react.views.view.ReactViewBackgroundManager; + import java.lang.reflect.Field; import java.util.List; @@ -65,7 +67,8 @@ public class ReactScrollView extends ScrollView ReactOverflowViewWithInset, HasScrollState, HasFlingAnimator, - HasScrollEventThrottle { + HasScrollEventThrottle, + HasSmoothScroll { private static @Nullable Field sScrollerField; private static boolean sTriedToGetScrollerField = false; @@ -109,6 +112,7 @@ public class ReactScrollView extends ScrollView private PointerEvents mPointerEvents = PointerEvents.AUTO; private long mLastScrollDispatchTime = 0; private int mScrollEventThrottle = 0; + private @Nullable MaintainVisibleScrollPositionHelper mMaintainVisibleContentPositionHelper = null; public ReactScrollView(Context context) { this(context, null); @@ -240,6 +244,19 @@ public void setOverflow(String overflow) { invalidate(); } + public void setMaintainVisibleContentPosition(@Nullable MaintainVisibleScrollPositionHelper.Config config) { + if (config != null && mMaintainVisibleContentPositionHelper == null) { + mMaintainVisibleContentPositionHelper = new MaintainVisibleScrollPositionHelper(this, false); + mMaintainVisibleContentPositionHelper.start(); + } else if (config == null && mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.stop(); + mMaintainVisibleContentPositionHelper = null; + } + if (mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.setConfig(config); + } + } + @Override public @Nullable String getOverflow() { return mOverflow; @@ -290,6 +307,17 @@ protected void onAttachedToWindow() { if (mRemoveClippedSubviews) { updateClippingRect(); } + if (mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.start(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.stop(); + } } /** @@ -1071,6 +1099,10 @@ public void onLayoutChange( return; } + if (mMaintainVisibleContentPositionHelper != null) { + mMaintainVisibleContentPositionHelper.updateScrollPosition(); + } + int currentScrollY = getScrollY(); int maxScrollY = getMaxScrollY(); if (currentScrollY > maxScrollY) { diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java index b574cb501b04..81d2382b212f 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.java @@ -602,4 +602,8 @@ public interface HasScrollEventThrottle { /** Get the scroll view dispatch time for throttling */ long getLastScrollDispatchTime(); } + + public interface HasSmoothScroll { + void reactSmoothScrollTo(int x, int y); + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java index 933455e8056a..e98a9c8c0f9d 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java @@ -329,6 +329,16 @@ public void setContentOffset(ReactScrollView view, ReadableMap value) { } } + @ReactProp(name = "maintainVisibleContentPosition") + public void setMaintainVisibleContentPosition(ReactScrollView view, ReadableMap value) { + if (value != null) { + view.setMaintainVisibleContentPosition( + MaintainVisibleScrollPositionHelper.Config.fromReadableMap(value)); + } else { + view.setMaintainVisibleContentPosition(null); + } + } + @Override public Object updateState( ReactScrollView view, ReactStylesDiffMap props, StateWrapper stateWrapper) { diff --git a/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp b/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp index 4eff278fdfc1..44bc80185068 100644 --- a/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp +++ b/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp @@ -127,6 +127,15 @@ ScrollViewProps::ScrollViewProps( "keyboardDismissMode", sourceProps.keyboardDismissMode, {})), + maintainVisibleContentPosition( + CoreFeatures::enablePropIteratorSetter + ? sourceProps.maintainVisibleContentPosition + : convertRawProp( + context, + rawProps, + "maintainVisibleContentPosition", + sourceProps.maintainVisibleContentPosition, + {})), maximumZoomScale( CoreFeatures::enablePropIteratorSetter ? sourceProps.maximumZoomScale @@ -336,6 +345,7 @@ void ScrollViewProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(directionalLockEnabled, {}); RAW_SET_PROP_SWITCH_CASE_BASIC(indicatorStyle, {}); RAW_SET_PROP_SWITCH_CASE_BASIC(keyboardDismissMode, {}); + RAW_SET_PROP_SWITCH_CASE_BASIC(maintainVisibleContentPosition, {}); RAW_SET_PROP_SWITCH_CASE_BASIC(maximumZoomScale, (Float)1.0); RAW_SET_PROP_SWITCH_CASE_BASIC(minimumZoomScale, (Float)1.0); RAW_SET_PROP_SWITCH_CASE_BASIC(scrollEnabled, true); @@ -413,6 +423,10 @@ SharedDebugStringConvertibleList ScrollViewProps::getDebugProps() const { "keyboardDismissMode", keyboardDismissMode, defaultScrollViewProps.keyboardDismissMode), + debugStringConvertibleItem( + "maintainVisibleContentPosition", + maintainVisibleContentPosition, + defaultScrollViewProps.maintainVisibleContentPosition), debugStringConvertibleItem( "maximumZoomScale", maximumZoomScale, diff --git a/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h b/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h index bd53bc4bb22d..3e30c30cfe4b 100644 --- a/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h +++ b/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h @@ -11,6 +11,8 @@ #include #include +#include + namespace facebook { namespace react { @@ -43,6 +45,7 @@ class ScrollViewProps final : public ViewProps { bool directionalLockEnabled{}; ScrollViewIndicatorStyle indicatorStyle{}; ScrollViewKeyboardDismissMode keyboardDismissMode{}; + std::optional maintainVisibleContentPosition{}; Float maximumZoomScale{1.0f}; Float minimumZoomScale{1.0f}; bool scrollEnabled{true}; diff --git a/ReactCommon/react/renderer/components/scrollview/conversions.h b/ReactCommon/react/renderer/components/scrollview/conversions.h index 4605f08ea203..97da851fdb82 100644 --- a/ReactCommon/react/renderer/components/scrollview/conversions.h +++ b/ReactCommon/react/renderer/components/scrollview/conversions.h @@ -10,6 +10,7 @@ #include #include #include +#include namespace facebook { namespace react { @@ -98,6 +99,28 @@ inline void fromRawValue( abort(); } +inline void fromRawValue( + const PropsParserContext &context, + const RawValue &value, + ScrollViewMaintainVisibleContentPosition &result) { + auto map = (butter::map)value; + + auto minIndexForVisible = map.find("minIndexForVisible"); + if (minIndexForVisible != map.end()) { + fromRawValue( + context, minIndexForVisible->second, result.minIndexForVisible); + } + auto autoscrollToTopThreshold = map.find("autoscrollToTopThreshold"); + if (autoscrollToTopThreshold != map.end()) { + fromRawValue( + context, + autoscrollToTopThreshold->second, + result.autoscrollToTopThreshold); + } +} + +#if RN_DEBUG_STRING_CONVERTIBLE + inline std::string toString(const ScrollViewSnapToAlignment &value) { switch (value) { case ScrollViewSnapToAlignment::Start: @@ -144,5 +167,17 @@ inline std::string toString(const ContentInsetAdjustmentBehavior &value) { } } +inline std::string toString( + const std::optional &value) { + if (!value) { + return "null"; + } + return "{minIndexForVisible: " + toString(value.value().minIndexForVisible) + + ", autoscrollToTopThreshold: " + + toString(value.value().autoscrollToTopThreshold) + "}"; +} + +#endif + } // namespace react } // namespace facebook diff --git a/ReactCommon/react/renderer/components/scrollview/primitives.h b/ReactCommon/react/renderer/components/scrollview/primitives.h index fe8a60e21d7c..f05f1125c3d0 100644 --- a/ReactCommon/react/renderer/components/scrollview/primitives.h +++ b/ReactCommon/react/renderer/components/scrollview/primitives.h @@ -7,6 +7,9 @@ #pragma once +#include +#include + namespace facebook { namespace react { @@ -23,5 +26,20 @@ enum class ContentInsetAdjustmentBehavior { Always }; +class ScrollViewMaintainVisibleContentPosition final { + public: + int minIndexForVisible{0}; + std::optional autoscrollToTopThreshold{}; + + bool operator==(const ScrollViewMaintainVisibleContentPosition &rhs) const { + return std::tie(this->minIndexForVisible, this->autoscrollToTopThreshold) == + std::tie(rhs.minIndexForVisible, rhs.autoscrollToTopThreshold); + } + + bool operator!=(const ScrollViewMaintainVisibleContentPosition &rhs) const { + return !(*this == rhs); + } +}; + } // namespace react } // namespace facebook diff --git a/ReactCommon/react/renderer/debug/DebugStringConvertible.h b/ReactCommon/react/renderer/debug/DebugStringConvertible.h index a9a1ef02b4e3..7df17f01e39a 100644 --- a/ReactCommon/react/renderer/debug/DebugStringConvertible.h +++ b/ReactCommon/react/renderer/debug/DebugStringConvertible.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -98,6 +99,14 @@ std::string toString(float const &value); std::string toString(double const &value); std::string toString(void const *value); +template +std::string toString(const std::optional &value) { + if (!value) { + return "null"; + } + return toString(value.value()); +} + /* * *Informal* `DebugStringConvertible` interface. * diff --git a/packages/rn-tester/js/components/ListExampleShared.js b/packages/rn-tester/js/components/ListExampleShared.js index 1acad0e6f5a5..c970c6921bbc 100644 --- a/packages/rn-tester/js/components/ListExampleShared.js +++ b/packages/rn-tester/js/components/ListExampleShared.js @@ -13,6 +13,7 @@ const React = require('react'); const { + ActivityIndicator, Animated, Image, Platform, @@ -33,16 +34,28 @@ export type Item = { ... }; -function genItemData(count: number, start: number = 0): Array { +function genItemData(i): Item { + const itemHash = Math.abs(hashCode('Item ' + i)); + return { + title: 'Item ' + i, + text: LOREM_IPSUM.substr(0, (itemHash % 301) + 20), + key: String(i), + pressed: false, + }; +} + +function genNewerItems(count: number, start: number = 0): Array { + const dataBlob = []; + for (let i = start; i < count + start; i++) { + dataBlob.push(genItemData(i)); + } + return dataBlob; +} + +function genOlderItems(count: number, start: number = 0): Array { const dataBlob = []; - for (let ii = start; ii < count + start; ii++) { - const itemHash = Math.abs(hashCode('Item ' + ii)); - dataBlob.push({ - title: 'Item ' + ii, - text: LOREM_IPSUM.substr(0, (itemHash % 301) + 20), - key: String(ii), - pressed: false, - }); + for (let i = count; i > 0; i--) { + dataBlob.push(genItemData(start - i)); } return dataBlob; } @@ -147,6 +160,12 @@ class SeparatorComponent extends React.PureComponent<{...}> { } } +const LoadingComponent: React.ComponentType<{}> = React.memo(() => ( + + + +)); + class ItemSeparatorComponent extends React.PureComponent<$FlowFixMeProps> { render(): React.Node { const style = this.props.highlighted @@ -352,6 +371,13 @@ const styles = StyleSheet.create({ text: { flex: 1, }, + loadingContainer: { + alignItems: 'center', + justifyContent: 'center', + height: 100, + borderTopWidth: 1, + borderTopColor: 'rgb(200, 199, 204)', + }, }); module.exports = { @@ -362,8 +388,10 @@ module.exports = { ItemSeparatorComponent, PlainInput, SeparatorComponent, + LoadingComponent, Spindicator, - genItemData, + genNewerItems, + genOlderItems, getItemLayout, pressItem, renderSmallSwitchOption, diff --git a/packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js b/packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js index 0f07b9847bac..d1649a702466 100644 --- a/packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js +++ b/packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js @@ -103,11 +103,17 @@ export default (BaseFlatListExample: React.AbstractComponent< FlatList, >); +const ITEM_INNER_HEIGHT = 70; +const ITEM_MARGIN = 8; +export const ITEM_HEIGHT: number = ITEM_INNER_HEIGHT + ITEM_MARGIN * 2; + const styles = StyleSheet.create({ item: { backgroundColor: 'pink', - padding: 20, - marginVertical: 8, + paddingHorizontal: 20, + height: ITEM_INNER_HEIGHT, + marginVertical: ITEM_MARGIN, + justifyContent: 'center', }, header: { fontSize: 32, diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js index 64f2800a3554..f34616d7ac46 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js @@ -35,8 +35,10 @@ import { ItemSeparatorComponent, PlainInput, SeparatorComponent, + LoadingComponent, Spindicator, - genItemData, + genNewerItems, + genOlderItems, getItemLayout, pressItem, renderSmallSwitchOption, @@ -44,6 +46,11 @@ import { import type {Item} from '../../components/ListExampleShared'; +const PAGE_SIZE = 100; +const NUM_PAGES = 10; +const INITIAL_PAGE_OFFSET = Math.floor(NUM_PAGES / 2); +const LOAD_TIME = 2000; + const VIEWABILITY_CONFIG = { minimumViewTime: 3000, viewAreaCoveragePercentThreshold: 100, @@ -53,6 +60,8 @@ const VIEWABILITY_CONFIG = { type Props = $ReadOnly<{||}>; type State = {| data: Array, + first: number, + last: number, debug: boolean, horizontal: boolean, inverted: boolean, @@ -66,13 +75,18 @@ type State = {| onPressDisabled: boolean, textSelectable: boolean, isRTL: boolean, + maintainVisibleContentPosition: boolean, + previousLoading: boolean, + nextLoading: boolean, |}; const IS_RTL = I18nManager.isRTL; class FlatListExample extends React.PureComponent { state: State = { - data: genItemData(100), + data: genNewerItems(PAGE_SIZE, PAGE_SIZE * INITIAL_PAGE_OFFSET), + first: PAGE_SIZE * INITIAL_PAGE_OFFSET, + last: PAGE_SIZE + PAGE_SIZE * INITIAL_PAGE_OFFSET, debug: false, horizontal: false, inverted: false, @@ -86,6 +100,9 @@ class FlatListExample extends React.PureComponent { onPressDisabled: false, textSelectable: true, isRTL: IS_RTL, + maintainVisibleContentPosition: true, + previousLoading: false, + nextLoading: false, }; /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's @@ -209,6 +226,11 @@ class FlatListExample extends React.PureComponent { this.state.isRTL, this._setIsRTL, )} + {renderSmallSwitchOption( + 'Maintain content position', + this.state.maintainVisibleContentPosition, + this._setBooleanValue('maintainVisibleContentPosition'), + )} {Platform.OS === 'android' && ( { } - ListFooterComponent={FooterComponent} + ListHeaderComponent={ + this.state.previousLoading ? LoadingComponent : HeaderComponent + } + ListFooterComponent={ + this.state.nextLoading ? LoadingComponent : FooterComponent + } ListEmptyComponent={ListEmptyComponent} data={this.state.empty ? [] : filteredData} debug={this.state.debug} @@ -249,6 +275,8 @@ class FlatListExample extends React.PureComponent { keyboardShouldPersistTaps="always" keyboardDismissMode="on-drag" numColumns={1} + onStartReached={this._onStartReached} + initialScrollIndex={Math.floor(PAGE_SIZE / 2)} onEndReached={this._onEndReached} onRefresh={this._onRefresh} onScroll={ @@ -259,6 +287,11 @@ class FlatListExample extends React.PureComponent { refreshing={false} contentContainerStyle={styles.list} viewabilityConfig={VIEWABILITY_CONFIG} + maintainVisibleContentPosition={ + this.state.maintainVisibleContentPosition + ? {minIndexForVisible: 2} + : undefined + } {...flatListItemRendererProps} /> @@ -279,13 +312,33 @@ class FlatListExample extends React.PureComponent { _getItemLayout = (data: any, index: number) => { return getItemLayout(data, index, this.state.horizontal); }; + _onStartReached = () => { + if (this.state.first <= 0 || this.state.previousLoading) { + return; + } + + this.setState({previousLoading: true}); + setTimeout(() => { + this.setState(state => ({ + previousLoading: false, + data: genOlderItems(PAGE_SIZE, state.first).concat(state.data), + first: state.first - PAGE_SIZE, + })); + }, LOAD_TIME); + }; _onEndReached = () => { - if (this.state.data.length >= 1000) { + if (this.state.last >= PAGE_SIZE * NUM_PAGES || this.state.nextLoading) { return; } - this.setState(state => ({ - data: state.data.concat(genItemData(100, state.data.length)), - })); + + this.setState({nextLoading: true}); + setTimeout(() => { + this.setState(state => ({ + nextLoading: false, + data: state.data.concat(genNewerItems(PAGE_SIZE, state.last)), + last: state.last + PAGE_SIZE, + })); + }, LOAD_TIME); }; // $FlowFixMe[missing-local-annot] _onPressCallback = () => { @@ -342,7 +395,7 @@ class FlatListExample extends React.PureComponent { _pressItem = (key: string) => { this._listRef?.recordInteraction(); - const index = Number(key); + const index = this.state.data.findIndex(item => item.key === key); const itemState = pressItem(this.state.data[index]); this.setState(state => ({ ...state, diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js b/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js index c92c688840fc..3d8c8256dccb 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js @@ -23,7 +23,7 @@ const { ItemComponent, PlainInput, SeparatorComponent, - genItemData, + genNewerItems, getItemLayout, pressItem, renderSmallSwitchOption, @@ -46,7 +46,7 @@ class MultiColumnExample extends React.PureComponent< numColumns: number, virtualized: boolean, |} = { - data: genItemData(1000), + data: genNewerItems(1000), filterText: '', fixedHeight: true, logViewable: false, diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-onStartReached.js b/packages/rn-tester/js/examples/FlatList/FlatList-onStartReached.js new file mode 100644 index 000000000000..82ec8e2aae61 --- /dev/null +++ b/packages/rn-tester/js/examples/FlatList/FlatList-onStartReached.js @@ -0,0 +1,56 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow + */ + +'use strict'; +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; +import BaseFlatListExample, {ITEM_HEIGHT} from './BaseFlatListExample'; +import * as React from 'react'; + +export function FlatList_onStartReached(): React.Node { + const [output, setOutput] = React.useState(''); + const exampleProps = { + onStartReached: (info: {distanceFromStart: number, ...}) => + setOutput('onStartReached'), + onStartReachedThreshold: 0, + initialScrollIndex: 5, + getItemLayout: (data: any, index: number) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }), + }; + const ref = React.useRef(null); + + const onTest = () => { + const scrollResponder = ref?.current?.getScrollResponder(); + if (scrollResponder != null) { + scrollResponder.scrollTo({y: 0}); + } + }; + + return ( + + ); +} + +export default ({ + title: 'onStartReached', + name: 'onStartReached', + description: + 'Scroll to start of list or tap Test button to see `onStartReached` triggered.', + render: function (): React.Element { + return ; + }, +}: RNTesterModuleExample); diff --git a/packages/rn-tester/js/examples/FlatList/FlatListExampleIndex.js b/packages/rn-tester/js/examples/FlatList/FlatListExampleIndex.js index 2fc61e2f55fb..bdfbb8a237ff 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatListExampleIndex.js +++ b/packages/rn-tester/js/examples/FlatList/FlatListExampleIndex.js @@ -10,6 +10,7 @@ import type {RNTesterModule} from '../../types/RNTesterTypes'; import BasicExample from './FlatList-basic'; +import OnStartReachedExample from './FlatList-onStartReached'; import OnEndReachedExample from './FlatList-onEndReached'; import ContentInsetExample from './FlatList-contentInset'; import InvertedExample from './FlatList-inverted'; @@ -28,6 +29,7 @@ export default ({ showIndividualExamples: true, examples: [ BasicExample, + OnStartReachedExample, OnEndReachedExample, ContentInsetExample, InvertedExample, diff --git a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js index 33f467a49231..9154c47a752a 100644 --- a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js +++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js @@ -35,6 +35,7 @@ class EnableDisableList extends React.Component<{}, {scrollEnabled: boolean}> { {ITEMS.map(createItemRow)} @@ -75,9 +76,10 @@ class AppendingList extends React.Component< {this.state.items.map(item => React.cloneElement(item, {key: item.props.msg}), @@ -169,7 +171,11 @@ class AppendingList extends React.Component< function CenterContentList(): React.Node { return ( - + This should be in center. ); @@ -208,6 +214,7 @@ const examples = ([ _scrollView = scrollView; }} automaticallyAdjustContentInsets={false} + nestedScrollEnabled onScroll={() => { console.log('onScroll!'); }} @@ -397,10 +404,7 @@ const examples = ([ return ; }, }, -]: Array); - -if (Platform.OS === 'ios') { - examples.push({ + { title: ' smooth bi-directional content loading\n', description: 'The `maintainVisibleContentPosition` prop allows insertions to either end of the content ' + @@ -408,7 +412,10 @@ if (Platform.OS === 'ios') { render: function () { return ; }, - }); + }, +]: Array); + +if (Platform.OS === 'ios') { examples.push({ title: ' (centerContent = true)\n', description: @@ -491,6 +498,7 @@ const AndroidScrollBarOptions = () => { {ITEMS.map(createItemRow)} @@ -1219,8 +1227,7 @@ const BouncesExampleHorizontal = () => { style={[styles.scrollView, {height: 200}]} horizontal={true} alwaysBounceHorizontal={bounce} - contentOffset={{x: 100, y: 0}} - nestedScrollEnabled> + contentOffset={{x: 100, y: 0}}> {ITEMS.map(createItemRow)} diff --git a/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js b/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js index aa8ea52ec989..58e3c2f5ca1f 100644 --- a/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js +++ b/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js @@ -22,7 +22,7 @@ const { PlainInput, SeparatorComponent, Spindicator, - genItemData, + genNewerItems, pressItem, renderSmallSwitchOption, renderStackedItem, @@ -170,7 +170,7 @@ export function SectionList_scrollable(Props: { const [logViewable, setLogViewable] = React.useState(false); const [debug, setDebug] = React.useState(false); const [inverted, setInverted] = React.useState(false); - const [data, setData] = React.useState(genItemData(1000)); + const [data, setData] = React.useState(genNewerItems(1000)); const filterRegex = new RegExp(String(filterText), 'i'); const filter = (item: Item) => From f166ba2bbb7ee779ac41edfae3e1764e5b87bb18 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 10 Feb 2023 16:36:07 -0800 Subject: [PATCH 2/5] Add verticalScrollbarPosition prop on Android --- .../react/views/scroll/ReactScrollViewManager.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java index e98a9c8c0f9d..031bf97b1bd1 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.java @@ -385,4 +385,15 @@ public void setPointerEvents(ReactScrollView view, @Nullable String pointerEvent public void setScrollEventThrottle(ReactScrollView view, int scrollEventThrottle) { view.setScrollEventThrottle(scrollEventThrottle); } + + @ReactProp(name = "verticalScrollbarPosition") + public void setVerticalScrollbarPosition(ReactScrollView view, String position) { + if ("right".equals(position)) { + view.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_RIGHT); + } else if ("left".equals(position)) { + view.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_LEFT); + } else { + view.setVerticalScrollbarPosition(View.SCROLLBAR_POSITION_DEFAULT); + } + } } From 2893ba88a98a4f074fcfa09de306b669f1964f20 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 10 Feb 2023 16:37:15 -0800 Subject: [PATCH 3/5] Fix autoscroll threshold on iOS --- React/Views/ScrollView/RCTScrollView.m | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/React/Views/ScrollView/RCTScrollView.m b/React/Views/ScrollView/RCTScrollView.m index f0f64021aca6..b73719752629 100644 --- a/React/Views/ScrollView/RCTScrollView.m +++ b/React/Views/ScrollView/RCTScrollView.m @@ -944,7 +944,7 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager CGPointMake(self->_scrollView.contentOffset.x + deltaX, self->_scrollView.contentOffset.y); if (autoscrollThreshold != nil) { // If the offset WAS within the threshold of the start, animate to the start. - if (x - deltaX <= [autoscrollThreshold integerValue]) { + if (x <= [autoscrollThreshold integerValue]) { [self scrollToOffset:CGPointMake(-leftInset, self->_scrollView.contentOffset.y) animated:YES]; } } @@ -960,7 +960,7 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager CGPointMake(self->_scrollView.contentOffset.x, self->_scrollView.contentOffset.y + deltaY); if (autoscrollThreshold != nil) { // If the offset WAS within the threshold of the start, animate to the start. - if (y - deltaY <= [autoscrollThreshold integerValue]) { + if (y <= [autoscrollThreshold integerValue]) { [self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, -bottomInset) animated:YES]; } } From 47a2ac9be83509e85d17dafcde9920dcb8413992 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 10 Feb 2023 17:00:23 -0800 Subject: [PATCH 4/5] Add custom publishing script --- .gitignore | 2 + README.md | 17 +++++ ReactAndroid/.npmignore | 2 + scripts/publish-npm-expensify.js | 91 ++++++++++++++++++++++++ sdks/hermes-engine/hermes-engine.podspec | 4 +- 5 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 scripts/publish-npm-expensify.js diff --git a/.gitignore b/.gitignore index 01027b3802ae..2b7fd38cfa38 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,5 @@ package-lock.json # Temporary files created by Metro to check the health of the file watcher .metro-health-check* + +react-native-*.tgz diff --git a/README.md b/README.md index cf73b68db74d..ef7a8699df69 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,19 @@ +# Expensify Release + +To publish a new version run the following script, where `` is the react-native version that the fork is based on, and `` is the version you want to publish. This will generate a tarball (`react-native-.tgz`) which can then be uploaded to a github release and consumed via npm. + +```bash +node ./scripts/publish-npm-expensify.js --base-version --fork-version +``` + +For example if the fork is based on react-native@0.71.0 and our fork version is 0.71.3. + +```bash +node ./scripts/publish-npm-expensify.js --base-version 0.71.0 --fork-version 0.71.3 +``` + +If you encounter build issues you can add the `--clean` flag to delete some folder that might cause some cache issues. +

React Native @@ -56,6 +72,7 @@ React Native is developed and supported by many companies and individual core co ## Contents +- [Expensify Release](#expensify-release) - [Requirements](#-requirements) - [Building your first React Native app](#-building-your-first-react-native-app) - [Documentation](#-documentation) diff --git a/ReactAndroid/.npmignore b/ReactAndroid/.npmignore index 914cb7646a18..b0f2e2817721 100644 --- a/ReactAndroid/.npmignore +++ b/ReactAndroid/.npmignore @@ -8,3 +8,5 @@ src/main/third-party/ # Exclude Android & JVM tests src/test/ src/androidTest/ +# Exclude prebuilt +src/main/jni/prebuilt/lib/ diff --git a/scripts/publish-npm-expensify.js b/scripts/publish-npm-expensify.js new file mode 100644 index 000000000000..3b92796dcef4 --- /dev/null +++ b/scripts/publish-npm-expensify.js @@ -0,0 +1,91 @@ +/** + * @format + */ + +'use strict'; + +const {exec, echo, exit, sed, rm} = require('shelljs'); +const os = require('os'); +const path = require('path'); +const yargs = require('yargs'); + +const argv = yargs + .option('base-version', { + type: 'string', + }) + .option('fork-version', { + type: 'string', + }) + .option('clean', { + type: 'boolean', + default: false, + }) + .strict().argv; +const baseVersion = argv.baseVersion; +const forkVersion = argv.forkVersion; +const clean = argv.clean; + +if (clean) { + rm('-rf', path.join(__dirname, '../android')); + rm('-rf', path.join(__dirname, '../sdks/download')); + rm('-rf', path.join(__dirname, '../sdks/hermes')); + rm('-rf', path.join(__dirname, '../sdks/hermesc')); +} + +// Update the version number. +if ( + exec( + `node scripts/set-rn-version.js --to-version ${forkVersion} --build-type release`, + ).code +) { + echo(`Failed to set version number to ${forkVersion}`); + exit(1); +} + +// Use the hermes prebuilt binaries from the base version. +sed( + '-i', + /^version = .*$/, + `version = '${baseVersion}'`, + path.join(__dirname, '../sdks/hermes-engine/hermes-engine.podspec'), +); + +// Download hermesc from the base version. +const rnTmpDir = path.join(os.tmpdir(), 'hermesc'); +const rnTgzOutput = path.join(rnTmpDir, `react-native-${baseVersion}.tgz`); +const hermescDest = path.join(__dirname, '../sdks'); +exec(`mkdir -p ${rnTmpDir}`); +if ( + exec( + `curl https://registry.npmjs.com/react-native/-/react-native-${baseVersion}.tgz --output ${rnTgzOutput}`, + ).code +) { + echo('Failed to download base react-native package'); + exit(1); +} +if (exec(`tar -xvf ${rnTgzOutput} -C ${rnTmpDir}`).code) { + echo('Failed to extract base react-native package'); + exit(1); +} +exec(`mkdir -p ${hermescDest}`); +if ( + exec(`cp -r ${path.join(rnTmpDir, 'package/sdks/hermesc')} ${hermescDest}`) + .code +) { + echo('Failed to copy hermesc from base react-native package'); + exit(1); +} + +// Build the android artifacts in the npm package. +if (exec('./gradlew publishAllInsideNpmPackage').code) { + echo('Could not generate artifacts'); + exit(1); +} + +// Generate tarball. +if (exec('npm pack').code) { + echo('Failed to generate tarball'); + exit(1); +} else { + exit(0); +} diff --git a/sdks/hermes-engine/hermes-engine.podspec b/sdks/hermes-engine/hermes-engine.podspec index b80054750b18..c1b8b8f89294 100644 --- a/sdks/hermes-engine/hermes-engine.podspec +++ b/sdks/hermes-engine/hermes-engine.podspec @@ -8,8 +8,8 @@ require_relative "./hermes-utils.rb" react_native_path = File.join(__dir__, "..", "..") -# Whether Hermes is built for Release or Debug is determined by the PRODUCTION envvar. -build_type = ENV['PRODUCTION'] == "1" ? :release : :debug +# Always use release build of hermes. +build_type = :release # package.json package = JSON.parse(File.read(File.join(react_native_path, "package.json"))) From 2bf456efbf6ff032ff6c72c9d56bbe759d0ba584 Mon Sep 17 00:00:00 2001 From: rory Date: Fri, 10 Feb 2023 17:00:49 -0800 Subject: [PATCH 5/5] Update package name --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 23e3bda438e2..e15093288095 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "react-native", + "name": "@expensify/react-native", "version": "0.71.2", "bin": "./cli.js", "description": "A framework for building native apps using React", @@ -208,4 +208,4 @@ } ] } -} \ No newline at end of file +}