From 91910d87deb67b737a01bdb236008cd1b9a4fc64 Mon Sep 17 00:00:00 2001 From: Valentin Shergin Date: Thu, 2 Feb 2017 09:51:19 -0800 Subject: [PATCH] Better RTL support especially for ScrollView's Reviewed By: fkgozali Differential Revision: D4478913 fbshipit-source-id: 525c17fa109ad3c35161b10940776f1426ba2535 --- Libraries/Components/ScrollView/ScrollView.js | 46 ++++++++++------ Libraries/Text/RCTTextField.m | 1 + React/Modules/RCTUIManager.m | 7 +++ React/Views/RCTScrollContentShadowView.h | 16 ++++++ React/Views/RCTScrollContentShadowView.m | 55 +++++++++++++++++++ React/Views/RCTScrollContentViewManager.h | 14 +++++ React/Views/RCTScrollContentViewManager.m | 23 ++++++++ React/Views/RCTScrollView.m | 37 +++++++++---- React/Views/RCTShadowView.h | 5 ++ React/Views/RCTShadowView.m | 6 ++ React/Views/RCTView.h | 7 +++ React/Views/RCTView.m | 12 ++++ React/Views/UIView+React.h | 7 +++ React/Views/UIView+React.m | 21 +++++++ 14 files changed, 228 insertions(+), 29 deletions(-) create mode 100644 React/Views/RCTScrollContentShadowView.h create mode 100644 React/Views/RCTScrollContentShadowView.m create mode 100644 React/Views/RCTScrollContentViewManager.h create mode 100644 React/Views/RCTScrollContentViewManager.m diff --git a/Libraries/Components/ScrollView/ScrollView.js b/Libraries/Components/ScrollView/ScrollView.js index afc4306d2..080fc8018 100644 --- a/Libraries/Components/ScrollView/ScrollView.js +++ b/Libraries/Components/ScrollView/ScrollView.js @@ -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 = - {this.props.children} - ; + ; 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; diff --git a/Libraries/Text/RCTTextField.m b/Libraries/Text/RCTTextField.m index bfa854b22..0ea13179b 100644 --- a/Libraries/Text/RCTTextField.m +++ b/Libraries/Text/RCTTextField.m @@ -227,6 +227,7 @@ static void RCTUpdatePlaceholder(RCTTextField *self) key:nil eventCount:_nativeEventCount]; } + - (void)textFieldSubmitEditing { _submitted = YES; diff --git a/React/Modules/RCTUIManager.m b/React/Modules/RCTUIManager.m index b72323589..627e2e0f4 100644 --- a/React/Modules/RCTUIManager.m +++ b/React/Modules/RCTUIManager.m @@ -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) { diff --git a/React/Views/RCTScrollContentShadowView.h b/React/Views/RCTScrollContentShadowView.h new file mode 100644 index 000000000..8b94dd2d6 --- /dev/null +++ b/React/Views/RCTScrollContentShadowView.h @@ -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 + +#import + +@interface RCTScrollContentShadowView : RCTShadowView + +@end diff --git a/React/Views/RCTScrollContentShadowView.m b/React/Views/RCTScrollContentShadowView.m new file mode 100644 index 000000000..51854ebfd --- /dev/null +++ b/React/Views/RCTScrollContentShadowView.m @@ -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 + +#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 *)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 diff --git a/React/Views/RCTScrollContentViewManager.h b/React/Views/RCTScrollContentViewManager.h new file mode 100644 index 000000000..e1c90e31b --- /dev/null +++ b/React/Views/RCTScrollContentViewManager.h @@ -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 + +@interface RCTScrollContentViewManager : RCTViewManager + +@end diff --git a/React/Views/RCTScrollContentViewManager.m b/React/Views/RCTScrollContentViewManager.m new file mode 100644 index 000000000..5639b4fc9 --- /dev/null +++ b/React/Views/RCTScrollContentViewManager.m @@ -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 diff --git a/React/Views/RCTScrollView.m b/React/Views/RCTScrollView.m index 5cb59d632..3115b9142 100644 --- a/React/Views/RCTScrollView.m +++ b/React/Views/RCTScrollView.m @@ -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 diff --git a/React/Views/RCTShadowView.h b/React/Views/RCTShadowView.h index 92c1b56dc..fc51df83a 100644 --- a/React/Views/RCTShadowView.h +++ b/React/Views/RCTShadowView.h @@ -62,6 +62,11 @@ typedef void (^RCTApplierBlock)(NSDictionary *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 }. diff --git a/React/Views/RCTShadowView.m b/React/Views/RCTShadowView.m index e46e2b306..d300d1ba7 100644 --- a/React/Views/RCTShadowView.m +++ b/React/Views/RCTShadowView.m @@ -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) \ diff --git a/React/Views/RCTView.h b/React/Views/RCTView.h index 2e1081bdb..bf8e0b43f 100644 --- a/React/Views/RCTView.h +++ b/React/Views/RCTView.h @@ -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 diff --git a/React/Views/RCTView.m b/React/Views/RCTView.m index a832022ed..ba7f84342 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -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) { diff --git a/React/Views/UIView+React.h b/React/Views/UIView+React.h index c263fb0b6..a5950af7b 100644 --- a/React/Views/UIView+React.h +++ b/React/Views/UIView+React.h @@ -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. */ diff --git a/React/Views/UIView+React.m b/React/Views/UIView+React.m index d637e7efa..84217a805 100644 --- a/React/Views/UIView+React.m +++ b/React/Views/UIView+React.m @@ -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];