Skip to content

[Fabric] Implement snapToInterval property for ScrollView #14847

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "Implement snapToInterval property for Fabric ScrollView",
"packageName": "react-native-windows",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
namespace winrt::Microsoft::ReactNative::Composition::implementation {

constexpr float c_scrollerLineDelta = 16.0f;
constexpr auto c_maxSnapPoints = 1000;

enum class ScrollbarHitRegion : int {
Unknown = -1,
Expand Down Expand Up @@ -740,6 +741,15 @@ void ScrollViewComponentView::updateBackgroundColor(const facebook::react::Share
}
}

winrt::Windows::Foundation::Collections::IVector<float> ScrollViewComponentView::CreateSnapToOffsets(
const std::vector<float> &offsets) {
auto snapToOffsets = winrt::single_threaded_vector<float>();
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 {
Expand Down Expand Up @@ -807,12 +817,17 @@ void ScrollViewComponentView::updateProps(
}

if (oldViewProps.snapToStart != newViewProps.snapToStart || oldViewProps.snapToEnd != newViewProps.snapToEnd ||
oldViewProps.snapToOffsets != newViewProps.snapToOffsets) {
const auto snapToOffsets = winrt::single_threaded_vector<float>();
for (const auto &offset : newViewProps.snapToOffsets) {
snapToOffsets.Append(static_cast<float>(offset));
oldViewProps.snapToOffsets != newViewProps.snapToOffsets ||
oldViewProps.snapToInterval != newViewProps.snapToInterval) {
if ((newViewProps.snapToInterval > 0 || oldViewProps.snapToInterval != newViewProps.snapToInterval) &&
(newViewProps.decelerationRate >= 0.99)) {
// Use the comprehensive updateSnapPoints method when snapToInterval is involved
// Typically used in combination with snapToAlignment and decelerationRate="fast".
updateSnapPoints();
} else {
auto snapToOffsets = CreateSnapToOffsets(newViewProps.snapToOffsets);
m_scrollVisual.SetSnapPoints(newViewProps.snapToStart, newViewProps.snapToEnd, snapToOffsets.GetView());
}
m_scrollVisual.SetSnapPoints(newViewProps.snapToStart, newViewProps.snapToEnd, snapToOffsets.GetView());
}
}

Expand Down Expand Up @@ -863,6 +878,9 @@ 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();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the scenario for this? In the sense when would this be called? is it during initialization?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's whenever the content size is changed the offsets / intervals would be updated as per new dimensions

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see. so basically if layout size changes during runtime and then this gets triggered. Got it. Thank you for helping me understand this code better. I hope all the tests are validated to the best of your knowledge.

}

void ScrollViewComponentView::prepareForRecycle() noexcept {}
Expand Down Expand Up @@ -1435,4 +1453,33 @@ void ScrollViewComponentView::updateShowsVerticalScrollIndicator(bool value) noe
void ScrollViewComponentView::updateDecelerationRate(float value) noexcept {
m_scrollVisual.SetDecelerationRate({value, value, value});
}

void ScrollViewComponentView::updateSnapPoints() noexcept {
const auto &viewProps = *std::static_pointer_cast<const facebook::react::ScrollViewProps>(this->viewProps());
const auto snapToOffsets = CreateSnapToOffsets(viewProps.snapToOffsets);

// snapToOffsets has priority over snapToInterval (matches React Native behavior)
if (viewProps.snapToInterval > 0) {
// 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<float>(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());
}
} // namespace winrt::Microsoft::ReactNative::Composition::implementation
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ 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;
Expand All @@ -134,6 +135,7 @@ struct ScrollInteractionTrackerOwner : public winrt::implements<
winrt::Microsoft::ReactNative::Composition::Experimental::IScrollPositionChangedArgs const &args) noexcept;
void updateShowsHorizontalScrollIndicator(bool value) noexcept;
void updateShowsVerticalScrollIndicator(bool value) noexcept;
winrt::Windows::Foundation::Collections::IVector<float> CreateSnapToOffsets(const std::vector<float> &offsets);

facebook::react::Size m_contentSize;
winrt::Microsoft::ReactNative::Composition::Experimental::IScrollVisual m_scrollVisual{nullptr};
Expand Down
Loading