diff --git a/change/react-native-windows-8a4ff7bf-ac06-4eb1-9a58-378dc5d976fa.json b/change/react-native-windows-8a4ff7bf-ac06-4eb1-9a58-378dc5d976fa.json new file mode 100644 index 00000000000..0f3cbb2d6da --- /dev/null +++ b/change/react-native-windows-8a4ff7bf-ac06-4eb1-9a58-378dc5d976fa.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "Implement onMomentumScrollEnd and onMomentumScrollBegin for Fabric ScrollView", + "packageName": "react-native-windows", + "email": "198982749+Copilot@users.noreply.github.com", + "dependentChangeType": "patch" +} \ No newline at end of file diff --git a/packages/playground/Samples/scrollViewSnapSample.tsx b/packages/playground/Samples/scrollViewSnapSample.tsx index 336792373dc..f31fb2e2389 100644 --- a/packages/playground/Samples/scrollViewSnapSample.tsx +++ b/packages/playground/Samples/scrollViewSnapSample.tsx @@ -288,6 +288,12 @@ export default class Bootstrap extends React.Component<{}, any> { onScrollEndDrag={() => { console.log('onScrollEndDrag'); }} + onMomentumScrollBegin={() => { + console.log('onMomentumScrollBegin'); + }} + onMomentumScrollEnd={() => { + console.log('onMomentumScrollEnd'); + }} onScroll={() => { console.log('onScroll'); }} diff --git a/vnext/Microsoft.ReactNative/CompositionSwitcher.idl b/vnext/Microsoft.ReactNative/CompositionSwitcher.idl index 002bbd03e4a..f6fb8fffe01 100644 --- a/vnext/Microsoft.ReactNative/CompositionSwitcher.idl +++ b/vnext/Microsoft.ReactNative/CompositionSwitcher.idl @@ -31,13 +31,6 @@ namespace Microsoft.ReactNative.Composition.Experimental SwitchThumb, }; - enum SnapAlignment - { - Start, - Center, - End, - }; - [webhosthidden] [uuid("172def51-9e1a-4e3c-841a-e5a470065acc")] // uuid needed for empty interfaces [version(0)] @@ -118,6 +111,8 @@ namespace Microsoft.ReactNative.Composition.Experimental event Windows.Foundation.EventHandler ScrollPositionChanged; event Windows.Foundation.EventHandler ScrollBeginDrag; event Windows.Foundation.EventHandler ScrollEndDrag; + event Windows.Foundation.EventHandler ScrollMomentumBegin; + event Windows.Foundation.EventHandler ScrollMomentumEnd; void ContentSize(Windows.Foundation.Numerics.Vector2 size); Windows.Foundation.Numerics.Vector3 ScrollPosition { get; }; void ScrollBy(Windows.Foundation.Numerics.Vector3 offset, Boolean animate); @@ -127,7 +122,7 @@ namespace Microsoft.ReactNative.Composition.Experimental void SetMaximumZoomScale(Single maximumZoomScale); void SetMinimumZoomScale(Single minimumZoomScale); Boolean Horizontal; - void SetSnapPoints(Boolean snapToStart, Boolean snapToEnd, Windows.Foundation.Collections.IVectorView offsets, SnapAlignment snapToAlignment); + void SetSnapPoints(Boolean snapToStart, Boolean snapToEnd, Windows.Foundation.Collections.IVectorView offsets); } [webhosthidden] diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp index e849d14934d..592375815d2 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp @@ -27,8 +27,6 @@ namespace Microsoft::ReactNative::Composition::Experimental { -using namespace winrt::Microsoft::ReactNative::Composition::Experimental; - template struct CompositionTypeTraits {}; @@ -711,8 +709,23 @@ struct CompScrollerVisual : winrt::implements< void IdleStateEntered( typename TTypeRedirects::InteractionTracker sender, typename TTypeRedirects::InteractionTrackerIdleStateEnteredArgs args) noexcept { + // If we were in inertia and are now idle, momentum has ended + if (m_outer->m_inertia) { + m_outer->FireScrollMomentumEnd({sender.Position().x, sender.Position().y}); + } + + // If we were interacting but never entered inertia (Interacting -> Idle), + // and the interaction was user-driven (requestId == 0), fire end-drag here. + // Note: if the interactionRequestId was non-zero it was caused by a Try* call + // (programmatic), so we should not fire onScrollEndDrag. + if (m_outer->m_interacting && args.RequestId() == 0) { + m_outer->FireScrollEndDrag({sender.Position().x, sender.Position().y}); + } + + // Clear state flags m_outer->m_custom = false; m_outer->m_inertia = false; + m_outer->m_interacting = false; } void InertiaStateEntered( typename TTypeRedirects::InteractionTracker sender, @@ -720,15 +733,26 @@ struct CompScrollerVisual : winrt::implements< m_outer->m_custom = false; m_outer->m_inertia = true; m_outer->m_currentPosition = args.NaturalRestingPosition(); - // When the user stops interacting with the object, tracker can go into two paths: - // 1. tracker goes into idle state immediately - // 2. tracker has just started gliding into Inertia state - // Fire ScrollEndDrag - m_outer->FireScrollEndDrag({args.NaturalRestingPosition().x, args.NaturalRestingPosition().y}); + + if (!m_outer->m_interacting && args.RequestId() == 0) { + m_outer->FireScrollBeginDrag({args.NaturalRestingPosition().x, args.NaturalRestingPosition().y}); + } + + // If interaction was user-driven (requestId == 0), + // fire ScrollEndDrag here (Interacting -> Inertia caused by user lift). + if (m_outer->m_interacting && args.RequestId() == 0) { + m_outer->FireScrollEndDrag({args.NaturalRestingPosition().x, args.NaturalRestingPosition().y}); + } + + // Fire momentum scroll begin when we enter inertia (user or programmatic) + m_outer->FireScrollMomentumBegin({args.NaturalRestingPosition().x, args.NaturalRestingPosition().y}); } void InteractingStateEntered( typename TTypeRedirects::InteractionTracker sender, typename TTypeRedirects::InteractionTrackerInteractingStateEnteredArgs args) noexcept { + // Mark that we're now interacting and remember the requestId (user manipulations => 0) + m_outer->m_interacting = true; + // Fire when the user starts dragging the object m_outer->FireScrollBeginDrag({sender.Position().x, sender.Position().y}); } @@ -738,6 +762,10 @@ struct CompScrollerVisual : winrt::implements< void ValuesChanged( typename TTypeRedirects::InteractionTracker sender, typename TTypeRedirects::InteractionTrackerValuesChangedArgs args) noexcept { + if (!m_outer->m_interacting && args.RequestId() == 0) { + m_outer->FireScrollBeginDrag({args.Position().x, args.Position().y}); + } + m_outer->m_interacting = true; m_outer->m_currentPosition = args.Position(); m_outer->FireScrollPositionChanged({args.Position().x, args.Position().y}); } @@ -873,11 +901,9 @@ struct CompScrollerVisual : winrt::implements< void SetSnapPoints( bool snapToStart, bool snapToEnd, - winrt::Windows::Foundation::Collections::IVectorView const &offsets, - SnapAlignment snapToAlignment) noexcept { + winrt::Windows::Foundation::Collections::IVectorView const &offsets) noexcept { m_snapToStart = snapToStart; m_snapToEnd = snapToEnd; - m_snapToAlignment = snapToAlignment; m_snapToOffsets.clear(); if (offsets) { for (auto const &offset : offsets) { @@ -985,6 +1011,20 @@ struct CompScrollerVisual : winrt::implements< return m_scrollEndDragEvent.add(handler); } + winrt::event_token ScrollMomentumBegin( + winrt::Windows::Foundation::EventHandler< + winrt::Microsoft::ReactNative::Composition::Experimental::IScrollPositionChangedArgs> const + &handler) noexcept { + return m_scrollMomentumBeginEvent.add(handler); + } + + winrt::event_token ScrollMomentumEnd( + winrt::Windows::Foundation::EventHandler< + winrt::Microsoft::ReactNative::Composition::Experimental::IScrollPositionChangedArgs> const + &handler) noexcept { + return m_scrollMomentumEndEvent.add(handler); + } + void ScrollPositionChanged(winrt::event_token const &token) noexcept { m_scrollPositionChangedEvent.remove(token); } @@ -997,6 +1037,14 @@ struct CompScrollerVisual : winrt::implements< m_scrollEndDragEvent.remove(token); } + void ScrollMomentumBegin(winrt::event_token const &token) noexcept { + m_scrollMomentumBeginEvent.remove(token); + } + + void ScrollMomentumEnd(winrt::event_token const &token) noexcept { + m_scrollMomentumEndEvent.remove(token); + } + void ContentSize(winrt::Windows::Foundation::Numerics::float2 const &size) noexcept { m_contentSize = size; m_contentVisual.Size(size); @@ -1075,6 +1123,14 @@ struct CompScrollerVisual : winrt::implements< m_scrollEndDragEvent(*this, winrt::make(position)); } + void FireScrollMomentumBegin(winrt::Windows::Foundation::Numerics::float2 position) noexcept { + m_scrollMomentumBeginEvent(*this, winrt::make(position)); + } + + void FireScrollMomentumEnd(winrt::Windows::Foundation::Numerics::float2 position) noexcept { + m_scrollMomentumEndEvent(*this, winrt::make(position)); + } + void UpdateMaxPosition() noexcept { m_interactionTracker.MaxPosition( {std::max(m_contentSize.x - m_visualSize.x, 0), @@ -1104,22 +1160,6 @@ struct CompScrollerVisual : winrt::implements< } snapPositions.insert(snapPositions.end(), m_snapToOffsets.begin(), m_snapToOffsets.end()); - - // Adjust snap positions based on alignment - const float viewportSize = m_horizontal ? visualSize.x : visualSize.y; - if (m_snapToAlignment == SnapAlignment::Center) { - // For center alignment, offset snap positions by half the viewport size - for (auto &position : snapPositions) { - position = std::max(0.0f, position - viewportSize / 2.0f); - } - } else if (m_snapToAlignment == SnapAlignment::End) { - // For end alignment, offset snap positions by the full viewport size - for (auto &position : snapPositions) { - position = std::max(0.0f, position - viewportSize); - } - } - // For Start alignment, no adjustment needed - std::sort(snapPositions.begin(), snapPositions.end()); snapPositions.erase(std::unique(snapPositions.begin(), snapPositions.end()), snapPositions.end()); @@ -1247,9 +1287,9 @@ struct CompScrollerVisual : winrt::implements< bool m_snapToStart{true}; bool m_snapToEnd{true}; std::vector m_snapToOffsets; - SnapAlignment m_snapToAlignment{SnapAlignment::Start}; bool m_inertia{false}; bool m_custom{false}; + bool m_interacting{false}; winrt::Windows::Foundation::Numerics::float3 m_targetPosition; winrt::Windows::Foundation::Numerics::float3 m_currentPosition; winrt::Windows::Foundation::Numerics::float2 m_contentSize{0}; @@ -1263,6 +1303,12 @@ struct CompScrollerVisual : winrt::implements< winrt::event> m_scrollEndDragEvent; + winrt::event> + m_scrollMomentumBeginEvent; + winrt::event> + m_scrollMomentumEndEvent; typename TTypeRedirects::SpriteVisual m_visual{nullptr}; typename TTypeRedirects::SpriteVisual m_contentVisual{nullptr}; typename TTypeRedirects::InteractionTracker m_interactionTracker{nullptr}; diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp index 8113b0ef4dc..3915bfd78d4 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp @@ -27,7 +27,6 @@ namespace winrt::Microsoft::ReactNative::Composition::implementation { constexpr float c_scrollerLineDelta = 16.0f; -constexpr auto c_maxSnapPoints = 1000; enum class ScrollbarHitRegion : int { Unknown = -1, @@ -741,15 +740,6 @@ void ScrollViewComponentView::updateBackgroundColor(const facebook::react::Share } } -winrt::Windows::Foundation::Collections::IVector ScrollViewComponentView::CreateSnapToOffsets( - const std::vector &offsets) { - auto snapToOffsets = winrt::single_threaded_vector(); - for (const auto &offset : offsets) { - snapToOffsets.Append(offset); - } - return snapToOffsets; -} - void ScrollViewComponentView::updateProps( facebook::react::Props::Shared const &props, facebook::react::Props::Shared const &oldProps) noexcept { @@ -818,13 +808,11 @@ void ScrollViewComponentView::updateProps( if (oldViewProps.snapToStart != newViewProps.snapToStart || oldViewProps.snapToEnd != newViewProps.snapToEnd || oldViewProps.snapToOffsets != newViewProps.snapToOffsets) { - if (oldViewProps.snapToInterval != newViewProps.snapToInterval) { - updateSnapPoints(); - } else { - const auto snapToOffsets = CreateSnapToOffsets(newViewProps.snapToOffsets); - m_scrollVisual.SetSnapPoints( - newViewProps.snapToStart, newViewProps.snapToEnd, snapToOffsets.GetView(), SnapAlignment::Center); + const auto snapToOffsets = winrt::single_threaded_vector(); + for (const auto &offset : newViewProps.snapToOffsets) { + snapToOffsets.Append(static_cast(offset)); } + m_scrollVisual.SetSnapPoints(newViewProps.snapToStart, newViewProps.snapToEnd, snapToOffsets.GetView()); } } @@ -875,9 +863,6 @@ void ScrollViewComponentView::updateContentVisualSize() noexcept { m_verticalScrollbarComponent->ContentSize(contentSize); m_horizontalScrollbarComponent->ContentSize(contentSize); m_scrollVisual.ContentSize(contentSize); - - // Update snap points if snapToInterval is being used, as content size affects the number of snap points - updateSnapPoints(); } void ScrollViewComponentView::prepareForRecycle() noexcept {} @@ -1354,6 +1339,32 @@ winrt::Microsoft::ReactNative::Composition::Experimental::IVisual ScrollViewComp } }); + m_scrollMomentumBeginRevoker = m_scrollVisual.ScrollMomentumBegin( + winrt::auto_revoke, + [this]( + winrt::IInspectable const & /*sender*/, + winrt::Microsoft::ReactNative::Composition::Experimental::IScrollPositionChangedArgs const &args) { + auto eventEmitter = GetEventEmitter(); + if (eventEmitter) { + auto scrollMetrics = getScrollMetrics(eventEmitter, args); + std::static_pointer_cast(eventEmitter) + ->onMomentumScrollBegin(scrollMetrics); + } + }); + + m_scrollMomentumEndRevoker = m_scrollVisual.ScrollMomentumEnd( + winrt::auto_revoke, + [this]( + winrt::IInspectable const & /*sender*/, + winrt::Microsoft::ReactNative::Composition::Experimental::IScrollPositionChangedArgs const &args) { + auto eventEmitter = GetEventEmitter(); + if (eventEmitter) { + auto scrollMetrics = getScrollMetrics(eventEmitter, args); + std::static_pointer_cast(eventEmitter) + ->onMomentumScrollEnd(scrollMetrics); + } + }); + return visual; } @@ -1450,50 +1461,4 @@ void ScrollViewComponentView::updateShowsVerticalScrollIndicator(bool value) noe void ScrollViewComponentView::updateDecelerationRate(float value) noexcept { m_scrollVisual.SetDecelerationRate({value, value, value}); } - -SnapAlignment ScrollViewComponentView::convertSnapToAlignment( - facebook::react::ScrollViewSnapToAlignment alignment) noexcept { - switch (alignment) { - case facebook::react::ScrollViewSnapToAlignment::Center: - return SnapAlignment::Center; - case facebook::react::ScrollViewSnapToAlignment::End: - return SnapAlignment::End; - case facebook::react::ScrollViewSnapToAlignment::Start: - default: - return SnapAlignment::Start; - } -} - -void ScrollViewComponentView::updateSnapPoints() noexcept { - const auto &viewProps = *std::static_pointer_cast(this->viewProps()); - const auto snapToOffsets = CreateSnapToOffsets(viewProps.snapToOffsets); - // Typically used in combination with snapToAlignment and decelerationRate="fast" - auto snapAlignment = SnapAlignment::Center; - auto decelerationRate = viewProps.decelerationRate; - - // snapToOffsets has priority over snapToInterval (matches React Native behavior) - if (viewProps.snapToInterval > 0 && decelerationRate >= 0.99) { - snapAlignment = convertSnapToAlignment(viewProps.snapToAlignment); - // Generate snap points based on interval - // Calculate the content size to determine how many intervals to create - float contentLength = viewProps.horizontal - ? std::max(m_contentSize.width, m_layoutMetrics.frame.size.width) * m_layoutMetrics.pointScaleFactor - : std::max(m_contentSize.height, m_layoutMetrics.frame.size.height) * m_layoutMetrics.pointScaleFactor; - - float interval = static_cast(viewProps.snapToInterval) * m_layoutMetrics.pointScaleFactor; - - // Ensure we have a reasonable minimum interval to avoid infinite loops or excessive memory usage - if (interval >= 1.0f && contentLength > 0) { - // Generate offsets at each interval, but limit the number of snap points to avoid excessive memory usage - int snapPointCount = 0; - - for (float offset = 0; offset <= contentLength && snapPointCount < c_maxSnapPoints; offset += interval) { - snapToOffsets.Append(offset); - snapPointCount++; - } - } - } - - m_scrollVisual.SetSnapPoints(viewProps.snapToStart, viewProps.snapToEnd, snapToOffsets.GetView(), snapAlignment); -} } // namespace winrt::Microsoft::ReactNative::Composition::implementation diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h index e22f6364607..495f0a1e2c4 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h @@ -18,8 +18,6 @@ namespace winrt::Microsoft::ReactNative::Composition::implementation { -using namespace Microsoft::ReactNative::Composition::Experimental; - struct ScrollBarComponent; struct ScrollViewComponentView : ScrollViewComponentViewT { @@ -123,7 +121,6 @@ struct ScrollInteractionTrackerOwner : public winrt::implements< private: void updateDecelerationRate(float value) noexcept; void updateContentVisualSize() noexcept; - void updateSnapPoints() noexcept; bool scrollToEnd(bool animate) noexcept; bool scrollToStart(bool animate) noexcept; bool scrollDown(float delta, bool animate) noexcept; @@ -137,8 +134,6 @@ struct ScrollInteractionTrackerOwner : public winrt::implements< winrt::Microsoft::ReactNative::Composition::Experimental::IScrollPositionChangedArgs const &args) noexcept; void updateShowsHorizontalScrollIndicator(bool value) noexcept; void updateShowsVerticalScrollIndicator(bool value) noexcept; - SnapAlignment convertSnapToAlignment(facebook::react::ScrollViewSnapToAlignment alignment) noexcept; - winrt::Windows::Foundation::Collections::IVector CreateSnapToOffsets(const std::vector &offsets); facebook::react::Size m_contentSize; winrt::Microsoft::ReactNative::Composition::Experimental::IScrollVisual m_scrollVisual{nullptr}; @@ -148,9 +143,12 @@ struct ScrollInteractionTrackerOwner : public winrt::implements< m_scrollPositionChangedRevoker{}; winrt::Microsoft::ReactNative::Composition::Experimental::IScrollVisual::ScrollBeginDrag_revoker m_scrollBeginDragRevoker{}; - winrt::Microsoft::ReactNative::Composition::Experimental::IScrollVisual::ScrollEndDrag_revoker m_scrollEndDragRevoker{}; + winrt::Microsoft::ReactNative::Composition::Experimental::IScrollVisual::ScrollMomentumBegin_revoker + m_scrollMomentumBeginRevoker{}; + winrt::Microsoft::ReactNative::Composition::Experimental::IScrollVisual::ScrollMomentumEnd_revoker + m_scrollMomentumEndRevoker{}; float m_zoomFactor{1.0f}; bool m_isScrollingFromInertia = false;