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
28 changes: 13 additions & 15 deletions packages/react-native/Libraries/Components/ScrollView/ScrollView.js
Original file line number Diff line number Diff line change
Expand Up @@ -442,21 +442,6 @@ export type ScrollViewPropsAndroid = Readonly<{
* @platform android
*/
persistentScrollbar?: ?boolean,
/**
* Controls the fading effect at the edges of the scroll content.
*
* A value greater than 0 will apply the fading effect, indicating more content is available
* to scroll.
*
* You can specify a single number to apply the same fading length to both edges.
* Alternatively, use an object with `start` and `end` properties to set different
* fading lengths for the start and end of the scroll content.
*
* The default value is 0.
*
* @platform android
*/
fadingEdgeLength?: ?number | {start: number, end: number},
/**
* When false, the ScrollView will not automatically scroll to a focused child when
* the child requests focus. This can be useful when you want to control the scroll
Expand Down Expand Up @@ -523,6 +508,19 @@ type ScrollViewBaseProps = Readonly<{
* @default `'normal'`
*/
decelerationRate?: ?DecelerationRateType,
/**
* Controls the fading effect at the edges of the scroll content.
*
* A value greater than 0 will apply the fading effect, indicating more content is available
* to scroll.
*
* You can specify a single number to apply the same fading length to both edges.
* Alternatively, use an object with `start` and `end` properties to set different
* fading lengths for the start and end of the scroll content.
*
* The default value is 0.
*/
fadingEdgeLength?: ?number | {start: number, end: number},

/**
* *Experimental, iOS Only*. The API is experimental and will change in future releases.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig =
endDraggingSensitivityMultiplier: true,
directionalLockEnabled: true,
disableIntervalMomentum: true,
fadingEdgeLength: true,
horizontal: true,
indicatorStyle: true,
inverted: true,
keyboardDismissMode: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ static UIScrollViewIndicatorStyle RCTUIScrollViewIndicatorStyleFromProps(const S
}
}

static CGFloat RCTFadingEdgeLengthFromValue(const folly::dynamic *value)
{
return (value != nullptr && value->isNumber()) ? fmax(value->asDouble(), 0) : 0;
}

static void RCTGetFadingEdgeLengths(const ScrollViewProps &props, CGFloat *start, CGFloat *end)
{
const auto &fadingEdgeLength = props.fadingEdgeLength;
if (fadingEdgeLength.isObject()) {
*start = RCTFadingEdgeLengthFromValue(fadingEdgeLength.get_ptr("start"));
*end = RCTFadingEdgeLengthFromValue(fadingEdgeLength.get_ptr("end"));
} else {
*start = *end = RCTFadingEdgeLengthFromValue(&fadingEdgeLength);
}
}

// Once Fabric implements proper NativeAnimationDriver, this should be removed.
// This is just a workaround to allow animations based on onScroll event.
// This is only used to animate sticky headers in ScrollViews, and only the contentOffset and tag is used.
Expand Down Expand Up @@ -125,6 +141,8 @@ @implementation RCTScrollViewComponentView {
BOOL _avoidAdjustmentForMaintainVisibleContentPosition;

RCTVirtualViewContainerState *_virtualViewContainerState;

CAGradientLayer *_fadingEdgeMaskLayer;
}

+ (RCTScrollViewComponentView *_Nullable)findScrollViewComponentViewForView:(UIView *)view
Expand Down Expand Up @@ -315,6 +333,7 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics
_containerView.transform = transform;
_scrollView.transform = transform;
}
[self _updateFadingEdgeMask];
}

- (bool)isInverted
Expand Down Expand Up @@ -454,10 +473,77 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
scrollView.keyboardDismissMode = RCTUIKeyboardDismissModeFromProps(newScrollViewProps);
}

BOOL fadingEdgeChanged = oldScrollViewProps.fadingEdgeLength != newScrollViewProps.fadingEdgeLength ||
oldScrollViewProps.horizontal != newScrollViewProps.horizontal;

[super updateProps:props oldProps:oldProps];

// Must run after `super updateProps:` so it is based on the latest calculated view.
((RCTEnhancedScrollView *)_scrollView).inverted = [self isInverted];

if (fadingEdgeChanged) {
[self _updateFadingEdgeMask];
}
}

// Android's `View.draw` geometry: each edge fades over its own length, scaled so that neither passes the middle.
- (void)_updateFadingEdgeMask
{
const auto &props = static_cast<const ScrollViewProps &>(*_props);
CGFloat start;
CGFloat end;
RCTGetFadingEdgeLengths(props, &start, &end);
CGFloat length = fmax(start, end);
CGFloat extent = props.horizontal ? _scrollView.bounds.size.width : _scrollView.bounds.size.height;

if (length == 0 || extent == 0) {
// A mask costs an offscreen pass, so a scroll view without a fading edge carries none.
if (_fadingEdgeMaskLayer != nil) {
_scrollView.layer.mask = nil;
_fadingEdgeMaskLayer = nil;
}
return;
}

CGFloat fadeLength = fmin(length, extent / 2);
if (_fadingEdgeMaskLayer == nil) {
_fadingEdgeMaskLayer = [CAGradientLayer layer];
_fadingEdgeMaskLayer.colors = @[
(id)UIColor.clearColor.CGColor,
(id)UIColor.blackColor.CGColor,
(id)UIColor.blackColor.CGColor,
(id)UIColor.clearColor.CGColor,
];
}

[CATransaction begin];
[CATransaction setDisableActions:YES];
// `start` and `end` are logical edges: in RTL the scroll view is mirrored, and its mask with it.
_fadingEdgeMaskLayer.startPoint = props.horizontal ? CGPointMake(0, 0.5) : CGPointMake(0.5, 0);
_fadingEdgeMaskLayer.endPoint = props.horizontal ? CGPointMake(1, 0.5) : CGPointMake(0.5, 1);
_fadingEdgeMaskLayer.locations = @[
@0,
@(fadeLength * start / length / extent),
@(1 - fadeLength * end / length / extent),
@1,
];
_fadingEdgeMaskLayer.frame = _scrollView.bounds;
[CATransaction commit];

// The scroll view rather than this view: RCTViewComponentView owns `currentContainerView.layer.mask`.
_scrollView.layer.mask = _fadingEdgeMaskLayer;
}

- (void)_layoutFadingEdgeMask
{
if (_fadingEdgeMaskLayer == nil) {
return;
}
// The mask is laid out in the scroll view's bounds, whose origin is the content offset.
[CATransaction begin];
[CATransaction setDisableActions:YES];
_fadingEdgeMaskLayer.frame = _scrollView.bounds;
[CATransaction commit];
}

- (void)updateState:(const State::Shared &)state oldState:(const State::Shared &)oldState
Expand Down Expand Up @@ -747,6 +833,8 @@ - (BOOL)touchesShouldCancelInContentView:(__unused UIView *)view

- (void)scrollViewDidScroll:(UIScrollView *)scrollView
{
[self _layoutFadingEdgeMask];

auto scrollMetrics = [self _scrollViewMetrics];

[self _updateStateWithContentOffset];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,19 @@
#import <XCTest/XCTest.h>
#import <react/renderer/components/scrollview/ScrollViewProps.h>
#import <react/renderer/components/scrollview/ScrollViewShadowNode.h>
#import <react/renderer/core/LayoutMetrics.h>
#import <react/renderer/core/PropsParserContext.h>
#import <react/renderer/core/RawProps.h>
#import <react/renderer/core/RawPropsParser.h>
#import <react/utils/ContextContainer.h>

using facebook::react::ContextContainer;
using facebook::react::EmptyLayoutMetrics;
using facebook::react::LayoutMetrics;
using facebook::react::Props;
using facebook::react::PropsParserContext;
using facebook::react::RawProps;
using facebook::react::RawPropsParser;
using facebook::react::ScrollViewProps;
using facebook::react::ScrollViewShadowNode;

Expand All @@ -23,6 +34,25 @@
return props;
}

static Props::Shared makeFadingEdgeProps(folly::dynamic fadingEdgeLength, bool horizontal = false)
{
auto props = std::make_shared<ScrollViewProps>();
props->fadingEdgeLength = std::move(fadingEdgeLength);
props->horizontal = horizontal;
return props;
}

static Props::Shared parseScrollViewProps(const folly::dynamic &rawValue, const ScrollViewProps &sourceProps)
{
ContextContainer contextContainer{};
PropsParserContext parserContext{-1, contextContainer};
auto rawProps = RawProps(rawValue);
auto parser = RawPropsParser();
parser.prepare<ScrollViewProps>();
rawProps.parse(parser);
return std::make_shared<const ScrollViewProps>(parserContext, sourceProps, rawProps);
}

@interface RCTScrollViewComponentView (Tests)
- (void)_keyboardWillChangeFrame:(NSNotification *)notification;
@end
Expand Down Expand Up @@ -61,6 +91,115 @@ - (void)testAutomaticallyAdjustKeyboardInsetsAcrossRecycling
XCTAssertEqual(view.scrollView.contentInset.bottom, 50);
}

- (CAGradientLayer *)fadingEdgeMaskOf:(RCTScrollViewComponentView *)view
{
CALayer *mask = view.scrollView.layer.mask;
XCTAssertTrue([mask isKindOfClass:[CAGradientLayer class]]);
return (CAGradientLayer *)mask;
}

- (void)assertLocations:(NSArray<NSNumber *> *)locations equal:(NSArray<NSNumber *> *)expected
{
XCTAssertEqual(locations.count, expected.count);
for (NSUInteger index = 0; index < MIN(locations.count, expected.count); index++) {
XCTAssertEqualWithAccuracy(locations[index].doubleValue, expected[index].doubleValue, 1e-6);
}
}

- (void)testFadingEdgeLengthFadesEachEdgeOverItsOwnLength
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 200)];
[view updateProps:makeFadingEdgeProps(folly::dynamic::object("start", 20)("end", 40))
oldProps:ScrollViewShadowNode::defaultSharedProps()];

CAGradientLayer *mask = [self fadingEdgeMaskOf:view];
XCTAssertTrue(CGPointEqualToPoint(mask.startPoint, CGPointMake(0.5, 0)));
XCTAssertTrue(CGPointEqualToPoint(mask.endPoint, CGPointMake(0.5, 1)));
[self assertLocations:mask.locations equal:@[ @0, @0.1, @0.8, @1 ]];
XCTAssertTrue(CGRectEqualToRect(mask.frame, view.scrollView.bounds));
}

- (void)testFadingEdgeLengthNumberFadesBothEdges
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 200)];
[view updateProps:makeFadingEdgeProps(30) oldProps:ScrollViewShadowNode::defaultSharedProps()];

[self assertLocations:[self fadingEdgeMaskOf:view].locations equal:@[ @0, @0.15, @0.85, @1 ]];
}

- (void)testHorizontalFadingEdgeLengthFadesAlongTheWidth
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 200)];
[view updateProps:makeFadingEdgeProps(folly::dynamic::object("start", 20)("end", 0), true)
oldProps:ScrollViewShadowNode::defaultSharedProps()];

CAGradientLayer *mask = [self fadingEdgeMaskOf:view];
XCTAssertTrue(CGPointEqualToPoint(mask.startPoint, CGPointMake(0, 0.5)));
XCTAssertTrue(CGPointEqualToPoint(mask.endPoint, CGPointMake(1, 0.5)));
[self assertLocations:mask.locations equal:@[ @0, @0.2, @1, @1 ]];
}

- (void)testParsedFadingEdgeFollowsTheAxisWhenHorizontalIsRemoved
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 200)];
auto horizontalProps =
parseScrollViewProps(folly::dynamic::object("fadingEdgeLength", 20)("horizontal", true), ScrollViewProps{});
[view updateProps:horizontalProps oldProps:ScrollViewShadowNode::defaultSharedProps()];
XCTAssertTrue(CGPointEqualToPoint([self fadingEdgeMaskOf:view].startPoint, CGPointMake(0, 0.5)));

auto verticalProps = parseScrollViewProps(
folly::dynamic::object("horizontal", nullptr), static_cast<const ScrollViewProps &>(*horizontalProps));
[view updateProps:verticalProps oldProps:horizontalProps];

CAGradientLayer *mask = [self fadingEdgeMaskOf:view];
XCTAssertTrue(CGPointEqualToPoint(mask.startPoint, CGPointMake(0.5, 0)));
XCTAssertTrue(CGPointEqualToPoint(mask.endPoint, CGPointMake(0.5, 1)));
[self assertLocations:mask.locations equal:@[ @0, @0.1, @0.9, @1 ]];
}

- (void)testFadingEdgesNeverOverlap
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 200)];
[view updateProps:makeFadingEdgeProps(folly::dynamic::object("start", 300)("end", 150))
oldProps:ScrollViewShadowNode::defaultSharedProps()];

[self assertLocations:[self fadingEdgeMaskOf:view].locations equal:@[ @0, @0.5, @0.75, @1 ]];
}

- (void)testZeroFadingEdgeLengthRemovesTheMask
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 200)];
[view updateProps:makeFadingEdgeProps(40) oldProps:ScrollViewShadowNode::defaultSharedProps()];
XCTAssertNotNil(view.scrollView.layer.mask);

[view updateProps:makeFadingEdgeProps(0) oldProps:nullptr];
XCTAssertNil(view.scrollView.layer.mask);
}

- (void)testFadingEdgeMaskFollowsTheContentOffset
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 200)];
[view updateProps:makeFadingEdgeProps(40) oldProps:ScrollViewShadowNode::defaultSharedProps()];

view.scrollView.contentSize = CGSizeMake(100, 1000);
view.scrollView.contentOffset = CGPointMake(0, 300);

XCTAssertTrue(CGRectEqualToRect([self fadingEdgeMaskOf:view].frame, CGRectMake(0, 300, 100, 200)));
}

- (void)testFadingEdgeMaskFollowsTheScrollViewSize
{
RCTScrollViewComponentView *view = [[RCTScrollViewComponentView alloc] initWithFrame:CGRectMake(0, 0, 100, 200)];
[view updateProps:makeFadingEdgeProps(40) oldProps:ScrollViewShadowNode::defaultSharedProps()];
[self assertLocations:[self fadingEdgeMaskOf:view].locations equal:@[ @0, @0.2, @0.8, @1 ]];

LayoutMetrics layoutMetrics = EmptyLayoutMetrics;
layoutMetrics.frame.size = {100, 400};
[view updateLayoutMetrics:layoutMetrics oldLayoutMetrics:EmptyLayoutMetrics];

[self assertLocations:[self fadingEdgeMaskOf:view].locations equal:@[ @0, @0.1, @0.9, @1 ]];
}

@end

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,13 @@ BaseScrollViewProps::BaseScrollViewProps(
rawProps,
"horizontal",
sourceProps.horizontal,
true)),
false)),
fadingEdgeLength(convertRawProp(
context,
rawProps,
"fadingEdgeLength",
sourceProps.fadingEdgeLength,
nullptr)),
scrollEventThrottle(convertRawProp(
context,
rawProps,
Expand Down Expand Up @@ -294,6 +300,7 @@ void BaseScrollViewProps::setProp(
RAW_SET_PROP_SWITCH_CASE_BASIC(showsVerticalScrollIndicator);
RAW_SET_PROP_SWITCH_CASE_BASIC(persistentScrollbar);
RAW_SET_PROP_SWITCH_CASE_BASIC(horizontal);
RAW_SET_PROP_SWITCH_CASE_BASIC(fadingEdgeLength);
RAW_SET_PROP_SWITCH_CASE_BASIC(scrollEventThrottle);
RAW_SET_PROP_SWITCH_CASE_BASIC(zoomScale);
RAW_SET_PROP_SWITCH_CASE_BASIC(contentInset);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

#include <React/RendererCore.h>
#include <React/View.h>
#include <folly/dynamic.h>
#include <react/renderer/components/scrollview/primitives.h>

#include <optional>
Expand Down Expand Up @@ -57,6 +58,7 @@ class BaseScrollViewProps : public ViewProps {
bool showsVerticalScrollIndicator{true};
bool persistentScrollbar{false};
bool horizontal{false};
folly::dynamic fadingEdgeLength{};
Float scrollEventThrottle{};
Float zoomScale{1.0f};
EdgeInsets contentInset{};
Expand Down
Loading
Loading