From 553766b8c862ff52d4d6b664354be1955e537ae2 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 26 Oct 2026 11:25:23 +0100 Subject: [PATCH 1/6] Fix measure in RTL horizontal ScrollView on iOS --- .../scrollview/ScrollViewShadowNode.cpp | 7 +++ .../core/tests/LayoutableShadowNodeTest.cpp | 54 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp index 623f32874854..5971b0a38700 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp @@ -68,6 +68,13 @@ Point ScrollViewShadowNode::getContentOriginOffset( bool includeTransform) const { auto stateData = getStateData(); auto contentOffset = stateData.contentOffset; +#ifndef ANDROID + if (getLayoutMetrics().layoutDirection == LayoutDirection::RightToLeft) { + // iOS mirrors the scroll view in RTL, so the offset starts from the right. + contentOffset.x = stateData.getContentSize().width - + getLayoutMetrics().frame.size.width - contentOffset.x; + } +#endif auto transform = includeTransform ? getTransform() : Transform::Identity(); auto result = transform * Vector{ diff --git a/packages/react-native/ReactCommon/react/renderer/core/tests/LayoutableShadowNodeTest.cpp b/packages/react-native/ReactCommon/react/renderer/core/tests/LayoutableShadowNodeTest.cpp index c974595b4fd2..0402187bf144 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/tests/LayoutableShadowNodeTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/tests/LayoutableShadowNodeTest.cpp @@ -262,6 +262,60 @@ TEST(LayoutableShadowNodeTest, contentOriginOffset) { EXPECT_EQ(relativeLayoutMetrics.frame.origin.y, 20); } +#ifndef ANDROID +/* + * ┌─────────────────────────────┬──────────────┐ + * │ │ │ + * │ │ (RTL) │ + * │ │ │ + * └─────────────────────────────┴──────────────┘ + */ +TEST(LayoutableShadowNodeTest, contentOriginOffsetInRTL) { + auto builder = simpleComponentBuilder(); + auto childShadowNode = std::shared_ptr{}; + auto contentOffsetX = Float{0}; + // clang-format off + auto element = + Element() + .finalize([](ScrollViewShadowNode &shadowNode){ + auto layoutMetrics = EmptyLayoutMetrics; + layoutMetrics.frame.size = {.width=100, .height=200}; + layoutMetrics.layoutDirection = LayoutDirection::RightToLeft; + shadowNode.setLayoutMetrics(layoutMetrics); + }) + .stateData([&](ScrollViewState &data) { + data.contentOffset = {.x=contentOffsetX, .y=0}; + data.contentBoundingRect.size = {.width=400, .height=200}; + }) + .children({ + Element() + .finalize([](ViewShadowNode &shadowNode){ + auto layoutMetrics = EmptyLayoutMetrics; + layoutMetrics.frame.size = {.width=400, .height=200}; + shadowNode.setLayoutMetrics(layoutMetrics); + }) + .reference(childShadowNode) + }); + // clang-format on + + auto parentShadowNode = builder.build(element); + + auto relativeLayoutMetrics = + LayoutableShadowNode::computeRelativeLayoutMetrics( + childShadowNode->getFamily(), *parentShadowNode, {}); + + EXPECT_EQ(relativeLayoutMetrics.frame.origin.x, -300); + + contentOffsetX = 300; + parentShadowNode = builder.build(element); + + relativeLayoutMetrics = LayoutableShadowNode::computeRelativeLayoutMetrics( + childShadowNode->getFamily(), *parentShadowNode, {}); + + EXPECT_EQ(relativeLayoutMetrics.frame.origin.x, 0); +} +#endif + /* * ┌────────────────────────┐ * │ │ From 48cc055321638ac82721e06a380c99495fb1c1e8 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 26 Oct 2026 11:57:31 +0100 Subject: [PATCH 2/6] Fix RTL content offsets in horizontal ScrollView on iOS --- .../ScrollView/RCTScrollViewComponentView.mm | 60 ++++++++++++++----- .../scrollview/ScrollViewShadowNode.cpp | 7 --- .../core/tests/LayoutableShadowNodeTest.cpp | 54 ----------------- 3 files changed, 45 insertions(+), 76 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 00653abc9fd0..abd3a039fca4 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -106,6 +106,9 @@ @implementation RCTScrollViewComponentView { CGPoint _contentOffsetWhenClipped; + // Set in `finalizeUpdates:`, when the content size and direction are known. + std::optional _pendingContentOffset; + __weak UIView *_contentView; CGRect _prevFirstVisibleFrame; @@ -404,8 +407,10 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & if (oldScrollViewProps.contentInset != newScrollViewProps.contentInset && !newScrollViewProps.centerContent) { _scrollView.contentInset = RCTUIEdgeInsetsFromEdgeInsets(newScrollViewProps.contentInset); } - if (oldScrollViewProps.contentOffset != newScrollViewProps.contentOffset) { - _scrollView.contentOffset = RCTCGPointFromPoint(newScrollViewProps.contentOffset); + // Skip a zero offset on mount, so RTL content starts on the right, like on Android. + if (oldProps ? oldScrollViewProps.contentOffset != newScrollViewProps.contentOffset + : newScrollViewProps.contentOffset != facebook::react::Point{}) { + _pendingContentOffset = RCTCGPointFromPoint(newScrollViewProps.contentOffset); } // RCTEnhancedScrollView specific props @@ -487,7 +492,7 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & // This is not easily done because we need to flip the order of method calls for // ShadowViewMutation::Insert. updateLayout must come before updateState. - _scrollView.contentOffset = contentOffset; + _pendingContentOffset = contentOffset; } CGSize contentSize = RCTCGSizeFromSize(data.getContentSize()); @@ -504,6 +509,24 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & }]; } +- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask +{ + [super finalizeUpdates:updateMask]; + + BOOL hasPendingContentOffset = _pendingContentOffset.has_value(); + if (hasPendingContentOffset) { + _scrollView.contentOffset = [self _mirrorContentOffsetIfNeeded:*_pendingContentOffset]; + _pendingContentOffset.reset(); + } + + // In RTL, a size change moves the offset from the left edge, so update the state. + if (_layoutMetrics.layoutDirection == LayoutDirection::RightToLeft && + (hasPendingContentOffset || + (updateMask & (RNComponentViewUpdateMaskState | RNComponentViewUpdateMaskLayoutMetrics)))) { + [self _updateStateWithContentOffset]; + } +} + - (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event { // This is the same algorithm as in the RCTViewComponentView with the exception of @@ -590,21 +613,29 @@ - (BOOL)_shouldDisableScrollInteraction return NO; } +/* + * In RTL, the scroll view is mirrored, so UIKit offsets start on the right. + * JS and the state use offsets from the left, like on Android. + * The same call converts both ways. + */ +- (CGPoint)_mirrorContentOffsetIfNeeded:(CGPoint)offset +{ + if (_layoutMetrics.layoutDirection == LayoutDirection::RightToLeft) { + offset.x = _scrollView.contentSize.width - _scrollView.bounds.size.width - offset.x; + } + return offset; +} + - (ScrollViewEventEmitter::Metrics)_scrollViewMetrics { auto metrics = ScrollViewEventEmitter::Metrics{}; metrics.contentSize = RCTSizeFromCGSize(_scrollView.contentSize); - metrics.contentOffset = RCTPointFromCGPoint(_scrollView.contentOffset); + metrics.contentOffset = RCTPointFromCGPoint([self _mirrorContentOffsetIfNeeded:_scrollView.contentOffset]); metrics.contentInset = RCTEdgeInsetsFromUIEdgeInsets(_scrollView.contentInset); metrics.containerSize = RCTSizeFromCGSize(_scrollView.bounds.size); metrics.zoomScale = _scrollView.zoomScale; metrics.timestamp = CACurrentMediaTime(); metrics.responderIgnoreScroll = !_isUserTriggeredScrolling; - - if (_layoutMetrics.layoutDirection == LayoutDirection::RightToLeft) { - metrics.contentOffset.x = metrics.contentSize.width - metrics.containerSize.width - metrics.contentOffset.x; - } - return metrics; } @@ -662,7 +693,7 @@ - (void)_updateStateWithContentOffset _avoidAdjustmentForMaintainVisibleContentPosition = enableImmediateUpdateModeForContentOffsetChanges; - auto contentOffset = RCTPointFromCGPoint(_scrollView.contentOffset); + auto contentOffset = RCTPointFromCGPoint([self _mirrorContentOffsetIfNeeded:_scrollView.contentOffset]); BOOL isAccessibilityAPIUsed = _isAccessibilityAPIUsed; _state->updateState( [contentOffset, isAccessibilityAPIUsed]( @@ -695,7 +726,9 @@ - (void)prepareForRecycle // in RTL the mirroring transform makes UIKit report zoomScale == -1, and changing // it to 1 makes UIKit move contentOffset. _scrollView.zoomScale = 1.0; - _scrollView.contentOffset = RCTCGPointFromPoint(props.contentOffset); + // The next mount sets a non-zero `contentOffset` again. + _scrollView.contentOffset = CGPointZero; + _pendingContentOffset.reset(); // Invalidate cached content size so that updateState: recalculates the // container frame after zoomScale reset (which may have mutated it in RTL). _contentSize = CGSizeZero; @@ -1024,10 +1057,7 @@ - (void)scrollToOffset:(CGPoint)offset - (void)scrollToOffset:(CGPoint)offset animated:(BOOL)animated { - if (_layoutMetrics.layoutDirection == LayoutDirection::RightToLeft) { - // Adjusting offset.x in right to left layout direction. - offset.x = self.contentSize.width - _scrollView.frame.size.width - offset.x; - } + offset = [self _mirrorContentOffsetIfNeeded:offset]; if (CGPointEqualToPoint(_scrollView.contentOffset, offset)) { return; diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp index 5971b0a38700..623f32874854 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewShadowNode.cpp @@ -68,13 +68,6 @@ Point ScrollViewShadowNode::getContentOriginOffset( bool includeTransform) const { auto stateData = getStateData(); auto contentOffset = stateData.contentOffset; -#ifndef ANDROID - if (getLayoutMetrics().layoutDirection == LayoutDirection::RightToLeft) { - // iOS mirrors the scroll view in RTL, so the offset starts from the right. - contentOffset.x = stateData.getContentSize().width - - getLayoutMetrics().frame.size.width - contentOffset.x; - } -#endif auto transform = includeTransform ? getTransform() : Transform::Identity(); auto result = transform * Vector{ diff --git a/packages/react-native/ReactCommon/react/renderer/core/tests/LayoutableShadowNodeTest.cpp b/packages/react-native/ReactCommon/react/renderer/core/tests/LayoutableShadowNodeTest.cpp index 0402187bf144..c974595b4fd2 100644 --- a/packages/react-native/ReactCommon/react/renderer/core/tests/LayoutableShadowNodeTest.cpp +++ b/packages/react-native/ReactCommon/react/renderer/core/tests/LayoutableShadowNodeTest.cpp @@ -262,60 +262,6 @@ TEST(LayoutableShadowNodeTest, contentOriginOffset) { EXPECT_EQ(relativeLayoutMetrics.frame.origin.y, 20); } -#ifndef ANDROID -/* - * ┌─────────────────────────────┬──────────────┐ - * │ │ │ - * │ │ (RTL) │ - * │ │ │ - * └─────────────────────────────┴──────────────┘ - */ -TEST(LayoutableShadowNodeTest, contentOriginOffsetInRTL) { - auto builder = simpleComponentBuilder(); - auto childShadowNode = std::shared_ptr{}; - auto contentOffsetX = Float{0}; - // clang-format off - auto element = - Element() - .finalize([](ScrollViewShadowNode &shadowNode){ - auto layoutMetrics = EmptyLayoutMetrics; - layoutMetrics.frame.size = {.width=100, .height=200}; - layoutMetrics.layoutDirection = LayoutDirection::RightToLeft; - shadowNode.setLayoutMetrics(layoutMetrics); - }) - .stateData([&](ScrollViewState &data) { - data.contentOffset = {.x=contentOffsetX, .y=0}; - data.contentBoundingRect.size = {.width=400, .height=200}; - }) - .children({ - Element() - .finalize([](ViewShadowNode &shadowNode){ - auto layoutMetrics = EmptyLayoutMetrics; - layoutMetrics.frame.size = {.width=400, .height=200}; - shadowNode.setLayoutMetrics(layoutMetrics); - }) - .reference(childShadowNode) - }); - // clang-format on - - auto parentShadowNode = builder.build(element); - - auto relativeLayoutMetrics = - LayoutableShadowNode::computeRelativeLayoutMetrics( - childShadowNode->getFamily(), *parentShadowNode, {}); - - EXPECT_EQ(relativeLayoutMetrics.frame.origin.x, -300); - - contentOffsetX = 300; - parentShadowNode = builder.build(element); - - relativeLayoutMetrics = LayoutableShadowNode::computeRelativeLayoutMetrics( - childShadowNode->getFamily(), *parentShadowNode, {}); - - EXPECT_EQ(relativeLayoutMetrics.frame.origin.x, 0); -} -#endif - /* * ┌────────────────────────┐ * │ │ From acf3445315697420e906f32d25c72e676238dfca Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 26 Oct 2026 12:03:54 +0100 Subject: [PATCH 3/6] Update RTL ScrollView state only when its size changes --- .../ScrollView/RCTScrollViewComponentView.mm | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index abd3a039fca4..238ca4a136f8 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -108,6 +108,8 @@ @implementation RCTScrollViewComponentView { // Set in `finalizeUpdates:`, when the content size and direction are known. std::optional _pendingContentOffset; + // In RTL, a size change moves the offset from the left edge, so the state needs an update. + BOOL _contentOffsetNeedsStateUpdate; __weak UIView *_contentView; @@ -309,6 +311,10 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics { [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; + if (layoutMetrics.frame.size.width != oldLayoutMetrics.frame.size.width || + layoutMetrics.layoutDirection != oldLayoutMetrics.layoutDirection) { + _contentOffsetNeedsStateUpdate = YES; + } if (layoutMetrics.layoutDirection != oldLayoutMetrics.layoutDirection) { CGAffineTransform transform = (layoutMetrics.layoutDirection == LayoutDirection::LeftToRight) ? CGAffineTransformIdentity @@ -502,6 +508,7 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared & } _contentSize = contentSize; + _contentOffsetNeedsStateUpdate = YES; _containerView.frame = CGRect{RCTCGPointFromPoint(data.contentBoundingRect.origin), contentSize}; [self _preserveContentOffsetIfNeededWithBlock:^{ @@ -513,17 +520,17 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask { [super finalizeUpdates:updateMask]; - BOOL hasPendingContentOffset = _pendingContentOffset.has_value(); - if (hasPendingContentOffset) { + if (_pendingContentOffset) { _scrollView.contentOffset = [self _mirrorContentOffsetIfNeeded:*_pendingContentOffset]; _pendingContentOffset.reset(); + _contentOffsetNeedsStateUpdate = YES; } - // In RTL, a size change moves the offset from the left edge, so update the state. - if (_layoutMetrics.layoutDirection == LayoutDirection::RightToLeft && - (hasPendingContentOffset || - (updateMask & (RNComponentViewUpdateMaskState | RNComponentViewUpdateMaskLayoutMetrics)))) { - [self _updateStateWithContentOffset]; + if (_contentOffsetNeedsStateUpdate) { + _contentOffsetNeedsStateUpdate = NO; + if (_layoutMetrics.layoutDirection == LayoutDirection::RightToLeft) { + [self _updateStateWithContentOffset]; + } } } @@ -729,6 +736,7 @@ - (void)prepareForRecycle // The next mount sets a non-zero `contentOffset` again. _scrollView.contentOffset = CGPointZero; _pendingContentOffset.reset(); + _contentOffsetNeedsStateUpdate = NO; // Invalidate cached content size so that updateState: recalculates the // container frame after zoomScale reset (which may have mutated it in RTL). _contentSize = CGSizeZero; From 451d29ca72ab381528c0643061c5e3bd4518fc94 Mon Sep 17 00:00:00 2001 From: Mathieu Acthernoene Date: Mon, 26 Oct 2026 12:04:36 +0100 Subject: [PATCH 4/6] Add RTL horizontal ScrollView examples to RNTester --- .../examples/ScrollView/ScrollViewExample.js | 133 +++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js index 72580d9c0485..d9f1f4e0c0c4 100644 --- a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js +++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js @@ -9,6 +9,7 @@ */ import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; +import type {HostInstance} from 'react-native'; import RNTesterText from '../../components/RNTesterText'; import ScrollViewPressableStickyHeaderExample from './ScrollViewPressableStickyHeaderExample'; @@ -17,6 +18,7 @@ import * as React from 'react'; import {cloneElement, useCallback, useRef, useState} from 'react'; import { Platform, + Pressable, RefreshControl, ScrollView, StyleSheet, @@ -294,7 +296,7 @@ const examples: Array = [ name: 'stubbyHorizontalScrollView', title: ' (horizontal = true) in RTL not filling content\n', description: - 'A horizontal RTL ScrollView whose content is smaller thatn its containner', + 'A horizontal RTL ScrollView whose content is smaller than its container', render(): React.Node { return ( @@ -303,6 +305,24 @@ const examples: Array = [ ); }, }, + { + name: 'horizontalScrollViewMeasureRTL', + title: ' (horizontal = true) in RTL measure\n', + description: + 'Scroll to the end and tap the button. Touch x and measureInWindow x should match.', + render(): React.Node { + return ; + }, + }, + { + name: 'horizontalScrollViewContentOffsetRTL', + title: ' (horizontal = true) in RTL contentOffset\n', + description: + 'contentOffset, scrollTo and onScroll all count x from the left edge. Remounting and tapping scrollTo should show the same page.', + render(): React.Node { + return ; + }, + }, { title: ' enable & disable\n', description: 'ScrollView scrolling behaviour can be disabled and enabled', @@ -810,6 +830,104 @@ const HorizontalScrollView = (props: { ); }; +const HorizontalScrollViewMeasureRTL = () => { + const buttonRef = useRef(null); + const [touchX, setTouchX] = useState(null); + const [measuredX, setMeasuredX] = useState(null); + + return ( + + + + { + const {pageX, locationX} = event.nativeEvent; + setTouchX(Math.round(pageX - locationX)); + buttonRef.current?.measureInWindow(x => { + setMeasuredX(Math.round(x)); + }); + }}> + + Tap to measure + + + + + Touch x: {touchX ?? '-'} + measureInWindow x: {measuredX ?? '-'} + + ); +}; + +const RTL_PAGE_WIDTH = 200; +const RTL_PAGE_OFFSET = RTL_PAGE_WIDTH * 3; + +const HorizontalScrollViewContentOffsetRTL = () => { + const scrollRef = useRef>(); + const [mountKey, setMountKey] = useState(0); + const [scrollX, setScrollX] = useState(null); + + return ( + + {/* $FlowFixMe[incompatible-use] */} + { + setScrollX(Math.round(event.nativeEvent.contentOffset.x)); + }} + pagingEnabled={true} + scrollEventThrottle={16} + style={[styles.horizontalScrollView, {width: RTL_PAGE_WIDTH}]}> + {[0, 1, 2, 3, 4, 5].map(page => ( + + Page {page} + + ))} + + + onScroll x: {scrollX ?? '-'} + +