Better RTL support especially for ScrollView's

Reviewed By: fkgozali

Differential Revision: D4478913

fbshipit-source-id: 525c17fa109ad3c35161b10940776f1426ba2535
This commit is contained in:
Valentin Shergin 2017-02-02 09:51:19 -08:00 committed by Facebook Github Bot
parent d82f2553fb
commit 91910d87de
14 changed files with 228 additions and 29 deletions

View File

@ -483,6 +483,30 @@ const ScrollView = React.createClass({
},
render: function() {
let ScrollViewClass;
let ScrollContentContainerViewClass;
if (Platform.OS === 'ios') {
ScrollViewClass = RCTScrollView;
ScrollContentContainerViewClass = RCTScrollContentView;
} else if (Platform.OS === 'android') {
if (this.props.horizontal) {
ScrollViewClass = AndroidHorizontalScrollView;
} else {
ScrollViewClass = AndroidScrollView;
}
ScrollContentContainerViewClass = View;
}
invariant(
ScrollViewClass !== undefined,
'ScrollViewClass must not be undefined'
);
invariant(
ScrollContentContainerViewClass !== undefined,
'ScrollContentContainerViewClass must not be undefined'
);
const contentContainerStyle = [
this.props.horizontal && styles.contentContainerHorizontal,
this.props.contentContainerStyle,
@ -507,14 +531,14 @@ const ScrollView = React.createClass({
}
const contentContainer =
<View
<ScrollContentContainerViewClass
{...contentSizeChangeProps}
ref={this._setInnerViewRef}
style={contentContainerStyle}
removeClippedSubviews={this.props.removeClippedSubviews}
collapsable={false}>
{this.props.children}
</View>;
</ScrollContentContainerViewClass>;
const alwaysBounceHorizontal =
this.props.alwaysBounceHorizontal !== undefined ?
@ -559,21 +583,6 @@ const ScrollView = React.createClass({
props.decelerationRate = processDecelerationRate(decelerationRate);
}
let ScrollViewClass;
if (Platform.OS === 'ios') {
ScrollViewClass = RCTScrollView;
} else if (Platform.OS === 'android') {
if (this.props.horizontal) {
ScrollViewClass = AndroidHorizontalScrollView;
} else {
ScrollViewClass = AndroidScrollView;
}
}
invariant(
ScrollViewClass !== undefined,
'ScrollViewClass must not be undefined'
);
const refreshControl = this.props.refreshControl;
if (refreshControl) {
if (Platform.OS === 'ios') {
@ -626,7 +635,7 @@ const styles = StyleSheet.create({
},
});
let nativeOnlyProps, AndroidScrollView, AndroidHorizontalScrollView, RCTScrollView;
let nativeOnlyProps, AndroidScrollView, AndroidHorizontalScrollView, RCTScrollView, RCTScrollContentView;
if (Platform.OS === 'android') {
nativeOnlyProps = {
nativeOnly: {
@ -649,6 +658,7 @@ if (Platform.OS === 'android') {
}
};
RCTScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps);
RCTScrollContentView = requireNativeComponent('RCTScrollContentView', View);
}
module.exports = ScrollView;

View File

@ -227,6 +227,7 @@ static void RCTUpdatePlaceholder(RCTTextField *self)
key:nil
eventCount:_nativeEventCount];
}
- (void)textFieldSubmitEditing
{
_submitted = YES;

View File

@ -552,6 +552,7 @@ dispatch_queue_t RCTGetUIManagerQueue(void)
typedef struct {
CGRect frame;
UIUserInterfaceLayoutDirection layoutDirection;
BOOL isNew;
BOOL parentIsNew;
BOOL isHidden;
@ -568,6 +569,7 @@ dispatch_queue_t RCTGetUIManagerQueue(void)
reactTags[index] = shadowView.reactTag;
frameDataArray[index++] = (RCTFrameData){
shadowView.frame,
shadowView.effectiveLayoutDirection,
shadowView.isNewView,
shadowView.superview.isNewView,
shadowView.isHidden,
@ -634,6 +636,7 @@ dispatch_queue_t RCTGetUIManagerQueue(void)
CGRect frame = frameData.frame;
BOOL isHidden = frameData.isHidden;
UIUserInterfaceLayoutDirection layoutDirection = frameData.layoutDirection;
BOOL isNew = frameData.isNew;
RCTAnimation *updateAnimation = isNew ? nil : layoutAnimation.updateAnimation;
BOOL shouldAnimateCreation = isNew && !frameData.parentIsNew;
@ -654,6 +657,10 @@ dispatch_queue_t RCTGetUIManagerQueue(void)
view.hidden = isHidden;
}
if (view.reactLayoutDirection != layoutDirection) {
view.reactLayoutDirection = layoutDirection;
}
RCTViewManagerUIBlock updateBlock = updateBlocks[reactTag];
if (createAnimation) {

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#import <UIKit/UIKit.h>
#import <React/RCTShadowView.h>
@interface RCTScrollContentShadowView : RCTShadowView
@end

View File

@ -0,0 +1,55 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#import "RCTScrollContentShadowView.h"
#import <yoga/Yoga.h>
#import "RCTUtils.h"
@interface RCTShadowView () {
// This will be removed after t15757916, which will remove
// side-effects from `setFrame:` method.
@public CGRect _frame;
}
@end
@implementation RCTScrollContentShadowView
- (void)applyLayoutNode:(YGNodeRef)node
viewsWithNewFrame:(NSMutableSet<RCTShadowView *> *)viewsWithNewFrame
absolutePosition:(CGPoint)absolutePosition
{
// Call super method if LTR layout is enforced.
if (self.effectiveLayoutDirection == UIUserInterfaceLayoutDirectionLeftToRight) {
[super applyLayoutNode:node
viewsWithNewFrame:viewsWithNewFrame
absolutePosition:absolutePosition];
return;
}
// Motivation:
// Yoga place `contentView` on the right side of `scrollView` when RTL layout is enfoced.
// That breaks everything; it is completly pointless to (re)position `contentView`
// because it is `contentView`'s job. So, we work around it here.
// Step 1. Compensate `absolutePosition` change.
CGFloat xCompensation = YGNodeLayoutGetRight(node) - YGNodeLayoutGetLeft(node);
absolutePosition.x += xCompensation;
// Step 2. Call super method.
[super applyLayoutNode:node
viewsWithNewFrame:viewsWithNewFrame
absolutePosition:absolutePosition];
// Step 3. Reset the position.
_frame.origin.x = RCTRoundPixelValue(YGNodeLayoutGetRight(node));
}
@end

View File

@ -0,0 +1,14 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#import <React/RCTViewManager.h>
@interface RCTScrollContentViewManager : RCTViewManager
@end

View File

@ -0,0 +1,23 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*/
#import "RCTScrollContentViewManager.h"
#import "RCTScrollContentShadowView.h"
@implementation RCTScrollContentViewManager
RCT_EXPORT_MODULE()
- (RCTShadowView *)shadowView
{
return [RCTScrollContentShadowView new];
}
@end

View File

@ -159,6 +159,11 @@ RCT_NOT_IMPLEMENTED(- (instancetype)init)
{
if ((self = [super initWithFrame:frame])) {
[self.panGestureRecognizer addTarget:self action:@selector(handleCustomPan:)];
// We intentionaly force `UIScrollView`s `semanticContentAttribute` to `LTR` here
// because this attribute affects a position of vertical scrollbar; we don't want this
// scrollbar flip because we also flip it with whole `UIScrollView` flip.
self.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight;
}
return self;
}
@ -191,7 +196,7 @@ RCT_NOT_IMPLEMENTED(- (instancetype)init)
self.panGestureRecognizer.enabled = YES;
// TODO: If mid bounce, animate the scroll view to a non-bounced position
// while disabling (but only if `stopScrollInteractionIfJSHasResponder` was
// called *during* a `pan`. Currently, it will just snap into place which
// called *during* a `pan`). Currently, it will just snap into place which
// is not so bad either.
// Another approach:
// self.scrollEnabled = NO;
@ -278,9 +283,10 @@ RCT_NOT_IMPLEMENTED(- (instancetype)init)
{
UIView *contentView = [self contentView];
CGFloat scrollTop = self.bounds.origin.y + self.contentInset.top;
#if !TARGET_OS_TV
// If the RefreshControl is refreshing, remove it's height so sticky headers are
// positioned properly when scrolling down while refreshing.
#if !TARGET_OS_TV
if (_rctRefreshControl != nil && _rctRefreshControl.refreshing) {
scrollTop -= _rctRefreshControl.frame.size.height;
}
@ -451,6 +457,21 @@ static inline BOOL isRectInvalid(CGRect rect) {
RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
static inline void RCTApplyTranformationAccordingLayoutDirection(UIView *view, UIUserInterfaceLayoutDirection layoutDirection) {
view.transform =
layoutDirection == UIUserInterfaceLayoutDirectionLeftToRight ?
CGAffineTransformIdentity :
CGAffineTransformMakeScale(-1, 1);
}
- (void)setReactLayoutDirection:(UIUserInterfaceLayoutDirection)layoutDirection
{
[super setReactLayoutDirection:layoutDirection];
RCTApplyTranformationAccordingLayoutDirection(_scrollView, layoutDirection);
RCTApplyTranformationAccordingLayoutDirection(_contentView, layoutDirection);
}
- (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
{
// Does nothing
@ -467,6 +488,7 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
{
RCTAssert(_contentView == nil, @"RCTScrollView may only contain a single subview");
_contentView = view;
RCTApplyTranformationAccordingLayoutDirection(_contentView, self.reactLayoutDirection);
[_scrollView addSubview:view];
}
}
@ -921,16 +943,9 @@ RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, onScroll)
{
if (!CGSizeEqualToSize(_contentSize, CGSizeZero)) {
return _contentSize;
} else if (!_contentView) {
return CGSizeZero;
} else {
CGSize singleSubviewSize = _contentView.frame.size;
CGPoint singleSubviewPosition = _contentView.frame.origin;
return (CGSize){
singleSubviewSize.width + singleSubviewPosition.x,
singleSubviewSize.height + singleSubviewPosition.y
};
}
return _contentView.frame.size;
}
- (void)reactBridgeDidFinishTransaction

View File

@ -62,6 +62,11 @@ typedef void (^RCTApplierBlock)(NSDictionary<NSNumber *, UIView *> *viewRegistry
*/
@property (nonatomic, assign, getter=isHidden) BOOL hidden;
/**
* Computed layout direction for the view backed to Yoga node value.
*/
@property (nonatomic, assign, readonly) UIUserInterfaceLayoutDirection effectiveLayoutDirection;
/**
* Position and dimensions.
* Defaults to { 0, 0, NAN, NAN }.

View File

@ -438,6 +438,12 @@ static void RCTProcessMetaPropsBorder(const YGValue metaProps[META_PROP_COUNT],
return description;
}
// Layout Direction
- (UIUserInterfaceLayoutDirection)effectiveLayoutDirection {
return YGNodeLayoutGetDirection(self.cssNode) == YGDirectionRTL ? UIUserInterfaceLayoutDirectionRightToLeft : UIUserInterfaceLayoutDirectionLeftToRight;
}
// Margin
#define RCT_MARGIN_PROPERTY(prop, metaProp) \

View File

@ -40,6 +40,13 @@
*/
+ (UIEdgeInsets)contentInsetsForView:(UIView *)curView;
/**
* Layout direction of the view.
* This is inherited from UIView+React, but we override it here
* to improve perfomance and make subclassing/overriding possible/easier.
*/
@property (nonatomic, assign) UIUserInterfaceLayoutDirection reactLayoutDirection;
/**
* z-index, used to override sibling order in didUpdateReactSubviews. This is
* inherited from UIView+React, but we override it here to reduce the boxing

View File

@ -124,6 +124,18 @@ static NSString *RCTRecursiveAccessibilityLabel(UIView *view)
RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:unused)
- (void)setReactLayoutDirection:(UIUserInterfaceLayoutDirection)layoutDirection
{
_reactLayoutDirection = layoutDirection;
#if __IPHONE_OS_VERSION_MIN_REQUIRED >= __IPHONE_9_0
self.semanticContentAttribute =
layoutDirection == UIUserInterfaceLayoutDirectionLeftToRight ?
UISemanticContentAttributeForceLeftToRight :
UISemanticContentAttributeForceRightToLeft;
#endif
}
- (NSString *)accessibilityLabel
{
if (super.accessibilityLabel) {

View File

@ -23,6 +23,13 @@
- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)atIndex NS_REQUIRES_SUPER;
- (void)removeReactSubview:(UIView *)subview NS_REQUIRES_SUPER;
/**
* Layout direction of the view.
* Internally backed to `semanticContentAttribute` property.
* Defaults to `LeftToRight` in case of ambiguity.
*/
@property (nonatomic, assign) UIUserInterfaceLayoutDirection reactLayoutDirection;
/**
* z-index, used to override sibling order in didUpdateReactSubviews.
*/

View File

@ -87,6 +87,27 @@
[subview removeFromSuperview];
}
- (UIUserInterfaceLayoutDirection)reactLayoutDirection
{
#if __IPHONE_OS_VERSION_MIN_REQUIRED >= __IPHONE_9_0
return [UIView userInterfaceLayoutDirectionForSemanticContentAttribute:self.semanticContentAttribute];
#else
return [objc_getAssociatedObject(self, @selector(reactLayoutDirection)) integerValue];
#endif
}
- (void)setReactLayoutDirection:(UIUserInterfaceLayoutDirection)layoutDirection
{
#if __IPHONE_OS_VERSION_MIN_REQUIRED >= __IPHONE_9_0
self.semanticContentAttribute =
layoutDirection == UIUserInterfaceLayoutDirectionLeftToRight ?
UISemanticContentAttributeForceLeftToRight :
UISemanticContentAttributeForceRightToLeft;
#else
objc_setAssociatedObject(self, @selector(reactLayoutDirection), @(layoutDirection), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
#endif
}
- (NSInteger)reactZIndex
{
return [objc_getAssociatedObject(self, _cmd) integerValue];