Skip to content

Commit 91910d8

Browse files
sherginfacebook-github-bot
authored andcommitted
Better RTL support especially for ScrollView's
Reviewed By: fkgozali Differential Revision: D4478913 fbshipit-source-id: 525c17fa109ad3c35161b10940776f1426ba2535
1 parent d82f255 commit 91910d8

File tree

14 files changed

+228
-29
lines changed

14 files changed

+228
-29
lines changed

Libraries/Components/ScrollView/ScrollView.js

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,30 @@ const ScrollView = React.createClass({
483483
},
484484

485485
render: function() {
486+
let ScrollViewClass;
487+
let ScrollContentContainerViewClass;
488+
if (Platform.OS === 'ios') {
489+
ScrollViewClass = RCTScrollView;
490+
ScrollContentContainerViewClass = RCTScrollContentView;
491+
} else if (Platform.OS === 'android') {
492+
if (this.props.horizontal) {
493+
ScrollViewClass = AndroidHorizontalScrollView;
494+
} else {
495+
ScrollViewClass = AndroidScrollView;
496+
}
497+
ScrollContentContainerViewClass = View;
498+
}
499+
500+
invariant(
501+
ScrollViewClass !== undefined,
502+
'ScrollViewClass must not be undefined'
503+
);
504+
505+
invariant(
506+
ScrollContentContainerViewClass !== undefined,
507+
'ScrollContentContainerViewClass must not be undefined'
508+
);
509+
486510
const contentContainerStyle = [
487511
this.props.horizontal && styles.contentContainerHorizontal,
488512
this.props.contentContainerStyle,
@@ -507,14 +531,14 @@ const ScrollView = React.createClass({
507531
}
508532

509533
const contentContainer =
510-
<View
534+
<ScrollContentContainerViewClass
511535
{...contentSizeChangeProps}
512536
ref={this._setInnerViewRef}
513537
style={contentContainerStyle}
514538
removeClippedSubviews={this.props.removeClippedSubviews}
515539
collapsable={false}>
516540
{this.props.children}
517-
</View>;
541+
</ScrollContentContainerViewClass>;
518542

519543
const alwaysBounceHorizontal =
520544
this.props.alwaysBounceHorizontal !== undefined ?
@@ -559,21 +583,6 @@ const ScrollView = React.createClass({
559583
props.decelerationRate = processDecelerationRate(decelerationRate);
560584
}
561585

562-
let ScrollViewClass;
563-
if (Platform.OS === 'ios') {
564-
ScrollViewClass = RCTScrollView;
565-
} else if (Platform.OS === 'android') {
566-
if (this.props.horizontal) {
567-
ScrollViewClass = AndroidHorizontalScrollView;
568-
} else {
569-
ScrollViewClass = AndroidScrollView;
570-
}
571-
}
572-
invariant(
573-
ScrollViewClass !== undefined,
574-
'ScrollViewClass must not be undefined'
575-
);
576-
577586
const refreshControl = this.props.refreshControl;
578587
if (refreshControl) {
579588
if (Platform.OS === 'ios') {
@@ -626,7 +635,7 @@ const styles = StyleSheet.create({
626635
},
627636
});
628637

629-
let nativeOnlyProps, AndroidScrollView, AndroidHorizontalScrollView, RCTScrollView;
638+
let nativeOnlyProps, AndroidScrollView, AndroidHorizontalScrollView, RCTScrollView, RCTScrollContentView;
630639
if (Platform.OS === 'android') {
631640
nativeOnlyProps = {
632641
nativeOnly: {
@@ -649,6 +658,7 @@ if (Platform.OS === 'android') {
649658
}
650659
};
651660
RCTScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps);
661+
RCTScrollContentView = requireNativeComponent('RCTScrollContentView', View);
652662
}
653663

654664
module.exports = ScrollView;

Libraries/Text/RCTTextField.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ - (void)textFieldEndEditing
227227
key:nil
228228
eventCount:_nativeEventCount];
229229
}
230+
230231
- (void)textFieldSubmitEditing
231232
{
232233
_submitted = YES;

React/Modules/RCTUIManager.m

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,7 @@ - (RCTViewManagerUIBlock)uiBlockWithLayoutUpdateForRootView:(RCTRootShadowView *
552552

553553
typedef struct {
554554
CGRect frame;
555+
UIUserInterfaceLayoutDirection layoutDirection;
555556
BOOL isNew;
556557
BOOL parentIsNew;
557558
BOOL isHidden;
@@ -568,6 +569,7 @@ - (RCTViewManagerUIBlock)uiBlockWithLayoutUpdateForRootView:(RCTRootShadowView *
568569
reactTags[index] = shadowView.reactTag;
569570
frameDataArray[index++] = (RCTFrameData){
570571
shadowView.frame,
572+
shadowView.effectiveLayoutDirection,
571573
shadowView.isNewView,
572574
shadowView.superview.isNewView,
573575
shadowView.isHidden,
@@ -634,6 +636,7 @@ - (RCTViewManagerUIBlock)uiBlockWithLayoutUpdateForRootView:(RCTRootShadowView *
634636
CGRect frame = frameData.frame;
635637

636638
BOOL isHidden = frameData.isHidden;
639+
UIUserInterfaceLayoutDirection layoutDirection = frameData.layoutDirection;
637640
BOOL isNew = frameData.isNew;
638641
RCTAnimation *updateAnimation = isNew ? nil : layoutAnimation.updateAnimation;
639642
BOOL shouldAnimateCreation = isNew && !frameData.parentIsNew;
@@ -654,6 +657,10 @@ - (RCTViewManagerUIBlock)uiBlockWithLayoutUpdateForRootView:(RCTRootShadowView *
654657
view.hidden = isHidden;
655658
}
656659

660+
if (view.reactLayoutDirection != layoutDirection) {
661+
view.reactLayoutDirection = layoutDirection;
662+
}
663+
657664
RCTViewManagerUIBlock updateBlock = updateBlocks[reactTag];
658665
if (createAnimation) {
659666

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Copyright (c) 2015-present, Facebook, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree. An additional grant
7+
* of patent rights can be found in the PATENTS file in the same directory.
8+
*/
9+
10+
#import <UIKit/UIKit.h>
11+
12+
#import <React/RCTShadowView.h>
13+
14+
@interface RCTScrollContentShadowView : RCTShadowView
15+
16+
@end
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Copyright (c) 2015-present, Facebook, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree. An additional grant
7+
* of patent rights can be found in the PATENTS file in the same directory.
8+
*/
9+
10+
#import "RCTScrollContentShadowView.h"
11+
12+
#import <yoga/Yoga.h>
13+
14+
#import "RCTUtils.h"
15+
16+
@interface RCTShadowView () {
17+
// This will be removed after t15757916, which will remove
18+
// side-effects from `setFrame:` method.
19+
@public CGRect _frame;
20+
}
21+
@end
22+
23+
@implementation RCTScrollContentShadowView
24+
25+
- (void)applyLayoutNode:(YGNodeRef)node
26+
viewsWithNewFrame:(NSMutableSet<RCTShadowView *> *)viewsWithNewFrame
27+
absolutePosition:(CGPoint)absolutePosition
28+
{
29+
// Call super method if LTR layout is enforced.
30+
if (self.effectiveLayoutDirection == UIUserInterfaceLayoutDirectionLeftToRight) {
31+
[super applyLayoutNode:node
32+
viewsWithNewFrame:viewsWithNewFrame
33+
absolutePosition:absolutePosition];
34+
return;
35+
}
36+
37+
// Motivation:
38+
// Yoga place `contentView` on the right side of `scrollView` when RTL layout is enfoced.
39+
// That breaks everything; it is completly pointless to (re)position `contentView`
40+
// because it is `contentView`'s job. So, we work around it here.
41+
42+
// Step 1. Compensate `absolutePosition` change.
43+
CGFloat xCompensation = YGNodeLayoutGetRight(node) - YGNodeLayoutGetLeft(node);
44+
absolutePosition.x += xCompensation;
45+
46+
// Step 2. Call super method.
47+
[super applyLayoutNode:node
48+
viewsWithNewFrame:viewsWithNewFrame
49+
absolutePosition:absolutePosition];
50+
51+
// Step 3. Reset the position.
52+
_frame.origin.x = RCTRoundPixelValue(YGNodeLayoutGetRight(node));
53+
}
54+
55+
@end
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
/**
2+
* Copyright (c) 2015-present, Facebook, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree. An additional grant
7+
* of patent rights can be found in the PATENTS file in the same directory.
8+
*/
9+
10+
#import <React/RCTViewManager.h>
11+
12+
@interface RCTScrollContentViewManager : RCTViewManager
13+
14+
@end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/**
2+
* Copyright (c) 2015-present, Facebook, Inc.
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the BSD-style license found in the
6+
* LICENSE file in the root directory of this source tree. An additional grant
7+
* of patent rights can be found in the PATENTS file in the same directory.
8+
*/
9+
10+
#import "RCTScrollContentViewManager.h"
11+
12+
#import "RCTScrollContentShadowView.h"
13+
14+
@implementation RCTScrollContentViewManager
15+
16+
RCT_EXPORT_MODULE()
17+
18+
- (RCTShadowView *)shadowView
19+
{
20+
return [RCTScrollContentShadowView new];
21+
}
22+
23+
@end

React/Views/RCTScrollView.m

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,11 @@ - (instancetype)initWithFrame:(CGRect)frame
159159
{
160160
if ((self = [super initWithFrame:frame])) {
161161
[self.panGestureRecognizer addTarget:self action:@selector(handleCustomPan:)];
162+
163+
// We intentionaly force `UIScrollView`s `semanticContentAttribute` to `LTR` here
164+
// because this attribute affects a position of vertical scrollbar; we don't want this
165+
// scrollbar flip because we also flip it with whole `UIScrollView` flip.
166+
self.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
162167
}
163168
return self;
164169
}
@@ -191,7 +196,7 @@ - (void)handleCustomPan:(__unused UIPanGestureRecognizer *)sender
191196
self.panGestureRecognizer.enabled = YES;
192197
// TODO: If mid bounce, animate the scroll view to a non-bounced position
193198
// while disabling (but only if `stopScrollInteractionIfJSHasResponder` was
194-
// called *during* a `pan`. Currently, it will just snap into place which
199+
// called *during* a `pan`). Currently, it will just snap into place which
195200
// is not so bad either.
196201
// Another approach:
197202
// self.scrollEnabled = NO;
@@ -278,9 +283,10 @@ - (void)dockClosestSectionHeader
278283
{
279284
UIView *contentView = [self contentView];
280285
CGFloat scrollTop = self.bounds.origin.y + self.contentInset.top;
286+
287+
#if !TARGET_OS_TV
281288
// If the RefreshControl is refreshing, remove it's height so sticky headers are
282289
// positioned properly when scrolling down while refreshing.
283-
#if !TARGET_OS_TV
284290
if (_rctRefreshControl != nil && _rctRefreshControl.refreshing) {
285291
scrollTop -= _rctRefreshControl.frame.size.height;
286292
}
@@ -451,6 +457,21 @@ - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher
451457
RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
452458
RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
453459

460+
static inline void RCTApplyTranformationAccordingLayoutDirection(UIView *view, UIUserInterfaceLayoutDirection layoutDirection) {
461+
view.transform =
462+
layoutDirection == UIUserInterfaceLayoutDirectionLeftToRight ?
463+
CGAffineTransformIdentity :
464+
CGAffineTransformMakeScale(-1, 1);
465+
}
466+
467+
- (void)setReactLayoutDirection:(UIUserInterfaceLayoutDirection)layoutDirection
468+
{
469+
[super setReactLayoutDirection:layoutDirection];
470+
471+
RCTApplyTranformationAccordingLayoutDirection(_scrollView, layoutDirection);
472+
RCTApplyTranformationAccordingLayoutDirection(_contentView, layoutDirection);
473+
}
474+
454475
- (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
455476
{
456477
// Does nothing
@@ -467,6 +488,7 @@ - (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
467488
{
468489
RCTAssert(_contentView == nil, @"RCTScrollView may only contain a single subview");
469490
_contentView = view;
491+
RCTApplyTranformationAccordingLayoutDirection(_contentView, self.reactLayoutDirection);
470492
[_scrollView addSubview:view];
471493
}
472494
}
@@ -921,16 +943,9 @@ - (CGSize)contentSize
921943
{
922944
if (!CGSizeEqualToSize(_contentSize, CGSizeZero)) {
923945
return _contentSize;
924-
} else if (!_contentView) {
925-
return CGSizeZero;
926-
} else {
927-
CGSize singleSubviewSize = _contentView.frame.size;
928-
CGPoint singleSubviewPosition = _contentView.frame.origin;
929-
return (CGSize){
930-
singleSubviewSize.width + singleSubviewPosition.x,
931-
singleSubviewSize.height + singleSubviewPosition.y
932-
};
933946
}
947+
948+
return _contentView.frame.size;
934949
}
935950

936951
- (void)reactBridgeDidFinishTransaction

React/Views/RCTShadowView.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ typedef void (^RCTApplierBlock)(NSDictionary<NSNumber *, UIView *> *viewRegistry
6262
*/
6363
@property (nonatomic, assign, getter=isHidden) BOOL hidden;
6464

65+
/**
66+
* Computed layout direction for the view backed to Yoga node value.
67+
*/
68+
@property (nonatomic, assign, readonly) UIUserInterfaceLayoutDirection effectiveLayoutDirection;
69+
6570
/**
6671
* Position and dimensions.
6772
* Defaults to { 0, 0, NAN, NAN }.

React/Views/RCTShadowView.m

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,12 @@ - (NSString *)recursiveDescription
438438
return description;
439439
}
440440

441+
// Layout Direction
442+
443+
- (UIUserInterfaceLayoutDirection)effectiveLayoutDirection {
444+
return YGNodeLayoutGetDirection(self.cssNode) == YGDirectionRTL ? UIUserInterfaceLayoutDirectionRightToLeft : UIUserInterfaceLayoutDirectionLeftToRight;
445+
}
446+
441447
// Margin
442448

443449
#define RCT_MARGIN_PROPERTY(prop, metaProp) \

0 commit comments

Comments
 (0)