From 625c8cb83c7be01b1d5f646b70f8fb1d4c70a45c Mon Sep 17 00:00:00 2001 From: Martin Kralik Date: Fri, 11 Nov 2016 05:22:43 -0800 Subject: [PATCH] new `removeClippedSubviews` implementation (take 2 - recursive) Reviewed By: mmmulani Differential Revision: D4081700 fbshipit-source-id: d4079138dc070565e475831e82651c9b2d5b8d59 --- .../UIExplorer.xcodeproj/project.pbxproj | 4 + .../RCTSubviewClippingTests.m | 372 ++++++++++++++++++ React/Views/RCTScrollView.m | 35 +- React/Views/RCTView.h | 16 - React/Views/RCTView.m | 189 +-------- React/Views/RCTViewManager.m | 20 +- React/Views/UIView+Private.h | 10 +- React/Views/UIView+React.h | 15 + React/Views/UIView+React.m | 176 +++++++++ 9 files changed, 620 insertions(+), 217 deletions(-) create mode 100644 Examples/UIExplorer/UIExplorerUnitTests/RCTSubviewClippingTests.m diff --git a/Examples/UIExplorer/UIExplorer.xcodeproj/project.pbxproj b/Examples/UIExplorer/UIExplorer.xcodeproj/project.pbxproj index 21c59053cffaac..67995b9791e777 100644 --- a/Examples/UIExplorer/UIExplorer.xcodeproj/project.pbxproj +++ b/Examples/UIExplorer/UIExplorer.xcodeproj/project.pbxproj @@ -105,6 +105,7 @@ 2DD323EA1DA2DE3F000FE1B8 /* libReact-tvOS.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 2DD323D91DA2DD8B000FE1B8 /* libReact-tvOS.a */; }; 3578590A1B28D2CF00341EDB /* libRCTLinking.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 357859011B28D2C500341EDB /* libRCTLinking.a */; }; 39AA31A41DC1DFDC000F7EBB /* RCTUnicodeDecodeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 39AA31A31DC1DFDC000F7EBB /* RCTUnicodeDecodeTests.m */; }; + 397D6A731DB12C1100E99986 /* RCTSubviewClippingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 397D6A711DB12C1100E99986 /* RCTSubviewClippingTests.m */; }; 3D13F8481D6F6AF900E69E0E /* ImageInBundle.png in Resources */ = {isa = PBXBuildFile; fileRef = 3D13F8441D6F6AF200E69E0E /* ImageInBundle.png */; }; 3D13F84A1D6F6AFD00E69E0E /* OtherImages.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3D13F8451D6F6AF200E69E0E /* OtherImages.xcassets */; }; 3D299BAF1D33EBFA00FA1057 /* RCTLoggingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 3D299BAE1D33EBFA00FA1057 /* RCTLoggingTests.m */; }; @@ -390,6 +391,7 @@ 2DD323A51DA2DD8B000FE1B8 /* UIExplorer-tvOSUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "UIExplorer-tvOSUnitTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 357858F81B28D2C400341EDB /* RCTLinking.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTLinking.xcodeproj; path = ../../Libraries/LinkingIOS/RCTLinking.xcodeproj; sourceTree = ""; }; 39AA31A31DC1DFDC000F7EBB /* RCTUnicodeDecodeTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTUnicodeDecodeTests.m; sourceTree = ""; }; + 397D6A711DB12C1100E99986 /* RCTSubviewClippingTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTSubviewClippingTests.m; sourceTree = ""; }; 3D13F83E1D6F6AE000E69E0E /* UIExplorerBundle.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UIExplorerBundle.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; 3D13F8401D6F6AE000E69E0E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = ../Info.plist; sourceTree = ""; }; 3D13F8441D6F6AF200E69E0E /* ImageInBundle.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = ImageInBundle.png; sourceTree = ""; }; @@ -616,6 +618,7 @@ 143BC57C1B21E18100462512 /* UIExplorerUnitTests */ = { isa = PBXGroup; children = ( + 397D6A711DB12C1100E99986 /* RCTSubviewClippingTests.m */, 13B6C1A21C34225900D3FAF5 /* RCTURLUtilsTests.m */, 68FF44371CF6111500720EFD /* RCTBundleURLProviderTests.m */, 1497CFA41B21F5E400C1F8F2 /* RCTAllocationTests.m */, @@ -1341,6 +1344,7 @@ 1497CFAE1B21F5E400C1F8F2 /* RCTJSCExecutorTests.m in Sources */, 13129DD41C85F87C007D611C /* RCTModuleInitNotificationRaceTests.m in Sources */, 1497CFAD1B21F5E400C1F8F2 /* RCTBridgeTests.m in Sources */, + 397D6A731DB12C1100E99986 /* RCTSubviewClippingTests.m in Sources */, 134CB92A1C85A38800265FA6 /* RCTModuleInitTests.m in Sources */, 1497CFB11B21F5E400C1F8F2 /* RCTEventDispatcherTests.m in Sources */, 1497CFB31B21F5E400C1F8F2 /* RCTUIManagerTests.m in Sources */, diff --git a/Examples/UIExplorer/UIExplorerUnitTests/RCTSubviewClippingTests.m b/Examples/UIExplorer/UIExplorerUnitTests/RCTSubviewClippingTests.m new file mode 100644 index 00000000000000..13f258c2df0b1e --- /dev/null +++ b/Examples/UIExplorer/UIExplorerUnitTests/RCTSubviewClippingTests.m @@ -0,0 +1,372 @@ +/** + * The examples provided by Facebook are for non-commercial testing and + * evaluation purposes only. + * + * Facebook reserves all rights not expressly granted. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +#import + +#import +#import + +#import "UIView+React.h" +#import "UIView+Private.h" +#import "RCTView.h" +#import "RCTScrollView.h" +#import "RCTRootView.h" +#import "RCTViewManager.h" +#import "RCTComponentData.h" + + +@interface RCTSubviewClippingTests : XCTestCase +@end + +@implementation RCTSubviewClippingTests + +- (void)testViewOverlappingBoundsOfClippingViewIsNotClipped +{ + RCTView *clippingView = [RCTView new]; + [clippingView rct_setRemovesClippedSubviews:YES]; + [clippingView reactSetFrame:CGRectMake(0, 0, 50, 50)]; + + RCTView *childView = [RCTView new]; + [childView reactSetFrame:CGRectMake(25, 25, 50, 50)]; + [clippingView insertReactSubview:childView atIndex:0]; + [clippingView didUpdateReactSubviews]; + + XCTAssertEqual(clippingView.subviews.count, 1u); +} + +- (void)testViewOutsideBoundsOfClippingViewIsClipped +{ + RCTView *clippingView = [RCTView new]; + [clippingView rct_setRemovesClippedSubviews:YES]; + [clippingView reactSetFrame:CGRectMake(0, 0, 50, 50)]; + + RCTView *childView = [RCTView new]; + [childView reactSetFrame:CGRectMake(50, 50, 50, 50)]; + [clippingView insertReactSubview:childView atIndex:0]; + [clippingView didUpdateReactSubviews]; + + XCTAssertEqual(clippingView.subviews.count, 0u); +} + +- (void)testTurningOnClippingShouldRemoveView +{ + RCTView *clippingView = [RCTView new]; + [clippingView reactSetFrame:CGRectMake(0, 0, 50, 50)]; + + RCTView *childView = [RCTView new]; + [childView reactSetFrame:CGRectMake(50, 50, 50, 50)]; + [clippingView insertReactSubview:childView atIndex:0]; + [clippingView didUpdateReactSubviews]; + + XCTAssertEqual(clippingView.subviews.count, 1u); + [clippingView rct_setRemovesClippedSubviews:YES]; + XCTAssertEqual(clippingView.subviews.count, 0u); +} + +- (void)testTurningOffClippingShouldAddViewBack +{ + RCTView *clippingView = [RCTView new]; + [clippingView rct_setRemovesClippedSubviews:YES]; + [clippingView reactSetFrame:CGRectMake(0, 0, 50, 50)]; + + RCTView *childView = [RCTView new]; + [childView reactSetFrame:CGRectMake(50, 50, 50, 50)]; + [clippingView insertReactSubview:childView atIndex:0]; + [clippingView didUpdateReactSubviews]; + + XCTAssertEqual(clippingView.subviews.count, 0u); + [clippingView rct_setRemovesClippedSubviews:NO]; + XCTAssertEqual(clippingView.subviews.count, 1u); +} + +- (void)testTransformedClippedViewBackToClippingViewAddsItBack +{ + RCTView *clippingView = [RCTView new]; + [clippingView rct_setRemovesClippedSubviews:YES]; + [clippingView reactSetFrame:CGRectMake(0, 0, 50, 50)]; + + RCTView *childView = [RCTView new]; + [childView reactSetFrame:CGRectMake(50, 50, 50, 50)]; + [clippingView insertReactSubview:childView atIndex:0]; + [clippingView didUpdateReactSubviews]; + + XCTAssertEqual(clippingView.subviews.count, 0u); + + // Setting the transform property on a view has to be done the same way RN from js would do it. + // That unfortuantely involves some arbitrary-looking setup based on how RCTComponentData's internals works. + id mockBridge = [OCMockObject mockForClass:[RCTBridge class]]; + [[[mockBridge stub] andReturn:[RCTViewManager new]] moduleForClass:OCMOCK_ANY]; + RCTComponentData *componentData = [[RCTComponentData alloc] initWithManagerClass:[RCTViewManager class] bridge:mockBridge]; + // this transform moves the childView to match bounds of its clippingView + [componentData setProps:@{@"transform": @[@1,@0,@0,@0,@0,@1,@0,@0,@0,@0,@1,@0,@-50,@-50,@0,@1]} forView:childView]; + + XCTAssertEqual(clippingView.subviews.count, 1u); +} + +- (void)testMovingClippedViewBackToClippingViewAddsItBack +{ + RCTView *clippingView = [RCTView new]; + [clippingView rct_setRemovesClippedSubviews:YES]; + [clippingView reactSetFrame:CGRectMake(0, 0, 50, 50)]; + + RCTView *childView = [RCTView new]; + [childView reactSetFrame:CGRectMake(50, 50, 50, 50)]; + [clippingView insertReactSubview:childView atIndex:0]; + [clippingView didUpdateReactSubviews]; + + XCTAssertEqual(clippingView.subviews.count, 0u); + [childView reactSetFrame:CGRectMake(0, 0, 50, 50)]; + XCTAssertEqual(clippingView.subviews.count, 1u); +} + +- (void)testResizingClippingViewToContainClippedViewAddsTheClippedViewBack +{ + RCTView *clippingView = [RCTView new]; + [clippingView rct_setRemovesClippedSubviews:YES]; + [clippingView reactSetFrame:CGRectMake(0, 0, 50, 50)]; + + RCTView *childView = [RCTView new]; + [childView reactSetFrame:CGRectMake(50, 50, 50, 50)]; + [clippingView insertReactSubview:childView atIndex:0]; + [clippingView didUpdateReactSubviews]; + + XCTAssertEqual(clippingView.subviews.count, 0u); + [clippingView reactSetFrame:(CGRect){{0,0},{100,100}}]; + XCTAssertEqual(clippingView.subviews.count, 1u); +} + +#pragma mark - zIndex tests + +/** + This test case models a following setup: + + +--------+ + | | + | | + ***** | | + *C * | | + * +-*--+ | + * | * | | + ***** | | + | | z3| + | +---+----+ + +----+ | + | | | + | | | + | | z2| + | +---+----+ + | | + | | + | | + | z1| + +--------+ + + */ +- (void)testZIndexOrderingIsPreservedAfterRetogglingClippingCase1 +{ + RCTView *clippingView = [RCTView new]; + [clippingView reactSetFrame:CGRectMake(0, 0, 50, 50)]; + + RCTView *childView1 = [RCTView new]; + [childView1 reactSetFrame:CGRectMake(-25, 75, 100, 100)]; + [childView1 setReactZIndex:1]; + [clippingView insertReactSubview:childView1 atIndex:0]; + + RCTView *childView2 = [RCTView new]; + [childView2 reactSetFrame:CGRectMake(25, 25, 100, 100)]; + [childView2 setReactZIndex:2]; + [clippingView insertReactSubview:childView2 atIndex:0]; + + RCTView *childView3 = [RCTView new]; + [childView3 reactSetFrame:CGRectMake(75, -25, 100, 100)]; + [childView3 setReactZIndex:3]; + [clippingView insertReactSubview:childView3 atIndex:0]; + [clippingView didUpdateReactSubviews]; + [clippingView clearSortedSubviews]; + + XCTAssert(([clippingView.subviews isEqualToArray:@[childView1, childView2, childView3]])); + [clippingView rct_setRemovesClippedSubviews:YES]; + XCTAssert(([clippingView.subviews isEqualToArray:@[childView2]])); + [clippingView rct_setRemovesClippedSubviews:NO]; + XCTAssert(([clippingView.subviews isEqualToArray:@[childView1, childView2, childView3]])); +} + +/** + This test case models a following setup: + + ********** + *C * + * +-*-----------+ + * | * | + * | * | + * | * | + * | * | + * | * | + * +----+ * | + ********** | + | | | + | | | + | | +---+ + | | | | + | | | | + | |z3 | | + | +---+---------+ | + | | | + | | | + | | | + | | | + |z1 | | + +--------+ | + | | + | | + |z2 | + +-------------+ + */ +- (void)testZIndexOrderingIsPreservedAfterRetogglingClippingCase2 +{ + RCTView *clippingView = [RCTView new]; + [clippingView reactSetFrame:CGRectMake(0, 0, 100, 100)]; + + RCTView *childView1 = [RCTView new]; + [childView1 reactSetFrame:CGRectMake(25, 75, 150, 150)]; + [childView1 setReactZIndex:1]; + [clippingView insertReactSubview:childView1 atIndex:0]; + + RCTView *childView2 = [RCTView new]; + [childView2 reactSetFrame:CGRectMake(125, 125, 150, 150)]; + [childView2 setReactZIndex:2]; + [clippingView insertReactSubview:childView2 atIndex:0]; + + RCTView *childView3 = [RCTView new]; + [childView3 reactSetFrame:CGRectMake(75, 25, 150, 150)]; + [childView3 setReactZIndex:3]; + [clippingView insertReactSubview:childView3 atIndex:0]; + [clippingView didUpdateReactSubviews]; + [clippingView clearSortedSubviews]; + + XCTAssert(([clippingView.subviews isEqualToArray:@[childView1, childView2, childView3]])); + [clippingView rct_setRemovesClippedSubviews:YES]; + XCTAssert(([clippingView.subviews isEqualToArray:@[childView1, childView3]])); + [clippingView rct_setRemovesClippedSubviews:NO]; + XCTAssert(([clippingView.subviews isEqualToArray:@[childView1, childView2, childView3]])); +} + +#pragma mark - recursive clipping tests + +- (void)testNotDirectSubviewIsClipped +{ + RCTView *clippingView = [RCTView new]; + [clippingView rct_setRemovesClippedSubviews:YES]; + [clippingView reactSetFrame:CGRectMake(0, 0, 50, 50)]; + + RCTView *directChildView = [RCTView new]; + [directChildView reactSetFrame:CGRectMake(0, 0, 50, 50)]; + [clippingView insertReactSubview:directChildView atIndex:0]; + [clippingView didUpdateReactSubviews]; + + RCTView *deeperChildView = [RCTView new]; + [deeperChildView reactSetFrame:CGRectMake(0, 100, 50, 50)]; + [directChildView insertReactSubview:deeperChildView atIndex:0]; + [directChildView didUpdateReactSubviews]; + + XCTAssertEqual(directChildView.subviews.count, 0u); +} + +/** There are three views, top two ones clip and the bottom one is outside of the top one's bounds and in side of the middle one. */ +- (void)testUpperClippingViewClips +{ + RCTView *upperClippingView = [RCTView new]; + [upperClippingView rct_setRemovesClippedSubviews:YES]; + [upperClippingView reactSetFrame:CGRectMake(0, 0, 50, 50)]; + + RCTView *lowerClippingView = [RCTView new]; + [lowerClippingView reactSetFrame:CGRectMake(0, 0, 50, 100)]; + [lowerClippingView rct_setRemovesClippedSubviews:YES]; + [upperClippingView insertReactSubview:lowerClippingView atIndex:0]; + [upperClippingView didUpdateReactSubviews]; + + RCTView *viewToBeClipped = [RCTView new]; + [viewToBeClipped reactSetFrame:CGRectMake(0, 50, 50, 50)]; + [lowerClippingView insertReactSubview:viewToBeClipped atIndex:0]; + [lowerClippingView didUpdateReactSubviews]; + + XCTAssertEqual(upperClippingView.subviews.count, 1u); + XCTAssertEqual(lowerClippingView.subviews.count, 0u); +} + +#pragma mark - ScrollView tests + +- (void)testScrollViewClips +{ + RCTScrollView *scrollView = [[RCTScrollView alloc] initWithEventDispatcher:[OCMockObject mockForClass:[RCTEventDispatcher class]]]; + [scrollView reactSetFrame:CGRectMake(0, 0, 320, 480)]; + [scrollView rct_setRemovesClippedSubviews:YES]; + RCTView *contentView = [RCTView new]; + [contentView rct_setRemovesClippedSubviews:YES]; + // Content view is big enough to fit all rows. It's an implementation detail of ScrollView.js. + [contentView reactSetFrame:CGRectMake(0, 0, 320, 550)]; + + [scrollView insertReactSubview:contentView atIndex:0]; + [scrollView didUpdateReactSubviews]; + + RCTView *rowView1 = [RCTView new]; + [rowView1 reactSetFrame:CGRectMake(0, 0, 320, 50)]; + RCTView *rowView2 = [RCTView new]; + [rowView2 reactSetFrame:CGRectMake(0, 200, 320, 50)]; + RCTView *rowView3 = [RCTView new]; + [rowView3 reactSetFrame:CGRectMake(0, 500, 320, 50)]; + [contentView insertReactSubview:rowView1 atIndex:0]; + [contentView insertReactSubview:rowView2 atIndex:0]; + [contentView insertReactSubview:rowView3 atIndex:0]; + [contentView didUpdateReactSubviews]; + // This makes sure the direct subview of scrollView gets frame too (implementation detial). + [scrollView layoutSubviews]; + + XCTAssert([[NSSet setWithArray:contentView.subviews] isEqualToSet:[NSSet setWithArray:(@[rowView1, rowView2])]]); +} + +- (void)testScrollViewClipsDuringScrolling +{ + // Scrollview will try to emit events during scrolling, so we need to use a "nice" mock. + RCTScrollView *scrollView = [[RCTScrollView alloc] initWithEventDispatcher:[OCMockObject niceMockForClass:[RCTEventDispatcher class]]]; + [scrollView reactSetFrame:CGRectMake(0, 0, 320, 480)]; + [scrollView rct_setRemovesClippedSubviews:YES]; + scrollView.reactTag = @2; + RCTView *contentView = [RCTView new]; + [contentView rct_setRemovesClippedSubviews:YES]; + // Content view is big enough to fit all rows. It's an implementation detail of ScrollView.js. + [contentView reactSetFrame:CGRectMake(0, 0, 320, 550)]; + + [scrollView insertReactSubview:contentView atIndex:0]; + [scrollView didUpdateReactSubviews]; + + RCTView *rowView1 = [RCTView new]; + [rowView1 reactSetFrame:CGRectMake(0, 0, 320, 50)]; + RCTView *rowView2 = [RCTView new]; + [rowView2 reactSetFrame:CGRectMake(0, 200, 320, 50)]; + RCTView *rowView3 = [RCTView new]; + [rowView3 reactSetFrame:CGRectMake(0, 500, 320, 50)]; + [contentView insertReactSubview:rowView1 atIndex:0]; + [contentView insertReactSubview:rowView2 atIndex:0]; + [contentView insertReactSubview:rowView3 atIndex:0]; + [contentView didUpdateReactSubviews]; + // This makes sure the direct subview of scrollView gets frame too (implementation detial). + [scrollView layoutSubviews]; + + [scrollView scrollToOffset:CGPointMake(0, 100)]; + + XCTAssert([[NSSet setWithArray:contentView.subviews] isEqualToSet:[NSSet setWithArray:(@[rowView2, rowView3])]]); +} + +@end diff --git a/React/Views/RCTScrollView.m b/React/Views/RCTScrollView.m index f3ea088546dc6f..fdcd6c4053f22d 100644 --- a/React/Views/RCTScrollView.m +++ b/React/Views/RCTScrollView.m @@ -450,11 +450,6 @@ - (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher 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]; @@ -486,7 +481,19 @@ - (void)removeReactSubview:(UIView *)subview - (void)didUpdateReactSubviews { - // Do nothing, as subviews are managed by `insertReactSubview:atIndex:` + // Do nothing for subview management, since it's done by `insertReactSubview:atIndex:` + + // Unfortunately we have to copy paste clipping related logic from superclass. + // Ideally subview management and clipping wouldn't happen in a single place. + if (self.rct_nextClippingView || self.rct_removesClippedSubviews) { + UIView *rct_nextClippingViewForSubviews = self.rct_removesClippedSubviews ? self : self.rct_nextClippingView; + [self rct_updateSubviewsWithNextClippingView:rct_nextClippingViewForSubviews]; + + CGRect clippingRect = [self rct_activeClippingRect]; + if (!CGRectIsNull(clippingRect)) { + [self rct_clipSubviewsWithAncestralClipRect:clippingRect]; + } + } } - (BOOL)centerContent @@ -529,7 +536,10 @@ - (void)layoutSubviews 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; + [self updateClippedSubviews]; + } _scrollView.contentOffset = originalOffset; #if !TARGET_OS_TV @@ -539,18 +549,10 @@ - (void)layoutSubviews refreshControl.frame = (CGRect){_scrollView.contentOffset, {_scrollView.frame.size.width, refreshControl.frame.size.height}}; } #endif - - [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; @@ -565,8 +567,7 @@ - (void)updateClippedSubviews (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]; + [self rct_reclip]; _lastClippedToRect = bounds; } } diff --git a/React/Views/RCTView.h b/React/Views/RCTView.h index 96c0b41820ff34..7f50935defd2e9 100644 --- a/React/Views/RCTView.h +++ b/React/Views/RCTView.h @@ -48,22 +48,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/React/Views/RCTView.m b/React/Views/RCTView.m index 0aa03e462ae7e7..2d45330306123e 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -15,69 +15,8 @@ #import "RCTLog.h" #import "RCTUtils.h" #import "UIView+React.h" +#import "UIView+Private.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) { @@ -215,6 +154,22 @@ - (BOOL)accessibilityPerformMagicTap } } +- (void)didUpdateReactSubviews +{ + if (!self.rct_nextClippingView && !self.rct_removesClippedSubviews) { + [super didUpdateReactSubviews]; + return; + } + + UIView *rct_nextClippingViewForSubviews = self.rct_removesClippedSubviews ? self : self.rct_nextClippingView; + [self rct_updateSubviewsWithNextClippingView:rct_nextClippingViewForSubviews]; + + CGRect clippingRect = [self rct_activeClippingRect]; + if (!CGRectIsNull(clippingRect)) { + [self rct_clipSubviewsWithAncestralClipRect:clippingRect]; + } +} + - (NSString *)description { NSString *superDescription = super.description; @@ -271,113 +226,6 @@ + (UIEdgeInsets)contentInsetsForView:(UIView *)view 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]; - } - - 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; - - // 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]; - } - - } else if (view.superview) { - - // View is completely outside the clipRect, so unmount it - [view removeFromSuperview]; - } - } -} - -- (void)setRemoveClippedSubviews:(BOOL)removeClippedSubviews -{ - if (!removeClippedSubviews && _removeClippedSubviews) { - [self react_remountAllSubviews]; - } - _removeClippedSubviews = removeClippedSubviews; -} - -- (void)didUpdateReactSubviews -{ - if (_removeClippedSubviews) { - [self updateClippedSubviews]; - } else { - [super didUpdateReactSubviews]; - } -} - -- (void)updateClippedSubviews -{ - // Find a suitable view to use for clipping - UIView *clipView = [self react_findClipView]; - if (clipView) { - [self react_updateClippedSubviewsWithClipRect:clipView.bounds relativeToView:clipView]; - } -} - -- (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]; - } -} - #pragma mark - Borders - (UIColor *)backgroundColor @@ -449,6 +297,9 @@ - (void)reactSetFrame:(CGRect)frame // TODO: detect up-front if re-rendering is necessary CGSize oldSize = self.bounds.size; [super reactSetFrame:frame]; + // When the frame changes, our view needs to reclip itself with its parent, + // and also clip any of its own subviews if `rct_removesClippedSubviews` is turned on. + [self rct_reclip]; if (!CGSizeEqualToSize(self.bounds.size, oldSize)) { [self.layer setNeedsDisplay]; } diff --git a/React/Views/RCTViewManager.m b/React/Views/RCTViewManager.m index cd1709f5619632..d2f6ef019216fd 100644 --- a/React/Views/RCTViewManager.m +++ b/React/Views/RCTViewManager.m @@ -101,6 +101,10 @@ - (RCTViewManagerUIBlock)uiBlockToAmendWithShadowViewRegistry:(__unused NSDictio RCT_EXPORT_VIEW_PROPERTY(accessibilityLabel, NSString) RCT_EXPORT_VIEW_PROPERTY(accessibilityTraits, UIAccessibilityTraits) RCT_EXPORT_VIEW_PROPERTY(backgroundColor, UIColor) +RCT_CUSTOM_VIEW_PROPERTY(removeClippedSubviews, BOOL, UIView) +{ + view.rct_removesClippedSubviews = [RCTConvert BOOL:json] ; +} 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) @@ -109,7 +113,7 @@ - (RCTViewManagerUIBlock)uiBlockToAmendWithShadowViewRegistry:(__unused NSDictio 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] != CSSOverflowVisible; @@ -117,23 +121,25 @@ - (RCTViewManagerUIBlock)uiBlockToAmendWithShadowViewRegistry:(__unused NSDictio 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); + [view rct_reclip]; } -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); + [view rct_reclip]; } RCT_CUSTOM_VIEW_PROPERTY(pointerEvents, RCTPointerEvents, RCTView) { @@ -162,12 +168,6 @@ - (RCTViewManagerUIBlock)uiBlockToAmendWithShadowViewRegistry:(__unused NSDictio 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/React/Views/UIView+Private.h b/React/Views/UIView+Private.h index 057c46293b712c..472341fc244449 100644 --- a/React/Views/UIView+Private.h +++ b/React/Views/UIView+Private.h @@ -11,12 +11,12 @@ @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; +- (CGRect)rct_activeClippingRect; +- (UIView *)rct_nextClippingView; +- (void)rct_updateSubviewsWithNextClippingView:(UIView *)clippingView; +- (void)rct_clipSubviewsWithAncestralClipRect:(CGRect)clipRect; + @end diff --git a/React/Views/UIView+React.h b/React/Views/UIView+React.h index 6533890c402f9a..5c3078cf206dd9 100644 --- a/React/Views/UIView+React.h +++ b/React/Views/UIView+React.h @@ -81,4 +81,19 @@ #endif +/** + * 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. + * + * Few clarifications: + * 1/ All subviews are affected, not just the direct ones. + * 2/ If there are multiple ancestors with a view clipping turned on then intersection of their bounds will be used for clipping. + * 3/ All UIViews are affected, not only RCTViews. Alhough this behavior is never triggered outside of React Native. + * 4/ Position in a UIWindow is not used for cliping. + */ +@property (nonatomic, assign, setter=rct_setRemovesClippedSubviews:) BOOL rct_removesClippedSubviews; +/** Recomputes clipping for a view and its subviews. You should call this if you move views manually in your view manager. */ +- (void)rct_reclip; + @end diff --git a/React/Views/UIView+React.m b/React/Views/UIView+React.m index 322445845cc758..c451783bb3f69b 100644 --- a/React/Views/UIView+React.m +++ b/React/Views/UIView+React.m @@ -216,4 +216,180 @@ - (BOOL)reactRespondsToTouch:(__unused UITouch *)touch return YES; } +#pragma mark - view clipping + +/** + * How does view clipping works? + * + * Each view knows if it has clipping turned on and its closest ancestor that has clipping turned on (if any). That helps with effective clipping evaluation. + + * There are four standard cases when we have to evaluate view clipping: + * 1. a view has clipping turned off: + * - we have to update NCV for its complete subtree + * - we have to add back all clipped views + * 2. a view has clipping turned on: + * - we have to update NCV for its complete subtree + * - we have to reclip it + * 3. a react subview is added: + * - we have to set it and all its subviews NCV + * - if it has NVC or clipping turned on we have to reclip it + * 4. a view is moved (new frame, tranformation, is a cell in a scrolling scrollview): + * - if it has NCV or clipping turned on we have to reclip it + */ + +- (BOOL)rct_removesClippedSubviews +{ + return [objc_getAssociatedObject(self, @selector(rct_removesClippedSubviews)) boolValue]; +} + +- (void)rct_setRemovesClippedSubviews:(BOOL)removeClippedSubviews +{ + objc_setAssociatedObject(self, @selector(rct_removesClippedSubviews), @(removeClippedSubviews), OBJC_ASSOCIATION_ASSIGN); + [self rct_updateSubviewsWithNextClippingView:removeClippedSubviews ? self : nil]; + if (removeClippedSubviews) { + [self rct_reclip]; + } +} + + +/** + * Returns a closest ancestor view which has view clipping turned on. + * `nil` is returned if there is no such view. + */ +- (UIView *)rct_nextClippingView +{ + return [(RCTWeakObjectContainer *)objc_getAssociatedObject(self, @selector(rct_nextClippingView)) object]; +} + +- (void)rct_setNextClippingView:(UIView *)rct_nextClippingView +{ + RCTAssert(self != rct_nextClippingView, @"A view cannot be next clipping view for itself."); + RCTWeakObjectContainer *wrapper = [RCTWeakObjectContainer new]; + wrapper.object = rct_nextClippingView; + objc_setAssociatedObject(self, @selector(rct_nextClippingView), wrapper, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +/** + * Reevaluates clipping for itself and recursively for its subviews, + * going as deep as the first clipped subview is. + * + * It works like this: + * 1/ Is any of our ancestores already clipped? If yes lets do nothing. + * 2/ Get clipping rect that applies here. + * 3/ Does our bounds intersect with the rect? If no clip ourself and we are done. + * 4/ If there is an intersection make sure we are not clipped and recurse into subviews. + * + * We do 1/ and 2/ in one step by retrieving "active clip rect" (see method `activeClipRect`). + */ +- (void)rct_reclip +{ + // If we are not clipping or have a view that clips us there is nothing to do. + if (!self.rct_nextClippingView && !self.rct_removesClippedSubviews) { + return; + } + // If we are currently clipped our active clipping rect would be null rect. That's why we ask for out superview's. + CGRect clippingRectForSuperview = self.reactSuperview ? [self.reactSuperview rct_activeClippingRect] : CGRectInfinite; + if (CGRectIsNull(clippingRectForSuperview)) { + return; + } + + if (!CGRectIntersectsRect(self.frame, clippingRectForSuperview)) { + // we are clipped + if (self.superview) { + [self removeFromSuperview]; + } + } else { + // we are not clipped + if (!self.superview) { + // We need to make sure we keep zIndex ordering when adding back a clipped view. + NSUInteger position = 0; + for (UIView *view in self.reactSuperview.sortedReactSubviews) { + if (view.superview) { + position += 1; + } + if (view == self) { + break; + } + } + [self.reactSuperview insertSubview:self atIndex:position]; + } + // Potential optimisation: We don't have to reevaluate clipping for subviews if the whole view was visible before and is still visible now. + CGRect clipRect = [self convertRect:clippingRectForSuperview fromView:self.superview]; + [self rct_clipSubviewsWithAncestralClipRect:clipRect]; + } +} + +/** + * This is not the same as `reclip`, since we reevaluate clipping for all subviews at once. + * It enables us to insert the not clipped ones into right position effectively. + */ +- (void)rct_clipSubviewsWithAncestralClipRect:(CGRect)clipRect +{ + UIView *lastSubview = nil; + if (self.rct_removesClippedSubviews) { + clipRect = CGRectIntersection(clipRect, self.bounds); + } + for (UIView *subview in self.sortedReactSubviews) { + // TODO inserting subviews based on react subviews is not safe if react hierarchy doesn't match view hierarchy + if (CGRectIntersectsRect(subview.frame, clipRect)) { + if (!subview.superview) { + if (lastSubview) { + [self insertSubview:subview aboveSubview:lastSubview]; + } else { + [self insertSubview:subview atIndex:0]; + } + } + lastSubview = subview; + [subview rct_clipSubviewsWithAncestralClipRect:[self convertRect:clipRect toView:subview]]; + } else { + [subview removeFromSuperview]; + } + } +} + +/** + * If this view is not clipped: + * Returns a rect that is used to clip this view, in the view's coordinate space. + * If this view has clipping turned on it's bounds are accounted for in the returned clipping rect. + * + * Returns CGRectNull if this view is clipped or none of its ancestors has clipping turned on. + */ +- (CGRect)rct_activeClippingRect +{ + UIView *clippingParent = self.rct_nextClippingView; + CGRect resultRect = CGRectInfinite; + + if (clippingParent) { + if (![self isDescendantOfView:clippingParent]) { + return CGRectNull; + } + resultRect = [self convertRect:[clippingParent rct_activeClippingRect] fromView:clippingParent]; + } + + if (self.rct_removesClippedSubviews) { + resultRect = CGRectIntersection(resultRect, self.bounds); + } + + return resultRect; +} + +/** + * Sets the next clipping view for all subviews if they are not already being clipped, recursively. + * Using a `nil` clipping view will result in adding clipped subviews back. + */ +- (void)rct_updateSubviewsWithNextClippingView:(UIView *)clippingView +{ + for (UIView *subview in self.sortedReactSubviews) { + // TODO inserting subviews based on react subviews is not safe if react hierarchy doesn't match view hierarchy + if (!clippingView) { + [self addSubview:subview]; + } + [subview rct_setNextClippingView:clippingView]; + // We don't have to recurse if the subview either clips itself or it already has correct next clipping view set. + if (!subview.rct_removesClippedSubviews && !(subview.rct_nextClippingView == clippingView)) { + [subview rct_updateSubviewsWithNextClippingView:clippingView]; + } + } +} + @end