From 8f575e992d1b629b14a50b22bd659cc5b5fa6a4d Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 8 Feb 2023 12:40:17 -0500 Subject: [PATCH 1/4] Add fabric support for maintainVisibleContentPosition on iOS This reverts commit 9d98e2cb07afd3f68945ec7d7125188f4b6367fa. --- .../ScrollView/RCTScrollViewComponentView.mm | 96 +++++++++++++++++++ .../components/scrollview/ScrollViewProps.cpp | 14 +++ .../components/scrollview/ScrollViewProps.h | 4 + .../components/scrollview/conversions.h | 35 +++++++ .../components/scrollview/primitives.h | 17 ++++ .../renderer/debug/DebugStringConvertible.h | 9 ++ .../examples/ScrollView/ScrollViewExample.js | 2 +- 7 files changed, 176 insertions(+), 1 deletion(-) diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 8c95cdbc0b9e18..1a243af970120f 100644 --- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -22,6 +22,7 @@ #import "RCTConversions.h" #import "RCTEnhancedScrollView.h" #import "RCTFabricComponentsPlugins.h" +#import "RCTPullToRefreshViewComponentView.h" using namespace facebook::react; @@ -99,6 +100,11 @@ @implementation RCTScrollViewComponentView { BOOL _shouldUpdateContentInsetAdjustmentBehavior; CGPoint _contentOffsetWhenClipped; + + __weak UIView *_contentView; + + CGRect _prevFirstVisibleFrame; + __weak UIView *_firstVisibleView; } + (RCTScrollViewComponentView *_Nullable)findScrollViewComponentViewForView:(UIView *)view @@ -148,10 +154,17 @@ - (void)dealloc #pragma mark - RCTMountingTransactionObserving +- (void)mountingTransactionWillMount:(const facebook::react::MountingTransaction &)transaction + withSurfaceTelemetry:(const facebook::react::SurfaceTelemetry &)surfaceTelemetry +{ + [self _prepareForMaintainVisibleScrollPosition]; +} + - (void)mountingTransactionDidMount:(MountingTransaction const &)transaction withSurfaceTelemetry:(facebook::react::SurfaceTelemetry const &)surfaceTelemetry { [self _remountChildren]; + [self _adjustForMaintainVisibleContentPosition]; } #pragma mark - RCTComponentViewProtocol @@ -336,11 +349,23 @@ - (void)_preserveContentOffsetIfNeededWithBlock:(void (^)())block - (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { [_containerView insertSubview:childComponentView atIndex:index]; + if ([childComponentView isKindOfClass:RCTPullToRefreshViewComponentView.class]) { + // Ignore the pull to refresh component. + } else { + RCTAssert(_contentView == nil, @"RCTScrollView may only contain a single subview."); + _contentView = childComponentView; + } } - (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { [childComponentView removeFromSuperview]; + if ([childComponentView isKindOfClass:RCTPullToRefreshViewComponentView.class]) { + // Ignore the pull to refresh component. + } else { + RCTAssert(_contentView == childComponentView, @"Attempted to remove non-existent subview"); + _contentView = nil; + } } /* @@ -403,6 +428,9 @@ - (void)prepareForRecycle CGRect oldFrame = self.frame; self.frame = CGRectZero; self.frame = oldFrame; + _contentView = nil; + _prevFirstVisibleFrame = CGRectZero; + _firstVisibleView = nil; [super prepareForRecycle]; } @@ -683,6 +711,74 @@ - (void)removeScrollListener:(NSObject *)scrollListener [self.scrollViewDelegateSplitter removeDelegate:scrollListener]; } +#pragma mark - Maintain visible content position + +- (void)_prepareForMaintainVisibleScrollPosition +{ + const auto &props = *std::static_pointer_cast(_props); + if (!props.maintainVisibleContentPosition) { + return; + } + + BOOL horizontal = _scrollView.contentSize.width > self.frame.size.width; + int minIdx = props.maintainVisibleContentPosition.value().minIndexForVisible; + for (NSUInteger ii = minIdx; ii < _contentView.subviews.count; ++ii) { + // Find the first entirely visible view. + UIView *subview = _contentView.subviews[ii]; + BOOL hasNewView = NO; + if (horizontal) { + hasNewView = subview.frame.origin.x > _scrollView.contentOffset.x; + } else { + hasNewView = subview.frame.origin.y > _scrollView.contentOffset.y; + } + if (hasNewView || ii == _contentView.subviews.count - 1) { + _prevFirstVisibleFrame = subview.frame; + _firstVisibleView = subview; + break; + } + } +} + +- (void)_adjustForMaintainVisibleContentPosition +{ + const auto &props = *std::static_pointer_cast(_props); + if (!props.maintainVisibleContentPosition) { + return; + } + + std::optional autoscrollThreshold = props.maintainVisibleContentPosition.value().autoscrollToTopThreshold; + BOOL horizontal = _scrollView.contentSize.width > self.frame.size.width; + // TODO: detect and handle/ignore re-ordering + if (horizontal) { + CGFloat deltaX = _firstVisibleView.frame.origin.x - _prevFirstVisibleFrame.origin.x; + if (ABS(deltaX) > 0.5) { + CGFloat x = _scrollView.contentOffset.x; + [self _forceDispatchNextScrollEvent]; + _scrollView.contentOffset = CGPointMake(_scrollView.contentOffset.x + deltaX, _scrollView.contentOffset.y); + if (autoscrollThreshold) { + // If the offset WAS within the threshold of the start, animate to the start. + if (x <= autoscrollThreshold.value()) { + [self scrollToOffset:CGPointMake(0, _scrollView.contentOffset.y) animated:YES]; + } + } + } + } else { + CGRect newFrame = _firstVisibleView.frame; + CGFloat deltaY = newFrame.origin.y - _prevFirstVisibleFrame.origin.y; + if (ABS(deltaY) > 0.5) { + CGFloat y = _scrollView.contentOffset.y; + [self _forceDispatchNextScrollEvent]; + _scrollView.contentOffset = CGPointMake(_scrollView.contentOffset.x, _scrollView.contentOffset.y + deltaY); + if (autoscrollThreshold) { + // If the offset WAS within the threshold of the start, animate to the start. + if (y <= autoscrollThreshold.value()) { + [self scrollToOffset:CGPointMake(_scrollView.contentOffset.x, 0) animated:YES]; + } + } + } + } +} + @end Class RCTScrollViewCls(void) diff --git a/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp b/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp index 4f0d53929c9602..ef2cee93e9f0e6 100644 --- a/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp +++ b/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp @@ -127,6 +127,15 @@ ScrollViewProps::ScrollViewProps( "keyboardDismissMode", sourceProps.keyboardDismissMode, {})), + maintainVisibleContentPosition( + CoreFeatures::enablePropIteratorSetter + ? sourceProps.maintainVisibleContentPosition + : convertRawProp( + context, + rawProps, + "maintainVisibleContentPosition", + sourceProps.maintainVisibleContentPosition, + {})), maximumZoomScale( CoreFeatures::enablePropIteratorSetter ? sourceProps.maximumZoomScale @@ -337,6 +346,7 @@ void ScrollViewProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(directionalLockEnabled); RAW_SET_PROP_SWITCH_CASE_BASIC(indicatorStyle); RAW_SET_PROP_SWITCH_CASE_BASIC(keyboardDismissMode); + RAW_SET_PROP_SWITCH_CASE_BASIC(maintainVisibleContentPosition); RAW_SET_PROP_SWITCH_CASE_BASIC(maximumZoomScale); RAW_SET_PROP_SWITCH_CASE_BASIC(minimumZoomScale); RAW_SET_PROP_SWITCH_CASE_BASIC(scrollEnabled); @@ -413,6 +423,10 @@ SharedDebugStringConvertibleList ScrollViewProps::getDebugProps() const { "keyboardDismissMode", keyboardDismissMode, defaultScrollViewProps.keyboardDismissMode), + debugStringConvertibleItem( + "maintainVisibleContentPosition", + maintainVisibleContentPosition, + defaultScrollViewProps.maintainVisibleContentPosition), debugStringConvertibleItem( "maximumZoomScale", maximumZoomScale, diff --git a/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h b/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h index bd53bc4bb22def..c76c99edddab6f 100644 --- a/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h +++ b/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h @@ -11,6 +11,8 @@ #include #include +#include + namespace facebook { namespace react { @@ -43,6 +45,8 @@ class ScrollViewProps final : public ViewProps { bool directionalLockEnabled{}; ScrollViewIndicatorStyle indicatorStyle{}; ScrollViewKeyboardDismissMode keyboardDismissMode{}; + std::optional + maintainVisibleContentPosition{}; Float maximumZoomScale{1.0f}; Float minimumZoomScale{1.0f}; bool scrollEnabled{true}; diff --git a/ReactCommon/react/renderer/components/scrollview/conversions.h b/ReactCommon/react/renderer/components/scrollview/conversions.h index 4605f08ea203dd..3c888b4813268b 100644 --- a/ReactCommon/react/renderer/components/scrollview/conversions.h +++ b/ReactCommon/react/renderer/components/scrollview/conversions.h @@ -10,6 +10,7 @@ #include #include #include +#include namespace facebook { namespace react { @@ -98,6 +99,26 @@ inline void fromRawValue( abort(); } +inline void fromRawValue( + const PropsParserContext &context, + const RawValue &value, + ScrollViewMaintainVisibleContentPosition &result) { + auto map = (butter::map)value; + + auto minIndexForVisible = map.find("minIndexForVisible"); + if (minIndexForVisible != map.end()) { + fromRawValue( + context, minIndexForVisible->second, result.minIndexForVisible); + } + auto autoscrollToTopThreshold = map.find("autoscrollToTopThreshold"); + if (autoscrollToTopThreshold != map.end()) { + fromRawValue( + context, + autoscrollToTopThreshold->second, + result.autoscrollToTopThreshold); + } +} + inline std::string toString(const ScrollViewSnapToAlignment &value) { switch (value) { case ScrollViewSnapToAlignment::Start: @@ -109,6 +130,8 @@ inline std::string toString(const ScrollViewSnapToAlignment &value) { } } +#if RN_DEBUG_STRING_CONVERTIBLE + inline std::string toString(const ScrollViewIndicatorStyle &value) { switch (value) { case ScrollViewIndicatorStyle::Default: @@ -144,5 +167,17 @@ inline std::string toString(const ContentInsetAdjustmentBehavior &value) { } } +inline std::string toString( + const std::optional &value) { + if (!value) { + return "null"; + } + return "{minIndexForVisible: " + toString(value.value().minIndexForVisible) + + ", autoscrollToTopThreshold: " + + toString(value.value().autoscrollToTopThreshold) + "}"; +} + +#endif + } // namespace react } // namespace facebook diff --git a/ReactCommon/react/renderer/components/scrollview/primitives.h b/ReactCommon/react/renderer/components/scrollview/primitives.h index fe8a60e21d7c0d..e627d767fa2958 100644 --- a/ReactCommon/react/renderer/components/scrollview/primitives.h +++ b/ReactCommon/react/renderer/components/scrollview/primitives.h @@ -7,6 +7,8 @@ #pragma once +#include + namespace facebook { namespace react { @@ -23,5 +25,20 @@ enum class ContentInsetAdjustmentBehavior { Always }; +class ScrollViewMaintainVisibleContentPosition final { + public: + int minIndexForVisible{0}; + std::optional autoscrollToTopThreshold{}; + + bool operator==(const ScrollViewMaintainVisibleContentPosition &rhs) const { + return std::tie(this->minIndexForVisible, this->autoscrollToTopThreshold) == + std::tie(rhs.minIndexForVisible, rhs.autoscrollToTopThreshold); + } + + bool operator!=(const ScrollViewMaintainVisibleContentPosition &rhs) const { + return !(*this == rhs); + } +}; + } // namespace react } // namespace facebook diff --git a/ReactCommon/react/renderer/debug/DebugStringConvertible.h b/ReactCommon/react/renderer/debug/DebugStringConvertible.h index a9a1ef02b4e349..7df17f01e39aec 100644 --- a/ReactCommon/react/renderer/debug/DebugStringConvertible.h +++ b/ReactCommon/react/renderer/debug/DebugStringConvertible.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -98,6 +99,14 @@ std::string toString(float const &value); std::string toString(double const &value); std::string toString(void const *value); +template +std::string toString(const std::optional &value) { + if (!value) { + return "null"; + } + return toString(value.value()); +} + /* * *Informal* `DebugStringConvertible` interface. * diff --git a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js index ec3b0d7a461446..a7d7a8f8258812 100644 --- a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js +++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js @@ -76,7 +76,7 @@ class AppendingList extends React.Component< Date: Wed, 8 Feb 2023 13:20:49 -0500 Subject: [PATCH 2/4] Fix for custom pull to refresh components --- .../RCTCustomPullToRefreshViewProtocol.h | 15 +++++++++++++++ .../RCTPullToRefreshViewComponentView.h | 3 ++- .../ScrollView/RCTScrollViewComponentView.mm | 6 +++--- 3 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 React/Fabric/Mounting/ComponentViews/ScrollView/RCTCustomPullToRefreshViewProtocol.h diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTCustomPullToRefreshViewProtocol.h b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTCustomPullToRefreshViewProtocol.h new file mode 100644 index 00000000000000..2b97cd1a372560 --- /dev/null +++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTCustomPullToRefreshViewProtocol.h @@ -0,0 +1,15 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +/** + * Denotes a view which implements custom pull to refresh functionality. + */ +@protocol RCTCustomPullToRefreshViewProtocol + +@end diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h index 3048aa48f9baf5..914a2494a57923 100644 --- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h +++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTPullToRefreshViewComponentView.h @@ -7,6 +7,7 @@ #import +#import #import NS_ASSUME_NONNULL_BEGIN @@ -16,7 +17,7 @@ NS_ASSUME_NONNULL_BEGIN * This view is designed to only serve ViewController-like purpose for the actual `UIRefreshControl` view which is being * attached to some `UIScrollView` (not to this view). */ -@interface RCTPullToRefreshViewComponentView : RCTViewComponentView +@interface RCTPullToRefreshViewComponentView : RCTViewComponentView @end diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 1a243af970120f..efa656f4333062 100644 --- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -20,9 +20,9 @@ #import #import "RCTConversions.h" +#import "RCTCustomPullToRefreshViewProtocol.h" #import "RCTEnhancedScrollView.h" #import "RCTFabricComponentsPlugins.h" -#import "RCTPullToRefreshViewComponentView.h" using namespace facebook::react; @@ -349,7 +349,7 @@ - (void)_preserveContentOffsetIfNeededWithBlock:(void (^)())block - (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { [_containerView insertSubview:childComponentView atIndex:index]; - if ([childComponentView isKindOfClass:RCTPullToRefreshViewComponentView.class]) { + if ([childComponentView conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)]) { // Ignore the pull to refresh component. } else { RCTAssert(_contentView == nil, @"RCTScrollView may only contain a single subview."); @@ -360,7 +360,7 @@ - (void)mountChildComponentView:(UIView *)childCompone - (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { [childComponentView removeFromSuperview]; - if ([childComponentView isKindOfClass:RCTPullToRefreshViewComponentView.class]) { + if ([childComponentView conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)]) { // Ignore the pull to refresh component. } else { RCTAssert(_contentView == childComponentView, @"Attempted to remove non-existent subview"); From 5def79e8d99bc5fcc21f6ece1df132559682837e Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 2 Mar 2023 16:52:06 -0500 Subject: [PATCH 3/4] Update React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm Co-authored-by: Riccardo Cipolleschi --- .../ComponentViews/ScrollView/RCTScrollViewComponentView.mm | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index efa656f4333062..afb66adf10734b 100644 --- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -349,10 +349,7 @@ - (void)_preserveContentOffsetIfNeededWithBlock:(void (^)())block - (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { [_containerView insertSubview:childComponentView atIndex:index]; - if ([childComponentView conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)]) { - // Ignore the pull to refresh component. - } else { - RCTAssert(_contentView == nil, @"RCTScrollView may only contain a single subview."); + if (![childComponentView conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)]) { _contentView = childComponentView; } } From 35bd9e365652d1f451987de0f8ba9c590a1fd44f Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 2 Mar 2023 16:52:16 -0500 Subject: [PATCH 4/4] Update React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm Co-authored-by: Riccardo Cipolleschi --- .../ComponentViews/ScrollView/RCTScrollViewComponentView.mm | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index afb66adf10734b..6515dd0b7e7469 100644 --- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -357,10 +357,8 @@ - (void)mountChildComponentView:(UIView *)childCompone - (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index { [childComponentView removeFromSuperview]; - if ([childComponentView conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)]) { - // Ignore the pull to refresh component. - } else { - RCTAssert(_contentView == childComponentView, @"Attempted to remove non-existent subview"); +if (![childComponentView conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)] && + _contentView == childComponentView) { _contentView = nil; } }