diff --git a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 2ebbb0e82ac91e..b57b9485c0a4ea 100644 --- a/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -23,6 +23,7 @@ #import "RCTConversions.h" #import "RCTEnhancedScrollView.h" #import "RCTFabricComponentsPlugins.h" +#import "RCTPullToRefreshViewComponentView.h" using namespace facebook::react; @@ -100,6 +101,11 @@ @implementation RCTScrollViewComponentView { BOOL _shouldUpdateContentInsetAdjustmentBehavior; CGPoint _contentOffsetWhenClipped; + + __weak UIView *_contentView; + + CGRect _prevFirstVisibleFrame; + __weak UIView *_firstVisibleView; } + (RCTScrollViewComponentView *_Nullable)findScrollViewComponentViewForView:(UIView *)view @@ -149,10 +155,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 @@ -337,11 +350,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; + } } /* @@ -404,6 +429,9 @@ - (void)prepareForRecycle CGRect oldFrame = self.frame; self.frame = CGRectZero; self.frame = oldFrame; + _contentView = nil; + _prevFirstVisibleFrame = CGRectZero; + _firstVisibleView = nil; [super prepareForRecycle]; } @@ -684,6 +712,75 @@ - (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. This must be done after we update the content offset + // or it will tend to grab rows that were made visible by the shift in position + 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 4eff278fdfc1f7..18fda68ec6954f 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 @@ -336,6 +345,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, (Float)1.0); RAW_SET_PROP_SWITCH_CASE_BASIC(minimumZoomScale, (Float)1.0); RAW_SET_PROP_SWITCH_CASE_BASIC(scrollEnabled, true); @@ -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..3e30c30cfe4bfc 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,7 @@ 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..1fc817d65900ff 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 { @@ -144,5 +145,35 @@ inline std::string toString(const ContentInsetAdjustmentBehavior &value) { } } +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 std::optional &value) { + if (!value) { + return "null"; + } + return "{minIndexForVisible: " + toString(value.value().minIndexForVisible) + + ", autoscrollToTopThreshold: " + + toString(value.value().autoscrollToTopThreshold) + "}"; +} + } // namespace react } // namespace facebook diff --git a/ReactCommon/react/renderer/components/scrollview/primitives.h b/ReactCommon/react/renderer/components/scrollview/primitives.h index fe8a60e21d7c0d..f05f1125c3d0a1 100644 --- a/ReactCommon/react/renderer/components/scrollview/primitives.h +++ b/ReactCommon/react/renderer/components/scrollview/primitives.h @@ -7,6 +7,9 @@ #pragma once +#include +#include + namespace facebook { namespace react { @@ -23,5 +26,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/Podfile.lock b/packages/rn-tester/Podfile.lock index d6bfa85c149f95..b8df4ace250b05 100644 --- a/packages/rn-tester/Podfile.lock +++ b/packages/rn-tester/Podfile.lock @@ -938,11 +938,11 @@ EXTERNAL SOURCES: :path: "../../ReactCommon/yoga" SPEC CHECKSUMS: - boost: 57d2868c099736d80fcd648bf211b4431e51a558 + boost: a7c83b31436843459a1961bfd74b96033dc77234 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 - FBLazyVector: d68947eddece25638eb0f642d1b957c90388afd1 - FBReactNativeSpec: 9a029e7dec747a8836d785b3b7a433db5960504b + FBLazyVector: d1314b8103bbf3209d50ba8dfba22d650e46030c + FBReactNativeSpec: 938f038a9a99787d4e2696564e39c69b4f880b95 Flipper: 26fc4b7382499f1281eb8cb921e5c3ad6de91fe0 Flipper-Boost-iOSX: fd1e2b8cbef7e662a122412d7ac5f5bea715403c Flipper-DoubleConversion: 2dc99b02f658daf147069aad9dbd29d8feb06d30 @@ -954,48 +954,48 @@ SPEC CHECKSUMS: FlipperKit: cbdee19bdd4e7f05472a66ce290f1b729ba3cb86 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b - hermes-engine: d344c89c3f4657f7031e5280e1b3dd531b425bfd + hermes-engine: 4c0c4d6c03ed75f7aedf2a6537918b34c68f27f6 libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c - RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 - RCTRequired: 54a4f03dbbebb0cfdb4e2ba8d3b1d0b1258f8c08 - RCTTypeSafety: a41e253b4ed644708899857d912b2f50c7b6214d - React: 2fc6c4c656cccd6753016528ad41199c16fd558e - React-callinvoker: a7d5e883a83bb9bd3985b08be832c5e76451d18f + RCT-Folly: 0080d0a6ebf2577475bda044aa59e2ca1f909cda + RCTRequired: abf913aae52045e6ac052b10e2984593b9e4c7d9 + RCTTypeSafety: db37cf760c05619eaa7f18b4f064d57d8afc19c6 + React: 512bd9eb992cf723bedece290520b6825dc3177c + React-callinvoker: 6e31d603d4bb3643819ed03b7599d395d986aedc React-Codegen: 4a022870a58b95e17da8f32641ba3d72b551f268 - React-Core: 719bec4b41c93b1affb1e2c3a43956ec482ecb9f - React-CoreModules: feaa45c54c58e1420981f6dd544c8b3d01200caa - React-cxxreact: c5f93e7a35f3545489d8e1f89beb9d2d56acfde5 - React-Fabric: 8a854fd89c932ab073f67036bb45d1787d0d31a4 - React-graphics: cb8a85648695c60f33a00d732b985f734d1470d8 - React-hermes: 299c7f56d32e8953480fd8e7fba2a7968a534b3f - React-jsi: d40e13b7f545f9af2af780f153f5321018b5e2f8 - React-jsidynamic: 8aa406dfc1eff081f3443e55a28b51d11616a3bf - React-jsiexecutor: 04a945f040cc085d79655359ec29e5f501fb6e01 - React-jsinspector: a56861590ddfcb5cb544877ade3e007a32ff9616 - React-logger: 07c9b44040a6f948b8e2033207b23cb623f0b9b4 - React-perflogger: b4b9fb2ddd856b78003708ab3cf66ce03e6bc7c4 - React-RCTActionSheet: 1b1501ef80928be10702cd0ce09120358094cd82 - React-RCTAnimation: 6741f7be3e269e057c1426074cc70f34b56e114b - React-RCTAppDelegate: 0b3b2c1e02c02f952f5033535ddb23d690e3b890 - React-RCTBlob: 94feb99abafd0527a78f6caaa17a0bcec9ce3167 - React-RCTFabric: db1d7fe55db4811b63ae4060078e7048ebb4a918 - React-RCTImage: 055685a12c88939437f6520d9e7c120cd666cbf1 - React-RCTLinking: b149b3ff1f96fa93fc445230b9c171adb0e5572c - React-RCTNetwork: 21abb4231182651f48b7035beaa011b1ab7ae8f4 - React-RCTPushNotification: f3af966de34c1fe2df8860625d225fb2f581d15e - React-RCTSettings: 64b6acabfddf7f96796229b101bd91f46d14391b - React-RCTTest: 81ebfa8c2e1b0b482effe12485e6486dc0ff70d7 - React-RCTText: 4e5ae05b778a0ed2b22b012af025da5e1a1c4e54 - React-RCTVibration: ecfd04c1886a9c9a4e31a466c0fbcf6b36e92fde - React-rncore: 08566b41339706758229f407c8907b2f7987f058 - React-runtimeexecutor: c7b2cd6babf6cc50340398bfbb7a9da13c93093f - ReactCommon: fc336a81ae40421e172c3ca9496677e34d7e3ed5 + React-Core: 5242baffa4b9742500d19dcd398dba441e8aeda7 + React-CoreModules: 38c9ae7f78e033c3875ba25d8ae2d39d844f828f + React-cxxreact: 2a785babb607be7e407309c7f124cee9d3b921b4 + React-Fabric: 5d7f6967018a6617438af98c389a4b4a2af7efc7 + React-graphics: b9f9cce87199f460270210c30d3cc3b5ae6fdf35 + React-hermes: 42092890a4d49b8fa579ade31a0ce69e6abc806c + React-jsi: 83acdf27e0d369569e431c245e6238e25bb6b338 + React-jsidynamic: 8383c0e355a3e72d36b72c70f8f39e920906a766 + React-jsiexecutor: 0a02e213d7e42fa7686639eb30b9c1c9b9ae33bf + React-jsinspector: 5d837065f3d3fb5cb72060371e669c3fd380471c + React-logger: 18e585e68be93ed6e630aedcb1a1dfa2ade1723f + React-perflogger: c5e84d3019949a31374fbc279915123c028e24e8 + React-RCTActionSheet: 4facc4e45a13e7f2551b3264a662b5d1104ca926 + React-RCTAnimation: 0865abae40177342efeb4065c53f052e06c03689 + React-RCTAppDelegate: 738bf72afc34a066964ac73c886402bc34047342 + React-RCTBlob: ff08ae9b3254a491145501bb247d3fef124b89ce + React-RCTFabric: bfac03a1533e117b53cfdb2401e0788ba15278fa + React-RCTImage: 37c72ad7673b500504793364d6186d880fbfd17c + React-RCTLinking: 68a464d8166a31b59ef3a218192b4f64d826c19b + React-RCTNetwork: 89e08588d5faf51d3d797fe11f36b8c03a831156 + React-RCTPushNotification: 68743863feb124d99c86e4b605d5c01d17ea1cce + React-RCTSettings: e5fad8931f831e2274c2422af671cd827756fb4e + React-RCTTest: a62120cd93d3df322eef48af9fae06fd730ce93c + React-RCTText: e34b1671bcedb3988aba964a1851cab222377161 + React-RCTVibration: f2567d752318e6fd1c838b435c254435a487852f + React-rncore: 714be3b4bc3834753121b42c1cac66e9c6012efa + React-runtimeexecutor: 3b7744e88c90f98f8ba81d0a7af0088a5562b829 + ReactCommon: aee00da9e33f228efaf3005589ebfa528b221267 ScreenshotManager: 2bd28f9b590a13c811f1f4ce32aab767f8845c6b SocketRocket: fccef3f9c5cedea1353a9ef6ada904fde10d6608 - Yoga: 1b1a12ff3d86a10565ea7cbe057d42f5e5fb2a07 + Yoga: e9cb8aab0a96ae9d6bcbb5dc4fb74761171e9f44 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a -PODFILE CHECKSUM: 20298ecd3f30aa788ad491637e593ed0d8c100ca +PODFILE CHECKSUM: 5d1fc1e8809808c4384337ae55c5be2de48ffe4c COCOAPODS: 1.11.3 diff --git a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js index 33f467a492311b..5b71700c7f666f 100644 --- a/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js +++ b/packages/rn-tester/js/examples/ScrollView/ScrollViewExample.js @@ -75,7 +75,7 @@ class AppendingList extends React.Component<