Skip to content

Commit 2198313

Browse files
authored
[Fabric] Implement snapToStart, snapToEnd, snapToOffsets property for ScrollView (#14800)
1 parent 068dba7 commit 2198313

File tree

4 files changed

+193
-1
lines changed

4 files changed

+193
-1
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "prerelease",
3+
"comment": "Add e2e test cases for snapToStart property in ScrollView fabric implementation",
4+
"packageName": "react-native-windows",
5+
"email": "198982749+Copilot@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}

vnext/Microsoft.ReactNative/CompositionSwitcher.idl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ namespace Microsoft.ReactNative.Composition.Experimental
120120
void SetMaximumZoomScale(Single maximumZoomScale);
121121
void SetMinimumZoomScale(Single minimumZoomScale);
122122
Boolean Horizontal;
123+
void SetSnapPoints(Boolean snapToStart, Boolean snapToEnd, Windows.Foundation.Collections.IVectorView<Single> offsets);
123124
}
124125

125126
[webhosthidden]

vnext/Microsoft.ReactNative/Fabric/Composition/CompositionContextHelper.cpp

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11

22
#include "pch.h"
33
#include "CompositionContextHelper.h"
4+
#include <algorithm>
45
#if __has_include("Composition.Experimental.SystemCompositionContextHelper.g.cpp")
56
#include "Composition.Experimental.SystemCompositionContextHelper.g.cpp"
67
#endif
@@ -74,6 +75,10 @@ struct CompositionTypeTraits<WindowsTypeTag> {
7475
winrt::Windows::UI::Composition::Interactions::InteractionTrackerRequestIgnoredArgs;
7576
using InteractionTrackerValuesChangedArgs =
7677
winrt::Windows::UI::Composition::Interactions::InteractionTrackerValuesChangedArgs;
78+
using InteractionTrackerInertiaRestingValue =
79+
winrt::Windows::UI::Composition::Interactions::InteractionTrackerInertiaRestingValue;
80+
using InteractionTrackerInertiaModifier =
81+
winrt::Windows::UI::Composition::Interactions::InteractionTrackerInertiaModifier;
7782
using ScalarKeyFrameAnimation = winrt::Windows::UI::Composition::ScalarKeyFrameAnimation;
7883
using ShapeVisual = winrt::Windows::UI::Composition::ShapeVisual;
7984
using SpriteVisual = winrt::Windows::UI::Composition::SpriteVisual;
@@ -143,6 +148,10 @@ struct CompositionTypeTraits<MicrosoftTypeTag> {
143148
winrt::Microsoft::UI::Composition::Interactions::InteractionTrackerRequestIgnoredArgs;
144149
using InteractionTrackerValuesChangedArgs =
145150
winrt::Microsoft::UI::Composition::Interactions::InteractionTrackerValuesChangedArgs;
151+
using InteractionTrackerInertiaRestingValue =
152+
winrt::Microsoft::UI::Composition::Interactions::InteractionTrackerInertiaRestingValue;
153+
using InteractionTrackerInertiaModifier =
154+
winrt::Microsoft::UI::Composition::Interactions::InteractionTrackerInertiaModifier;
146155
using ScalarKeyFrameAnimation = winrt::Microsoft::UI::Composition::ScalarKeyFrameAnimation;
147156
using ShapeVisual = winrt::Microsoft::UI::Composition::ShapeVisual;
148157
using SpriteVisual = winrt::Microsoft::UI::Composition::SpriteVisual;
@@ -782,9 +791,13 @@ struct CompScrollerVisual : winrt::implements<
782791
}
783792

784793
void Horizontal(bool value) noexcept {
794+
bool previousHorizontal = m_horizontal;
785795
m_horizontal = value;
786796

787-
UpdateInteractionModes();
797+
if (previousHorizontal != m_horizontal) {
798+
UpdateInteractionModes();
799+
ConfigureSnapInertiaModifiers(); // Reconfigure modifiers when direction changes
800+
}
788801
}
789802

790803
void UpdateInteractionModes() noexcept {
@@ -855,6 +868,21 @@ struct CompScrollerVisual : winrt::implements<
855868
m_interactionTracker.MinScale(minimumZoomScale);
856869
}
857870

871+
void SetSnapPoints(
872+
bool snapToStart,
873+
bool snapToEnd,
874+
winrt::Windows::Foundation::Collections::IVectorView<float> const &offsets) noexcept {
875+
m_snapToStart = snapToStart;
876+
m_snapToEnd = snapToEnd;
877+
m_snapToOffsets.clear();
878+
if (offsets) {
879+
for (auto const &offset : offsets) {
880+
m_snapToOffsets.push_back(offset);
881+
}
882+
}
883+
ConfigureSnapInertiaModifiers();
884+
}
885+
858886
void Opacity(float opacity) noexcept {
859887
m_visual.Opacity(opacity);
860888
}
@@ -1050,8 +1078,155 @@ struct CompScrollerVisual : winrt::implements<
10501078
0});
10511079
}
10521080

1081+
void ConfigureSnapInertiaModifiers() noexcept {
1082+
if (!m_visual || !m_contentVisual || !m_interactionTracker) {
1083+
return;
1084+
}
1085+
1086+
auto visualSize = m_visual.Size();
1087+
auto contentSize = m_contentVisual.Size();
1088+
if (visualSize.x <= 0 || visualSize.y <= 0 || contentSize.x <= 0 || contentSize.y <= 0) {
1089+
OutputDebugStringW(L"Invalid visual/content size\n");
1090+
return;
1091+
}
1092+
1093+
auto compositor = m_interactionTracker.Compositor();
1094+
1095+
// Collect and deduplicate all snap positions
1096+
std::vector<float> snapPositions;
1097+
1098+
if (m_snapToStart) {
1099+
snapPositions.push_back(0.0f);
1100+
}
1101+
1102+
snapPositions.insert(snapPositions.end(), m_snapToOffsets.begin(), m_snapToOffsets.end());
1103+
std::sort(snapPositions.begin(), snapPositions.end());
1104+
snapPositions.erase(std::unique(snapPositions.begin(), snapPositions.end()), snapPositions.end());
1105+
1106+
std::vector<typename TTypeRedirects::InteractionTrackerInertiaRestingValue> restingValues;
1107+
1108+
for (size_t i = 0; i < snapPositions.size(); ++i) {
1109+
const auto position = snapPositions[i];
1110+
auto restingValue = TTypeRedirects::InteractionTrackerInertiaRestingValue::Create(compositor);
1111+
1112+
winrt::hstring axisComponent = m_horizontal ? L"X" : L"Y";
1113+
winrt::hstring conditionExpr;
1114+
1115+
// Build condition expression based on whether there's one or multiple snap points
1116+
if (snapPositions.size() == 1) {
1117+
conditionExpr = L"abs(this.Target.NaturalRestingPosition." + axisComponent + L" - snap) < 50";
1118+
} else {
1119+
if (i == 0) {
1120+
conditionExpr = L"this.Target.NaturalRestingPosition." + axisComponent + L" < midpoint";
1121+
} else if (i == snapPositions.size() - 1) {
1122+
conditionExpr = L"this.Target.NaturalRestingPosition." + axisComponent + L" >= midpoint";
1123+
} else {
1124+
conditionExpr = L"this.Target.NaturalRestingPosition." + axisComponent +
1125+
L" >= prevMidpoint && this.Target.NaturalRestingPosition." + axisComponent + L" < nextMidpoint";
1126+
}
1127+
}
1128+
1129+
auto conditionAnim = compositor.CreateExpressionAnimation();
1130+
conditionAnim.Expression(conditionExpr);
1131+
1132+
if (snapPositions.size() == 1) {
1133+
conditionAnim.SetScalarParameter(L"snap", position);
1134+
} else {
1135+
// Multiple snap points - use range-based conditions
1136+
if (i == 0) {
1137+
const auto nextPosition = snapPositions[i + 1];
1138+
const auto midpoint = (position + nextPosition) / 2.0f;
1139+
conditionAnim.SetScalarParameter(L"midpoint", midpoint);
1140+
} else if (i == snapPositions.size() - 1) {
1141+
const auto prevPosition = snapPositions[i - 1];
1142+
const auto midpoint = (prevPosition + position) / 2.0f;
1143+
conditionAnim.SetScalarParameter(L"midpoint", midpoint);
1144+
} else {
1145+
const auto prevPosition = snapPositions[i - 1];
1146+
const auto nextPosition = snapPositions[i + 1];
1147+
const auto prevMidpoint = (prevPosition + position) / 2.0f;
1148+
const auto nextMidpoint = (position + nextPosition) / 2.0f;
1149+
conditionAnim.SetScalarParameter(L"prevMidpoint", prevMidpoint);
1150+
conditionAnim.SetScalarParameter(L"nextMidpoint", nextMidpoint);
1151+
}
1152+
}
1153+
1154+
restingValue.Condition(conditionAnim);
1155+
1156+
// Resting value simply snaps to this position
1157+
auto restingAnim = compositor.CreateExpressionAnimation();
1158+
restingAnim.Expression(L"snap");
1159+
restingAnim.SetScalarParameter(L"snap", position);
1160+
restingValue.RestingValue(restingAnim);
1161+
1162+
restingValues.push_back(restingValue);
1163+
}
1164+
1165+
if (m_snapToEnd) {
1166+
auto endRestingValue = TTypeRedirects::InteractionTrackerInertiaRestingValue::Create(compositor);
1167+
1168+
// Create property sets to dynamically compute content - visual size
1169+
auto contentSizePropertySet = compositor.CreatePropertySet();
1170+
contentSizePropertySet.InsertVector2(L"Size", m_contentVisual.Size());
1171+
1172+
auto visualSizePropertySet = compositor.CreatePropertySet();
1173+
visualSizePropertySet.InsertVector2(L"Size", m_visual.Size());
1174+
1175+
winrt::hstring endPositionExpr = m_horizontal ? L"max(contentSize.Size.x - visualSize.Size.x, 0)"
1176+
: L"max(contentSize.Size.y - visualSize.Size.y, 0)";
1177+
1178+
float prevPosition = snapPositions.empty() ? 0.0f : snapPositions.back();
1179+
1180+
winrt::hstring endConditionExpr = m_horizontal
1181+
? L"this.Target.NaturalRestingPosition.X >= ((max(contentSize.Size.x - visualSize.Size.x, 0) + prevSnap) / 2.0)"
1182+
: L"this.Target.NaturalRestingPosition.Y >= ((max(contentSize.Size.y - visualSize.Size.y, 0) + prevSnap) / 2.0)";
1183+
1184+
auto endCondition = compositor.CreateExpressionAnimation();
1185+
endCondition.Expression(endConditionExpr);
1186+
endCondition.SetReferenceParameter(L"contentSize", contentSizePropertySet);
1187+
endCondition.SetReferenceParameter(L"visualSize", visualSizePropertySet);
1188+
endCondition.SetScalarParameter(L"prevSnap", prevPosition);
1189+
1190+
auto endResting = compositor.CreateExpressionAnimation();
1191+
endResting.Expression(endPositionExpr);
1192+
endResting.SetReferenceParameter(L"contentSize", contentSizePropertySet);
1193+
endResting.SetReferenceParameter(L"visualSize", visualSizePropertySet);
1194+
1195+
endRestingValue.Condition(endCondition);
1196+
endRestingValue.RestingValue(endResting);
1197+
1198+
restingValues.push_back(endRestingValue);
1199+
}
1200+
1201+
if (!restingValues.empty()) {
1202+
auto modifiers = winrt::single_threaded_vector<typename TTypeRedirects::InteractionTrackerInertiaModifier>();
1203+
for (auto &v : restingValues) {
1204+
auto modifier = v.as<typename TTypeRedirects::InteractionTrackerInertiaModifier>();
1205+
if (modifier) {
1206+
modifiers.Append(modifier);
1207+
}
1208+
}
1209+
1210+
if (m_horizontal) {
1211+
m_interactionTracker.ConfigurePositionXInertiaModifiers(modifiers);
1212+
} else {
1213+
m_interactionTracker.ConfigurePositionYInertiaModifiers(modifiers);
1214+
}
1215+
} else {
1216+
// Clear inertia modifiers when no snapping is configured
1217+
if (m_horizontal) {
1218+
m_interactionTracker.ConfigurePositionXInertiaModifiers({});
1219+
} else {
1220+
m_interactionTracker.ConfigurePositionYInertiaModifiers({});
1221+
}
1222+
}
1223+
}
1224+
10531225
bool m_isScrollEnabled{true};
10541226
bool m_horizontal{false};
1227+
bool m_snapToStart{true};
1228+
bool m_snapToEnd{true};
1229+
std::vector<float> m_snapToOffsets;
10551230
bool m_inertia{false};
10561231
bool m_custom{false};
10571232
winrt::Windows::Foundation::Numerics::float3 m_targetPosition;

vnext/Microsoft.ReactNative/Fabric/Composition/ScrollViewComponentView.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,15 @@ void ScrollViewComponentView::updateProps(
805805
if (oldViewProps.zoomScale != newViewProps.zoomScale) {
806806
m_scrollVisual.Scale({newViewProps.zoomScale, newViewProps.zoomScale, newViewProps.zoomScale});
807807
}
808+
809+
if (oldViewProps.snapToStart != newViewProps.snapToStart || oldViewProps.snapToEnd != newViewProps.snapToEnd ||
810+
oldViewProps.snapToOffsets != newViewProps.snapToOffsets) {
811+
const auto snapToOffsets = winrt::single_threaded_vector<float>();
812+
for (const auto &offset : newViewProps.snapToOffsets) {
813+
snapToOffsets.Append(static_cast<float>(offset));
814+
}
815+
m_scrollVisual.SetSnapPoints(newViewProps.snapToStart, newViewProps.snapToEnd, snapToOffsets.GetView());
816+
}
808817
}
809818

810819
void ScrollViewComponentView::updateState(

0 commit comments

Comments
 (0)