Skip to content

Instantly share code, notes, and snippets.

@majak
Created September 27, 2016 16:33
Show Gist options
  • Select an option

  • Save majak/b0ee1fb6ca725d2cf810d2f0ab394f2e to your computer and use it in GitHub Desktop.

Select an option

Save majak/b0ee1fb6ca725d2cf810d2f0ab394f2e to your computer and use it in GitHub Desktop.
ios view clipping issue #8607
diff --git a/node_modules/react-native/React/Views/RCTScrollView.m b/node_modules/react-native/React/Views/RCTScrollView.m
--- a/node_modules/react-native/React/Views/RCTScrollView.m
+++ b/node_modules/react-native/React/Views/RCTScrollView.m
@@ -136,7 +136,7 @@
* default UIKit behaviors such as textFields automatically scrolling
* scroll views that contain them and support sticky headers.
*/
-@interface RCTCustomScrollView : UIScrollView<UIGestureRecognizerDelegate>
+@interface RCTCustomScrollView : UIScrollView<UIGestureRecognizerDelegate, RCTClippingView>
@property (nonatomic, copy) NSIndexSet *stickyHeaderIndices;
@property (nonatomic, assign) BOOL centerContent;
@@ -147,6 +147,7 @@
@implementation RCTCustomScrollView
{
+ BOOL _removeClippedSubviews;
__weak UIView *_dockedHeaderView;
}
@@ -367,6 +368,49 @@
[self addSubview:_rctRefreshControl];
}
+#pragma mark - RCTClippingView
+
+- (void)reclipView:(UIView<RCTClippableView> *)clippableView
+{
+ // noop, our child is the content view, which is never clipped
+}
+
+- (CGRect)clippingRectForClippingView:(UIView<RCTClippingView> *)clippingView
+{
+ RCTAssert(clippingView == [self contentView], @"Unexpected clipping view. Expected %@ got %@.", [self contentView], clippingView);
+ // Scrollview's content view is as big as all rows together. If we used its bound for clipping it wouldn't achieve anything.
+ // So we use our (scrollview's) bounds.
+ return [self convertRect:self.bounds toView:clippingView];
+}
+
+- (void)setRemoveClippedSubviews:(BOOL)removeClippedSubviews
+{
+ if (removeClippedSubviews != _removeClippedSubviews) {
+ _removeClippedSubviews = removeClippedSubviews;
+ // No matter if clipping has been turned on or off the child views will do the right thing.
+ if ([self contentView]) {
+ [(UIView<RCTClippableView> *)[self contentView] setReactClippingSuperview:self];
+ }
+ }
+}
+
+- (BOOL)removeClippedSubviews
+{
+ return _removeClippedSubviews;
+}
+
+- (void)reactSetFrame:(CGRect)frame
+{
+ if (!CGRectEqualToRect(frame, self.frame)) {
+ [super setFrame:frame];
+ for (UIView *view in [[self contentView] reactSubviews]) {
+ if ([view conformsToProtocol:@protocol(RCTClippableView)]) {
+ [((UIView<RCTClippingView> *)[self contentView]) reclipView:(UIView<RCTClippableView> *)view];
+ }
+ }
+ }
+}
+
@end
@implementation RCTScrollView
@@ -411,11 +455,6 @@
RCT_NOT_IMPLEMENTED(- (instancetype)initWithFrame:(CGRect)frame)
RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
-- (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews
-{
- // Does nothing
-}
-
- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
{
[super insertReactSubview:view atIndex:atIndex];
@@ -425,6 +464,9 @@
RCTAssert(_contentView == nil, @"RCTScrollView may only contain a single subview");
_contentView = view;
[_scrollView addSubview:view];
+ if ([_contentView conformsToProtocol:@protocol(RCTClippableView)]) {
+ [(UIView<RCTClippableView> *)_contentView setReactClippingSuperview:_scrollView];
+ }
}
}
@@ -472,6 +514,14 @@
_scrollView.clipsToBounds = clipsToBounds;
}
+- (void)setRemoveClippedSubviews:(BOOL)removeClippedSubviews
+{
+ if (removeClippedSubviews != [super removeClippedSubviews]) {
+ [_scrollView setRemoveClippedSubviews:removeClippedSubviews];
+ }
+ [super setRemoveClippedSubviews:removeClippedSubviews];
+}
+
- (void)dealloc
{
_scrollView.delegate = nil;
@@ -484,26 +534,28 @@
RCTAssert([self.subviews lastObject] == _scrollView, @"our only subview should be a scrollview");
CGPoint originalOffset = _scrollView.contentOffset;
- _scrollView.frame = self.bounds;
+ if (!CGRectEqualToRect(_scrollView.frame, self.bounds)) {
+ _scrollView.frame = self.bounds;
+
+ for (UIView *view in [_contentView reactSubviews]) {
+ if ([view conformsToProtocol:@protocol(RCTClippableView)]) {
+ UIView<RCTClippableView> *clippableView = (UIView<RCTClippableView> *)view;
+ [clippableView.reactClippingSuperview reclipView:clippableView];
+ }
+ }
+
+ }
_scrollView.contentOffset = originalOffset;
// Adjust the refresh control frame if the scrollview layout changes.
RCTRefreshControl *refreshControl = _scrollView.rctRefreshControl;
if (refreshControl && refreshControl.refreshing) {
refreshControl.frame = (CGRect){_scrollView.contentOffset, {_scrollView.frame.size.width, refreshControl.frame.size.height}};
}
-
- [self updateClippedSubviews];
}
- (void)updateClippedSubviews
{
- // Find a suitable view to use for clipping
- UIView *clipView = [self react_findClipView];
- if (!clipView) {
- return;
- }
-
static const CGFloat leeway = 1.0;
const CGSize contentSize = _scrollView.contentSize;
@@ -518,8 +570,12 @@
(scrollsVertically && (bounds.size.height < leeway || fabs(_lastClippedToRect.origin.y - bounds.origin.y) >= leeway));
if (shouldClipAgain) {
- const CGRect clipRect = CGRectInset(clipView.bounds, -leeway, -leeway);
- [self react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
+ for (UIView *view in [_contentView reactSubviews]) {
+ if ([view conformsToProtocol:@protocol(RCTClippableView)]) {
+ UIView<RCTClippableView> *clippableView = (UIView<RCTClippableView> *)view;
+ [clippableView.reactClippingSuperview reclipView:clippableView];
+ }
+ }
_lastClippedToRect = bounds;
}
}
diff --git a/node_modules/react-native/React/Views/RCTView.h b/node_modules/react-native/React/Views/RCTView.h
--- a/node_modules/react-native/React/Views/RCTView.h
+++ b/node_modules/react-native/React/Views/RCTView.h
@@ -19,7 +19,46 @@
@class RCTView;
-@interface RCTView : UIView
+
+@protocol RCTClippableView;
+
+
+/**
+ * Having views in view hierarchy that are not visible wastes resources.
+ * That's why we have implemented view clipping. The key idea is simple:
+ * When a view has clipping turned on, its subview is removed as long as it is outside of the view's bounds.
+ * These following two protocols help achieving this behavior.
+ * View that clips has to implement `RCTClippingView` protocol and a view that can be clipped `RCTClippableView` protocol.
+ *
+ * The parent (clipping) view is always responsible for doing the view manipulation.
+ * If a clippable view changes its position it should notify it's clipping superview to redo the clipping using the `reclipView:` call.
+ *
+ * In some special cases the direct superview doesn't have the right size for clipping.
+ * A scrollview is a nice example: Scrollview doesn't have rows as its direct subviews.
+ * There is a content view between them whose bounds contain all rows. Therefore in this case clipping have to be done based not on a direct superview, but one above.
+ *
+ * This is enabled by calling `clippingRectForClippingView:`.
+ * All clipping views should call this method during `reclipView:` on their parent to enable clipping rect override by parent. See implementation in `RCTView`.
+ */
+@protocol RCTClippingView <NSObject>
+@property (nonatomic, assign) BOOL removeClippedSubviews;
+- (void)reclipView:(UIView<RCTClippableView> *)clippableView;
+- (CGRect)clippingRectForClippingView:(UIView<RCTClippingView> *)clippingView;
+@end
+
+/**
+ * A view implementing this protocol can be clipped.
+ */
+@protocol RCTClippableView <NSObject>
+/**
+ * This property has to be set by a clipping parent when clipping is active.
+ * Even if this view was clipped (it has no superview), this points to its parent view in the react hierarchy.
+ * (That doesn't have to be true for `reactSuperview)
+ */
+@property (weak, nonatomic) UIView<RCTClippingView> *reactClippingSuperview;
+@end
+
+@interface RCTView : UIView <RCTClippingView, RCTClippableView>
/**
* Accessibility event handlers
@@ -48,22 +87,6 @@
*/
@property (nonatomic, assign) NSInteger reactZIndex;
-/**
- * This is an optimization used to improve performance
- * for large scrolling views with many subviews, such as a
- * list or table. If set to YES, any clipped subviews will
- * be removed from the view hierarchy whenever -updateClippedSubviews
- * is called. This would typically be triggered by a scroll event
- */
-@property (nonatomic, assign) BOOL removeClippedSubviews;
-
-/**
- * Hide subviews if they are outside the view bounds.
- * This is an optimisation used predominantly with RKScrollViews
- * but it is applied recursively to all subviews that have
- * removeClippedSubviews set to YES
- */
-- (void)updateClippedSubviews;
/**
* Border radii.
diff --git a/node_modules/react-native/React/Views/RCTView.m b/node_modules/react-native/React/Views/RCTView.m
--- a/node_modules/react-native/React/Views/RCTView.m
+++ b/node_modules/react-native/React/Views/RCTView.m
@@ -16,69 +16,6 @@
#import "RCTUtils.h"
#import "UIView+React.h"
-@implementation UIView (RCTViewUnmounting)
-
-- (void)react_remountAllSubviews
-{
- // Normal views don't support unmounting, so all
- // this does is forward message to our subviews,
- // in case any of those do support it
-
- for (UIView *subview in self.subviews) {
- [subview react_remountAllSubviews];
- }
-}
-
-- (void)react_updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
-{
- // Even though we don't support subview unmounting
- // we do support clipsToBounds, so if that's enabled
- // we'll update the clipping
-
- if (self.clipsToBounds && self.subviews.count > 0) {
- clipRect = [clipView convertRect:clipRect toView:self];
- clipRect = CGRectIntersection(clipRect, self.bounds);
- clipView = self;
- }
-
- // Normal views don't support unmounting, so all
- // this does is forward message to our subviews,
- // in case any of those do support it
-
- for (UIView *subview in self.subviews) {
- [subview react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
- }
-}
-
-- (UIView *)react_findClipView
-{
- UIView *testView = self;
- UIView *clipView = nil;
- CGRect clipRect = self.bounds;
- // We will only look for a clipping view up the view hierarchy until we hit the root view.
- while (testView) {
- if (testView.clipsToBounds) {
- if (clipView) {
- CGRect testRect = [clipView convertRect:clipRect toView:testView];
- if (!CGRectContainsRect(testView.bounds, testRect)) {
- clipView = testView;
- clipRect = CGRectIntersection(testView.bounds, testRect);
- }
- } else {
- clipView = testView;
- clipRect = [self convertRect:self.bounds toView:clipView];
- }
- }
- if ([testView isReactRootView]) {
- break;
- }
- testView = testView.superview;
- }
- return clipView ?: self.window;
-}
-
-@end
-
static NSString *RCTRecursiveAccessibilityLabel(UIView *view)
{
NSMutableString *str = [NSMutableString stringWithString:@""];
@@ -215,6 +152,30 @@
}
}
+- (void)didUpdateReactSubviews
+{
+ if (!_removeClippedSubviews) {
+ [super didUpdateReactSubviews];
+ return;
+ }
+
+ // We have to set clipping view to all new children. New children doesn't have reactClippingSuperview set yet.
+ for (UIView *view in self.sortedReactSubviews) {
+ if ([view conformsToProtocol:@protocol(RCTClippableView)]) {
+ UIView<RCTClippableView> *clippableView = (UIView<RCTClippableView> *)view;
+ if (!clippableView.reactClippingSuperview) {
+ [clippableView setReactClippingSuperview:self];
+ [self reclipView:clippableView];
+ }
+ } else {
+ if (!view.superview) {
+ [self addSubview:view];
+ }
+ }
+ }
+
+}
+
- (NSString *)description
{
NSString *superDescription = super.description;
@@ -271,112 +232,70 @@
return UIEdgeInsetsZero;
}
-#pragma mark - View unmounting
-
-- (void)react_remountAllSubviews
-{
- if (_removeClippedSubviews) {
- for (UIView *view in self.sortedReactSubviews) {
- if (view.superview != self) {
- [self addSubview:view];
- [view react_remountAllSubviews];
- }
- }
- } else {
- // If _removeClippedSubviews is false, we must already be showing all subviews
- [super react_remountAllSubviews];
- }
-}
-
-- (void)react_updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
-{
- // TODO (#5906496): for scrollviews (the primary use-case) we could
- // optimize this by only doing a range check along the scroll axis,
- // instead of comparing the whole frame
-
- if (!_removeClippedSubviews) {
- // Use default behavior if unmounting is disabled
- return [super react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
- }
+#pragma mark - RCTClippingView
- if (self.reactSubviews.count == 0) {
- // Do nothing if we have no subviews
- return;
- }
-
- if (CGSizeEqualToSize(self.bounds.size, CGSizeZero)) {
- // Do nothing if layout hasn't happened yet
- return;
- }
-
- // Convert clipping rect to local coordinates
- clipRect = [clipView convertRect:clipRect toView:self];
- clipRect = CGRectIntersection(clipRect, self.bounds);
- clipView = self;
+@synthesize removeClippedSubviews = _removeClippedSubviews;
- // Mount / unmount views
- for (UIView *view in self.sortedReactSubviews) {
- if (!CGRectIsEmpty(CGRectIntersection(clipRect, view.frame))) {
-
- // View is at least partially visible, so remount it if unmounted
- [self addSubview:view];
-
- // Then test its subviews
- if (CGRectContainsRect(clipRect, view.frame)) {
- // View is fully visible, so remount all subviews
- [view react_remountAllSubviews];
- } else {
- // View is partially visible, so update clipped subviews
- [view react_updateClippedSubviewsWithClipRect:clipRect relativeToView:clipView];
+- (void)setRemoveClippedSubviews:(BOOL)removeClippedSubviews
+{
+ if (removeClippedSubviews != _removeClippedSubviews) {
+ _removeClippedSubviews = removeClippedSubviews;
+ for (UIView *view in self.sortedReactSubviews) {
+ if ([view conformsToProtocol:@protocol(RCTClippableView)]) {
+ UIView<RCTClippableView> *clippableView = (UIView<RCTClippableView> *)view;
+ if (removeClippedSubviews) {
+ [clippableView setReactClippingSuperview:self];
+ [self reclipView:clippableView];
+ } else {
+ [clippableView setReactClippingSuperview:nil];
+ [self addSubview:clippableView];
+ }
}
-
- } else if (view.superview) {
-
- // View is completely outside the clipRect, so unmount it
- [view removeFromSuperview];
}
}
}
-- (void)setRemoveClippedSubviews:(BOOL)removeClippedSubviews
+- (CGRect)clippingRectForClippingView:(UIView<RCTClippingView> *)clippingView
{
- if (!removeClippedSubviews && _removeClippedSubviews) {
- [self react_remountAllSubviews];
- }
- _removeClippedSubviews = removeClippedSubviews;
+ return clippingView.bounds;
}
-- (void)didUpdateReactSubviews
+- (void)reclipView:(UIView<RCTClippableView> *)clippableView
{
- if (_removeClippedSubviews) {
- [self updateClippedSubviews];
- } else {
- [super didUpdateReactSubviews];
+ RCTAssert(self.removeClippedSubviews, @"We are trying to evalute clipping while it's turned off. (%@ clips %@?)", self, clippableView);
+
+ CGRect clippingRect = self.bounds;
+ // If the superview is clipping as well it may want to override our clipping rect.
+ if (_reactClippingSuperview) {
+ clippingRect = [_reactClippingSuperview clippingRectForClippingView:self];
+ }
+
+ if (!clippableView.superview && CGRectIntersectsRect(clippableView.frame, clippingRect)) {
+
+ // When adding a clipped view back, we have to make sure zIndex oredering is preserved.
+ // That means we have to find the first subview below the subview being added back.
+ // This is not the most effiecient way of doing it when all subviews are being reclipped one by one.
+ UIView *lastSubview = nil;
+ for (UIView *view in self.sortedReactSubviews) {
+ if (view.superview) {
+ lastSubview = view;
+ } else if (view == clippableView) {
+ break;
+ }
+ }
+ if (lastSubview) {
+ [self insertSubview:clippableView aboveSubview:lastSubview];
+ } else {
+ [self insertSubview:clippableView atIndex:0];
+ }
+ } else if (clippableView.superview && !CGRectIntersectsRect(clippableView.frame, clippingRect)) {
+ [clippableView removeFromSuperview];
}
}
-- (void)updateClippedSubviews
-{
- // Find a suitable view to use for clipping
- UIView *clipView = [self react_findClipView];
- if (clipView) {
- [self react_updateClippedSubviewsWithClipRect:clipView.bounds relativeToView:clipView];
- }
-}
+#pragma mark - RCTClippableView
-- (void)layoutSubviews
-{
- // TODO (#5906496): this a nasty performance drain, but necessary
- // to prevent gaps appearing when the loading spinner disappears.
- // We might be able to fix this another way by triggering a call
- // to updateClippedSubviews manually after loading
-
- [super layoutSubviews];
-
- if (_removeClippedSubviews) {
- [self updateClippedSubviews];
- }
-}
+@synthesize reactClippingSuperview = _reactClippingSuperview;
#pragma mark - Borders
@@ -449,7 +368,16 @@
// TODO: detect up-front if re-rendering is necessary
CGSize oldSize = self.bounds.size;
[super reactSetFrame:frame];
+ [_reactClippingSuperview reclipView:self];
if (!CGSizeEqualToSize(self.bounds.size, oldSize)) {
+ if (_removeClippedSubviews) {
+ for (UIView *view in self.sortedReactSubviews) {
+ if ([view conformsToProtocol:@protocol(RCTClippableView)]) {
+ UIView<RCTClippableView> *clippableView = (UIView<RCTClippableView> *)view;
+ [self reclipView:clippableView];
+ }
+ }
+ }
[self.layer setNeedsDisplay];
}
}
diff --git a/node_modules/react-native/React/Views/RCTViewManager.m b/node_modules/react-native/React/Views/RCTViewManager.m
--- a/node_modules/react-native/React/Views/RCTViewManager.m
+++ b/node_modules/react-native/React/Views/RCTViewManager.m
@@ -111,40 +111,48 @@
RCT_EXPORT_VIEW_PROPERTY(accessibilityLabel, NSString)
RCT_EXPORT_VIEW_PROPERTY(accessibilityTraits, UIAccessibilityTraits)
RCT_EXPORT_VIEW_PROPERTY(backgroundColor, UIColor)
+RCT_EXPORT_VIEW_PROPERTY(removeClippedSubviews, BOOL)
RCT_REMAP_VIEW_PROPERTY(accessible, isAccessibilityElement, BOOL)
RCT_REMAP_VIEW_PROPERTY(testID, accessibilityIdentifier, NSString)
RCT_REMAP_VIEW_PROPERTY(backfaceVisibility, layer.doubleSided, css_backface_visibility_t)
RCT_REMAP_VIEW_PROPERTY(opacity, alpha, CGFloat)
RCT_REMAP_VIEW_PROPERTY(shadowColor, layer.shadowColor, CGColor)
RCT_REMAP_VIEW_PROPERTY(shadowOffset, layer.shadowOffset, CGSize)
RCT_REMAP_VIEW_PROPERTY(shadowOpacity, layer.shadowOpacity, float)
RCT_REMAP_VIEW_PROPERTY(shadowRadius, layer.shadowRadius, CGFloat)
-RCT_CUSTOM_VIEW_PROPERTY(overflow, CSSOverflow, RCTView)
+RCT_CUSTOM_VIEW_PROPERTY(overflow, CSSOverflow, UIView)
{
if (json) {
view.clipsToBounds = [RCTConvert CSSOverflow:json] == CSSOverflowHidden;
} else {
view.clipsToBounds = defaultView.clipsToBounds;
}
}
-RCT_CUSTOM_VIEW_PROPERTY(shouldRasterizeIOS, BOOL, RCTView)
+RCT_CUSTOM_VIEW_PROPERTY(shouldRasterizeIOS, BOOL, UIView)
{
view.layer.shouldRasterize = json ? [RCTConvert BOOL:json] : defaultView.layer.shouldRasterize;
view.layer.rasterizationScale = view.layer.shouldRasterize ? [UIScreen mainScreen].scale : defaultView.layer.rasterizationScale;
}
// TODO: t11041683 Remove this duplicate property name.
-RCT_CUSTOM_VIEW_PROPERTY(transformMatrix, CATransform3D, RCTView)
+RCT_CUSTOM_VIEW_PROPERTY(transformMatrix, CATransform3D, UIView)
{
view.layer.transform = json ? [RCTConvert CATransform3D:json] : defaultView.layer.transform;
// TODO: Improve this by enabling edge antialiasing only for transforms with rotation or skewing
view.layer.allowsEdgeAntialiasing = !CATransform3DIsIdentity(view.layer.transform);
+ if ([view conformsToProtocol:@protocol(RCTClippableView)]) {
+ UIView<RCTClippableView> *clippableView = (UIView<RCTClippableView> *)view;
+ [clippableView.reactClippingSuperview reclipView:clippableView];
+ }
}
-RCT_CUSTOM_VIEW_PROPERTY(transform, CATransform3D, RCTView)
+RCT_CUSTOM_VIEW_PROPERTY(transform, CATransform3D, UIView)
{
view.layer.transform = json ? [RCTConvert CATransform3D:json] : defaultView.layer.transform;
// TODO: Improve this by enabling edge antialiasing only for transforms with rotation or skewing
view.layer.allowsEdgeAntialiasing = !CATransform3DIsIdentity(view.layer.transform);
-}
+ if ([view conformsToProtocol:@protocol(RCTClippableView)]) {
+ UIView<RCTClippableView> *clippableView = (UIView<RCTClippableView> *)view;
+ [clippableView.reactClippingSuperview reclipView:clippableView];
+ }}
RCT_CUSTOM_VIEW_PROPERTY(pointerEvents, RCTPointerEvents, RCTView)
{
if ([view respondsToSelector:@selector(setPointerEvents:)]) {
@@ -172,12 +180,6 @@
RCTLogError(@"UIView base class does not support pointerEvent value: %@", json);
}
}
-RCT_CUSTOM_VIEW_PROPERTY(removeClippedSubviews, BOOL, RCTView)
-{
- if ([view respondsToSelector:@selector(setRemoveClippedSubviews:)]) {
- view.removeClippedSubviews = json ? [RCTConvert BOOL:json] : defaultView.removeClippedSubviews;
- }
-}
RCT_CUSTOM_VIEW_PROPERTY(borderRadius, CGFloat, RCTView) {
if ([view respondsToSelector:@selector(setBorderRadius:)]) {
view.borderRadius = json ? [RCTConvert CGFloat:json] : defaultView.borderRadius;
diff --git a/node_modules/react-native/React/Views/UIView+Private.h b/node_modules/react-native/React/Views/UIView+Private.h
--- a/node_modules/react-native/React/Views/UIView+Private.h
+++ b/node_modules/react-native/React/Views/UIView+Private.h
@@ -11,11 +11,6 @@
@interface UIView (Private)
-// remove clipped subviews implementation
-- (void)react_remountAllSubviews;
-- (void)react_updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView;
-- (UIView *)react_findClipView;
-
// zIndex sorting
- (void)clearSortedSubviews;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment