Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ @implementation RCTScrollViewComponentView {
BOOL _automaticallyAdjustKeyboardInsets;

CGPoint _contentOffsetWhenClipped;
// Views with `removeClippedSubviews` on in a window whose nearest ScrollView is this one.
// See `_remountChildren`.
NSHashTable<UIView *> *_clippingViews;

__weak UIView *_contentView;

Expand Down Expand Up @@ -1007,10 +1010,32 @@ - (void)_remountChildren
return;
}

// The walk below only changes something at a view with `removeClippedSubviews` on, and such
// views register with their nearest ScrollView once they are in a window. Outside a window,
// walk anyway: the content may be mounted before the ScrollView is shown.
if (self.window != nil && _clippingViews.count == 0) {
return;
}

[_scrollView updateClippedSubviewsWithClipRect:CGRectInset(_scrollView.bounds, -kClippingLeeway, -kClippingLeeway)
relativeToView:_scrollView];
}

// Called by `RCTViewComponentView` (see `_updateClippingScrollView`) for views with
// `removeClippedSubviews` on.
- (void)_registerClippingView:(UIView *)view
{
if (_clippingViews == nil) {
_clippingViews = [NSHashTable weakObjectsHashTable];
}
[_clippingViews addObject:view];
}

- (void)_unregisterClippingView:(UIView *)view
{
[_clippingViews removeObject:view];
}

#pragma mark - RCTScrollableProtocol

- (CGSize)contentSize
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
#import <react/renderer/components/view/accessibilityPropsConversions.h>
#import <react/renderer/graphics/BlendMode.h>

#import <React/RCTScrollViewComponentView.h>

#ifdef RCT_DYNAMIC_FRAMEWORKS
#import <React/RCTComponentViewFactory.h>
#endif
Expand Down Expand Up @@ -105,6 +107,12 @@ static BOOL RCTViewIsInteractiveAccessibilityElement(UIView *view, const ViewPro
}
#endif

// Implemented in RCTScrollViewComponentView.mm.
@interface RCTScrollViewComponentView (ClippingViews)
- (void)_registerClippingView:(UIView *)view;
- (void)_unregisterClippingView:(UIView *)view;
@end

@implementation RCTViewComponentView {
UIColor *_backgroundColor;
CALayer *_backgroundColorLayer;
Expand All @@ -117,6 +125,7 @@ @implementation RCTViewComponentView {
BOOL _isJSResponder;
BOOL _removeClippedSubviews;
NSMutableArray<UIView *> *_reactSubviews;
__weak RCTScrollViewComponentView *_clippingScrollView;
NSSet<NSString *> *_Nullable _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN;
UIView *_containerView;
BOOL _useCustomContainerView;
Expand Down Expand Up @@ -368,6 +377,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
if (_removeClippedSubviews != newViewProps.removeClippedSubviews) {
_removeClippedSubviews = newViewProps.removeClippedSubviews;
[self _updateRemoveClippedSubviewsState];
[self _updateClippingScrollView];
}
}

Expand Down Expand Up @@ -816,6 +826,26 @@ - (void)didMoveToWindow
if ([self _observesSafeAreaInsets]) {
[self setNeedsLayout];
}
[self _updateClippingScrollView];
}

// A ScrollView walks its content for views with `removeClippedSubviews` on (see
// `-[RCTScrollViewComponentView _remountChildren]`). Register this view with its nearest
// ScrollView while it has the prop on and is in a window, so that the ScrollView can skip
// the walk when it has no such views. A ScrollView doesn't register: the walk of an outer
// ScrollView stops at it, and it clips its own content.
- (void)_updateClippingScrollView
{
BOOL needsClippingPass =
_removeClippedSubviews && self.window != nil && ![self isKindOfClass:[RCTScrollViewComponentView class]];
RCTScrollViewComponentView *scrollView =
needsClippingPass ? [RCTScrollViewComponentView findScrollViewComponentViewForView:self] : nil;
if (scrollView == _clippingScrollView) {
return;
}
[_clippingScrollView _unregisterClippingView:self];
[scrollView _registerClippingView:self];
_clippingScrollView = scrollView;
}

- (void)layoutSubviews
Expand Down Expand Up @@ -888,6 +918,7 @@ - (void)prepareForRecycle
_eventEmitter.reset();
_isJSResponder = NO;
_removeClippedSubviews = NO;
[self _updateClippingScrollView];
_reactSubviews = [NSMutableArray new];
_layoutMetrics = EmptyLayoutMetrics;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
* LICENSE file in the root directory of this source tree.
*/

#import <React/RCTScrollViewComponentView.h>
#import <React/RCTViewComponentView.h>
#import <XCTest/XCTest.h>
#import <react/renderer/components/scrollview/ScrollViewShadowNode.h>
#import <react/renderer/components/view/ViewProps.h>
#import <react/renderer/components/view/ViewShadowNode.h>
#import <react/renderer/graphics/Color.h>
Expand All @@ -20,6 +22,32 @@
return props;
}

// The ScrollView's clipping pass, which runs after each mounting transaction and while scrolling.
@interface RCTScrollViewComponentView (Testing)
- (void)_remountChildren;
@end

// Counts the clipping passes that reach it.
@interface RCTClippingPassSpyView : UIView
@property (nonatomic, assign) NSUInteger passCount;
@end

@implementation RCTClippingPassSpyView
- (void)updateClippedSubviewsWithClipRect:(CGRect)clipRect relativeToView:(UIView *)clipView
{
self.passCount++;
}
@end

// A ScrollView, 400 points tall, with `content` as its content view.
static RCTScrollViewComponentView *makeScrollView(UIView *content)
{
RCTScrollViewComponentView *scrollView =
[[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 400)];
[scrollView mountChildComponentView:(id)content index:0];
return scrollView;
}

@interface RCTViewComponentViewTests : XCTestCase
@end

Expand Down Expand Up @@ -163,6 +191,152 @@ - (void)testRemoveClippedSubviewsIsKeptOnRecycledView
XCTAssertEqual(reactSubviews.count, 1u);
}

#pragma mark - ScrollView clipping pass

- (void)testScrollViewSkipsClippingPassWithoutClippingViews
{
UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)];
RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new];
RCTScrollViewComponentView *scrollView = makeScrollView(spy);
[window addSubview:scrollView];

[scrollView _remountChildren];
XCTAssertEqual(spy.passCount, 0u);
}

- (void)testScrollViewOutsideWindowRunsClippingPass
{
// Content can be mounted before its ScrollView is in a window, so the pass runs there as before.
RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new];
RCTScrollViewComponentView *scrollView = makeScrollView(spy);

[scrollView _remountChildren];
XCTAssertEqual(spy.passCount, 1u);
}

- (void)testClippingViewInWindowGetsClippingPass
{
UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)];
RCTViewComponentView *content = [RCTViewComponentView new];
content.frame = CGRectMake(0, 0, 100, 2000);
RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new];
[content mountChildComponentView:(id)spy index:0];

// Mount the rows before turning clipping on, so that both start attached.
RCTViewComponentView *list = [RCTViewComponentView new];
list.frame = CGRectMake(0, 0, 100, 2000);
UIView *visibleRow = [UIView new];
visibleRow.frame = CGRectMake(0, 0, 100, 100);
UIView *offscreenRow = [UIView new];
offscreenRow.frame = CGRectMake(0, 1500, 100, 100);
[list mountChildComponentView:(id)visibleRow index:0];
[list mountChildComponentView:(id)offscreenRow index:1];
[list updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
[content mountChildComponentView:list index:1];
XCTAssertEqual(offscreenRow.superview, list);

RCTScrollViewComponentView *scrollView = makeScrollView(content);
[window addSubview:scrollView];

[scrollView _remountChildren];
XCTAssertEqual(spy.passCount, 1u);
XCTAssertEqual(visibleRow.superview, list);
XCTAssertNil(offscreenRow.superview);
}

- (void)testClippingPassFollowsClippingViewProps
{
UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)];
RCTViewComponentView *content = [RCTViewComponentView new];
RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new];
[content mountChildComponentView:(id)spy index:0];
RCTViewComponentView *list = [RCTViewComponentView new];
[list updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
[content mountChildComponentView:list index:1];
RCTScrollViewComponentView *scrollView = makeScrollView(content);
[window addSubview:scrollView];

[scrollView _remountChildren];
XCTAssertEqual(spy.passCount, 1u);

[list updateProps:makeViewProps(false) oldProps:nullptr];
[scrollView _remountChildren];
XCTAssertEqual(spy.passCount, 1u);

[list updateProps:makeViewProps(true) oldProps:nullptr];
[scrollView _remountChildren];
XCTAssertEqual(spy.passCount, 2u);

// A clipping view outside the window doesn't need the pass.
[content unmountChildComponentView:list index:1];
[scrollView _remountChildren];
XCTAssertEqual(spy.passCount, 2u);

[content mountChildComponentView:list index:1];
[scrollView _remountChildren];
XCTAssertEqual(spy.passCount, 3u);

// A recycled view starts without `removeClippedSubviews`.
[content unmountChildComponentView:list index:1];
[list prepareForRecycle];
[content mountChildComponentView:list index:1];
[scrollView _remountChildren];
XCTAssertEqual(spy.passCount, 3u);

// Turned on outside the window, the view registers once it is attached.
[content unmountChildComponentView:list index:1];
[list updateProps:makeViewProps(true) oldProps:nullptr];
[content mountChildComponentView:list index:1];
[scrollView _remountChildren];
XCTAssertEqual(spy.passCount, 4u);
}

- (void)testClippingViewOnlyRegistersWithNearestScrollView
{
UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)];
RCTViewComponentView *innerContent = [RCTViewComponentView new];
RCTClippingPassSpyView *innerSpy = [RCTClippingPassSpyView new];
[innerContent mountChildComponentView:(id)innerSpy index:0];
RCTViewComponentView *list = [RCTViewComponentView new];
[list updateProps:makeViewProps(true) oldProps:ViewShadowNode::defaultSharedProps()];
[innerContent mountChildComponentView:list index:1];
RCTScrollViewComponentView *innerScrollView = makeScrollView(innerContent);

RCTViewComponentView *content = [RCTViewComponentView new];
RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new];
[content mountChildComponentView:(id)spy index:0];
[content mountChildComponentView:innerScrollView index:1];
RCTScrollViewComponentView *outerScrollView = makeScrollView(content);
[window addSubview:outerScrollView];

// The outer pass stops at the inner ScrollView, which handles `list` itself.
[outerScrollView _remountChildren];
XCTAssertEqual(spy.passCount, 0u);
[innerScrollView _remountChildren];
XCTAssertEqual(innerSpy.passCount, 1u);
}

- (void)testScrollViewWithRemoveClippedSubviewsDoesNotRegisterWithOuterScrollView
{
// For example a horizontal list inside a vertical one: ScrollView forwards the prop to the
// native ScrollView too, but the inner ScrollView clips its own content.
UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 100, 400)];
RCTScrollViewComponentView *innerScrollView = makeScrollView([RCTViewComponentView new]);
auto innerProps = std::make_shared<ScrollViewProps>();
innerProps->removeClippedSubviews = true;
[innerScrollView updateProps:innerProps oldProps:ScrollViewShadowNode::defaultSharedProps()];

RCTViewComponentView *content = [RCTViewComponentView new];
RCTClippingPassSpyView *spy = [RCTClippingPassSpyView new];
[content mountChildComponentView:(id)spy index:0];
[content mountChildComponentView:innerScrollView index:1];
RCTScrollViewComponentView *outerScrollView = makeScrollView(content);
[window addSubview:outerScrollView];

[outerScrollView _remountChildren];
XCTAssertEqual(spy.passCount, 0u);
}

#pragma mark - hitTest against non-invertible transforms (#50797)

- (void)testHitTestReturnsNilForZeroScaleYView
Expand Down
Loading