Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2553,6 +2553,7 @@ describe('horizontal ScrollView in RTL script', () => {
contentOffset={{x: 100, y: 0}}>
<View nativeID={'item1'} style={{height: 90, width: 90, margin: 5}} />
<View nativeID={'item2'} style={{height: 90, width: 90, margin: 5}} />
<View nativeID={'item3'} style={{height: 90, width: 90, margin: 5}} />
</ScrollView>,
);
});
Expand All @@ -2567,6 +2568,35 @@ describe('horizontal ScrollView in RTL script', () => {
'Insert {type: "ScrollView", parentNativeID: (root), index: 0, nativeID: (N/A)}',
]);
});

it('measures scroll position from the left edge', () => {
const root = Fantom.createRoot({viewportWidth: 100, viewportHeight: 100});
const nodeRef = createRef<HostInstance>();

Fantom.runTask(() => {
root.render(
<ScrollView
ref={nodeRef}
style={{direction: 'rtl', height: 100, width: 100}}
horizontal={true}>
<View nativeID={'item1'} style={{height: 90, width: 90, margin: 5}} />
<View nativeID={'item2'} style={{height: 90, width: 90, margin: 5}} />
</ScrollView>,
);
});

root.takeMountingManagerLogs();

Fantom.scrollTo(nodeRef, {x: 0, y: 0});

expect(root.takeMountingManagerLogs()).toEqual([
'Update {type: "ScrollView", nativeID: (N/A)}',
'Remove {type: "View", parentNativeID: (N/A), index: 0, nativeID: "item1"}',
'Delete {type: "View", nativeID: "item1"}',
'Create {type: "View", nativeID: "item2"}',
'Insert {type: "View", parentNativeID: (N/A), index: 0, nativeID: "item2"}',
]);
});
});

describe('Views with no layout', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ @implementation RCTScrollViewComponentView {

CGPoint _contentOffsetWhenClipped;

// Set in `finalizeUpdates:`, when the content size and direction are known.
std::optional<CGPoint> _pendingContentOffset;
BOOL _isPendingContentOffsetFromState;
BOOL _contentOffsetNeedsStateUpdate;

__weak UIView *_contentView;

CGRect _prevFirstVisibleFrame;
Expand Down Expand Up @@ -305,7 +310,15 @@ + (ComponentDescriptorProvider)componentDescriptorProvider
- (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
oldLayoutMetrics:(const LayoutMetrics &)oldLayoutMetrics
{
if (layoutMetrics.layoutDirection != oldLayoutMetrics.layoutDirection && !_pendingContentOffset) {
_pendingContentOffset = [self _mirrorContentOffsetIfNeeded:_scrollView.contentOffset forState:YES];
_isPendingContentOffsetFromState = YES;
}
[super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics];
if (layoutMetrics.layoutDirection == LayoutDirection::RightToLeft &&
layoutMetrics.frame.size.width != oldLayoutMetrics.frame.size.width) {
_contentOffsetNeedsStateUpdate = YES;
}
if (layoutMetrics.layoutDirection != oldLayoutMetrics.layoutDirection) {
CGAffineTransform transform = (layoutMetrics.layoutDirection == LayoutDirection::LeftToRight)
? CGAffineTransformIdentity
Expand Down Expand Up @@ -404,8 +417,11 @@ - (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.
if (oldProps ? oldScrollViewProps.contentOffset != newScrollViewProps.contentOffset
: newScrollViewProps.contentOffset != facebook::react::Point{}) {
_pendingContentOffset = RCTCGPointFromPoint(newScrollViewProps.contentOffset);
_isPendingContentOffsetFromState = NO;
}

// RCTEnhancedScrollView specific props
Expand Down Expand Up @@ -465,29 +481,11 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &
_state = std::static_pointer_cast<const ScrollViewShadowNode::ConcreteState>(state);
auto &data = _state->getData();

auto contentOffset = RCTCGPointFromPoint(data.contentOffset);
if (!oldState && !CGPointEqualToPoint(contentOffset, CGPointZero)) {
/*
* When <ScrollView /> is suspended, it is removed from view hierarchy and its offset is stored in
* state. We want to restore this offset from the state but it must be snapped to be within UIScrollView's
* content to remove any overscroll.
*
* This can happen, for example, with pull to refresh. The UIScrollView will be overscrolled into negative offset.
* If the offset is not adjusted to be within the content area, it leads to a gap and UIScrollView does not adjust
* its offset until user scrolls.
*/

// Adjusting overscroll on the top.
contentOffset.y = fmax(contentOffset.y, -_scrollView.contentInset.top);

// Adjusting overscroll on the left.
contentOffset.x = fmax(contentOffset.x, -_scrollView.contentInset.left);

// TODO: T190695447 - Protect against over scroll on the bottom and right as well.
// 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;
if (!oldState) {
// When <ScrollView /> is suspended, it is removed from view hierarchy and its offset is stored in state.
// `finalizeUpdates:` restores it.
_pendingContentOffset = RCTCGPointFromPoint(data.contentOffset);
_isPendingContentOffsetFromState = YES;
}

CGSize contentSize = RCTCGSizeFromSize(data.getContentSize());
Expand All @@ -496,6 +494,9 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &
return;
}

if (_layoutMetrics.layoutDirection == LayoutDirection::RightToLeft && contentSize.width != _contentSize.width) {
_contentOffsetNeedsStateUpdate = YES;
}
_contentSize = contentSize;
_containerView.frame = CGRect{RCTCGPointFromPoint(data.contentBoundingRect.origin), contentSize};

Expand All @@ -504,6 +505,33 @@ - (void)updateState:(const State::Shared &)state oldState:(const State::Shared &
}];
}

- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
{
[super finalizeUpdates:updateMask];

if (_pendingContentOffset) {
CGPoint contentOffset = [self _mirrorContentOffsetIfNeeded:*_pendingContentOffset
forState:_isPendingContentOffsetFromState];
if (_isPendingContentOffsetFromState) {
// Snap a restored offset into the content to remove any overscroll, for example after pull to refresh.
// TODO: T190695447 - Protect against over scroll on the bottom and right as well.
contentOffset.x = fmax(contentOffset.x, -_scrollView.contentInset.left);
contentOffset.y = fmax(contentOffset.y, -_scrollView.contentInset.top);
}
_pendingContentOffset.reset();
if (!CGPointEqualToPoint(_scrollView.contentOffset, contentOffset)) {
// `scrollViewDidScroll:` updates the state.
_scrollView.contentOffset = contentOffset;
_contentOffsetNeedsStateUpdate = NO;
}
}

if (_contentOffsetNeedsStateUpdate) {
_contentOffsetNeedsStateUpdate = NO;
[self _updateStateWithContentOffset];
}
}

- (UIView *)betterHitTest:(CGPoint)point withEvent:(UIEvent *)event
{
// This is the same algorithm as in the RCTViewComponentView with the exception of
Expand Down Expand Up @@ -590,21 +618,31 @@ - (BOOL)_shouldDisableScrollInteraction
return NO;
}

/*
* In RTL, UIKit offsets start on the right, but JS and state offsets start on the left.
* When the content is narrower than the view, the JS offset is 0, like on Android.
* The state offset is negative, because the shadow tree puts the content on the left.
*/
- (CGPoint)_mirrorContentOffsetIfNeeded:(CGPoint)offset forState:(BOOL)forState
{
if (_layoutMetrics.layoutDirection == LayoutDirection::RightToLeft) {
CGFloat maxOffset = _scrollView.contentSize.width - _scrollView.bounds.size.width;
offset.x = (forState ? maxOffset : MAX(maxOffset, 0)) - 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
forState:NO]);
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;
}

Expand Down Expand Up @@ -662,7 +700,7 @@ - (void)_updateStateWithContentOffset

_avoidAdjustmentForMaintainVisibleContentPosition = enableImmediateUpdateModeForContentOffsetChanges;

auto contentOffset = RCTPointFromCGPoint(_scrollView.contentOffset);
auto contentOffset = RCTPointFromCGPoint([self _mirrorContentOffsetIfNeeded:_scrollView.contentOffset forState:YES]);
BOOL isAccessibilityAPIUsed = _isAccessibilityAPIUsed;
_state->updateState(
[contentOffset, isAccessibilityAPIUsed](
Expand Down Expand Up @@ -695,7 +733,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);
_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;
Expand Down Expand Up @@ -1024,10 +1064,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 forState:NO];

if (CGPointEqualToPoint(_scrollView.contentOffset, offset)) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ void ScrollViewShadowNode::updateStateIfNeeded() {

if (state.contentBoundingRect != contentBoundingRect) {
state.contentBoundingRect = contentBoundingRect;
#ifndef ANDROID
// RTL scroll views start on the right until the native view reports.
if (getState()->getRevision() == State::initialRevisionValue &&
getLayoutMetrics().layoutDirection == LayoutDirection::RightToLeft &&
state.contentOffset.x == 0) {
state.contentOffset.x =
contentBoundingRect.size.width - getLayoutMetrics().frame.size.width;
}
#endif
setStateData(std::move(state));
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ CullingContext CullingContext::adjustCullingContextIfNeeded(
if (scrollViewShadowNode->getConcreteProps().yogaStyle.overflow() !=
yoga::Overflow::Visible &&
!scrollViewShadowNode->getStateData().disableViewCulling) {
auto layoutMetrics = scrollViewShadowNode->getLayoutMetrics();
cullingContext.frame.origin =
-scrollViewShadowNode->getContentOriginOffset(
/* includeTransform */ true);
Expand All @@ -48,16 +47,17 @@ CullingContext CullingContext::adjustCullingContextIfNeeded(

cullingContext.transform = Transform::Identity();

#ifdef ANDROID
auto layoutMetrics = scrollViewShadowNode->getLayoutMetrics();
if (layoutMetrics.layoutDirection == LayoutDirection::RightToLeft) {
// In RTL, content offset is flipped horizontally.
// We need to flip the culling context frame to match.
// See:
// https://github.com/facebook/react-native/blob/c2f39cfdd87c32b9a59efe8a788b8a03f02b0ea0/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm#L579
auto stateData = scrollViewShadowNode->getStateData();
cullingContext.frame.origin.x =
stateData.contentBoundingRect.size.width -
layoutMetrics.frame.size.width - cullingContext.frame.origin.x;
}
#endif
} else {
cullingContext = {};
}
Expand Down
Loading
Loading