From d5f12be6016233557fc34a6c3ed678e5a5d118db Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Tue, 10 Oct 2023 08:55:31 -0700 Subject: [PATCH 1/3] Deterministic onLayout event ordering for iOS Paper Summary: The ordering of `onLayout` events is non-deterministic on iOS, due to nodes being added to an `NSHashTable` before iteration, instead of an ordered collection. We don't do any lookups on the collection, so I think this was chosen over `NSMutableArray` for the sake of `[NSHashTable weakObjectsHashTable]`, to avoid retain/release. Using a collection which does retain/release seems to cause a crash due to double release or similar, so those semantics seem intentional (though I'm not super familiar with the model here). We can replicate the memory semantics with ordering by using `NSPointerArray`. This change does that, so we get consistetly top-down layout events (matching Fabric, and Android Paper after a recent change). This lets us use multiple layout events to calculate right/bottom edge insets deterministically. Changelog: [iOS][Changed] - Deterministic onLayout event ordering for iOS Paper Differential Revision: D50093411 fbshipit-source-id: d9229f98d8032673707fb8b9e0fd169f660edb08 --- .../React/Base/Surface/RCTSurfaceRootShadowView.h | 2 +- .../React/Base/Surface/RCTSurfaceRootShadowView.m | 2 +- packages/react-native/React/Modules/RCTUIManager.m | 2 +- packages/react-native/React/Views/RCTLayout.h | 2 +- packages/react-native/React/Views/RCTRootShadowView.h | 2 +- packages/react-native/React/Views/RCTRootShadowView.m | 2 +- packages/react-native/React/Views/RCTShadowView.m | 2 +- packages/rn-tester/RNTesterUnitTests/RCTShadowViewTests.m | 4 ++-- 8 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/react-native/React/Base/Surface/RCTSurfaceRootShadowView.h b/packages/react-native/React/Base/Surface/RCTSurfaceRootShadowView.h index 2898b16b385d15..4e0ed8458d2ce1 100644 --- a/packages/react-native/React/Base/Surface/RCTSurfaceRootShadowView.h +++ b/packages/react-native/React/Base/Surface/RCTSurfaceRootShadowView.h @@ -27,6 +27,6 @@ */ @property (nonatomic, assign) YGDirection baseDirection; -- (void)layoutWithAffectedShadowViews:(NSHashTable *)affectedShadowViews; +- (void)layoutWithAffectedShadowViews:(NSPointerArray *)affectedShadowViews; @end diff --git a/packages/react-native/React/Base/Surface/RCTSurfaceRootShadowView.m b/packages/react-native/React/Base/Surface/RCTSurfaceRootShadowView.m index 37c551cb4bd318..95cf3390657f6c 100644 --- a/packages/react-native/React/Base/Surface/RCTSurfaceRootShadowView.m +++ b/packages/react-native/React/Base/Surface/RCTSurfaceRootShadowView.m @@ -41,7 +41,7 @@ - (void)insertReactSubview:(RCTShadowView *)subview atIndex:(NSInteger)atIndex } } -- (void)layoutWithAffectedShadowViews:(NSHashTable *)affectedShadowViews +- (void)layoutWithAffectedShadowViews:(NSPointerArray *)affectedShadowViews { NSHashTable *other = [NSHashTable new]; diff --git a/packages/react-native/React/Modules/RCTUIManager.m b/packages/react-native/React/Modules/RCTUIManager.m index 7027921fda706b..9fe11430c8b7f0 100644 --- a/packages/react-native/React/Modules/RCTUIManager.m +++ b/packages/react-native/React/Modules/RCTUIManager.m @@ -534,7 +534,7 @@ - (RCTViewManagerUIBlock)uiBlockWithLayoutUpdateForRootView:(RCTRootShadowView * { RCTAssertUIManagerQueue(); - NSHashTable *affectedShadowViews = [NSHashTable weakObjectsHashTable]; + NSPointerArray *affectedShadowViews = [NSPointerArray weakObjectsPointerArray]; [rootShadowView layoutWithAffectedShadowViews:affectedShadowViews]; if (!affectedShadowViews.count) { diff --git a/packages/react-native/React/Views/RCTLayout.h b/packages/react-native/React/Views/RCTLayout.h index 55340ad938d1bf..8016463e145cb6 100644 --- a/packages/react-native/React/Views/RCTLayout.h +++ b/packages/react-native/React/Views/RCTLayout.h @@ -31,7 +31,7 @@ typedef struct CG_BOXABLE RCTLayoutMetrics RCTLayoutMetrics; struct RCTLayoutContext { CGPoint absolutePosition; - __unsafe_unretained NSHashTable *_Nonnull affectedShadowViews; + __unsafe_unretained NSPointerArray *_Nonnull affectedShadowViews; __unsafe_unretained NSHashTable *_Nonnull other; }; typedef struct CG_BOXABLE RCTLayoutContext RCTLayoutContext; diff --git a/packages/react-native/React/Views/RCTRootShadowView.h b/packages/react-native/React/Views/RCTRootShadowView.h index a312d9abfede82..aecfc4d0a6ecef 100644 --- a/packages/react-native/React/Views/RCTRootShadowView.h +++ b/packages/react-native/React/Views/RCTRootShadowView.h @@ -29,6 +29,6 @@ */ @property (nonatomic, assign) YGDirection baseDirection; -- (void)layoutWithAffectedShadowViews:(NSHashTable *)affectedShadowViews; +- (void)layoutWithAffectedShadowViews:(NSPointerArray *)affectedShadowViews; @end diff --git a/packages/react-native/React/Views/RCTRootShadowView.m b/packages/react-native/React/Views/RCTRootShadowView.m index 9db376d7c49fd9..b80ab28ba8531e 100644 --- a/packages/react-native/React/Views/RCTRootShadowView.m +++ b/packages/react-native/React/Views/RCTRootShadowView.m @@ -23,7 +23,7 @@ - (instancetype)init return self; } -- (void)layoutWithAffectedShadowViews:(NSHashTable *)affectedShadowViews +- (void)layoutWithAffectedShadowViews:(NSPointerArray *)affectedShadowViews { NSHashTable *other = [NSHashTable new]; diff --git a/packages/react-native/React/Views/RCTShadowView.m b/packages/react-native/React/Views/RCTShadowView.m index e0084ea7f1d656..2ef25b1a200aef 100644 --- a/packages/react-native/React/Views/RCTShadowView.m +++ b/packages/react-native/React/Views/RCTShadowView.m @@ -304,7 +304,7 @@ - (void)layoutWithMetrics:(RCTLayoutMetrics)layoutMetrics layoutContext:(RCTLayo { if (!RCTLayoutMetricsEqualToLayoutMetrics(self.layoutMetrics, layoutMetrics)) { self.layoutMetrics = layoutMetrics; - [layoutContext.affectedShadowViews addObject:self]; + [layoutContext.affectedShadowViews addPointer:((__bridge void *)self)]; } } diff --git a/packages/rn-tester/RNTesterUnitTests/RCTShadowViewTests.m b/packages/rn-tester/RNTesterUnitTests/RCTShadowViewTests.m index 0bf29c2d336fe5..f63a54b327a8b5 100644 --- a/packages/rn-tester/RNTesterUnitTests/RCTShadowViewTests.m +++ b/packages/rn-tester/RNTesterUnitTests/RCTShadowViewTests.m @@ -84,7 +84,7 @@ - (void)testApplyingLayoutRecursivelyToShadowView [self.parentView insertReactSubview:mainView atIndex:1]; [self.parentView insertReactSubview:footerView atIndex:2]; - [self.parentView layoutWithAffectedShadowViews:[NSHashTable weakObjectsHashTable]]; + [self.parentView layoutWithAffectedShadowViews:[NSPointerArray weakObjectsPointerArray]]; XCTAssertTrue( CGRectEqualToRect([self.parentView measureLayoutRelativeToAncestor:self.parentView], CGRectMake(0, 0, 440, 440))); @@ -187,7 +187,7 @@ - (void)_withShadowViewWithStyle:(void (^)(YGNodeRef node))configBlock RCTShadowView *view = [self _shadowViewWithConfig:configBlock]; [self.parentView insertReactSubview:view atIndex:0]; view.intrinsicContentSize = contentSize; - [self.parentView layoutWithAffectedShadowViews:[NSHashTable weakObjectsHashTable]]; + [self.parentView layoutWithAffectedShadowViews:[NSPointerArray weakObjectsPointerArray]]; CGRect actualRect = [view measureLayoutRelativeToAncestor:self.parentView]; XCTAssertTrue( CGRectEqualToRect(expectedRect, actualRect), From abe81cde17061f713ca47214ecf72330086e2b94 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Tue, 10 Oct 2023 08:55:31 -0700 Subject: [PATCH 2/3] Remove code to support bottom-up layout events in horizontal RTL Summary: We can dramatically simplify this code and remove quirks, now that we can assume layout events are always fired top down. Changelog: [Internal] Differential Revision: D49628669 fbshipit-source-id: daa14665a13c8587f7d03dfd0d7242429c3f9329 --- .../Lists/ListMetricsAggregator.js | 100 ++---------- .../Lists/VirtualizedList.js | 35 +--- .../__tests__/ListMetricsAggregator-test.js | 149 ++++-------------- 3 files changed, 41 insertions(+), 243 deletions(-) diff --git a/packages/virtualized-lists/Lists/ListMetricsAggregator.js b/packages/virtualized-lists/Lists/ListMetricsAggregator.js index 23f7f8d327ca16..01d6e5aff29c5f 100644 --- a/packages/virtualized-lists/Lists/ListMetricsAggregator.js +++ b/packages/virtualized-lists/Lists/ListMetricsAggregator.js @@ -14,8 +14,6 @@ import {keyExtractor as defaultKeyExtractor} from './VirtualizeUtils'; import invariant from 'invariant'; -type LayoutEventDirection = 'top-down' | 'bottom-up'; - export type CellMetrics = { /** * Index of the item in the list @@ -55,25 +53,12 @@ export type CellMetricProps = { ... }; -type UnresolvedCellMetrics = { - index: number, - layout: Layout, - isMounted: boolean, - - // The length of list content at the time of layout is needed to correctly - // resolve flow relative offset in RTL. We are lazily notified of this after - // the layout of the cell, unless the cell relayout does not cause a length - // change. To keep stability, we use content length at time of query, or - // unmount if never queried. - listContentLength?: ?number, -}; - /** * Provides an interface to query information about the metrics of a list and its cells. */ export default class ListMetricsAggregator { _averageCellLength = 0; - _cellMetrics: Map = new Map(); + _cellMetrics: Map = new Map(); _contentLength: ?number; _highestMeasuredCellIndex = 0; _measuredCellsLength = 0; @@ -83,10 +68,6 @@ export default class ListMetricsAggregator { rtl: false, }; - // Fabric and Paper may call onLayout in different orders. We can tell which - // direction layout events happen on the first layout. - _onLayoutDirection: LayoutEventDirection = 'top-down'; - /** * Notify the ListMetricsAggregator that a cell has been laid out. * @@ -103,39 +84,22 @@ export default class ListMetricsAggregator { orientation: ListOrientation, layout: Layout, }): boolean { - if (this._contentLength == null) { - this._onLayoutDirection = 'bottom-up'; - } - this._invalidateIfOrientationChanged(orientation); - // If layout is top-down, our most recently cached content length - // corresponds to this cell. Otherwise, we need to resolve when events fire - // up the tree to the new length. - const listContentLength = - this._onLayoutDirection === 'top-down' ? this._contentLength : null; - - const next: UnresolvedCellMetrics = { + const next: CellMetrics = { index: cellIndex, - layout: layout, + length: this._selectLength(layout), isMounted: true, - listContentLength, + offset: this.flowRelativeOffset(layout), }; const curr = this._cellMetrics.get(cellKey); - if ( - !curr || - this._selectOffset(next.layout) !== this._selectOffset(curr.layout) || - this._selectLength(next.layout) !== this._selectLength(curr.layout) || - (curr.listContentLength != null && - curr.listContentLength !== this._contentLength) - ) { + if (!curr || next.offset !== curr.offset || next.length !== curr.length) { if (curr) { - const dLength = - this._selectLength(next.layout) - this._selectLength(curr.layout); + const dLength = next.length - curr.length; this._measuredCellsLength += dLength; } else { - this._measuredCellsLength += this._selectLength(next.layout); + this._measuredCellsLength += next.length; this._measuredCellsCount += 1; } @@ -174,21 +138,7 @@ export default class ListMetricsAggregator { layout: $ReadOnly<{width: number, height: number}>, }): void { this._invalidateIfOrientationChanged(orientation); - const newLength = this._selectLength(layout); - - // Fill in any just-measured cells which did not have this length available. - // This logic assumes that cell relayout will always change list content - // size, which isn't strictly correct, but issues should be rare and only - // on Paper. - if (this._onLayoutDirection === 'bottom-up') { - for (const cellMetric of this._cellMetrics.values()) { - if (cellMetric.listContentLength == null) { - cellMetric.listContentLength = newLength; - } - } - } - - this._contentLength = newLength; + this._contentLength = this._selectLength(layout); } /** @@ -245,7 +195,7 @@ export default class ListMetricsAggregator { keyExtractor(getItem(data, index), index), ); if (frame && frame.index === index) { - return this._resolveCellMetrics(frame); + return frame; } if (getItemLayout) { @@ -286,26 +236,6 @@ export default class ListMetricsAggregator { return this._contentLength != null; } - /** - * Whether the ListMetricsAggregator is notified of cell metrics before - * ScrollView metrics (bottom-up) or ScrollView metrics before cell metrics - * (top-down). - * - * Must be queried after cell layout - */ - getLayoutEventDirection(): LayoutEventDirection { - return this._onLayoutDirection; - } - - /** - * Whether the ListMetricsAggregator must be aware of the current length of - * ScrollView content to be able to correctly resolve the (flow-relative) - * metrics of a cell. - */ - needsContentLengthForCellMetrics(): boolean { - return this._orientation.horizontal && this._orientation.rtl; - } - /** * Finds the flow-relative offset (e.g. starting from the left in LTR, but * right in RTL) from a layout box. @@ -352,7 +282,6 @@ export default class ListMetricsAggregator { if (orientation.horizontal !== this._orientation.horizontal) { this._averageCellLength = 0; - this._contentLength = null; this._highestMeasuredCellIndex = 0; this._measuredCellsLength = 0; this._measuredCellsCount = 0; @@ -371,15 +300,4 @@ export default class ListMetricsAggregator { _selectOffset({x, y}: $ReadOnly<{x: number, y: number, ...}>): number { return this._orientation.horizontal ? x : y; } - - _resolveCellMetrics(metrics: UnresolvedCellMetrics): CellMetrics { - const {index, layout, isMounted, listContentLength} = metrics; - - return { - index, - length: this._selectLength(layout), - isMounted, - offset: this.flowRelativeOffset(layout, listContentLength), - }; - } } diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index fed3f3f140b9a4..dcd9cbaaeb103d 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -1302,39 +1302,13 @@ class VirtualizedList extends StateSafePureComponent { orientation: this._orientation(), }); - // In RTL layout we need parent content length to calculate the offset of a - // cell from the start of the list. In Paper, layout events are bottom up, - // so we do not know this yet, and must defer calculation until after - // `onContentSizeChange` is called. - const deferCellMetricCalculation = - this._listMetrics.getLayoutEventDirection() === 'bottom-up' && - this._listMetrics.needsContentLengthForCellMetrics(); - - // Note: In Paper RTL logical position may have changed when - // `layoutHasChanged` is false if a cell maintains same X/Y coordinates, - // but contentLength shifts. This will be corrected by - // `onContentSizeChange` triggering a cell update. if (layoutHasChanged) { - this._scheduleCellsToRenderUpdate({ - allowImmediateExecution: !deferCellMetricCalculation, - }); + this._scheduleCellsToRenderUpdate(); } this._triggerRemeasureForChildListsInCell(cellKey); - this._computeBlankness(); - - if (deferCellMetricCalculation) { - if (!this._pendingViewabilityUpdate) { - this._pendingViewabilityUpdate = true; - setTimeout(() => { - this._updateViewableItems(this.props, this.state.cellsAroundViewport); - this._pendingViewabilityUpdate = false; - }, 0); - } - } else { - this._updateViewableItems(this.props, this.state.cellsAroundViewport); - } + this._updateViewableItems(this.props, this.state.cellsAroundViewport); }; _onCellFocusCapture(cellKey: string) { @@ -1765,9 +1739,7 @@ class VirtualizedList extends StateSafePureComponent { } } - _scheduleCellsToRenderUpdate(opts?: {allowImmediateExecution?: boolean}) { - const allowImmediateExecution = opts?.allowImmediateExecution ?? true; - + _scheduleCellsToRenderUpdate() { // Only trigger high-priority updates if we've actually rendered cells, // and with that size estimate, accurately compute how many cells we should render. // Otherwise, it would just render as many cells as it can (of zero dimension), @@ -1776,7 +1748,6 @@ class VirtualizedList extends StateSafePureComponent { // If this is triggered in an `componentDidUpdate` followed by a hiPri cellToRenderUpdate // We shouldn't do another hipri cellToRenderUpdate if ( - allowImmediateExecution && this._shouldRenderWithPriority() && (this._listMetrics.getAverageCellLength() || this.props.getItemLayout) && !this._hiPriInProgress diff --git a/packages/virtualized-lists/Lists/__tests__/ListMetricsAggregator-test.js b/packages/virtualized-lists/Lists/__tests__/ListMetricsAggregator-test.js index 7426b4c1617b8a..da2bb5df90f6fc 100644 --- a/packages/virtualized-lists/Lists/__tests__/ListMetricsAggregator-test.js +++ b/packages/virtualized-lists/Lists/__tests__/ListMetricsAggregator-test.js @@ -437,6 +437,11 @@ describe('ListMetricsAggregator', () => { getItem: (i: number) => nullthrows(props.data)[i], }; + listMetrics.notifyListContentLayout({ + layout: {width: 100, height: 5}, + orientation, + }); + listMetrics.notifyCellLayout({ cellIndex: 0, cellKey: '0', @@ -461,11 +466,6 @@ describe('ListMetricsAggregator', () => { }, }); - listMetrics.notifyListContentLayout({ - layout: {width: 100, height: 5}, - orientation, - }); - expect(listMetrics.getCellMetrics(1, props)).toEqual({ index: 1, length: 20, @@ -489,6 +489,11 @@ describe('ListMetricsAggregator', () => { getItem: (i: number) => nullthrows(props.data)[i], }; + listMetrics.notifyListContentLayout({ + layout: {width: 100, height: 5}, + orientation, + }); + listMetrics.notifyCellLayout({ cellIndex: 0, cellKey: '0', @@ -513,11 +518,6 @@ describe('ListMetricsAggregator', () => { }, }); - listMetrics.notifyListContentLayout({ - layout: {width: 100, height: 5}, - orientation, - }); - expect(listMetrics.getCellMetrics(2, props)).toBeNull(); expect(listMetrics.getCellMetricsApprox(2, props)).toEqual({ index: 2, @@ -537,6 +537,11 @@ describe('ListMetricsAggregator', () => { getItemLayout: () => ({index: 2, length: 40, offset: 30}), }; + listMetrics.notifyListContentLayout({ + layout: {width: 100, height: 5}, + orientation, + }); + listMetrics.notifyCellLayout({ cellIndex: 0, cellKey: '0', @@ -561,11 +566,6 @@ describe('ListMetricsAggregator', () => { }, }); - listMetrics.notifyListContentLayout({ - layout: {width: 100, height: 5}, - orientation, - }); - expect(listMetrics.getCellMetrics(2, props)).toMatchObject({ index: 2, length: 40, @@ -713,98 +713,7 @@ describe('ListMetricsAggregator', () => { }); }); - it('resolves metrics of unmounted cell after list shift when using bottom-up layout propagation', () => { - const listMetrics = new ListMetricsAggregator(); - const orientation = {horizontal: true, rtl: true}; - const props: CellMetricProps = { - data: [1, 2, 3, 4, 5], - getItemCount: () => nullthrows(props.data).length, - getItem: (i: number) => nullthrows(props.data)[i], - }; - - listMetrics.notifyCellLayout({ - cellIndex: 0, - cellKey: '0', - orientation, - layout: { - height: 5, - width: 10, - x: 90, - y: 0, - }, - }); - - listMetrics.notifyCellLayout({ - cellIndex: 1, - cellKey: '1', - orientation, - layout: { - height: 5, - width: 20, - x: 70, - y: 0, - }, - }); - - listMetrics.notifyListContentLayout({ - layout: {width: 100, height: 5}, - orientation, - }); - - expect(listMetrics.getCellMetrics(1, props)).toEqual({ - index: 1, - length: 20, - offset: 10, - isMounted: true, - }); - - listMetrics.notifyCellLayout({ - cellIndex: 2, - cellKey: '2', - orientation, - layout: { - height: 5, - width: 20, - x: 50, - y: 0, - }, - }); - - listMetrics.notifyListContentLayout({ - layout: {width: 120, height: 5}, - orientation, - }); - - expect(listMetrics.getCellMetrics(1, props)).toEqual({ - index: 1, - length: 20, - offset: 10, - isMounted: true, - }); - - listMetrics.notifyCellUnmounted('1'); - - expect(listMetrics.getCellMetrics(1, props)).toEqual({ - index: 1, - length: 20, - offset: 10, - isMounted: false, - }); - - listMetrics.notifyListContentLayout({ - layout: {width: 100, height: 5}, - orientation, - }); - - expect(listMetrics.getCellMetrics(1, props)).toEqual({ - index: 1, - length: 20, - offset: 10, - isMounted: false, - }); - }); - - it('resolves metrics of unmounted cell after list shift when using top-down layout propagation', () => { + it('resolves metrics of unmounted cell after list shift', () => { const listMetrics = new ListMetricsAggregator(); const orientation = {horizontal: true, rtl: true}; const props: CellMetricProps = { @@ -1089,18 +998,18 @@ describe('ListMetricsAggregator', () => { getItem: (i: number) => nullthrows(props.data)[i], }; - listMetrics.notifyCellLayout({ - cellIndex: 0, - cellKey: '0', - orientation, - layout: { - height: 10, - width: 5, - x: 0, - y: 0, - }, - }); - - expect(() => listMetrics.getCellMetrics(0, props)).toThrow(); + expect(() => + listMetrics.notifyCellLayout({ + cellIndex: 0, + cellKey: '0', + orientation, + layout: { + height: 10, + width: 5, + x: 0, + y: 0, + }, + }), + ).toThrow(); }); }); From 067d2bf2e01bb614621f51701ff76e4487c0cee1 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Tue, 10 Oct 2023 08:55:48 -0700 Subject: [PATCH 3/3] Fix iOS Paper Scroll Event RTL check (#40751) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/40751 In D48379915 I fixed inverted `contentOffset` in `onScroll` events on iOS. I thought I tested on Paper, but I think this was during a period where the Paper route in Catalyst was actually launching Fabric (oops). In Paper, at least under `forceRTL` and English, `[UIApplication sharedApplication].userInterfaceLayoutDirection` is not set to RTL. We instead have a per-view `reactLayoutDirection` we should be reading. This sort of thing isn't currently set on Fabric, which checks application-level RTL. This seems... not right with being able to set `direction` in a subtree context, but Android does the same thing, and that would take some greater changes. Changelog: [iOS][Fixed] - Fix iOS Paper Scroll Event RTL check Reviewed By: luluwu2032 Differential Revision: D50098310 fbshipit-source-id: 43c3351cf01f624643c5f7be5c544e3a99dcb927 --- packages/react-native/React/Views/ScrollView/RCTScrollView.m | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native/React/Views/ScrollView/RCTScrollView.m b/packages/react-native/React/Views/ScrollView/RCTScrollView.m index 1b506c877ebdc9..9e8b02708de90c 100644 --- a/packages/react-native/React/Views/ScrollView/RCTScrollView.m +++ b/packages/react-native/React/Views/ScrollView/RCTScrollView.m @@ -1058,7 +1058,7 @@ - (void)sendScrollEventWithName:(NSString *)eventName } CGPoint offset = scrollView.contentOffset; - if ([UIApplication sharedApplication].userInterfaceLayoutDirection == UIUserInterfaceLayoutDirectionRightToLeft) { + if ([self reactLayoutDirection] == UIUserInterfaceLayoutDirectionRightToLeft) { offset.x = scrollView.contentSize.width - scrollView.frame.size.width - offset.x; }