new `removeClippedSubviews` implementation (take 2 - recursive)

Reviewed By: mmmulani

Differential Revision: D4081700

fbshipit-source-id: d4079138dc070565e475831e82651c9b2d5b8d59
This commit is contained in:
Martin Kralik 2016-11-11 05:22:43 -08:00 committed by Facebook Github Bot
parent a3ad34c34f
commit 625c8cb83c
9 changed files with 620 additions and 217 deletions

View File

@ -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 = "<group>"; };
39AA31A31DC1DFDC000F7EBB /* RCTUnicodeDecodeTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTUnicodeDecodeTests.m; sourceTree = "<group>"; };
397D6A711DB12C1100E99986 /* RCTSubviewClippingTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTSubviewClippingTests.m; sourceTree = "<group>"; };
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 = "<group>"; };
3D13F8441D6F6AF200E69E0E /* ImageInBundle.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = ImageInBundle.png; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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 <objc/message.h>
#import <OCMock/OCMock.h>
#import <XCTest/XCTest.h>
#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

View File

@ -450,11 +450,6 @@ static inline BOOL isRectInvalid(CGRect rect) {
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 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
- (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 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
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 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
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 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:(NSCoder *)aDecoder)
(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;
}
}

View File

@ -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.

View File

@ -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 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:unused)
}
}
- (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 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:unused)
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 @@ RCT_NOT_IMPLEMENTED(- (instancetype)initWithCoder:unused)
// 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];
}

View File

@ -101,6 +101,10 @@ RCT_EXPORT_MODULE()
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 @@ 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] != CSSOverflowVisible;
@ -117,23 +121,25 @@ RCT_CUSTOM_VIEW_PROPERTY(overflow, CSSOverflow, RCTView)
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 @@ RCT_CUSTOM_VIEW_PROPERTY(pointerEvents, RCTPointerEvents, RCTView)
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;

View File

@ -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

View File

@ -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

View File

@ -216,4 +216,180 @@
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