diff --git a/change/react-native-windows-67c23522-cd6e-44c7-a9f1-5848217a698e.json b/change/react-native-windows-67c23522-cd6e-44c7-a9f1-5848217a698e.json new file mode 100644 index 00000000000..8b5649ad431 --- /dev/null +++ b/change/react-native-windows-67c23522-cd6e-44c7-a9f1-5848217a698e.json @@ -0,0 +1,7 @@ +{ + "type": "prerelease", + "comment": "[Fabric ] Implement snapToStart, snapToEnd, snapToInterval and snapToOffsets in Scrollview", + "packageName": "react-native-windows", + "email": "54227869+anupriya13@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/playground/Samples/scrollViewSnapSample.tsx b/packages/playground/Samples/scrollViewSnapSample.tsx index fed5b5adf8a..39c22f76977 100644 --- a/packages/playground/Samples/scrollViewSnapSample.tsx +++ b/packages/playground/Samples/scrollViewSnapSample.tsx @@ -290,6 +290,7 @@ export default class Bootstrap extends React.Component<{}, any> { zoomScale={this.state.zoomValue ? 2.0 : 1.0} snapToStart={this.state.snapToStartValue} snapToEnd={this.state.snapToEndValue} + snapToInterval={150} snapToAlignment={this.state.alignToStartValue ? 'start' : 'end'} horizontal={this.state.horizontalValue} showsHorizontalScrollIndicator={ diff --git a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp index 955119bb39d..7e6ceb3005b 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp @@ -790,6 +790,22 @@ void ScrollViewComponentView::updateProps( if (oldViewProps.zoomScale != newViewProps.zoomScale) { m_scrollVisual.Scale({newViewProps.zoomScale, newViewProps.zoomScale, newViewProps.zoomScale}); } + + if (oldViewProps.snapToEnd != newViewProps.snapToEnd) { + m_snapToEnd = newViewProps.snapToEnd; + } + + if (oldViewProps.snapToInterval != newViewProps.snapToInterval) { + m_snapToInterval = newViewProps.snapToInterval; + } + + if (oldViewProps.snapToOffsets != newViewProps.snapToOffsets) { + m_snapToOffsets = newViewProps.snapToOffsets; + } + + if (oldViewProps.snapToStart != newViewProps.snapToStart) { + m_snapToStart = newViewProps.snapToStart; + } } void ScrollViewComponentView::updateState( @@ -800,12 +816,21 @@ void ScrollViewComponentView::updateState( updateContentVisualSize(); } -void ScrollViewComponentView::updateStateWithContentOffset() noexcept { +void ScrollViewComponentView::updateStateWithContentOffset(bool applySnapping) noexcept { if (!m_state) { return; } auto scrollPosition = m_scrollVisual.ScrollPosition(); + + // Apply snapping if applicable + if (applySnapping) { + float snapX = calculateSnapPosition(scrollPosition.x, true); + float snapY = calculateSnapPosition(scrollPosition.y, false); + scrollPosition.x = snapX; + scrollPosition.y = snapY; + } + m_verticalScrollbarComponent->ContentOffset(scrollPosition); m_horizontalScrollbarComponent->ContentOffset(scrollPosition); @@ -1240,7 +1265,8 @@ winrt::Microsoft::ReactNative::Composition::Experimental::IVisual ScrollViewComp [this]( winrt::IInspectable const & /*sender*/, winrt::Microsoft::ReactNative::Composition::Experimental::IScrollPositionChangedArgs const &args) { - updateStateWithContentOffset(); + updateStateWithContentOffset(true); // TO-DO: When onScrollEndDrag is implemented, we can set this to false here + // and true for onScrollEndDrag only auto eventEmitter = GetEventEmitter(); if (eventEmitter) { facebook::react::ScrollViewEventEmitter::Metrics scrollMetrics; @@ -1261,7 +1287,7 @@ winrt::Microsoft::ReactNative::Composition::Experimental::IVisual ScrollViewComp [this]( winrt::IInspectable const & /*sender*/, winrt::Microsoft::ReactNative::Composition::Experimental::IScrollPositionChangedArgs const &args) { - updateStateWithContentOffset(); + updateStateWithContentOffset(false); auto eventEmitter = GetEventEmitter(); if (eventEmitter) { facebook::react::ScrollViewEventEmitter::Metrics scrollMetrics; @@ -1373,4 +1399,71 @@ void ScrollViewComponentView::updateShowsVerticalScrollIndicator(bool value) noe void ScrollViewComponentView::updateDecelerationRate(float value) noexcept { m_scrollVisual.SetDecelerationRate({value, value, value}); } + +float ScrollViewComponentView::calculateSnapPosition(float currentOffset, bool isHorizontal) noexcept { + float targetOffset = currentOffset; + + // Calculate maximum content offset + float viewportSize = isHorizontal ? m_layoutMetrics.frame.size.width : m_layoutMetrics.frame.size.height; + float maximumOffset = isHorizontal ? std::max(0.0f, m_contentSize.width - viewportSize) + : std::max(0.0f, m_contentSize.height - viewportSize); + + // Handle snapToOffsets + if (!m_snapToOffsets.empty()) { + float targetOffset = currentOffset; + float smallerOffset = 0.0f; + float largerOffset = maximumOffset; + + // Find the closest smaller and larger offsets + for (float offset : m_snapToOffsets) { + if (offset <= targetOffset && (targetOffset - offset < targetOffset - smallerOffset)) { + smallerOffset = offset; + } + if (offset >= targetOffset && (offset - targetOffset < largerOffset - targetOffset)) { + largerOffset = offset; + } + } + + // Determine the nearest offset + float nearestOffset = (targetOffset - smallerOffset < largerOffset - targetOffset) ? smallerOffset : largerOffset; + + // Handle snapToStart and snapToEnd + if (!m_snapToStart && targetOffset <= m_snapToOffsets.front()) { + if (currentOffset <= m_snapToOffsets.front()) { + // Free scrolling + targetOffset = currentOffset; + } else { + // Snap to start + targetOffset = m_snapToOffsets.front(); + } + } else if (!m_snapToEnd && targetOffset >= m_snapToOffsets.back()) { + if (currentOffset >= m_snapToOffsets.back()) { + // Free scrolling + targetOffset = currentOffset; + } else { + // Snap to end + targetOffset = m_snapToOffsets.back(); + } + } else { + targetOffset = nearestOffset; + } + } else if (m_snapToInterval > 0.0f) { + // Handle snapToInterval + float alignmentOffset = 0.0f; + + // TODO: Add additional checks for disableIntervalMomentum and snapToAlignment here + + // Calculate the fractional index for snapping + float fractionalIndex = (currentOffset + alignmentOffset) / m_snapToInterval; + + // Determine the snap index based on direction + int snapIndex = static_cast(std::round(fractionalIndex)); + targetOffset = (snapIndex * m_snapToInterval) - alignmentOffset; + } + + // Ensure the snap position is within bounds + targetOffset = std::clamp(targetOffset, 0.0f, maximumOffset); + + return targetOffset; +} } // 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 0a87852cfca..88b539e92a5 100644 --- a/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h +++ b/vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.h @@ -128,9 +128,10 @@ struct ScrollInteractionTrackerOwner : public winrt::implements< bool scrollLeft(float delta, bool aniamte) noexcept; bool scrollRight(float delta, bool animate) noexcept; void updateBackgroundColor(const facebook::react::SharedColor &color) noexcept; - void updateStateWithContentOffset() noexcept; + void updateStateWithContentOffset(bool applySnapping) noexcept; void updateShowsHorizontalScrollIndicator(bool value) noexcept; void updateShowsVerticalScrollIndicator(bool value) noexcept; + float calculateSnapPosition(float currentOffset, bool isHorizontal) noexcept; facebook::react::Size m_contentSize; winrt::Microsoft::ReactNative::Composition::Experimental::IScrollVisual m_scrollVisual{nullptr}; @@ -147,6 +148,10 @@ struct ScrollInteractionTrackerOwner : public winrt::implements< bool m_isHorizontal = false; bool m_changeViewAfterLoaded = false; bool m_dismissKeyboardOnDrag = false; + bool m_snapToEnd{true}; + float m_snapToInterval{0.0f}; + std::vector m_snapToOffsets{}; + bool m_snapToStart{true}; std::shared_ptr m_state; };