From 95a8c99f3fe72550c4b08be0718a6722a605db52 Mon Sep 17 00:00:00 2001 From: Samuel Susla Date: Mon, 25 Mar 2024 06:32:29 -0700 Subject: [PATCH] introduce experimental mechanism to subscribe to synchronous onScroll (#43593) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/43593 changelog: [internal] Introduce experimental mechanism to subscribe to synchronous onScroll from outside of ScrollView. Reviewed By: yungsters Differential Revision: D55066385 fbshipit-source-id: b7cc173cb32e736f49496c1e0ba37de782ca02aa --- .../Components/ScrollView/ScrollView.js | 49 +++++++++++++++++++ .../ScrollView/ScrollViewNativeComponent.js | 1 + .../ScrollView/RCTScrollViewComponentView.mm | 31 +++++++++--- .../scrollview/ScrollViewEventEmitter.cpp | 10 ++++ .../scrollview/ScrollViewEventEmitter.h | 1 + .../components/scrollview/ScrollViewProps.cpp | 11 +++++ .../components/scrollview/ScrollViewProps.h | 1 + .../renderer/uimanager/UIManagerBinding.cpp | 2 +- 8 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index d8814ccefc4..abf13fe9d81 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -33,6 +33,7 @@ import StyleSheet from '../../StyleSheet/StyleSheet'; import Dimensions from '../../Utilities/Dimensions'; import dismissKeyboard from '../../Utilities/dismissKeyboard'; import Platform from '../../Utilities/Platform'; +import EventEmitter from '../../vendor/emitter/EventEmitter'; import Keyboard from '../Keyboard/Keyboard'; import TextInputState from '../TextInput/TextInputState'; import View from '../View/View'; @@ -676,6 +677,9 @@ export type Props = $ReadOnly<{| type State = {| layoutHeight: ?number, + onScrollEmitter: ?EventEmitter<{ + scroll: [{x: number, y: number}], + }>, |}; const IS_ANIMATING_TOUCH_START_THRESHOLD_MS = 16; @@ -761,6 +765,7 @@ class ScrollView extends React.Component { state: State = { layoutHeight: null, + onScrollEmitter: null, }; componentDidMount() { @@ -829,6 +834,8 @@ class ScrollView extends React.Component { if (this._scrollAnimatedValueAttachment) { this._scrollAnimatedValueAttachment.detach(); } + + this.state.onScrollEmitter?.removeAllListeners(); } /** @@ -948,6 +955,40 @@ class ScrollView extends React.Component { Commands.flashScrollIndicators(this._scrollView.nativeInstance); }; + _subscribeToOnScroll: ( + callback: ({x: number, y: number}) => void, + ) => EventSubscription = callback => { + // An undefined value means the listener has not been added, yet. + // A null value means the listener has been removed. + let subscription: ?EventSubscription; + + this.setState( + ({onScrollEmitter}) => ({ + onScrollEmitter: onScrollEmitter ?? new EventEmitter(), + }), + () => { + // If `subscription` is null, that means it was removed before we got + // here so do nothing. + if (subscription !== null) { + subscription = nullthrows(this.state.onScrollEmitter).addListener( + 'scroll', + callback, + ); + } + }, + ); + + return { + remove() { + // If `subscription` was created before this invocation, remove it. + subscription?.remove(); + // Record this invocation by setting `subscription` to null, in case it + // ends up being created after this invocation. + subscription = null; + }, + }; + }; + /** * This method should be used as the callback to onFocus in a TextInputs' * parent view. Note that any module using this mixin needs to return @@ -1154,6 +1195,11 @@ class ScrollView extends React.Component { _handleScroll = (e: ScrollEvent) => { this._observedScrollSinceBecomingResponder = true; this.props.onScroll && this.props.onScroll(e); + + this.state.onScrollEmitter?.emit('scroll', { + x: e.nativeEvent.contentOffset.x, + y: e.nativeEvent.contentOffset.y, + }); }; _handleLayout = (e: LayoutEvent) => { @@ -1202,6 +1248,8 @@ class ScrollView extends React.Component { scrollToEnd: this.scrollToEnd, flashScrollIndicators: this.flashScrollIndicators, scrollResponderZoomTo: this.scrollResponderZoomTo, + // TODO: Replace unstable_subscribeToOnScroll once scrollView.addEventListener('scroll', (e: ScrollEvent) => {}, {passive: false}); + unstable_subscribeToOnScroll: this._subscribeToOnScroll, scrollResponderScrollNativeHandleToKeyboard: this.scrollResponderScrollNativeHandleToKeyboard, }, @@ -1778,6 +1826,7 @@ class ScrollView extends React.Component { onScroll: this._handleScroll, endDraggingSensitivityMultiplier: experimental_endDraggingSensitivityMultiplier, + enableSyncOnScroll: this.state.onScrollEmitter ? true : undefined, scrollEventThrottle: hasStickyHeaders ? 1 : this.props.scrollEventThrottle, diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js index 8c112684902..1d4a724f78e 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollViewNativeComponent.js @@ -133,6 +133,7 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = contentInsetAdjustmentBehavior: true, decelerationRate: true, endDraggingSensitivityMultiplier: true, + enableSyncOnScroll: true, // iOS-Fabric only. directionalLockEnabled: true, disableIntervalMomentum: true, indicatorStyle: true, diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 4fcbd27ae0e..50389ed4ae6 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -459,18 +459,33 @@ static void RCTSendScrollEventForNativeAnimations_DEPRECATED(UIScrollView *scrol - (void)scrollViewDidScroll:(UIScrollView *)scrollView { - if (!_isUserTriggeredScrolling || CoreFeatures::enableGranularScrollViewStateUpdatesIOS) { - [self _updateStateWithContentOffset]; - } + const auto &props = static_cast(*_props); + auto scrollMetrics = [self _scrollViewMetrics]; - NSTimeInterval now = CACurrentMediaTime(); - if ((_lastScrollEventDispatchTime == 0) || (now - _lastScrollEventDispatchTime > _scrollEventThrottle)) { - _lastScrollEventDispatchTime = now; + if (props.enableSyncOnScroll) { if (_eventEmitter) { - static_cast(*_eventEmitter).onScroll([self _scrollViewMetrics]); + const auto &eventEmitter = static_cast(*_eventEmitter); + // TODO: temporary API to unblock testing of synchronous rendering. + eventEmitter.experimental_flushSync([&eventEmitter, &scrollMetrics, &self]() { + [self _updateStateWithContentOffset]; + // TODO: temporary API to unblock testing of synchronous rendering. + eventEmitter.experimental_onDiscreteScroll(scrollMetrics); + }); + } + } else { + if (!_isUserTriggeredScrolling || CoreFeatures::enableGranularScrollViewStateUpdatesIOS) { + [self _updateStateWithContentOffset]; } - RCTSendScrollEventForNativeAnimations_DEPRECATED(scrollView, self.tag); + NSTimeInterval now = CACurrentMediaTime(); + if ((_lastScrollEventDispatchTime == 0) || (now - _lastScrollEventDispatchTime > _scrollEventThrottle)) { + _lastScrollEventDispatchTime = now; + if (_eventEmitter) { + static_cast(*_eventEmitter).onScroll(scrollMetrics); + } + + RCTSendScrollEventForNativeAnimations_DEPRECATED(scrollView, self.tag); + } } [self _remountChildrenIfNeeded]; diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewEventEmitter.cpp b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewEventEmitter.cpp index 3a437647ca6..ed04e6b27b4 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewEventEmitter.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewEventEmitter.cpp @@ -63,6 +63,16 @@ void ScrollViewEventEmitter::onScroll(const Metrics& scrollViewMetrics) const { }); } +void ScrollViewEventEmitter::experimental_onDiscreteScroll( + const Metrics& scrollViewMetrics) const { + dispatchEvent( + "scroll", + [scrollViewMetrics](jsi::Runtime& runtime) { + return scrollViewMetricsPayload(runtime, scrollViewMetrics); + }, + RawEvent::Category::Discrete); +} + void ScrollViewEventEmitter::onScrollToTop( const Metrics& scrollViewMetrics) const { dispatchUniqueEvent( diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewEventEmitter.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewEventEmitter.h index 4cdd66c3e3f..5a7e16d3240 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewEventEmitter.h +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewEventEmitter.h @@ -28,6 +28,7 @@ class ScrollViewEventEmitter : public ViewEventEmitter { }; void onScroll(const Metrics& scrollViewMetrics) const; + void experimental_onDiscreteScroll(const Metrics& scrollViewMetrics) const; void onScrollBeginDrag(const Metrics& scrollViewMetrics) const; void onScrollEndDrag(const Metrics& scrollViewMetrics) const; void onMomentumScrollBegin(const Metrics& scrollViewMetrics) const; diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp index bb0239ea08f..cc430904bfd 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.cpp @@ -109,6 +109,15 @@ ScrollViewProps::ScrollViewProps( "endDraggingSensitivityMultiplier", sourceProps.endDraggingSensitivityMultiplier, 1)), + enableSyncOnScroll( + CoreFeatures::enablePropIteratorSetter + ? sourceProps.enableSyncOnScroll + : convertRawProp( + context, + rawProps, + "enableSyncOnScroll", + sourceProps.enableSyncOnScroll, + false)), directionalLockEnabled( CoreFeatures::enablePropIteratorSetter ? sourceProps.directionalLockEnabled @@ -368,6 +377,8 @@ void ScrollViewProps::setProp( RAW_SET_PROP_SWITCH_CASE_BASIC(maximumZoomScale); RAW_SET_PROP_SWITCH_CASE_BASIC(minimumZoomScale); RAW_SET_PROP_SWITCH_CASE_BASIC(scrollEnabled); + RAW_SET_PROP_SWITCH_CASE_BASIC(enableSyncOnScroll); + RAW_SET_PROP_SWITCH_CASE_BASIC(endDraggingSensitivityMultiplier); RAW_SET_PROP_SWITCH_CASE_BASIC(pagingEnabled); RAW_SET_PROP_SWITCH_CASE_BASIC(pinchGestureEnabled); RAW_SET_PROP_SWITCH_CASE_BASIC(scrollsToTop); diff --git a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h index ef8799caeb7..20a94337ae3 100644 --- a/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h +++ b/packages/react-native/ReactCommon/react/renderer/components/scrollview/ScrollViewProps.h @@ -42,6 +42,7 @@ class ScrollViewProps final : public ViewProps { bool automaticallyAdjustsScrollIndicatorInsets{true}; Float decelerationRate{0.998f}; Float endDraggingSensitivityMultiplier{1}; + bool enableSyncOnScroll{false}; bool directionalLockEnabled{}; ScrollViewIndicatorStyle indicatorStyle{}; ScrollViewKeyboardDismissMode keyboardDismissMode{}; diff --git a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp index 2f9e26e0868..94f4feab378 100644 --- a/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp +++ b/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerBinding.cpp @@ -583,7 +583,7 @@ jsi::Value UIManagerBinding::get( auto layoutMetrics = uiManager->getRelativeLayoutMetrics( *shadowNodeFromValue(runtime, arguments[0]), shadowNodeFromValue(runtime, arguments[1]).get(), - {/* .includeTransform = */ true}); + {/* .includeTransform = */ false}); auto frame = layoutMetrics.frame; auto result = jsi::Object(runtime); result.setProperty(runtime, "left", frame.origin.x);