ScrollSnap.cpp (35376B)
1 /* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ 2 /* vim: set ts=8 sts=2 et sw=2 tw=80: */ 3 /* This Source Code Form is subject to the terms of the Mozilla Public 4 * License, v. 2.0. If a copy of the MPL was not distributed with this 5 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 6 7 #include "ScrollSnap.h" 8 9 #include "FrameMetrics.h" 10 #include "mozilla/ScrollContainerFrame.h" 11 #include "mozilla/ScrollSnapInfo.h" 12 #include "mozilla/ServoStyleConsts.h" 13 #include "mozilla/StaticPrefs_layout.h" 14 #include "nsIFrame.h" 15 #include "nsLayoutUtils.h" 16 #include "nsPresContext.h" 17 #include "nsTArray.h" 18 19 namespace mozilla { 20 21 /** 22 * Keeps track of the current best edge to snap to. The criteria for 23 * adding an edge depends on the scrolling unit. 24 */ 25 class CalcSnapPoints final { 26 using SnapTarget = ScrollSnapInfo::SnapTarget; 27 28 public: 29 CalcSnapPoints(ScrollUnit aUnit, ScrollSnapFlags aSnapFlags, 30 const nsPoint& aDestination, const nsPoint& aStartPos); 31 struct SnapPosition : public SnapTarget { 32 SnapPosition(const SnapTarget& aSnapTarget, nscoord aPosition, 33 nscoord aDistanceOnOtherAxis) 34 : SnapTarget(aSnapTarget), 35 mPosition(aPosition), 36 mDistanceOnOtherAxis(aDistanceOnOtherAxis) {} 37 38 nscoord mPosition; 39 // The distance from the scroll destination to this snap position on the 40 // other axis. This value is used if there are multiple SnapPositions on 41 // this axis, but the positions on the other axis are different. 42 nscoord mDistanceOnOtherAxis; 43 }; 44 45 void AddHorizontalEdge(const SnapTarget& aTarget); 46 void AddVerticalEdge(const SnapTarget& aTarget); 47 48 struct CandidateTracker { 49 // keeps track of the position of the current second best edge on the 50 // opposite side of the best edge on this axis. 51 // We use NSCoordSaturatingSubtract to calculate the distance between a 52 // given position and this second best edge position so that it can be an 53 // uninitialized value as the maximum possible value, because the first 54 // distance calculation would always be nscoord_MAX. 55 nscoord mSecondBestEdge = nscoord_MAX; 56 57 // Assuming in most cases there's no multiple coincide snap points. 58 AutoTArray<ScrollSnapTargetId, 1> mTargetIds; 59 // keeps track of the positions of the current best edge on this axis. 60 // NOTE: Each SnapPosition.mPosition points the same snap position on this 61 // axis but other member variables of SnapPosition may have different 62 // values. 63 AutoTArray<SnapPosition, 1> mBestEdges; 64 bool EdgeFound() const { return !mBestEdges.IsEmpty(); } 65 }; 66 void AddEdge(const SnapPosition& aEdge, nscoord aDestination, 67 nscoord aStartPos, nscoord aScrollingDirection, 68 CandidateTracker* aCandidateTracker); 69 SnapDestination GetBestEdge(const nsSize& aSnapportSize) const; 70 nscoord XDistanceBetweenBestAndSecondEdge() const { 71 return std::abs(NSCoordSaturatingSubtract( 72 mTrackerOnX.mSecondBestEdge, 73 mTrackerOnX.EdgeFound() ? mTrackerOnX.mBestEdges[0].mPosition 74 : mDestination.x, 75 nscoord_MAX)); 76 } 77 nscoord YDistanceBetweenBestAndSecondEdge() const { 78 return std::abs(NSCoordSaturatingSubtract( 79 mTrackerOnY.mSecondBestEdge, 80 mTrackerOnY.EdgeFound() ? mTrackerOnY.mBestEdges[0].mPosition 81 : mDestination.y, 82 nscoord_MAX)); 83 } 84 const nsPoint& Destination() const { return mDestination; } 85 86 protected: 87 ScrollUnit mUnit; 88 ScrollSnapFlags mSnapFlags; 89 nsPoint mDestination; // gives the position after scrolling but before 90 // snapping 91 nsPoint mStartPos; // gives the position before scrolling 92 nsIntPoint mScrollingDirection; // always -1, 0, or 1 93 CandidateTracker mTrackerOnX; 94 CandidateTracker mTrackerOnY; 95 }; 96 97 CalcSnapPoints::CalcSnapPoints(ScrollUnit aUnit, ScrollSnapFlags aSnapFlags, 98 const nsPoint& aDestination, 99 const nsPoint& aStartPos) 100 : mUnit(aUnit), 101 mSnapFlags(aSnapFlags), 102 mDestination(aDestination), 103 mStartPos(aStartPos) { 104 MOZ_ASSERT(aSnapFlags != ScrollSnapFlags::Disabled); 105 106 nsPoint direction = aDestination - aStartPos; 107 mScrollingDirection = nsIntPoint(0, 0); 108 if (direction.x < 0) { 109 mScrollingDirection.x = -1; 110 } 111 if (direction.x > 0) { 112 mScrollingDirection.x = 1; 113 } 114 if (direction.y < 0) { 115 mScrollingDirection.y = -1; 116 } 117 if (direction.y > 0) { 118 mScrollingDirection.y = 1; 119 } 120 } 121 122 SnapDestination CalcSnapPoints::GetBestEdge(const nsSize& aSnapportSize) const { 123 if (mTrackerOnX.EdgeFound() && mTrackerOnY.EdgeFound()) { 124 nsPoint bestCandidate(mTrackerOnX.mBestEdges[0].mPosition, 125 mTrackerOnY.mBestEdges[0].mPosition); 126 nsRect snappedPort = nsRect(bestCandidate, aSnapportSize); 127 128 // If we've found the candidates on both axes, it's possible some of 129 // candidates will be outside of the snapport if we snap to the point 130 // (mTrackerOnX.mBestEdges[0].mPosition, 131 // mTrackerOnY.mBestEdges[0].mPosition). So we need to get the intersection 132 // of the snap area of each snap target element on each axis and the 133 // snapport to tell whether it's outside of the snapport or not. 134 // 135 // Also if at least either one of the elements will be outside of the 136 // snapport if we snap to (mTrackerOnX.mBestEdges[0].mPosition, 137 // mTrackerOnY.mBestEdges[0].mPosition). We need to choose one of 138 // combinations of the candidates which is closest to the destination. 139 // 140 // So here we iterate over mTrackerOnX and mTrackerOnY just once 141 // respectively for both purposes to avoid iterating over them again and 142 // again. 143 // 144 // NOTE: Ideally we have to iterate over every possible combinations of 145 // (mTrackerOnX.mBestEdges[i].mSnapPoint.mY, 146 // mTrackerOnY.mBestEdges[j].mSnapPoint.mX) and tell whether the given 147 // combination will be visible in the snapport or not (maybe we should 148 // choose the one that the visible area, i.e., the intersection area of 149 // the snap target elements and the snapport, is the largest one rather than 150 // the closest one?). But it will be inefficient, so here we will not 151 // iterate all the combinations, we just iterate all the snap target 152 // elements in each axis respectively. 153 154 AutoTArray<ScrollSnapTargetId, 1> visibleTargetIdsOnX; 155 nscoord minimumDistanceOnY = nscoord_MAX; 156 size_t minimumXIndex = 0; 157 AutoTArray<ScrollSnapTargetId, 1> minimumDistanceTargetIdsOnX; 158 for (size_t i = 0; i < mTrackerOnX.mBestEdges.Length(); i++) { 159 const auto& targetX = mTrackerOnX.mBestEdges[i]; 160 if (targetX.mSnapArea.Intersects(snappedPort)) { 161 visibleTargetIdsOnX.AppendElement(targetX.mTargetId); 162 } 163 164 if (targetX.mDistanceOnOtherAxis < minimumDistanceOnY) { 165 minimumDistanceOnY = targetX.mDistanceOnOtherAxis; 166 minimumXIndex = i; 167 minimumDistanceTargetIdsOnX = 168 AutoTArray<ScrollSnapTargetId, 1>{targetX.mTargetId}; 169 } else if (minimumDistanceOnY != nscoord_MAX && 170 targetX.mDistanceOnOtherAxis == minimumDistanceOnY) { 171 minimumDistanceTargetIdsOnX.AppendElement(targetX.mTargetId); 172 } 173 } 174 175 AutoTArray<ScrollSnapTargetId, 1> visibleTargetIdsOnY; 176 nscoord minimumDistanceOnX = nscoord_MAX; 177 size_t minimumYIndex = 0; 178 AutoTArray<ScrollSnapTargetId, 1> minimumDistanceTargetIdsOnY; 179 for (size_t i = 0; i < mTrackerOnY.mBestEdges.Length(); i++) { 180 const auto& targetY = mTrackerOnY.mBestEdges[i]; 181 if (targetY.mSnapArea.Intersects(snappedPort)) { 182 visibleTargetIdsOnY.AppendElement(targetY.mTargetId); 183 } 184 185 if (targetY.mDistanceOnOtherAxis < minimumDistanceOnX) { 186 minimumDistanceOnX = targetY.mDistanceOnOtherAxis; 187 minimumYIndex = i; 188 minimumDistanceTargetIdsOnY = 189 AutoTArray<ScrollSnapTargetId, 1>{targetY.mTargetId}; 190 } else if (minimumDistanceOnX != nscoord_MAX && 191 targetY.mDistanceOnOtherAxis == minimumDistanceOnX) { 192 minimumDistanceTargetIdsOnY.AppendElement(targetY.mTargetId); 193 } 194 } 195 196 // If we have the target ids on both axes, it means the target elements 197 // (ids) specifying the best edge on X axis and the target elements 198 // specifying the best edge on Y axis are visible if we snap to the best 199 // edge. Thus they are valid snap positions. 200 if (!visibleTargetIdsOnX.IsEmpty() && !visibleTargetIdsOnY.IsEmpty()) { 201 return SnapDestination{ 202 bestCandidate, 203 ScrollSnapTargetIds{visibleTargetIdsOnX, visibleTargetIdsOnY}}; 204 } 205 206 // Now we've already known that snapping to 207 // (mTrackerOnX.mBestEdges[0].mPosition, 208 // mTrackerOnY.mBestEdges[0].mPosition) will make all candidates of 209 // mTrackerX or mTrackerY (or both) outside of the snapport. We need to 210 // choose another combination where candidates of both mTrackerX/Y are 211 // inside the snapport. 212 213 // There are three possibilities; 214 // 1) There's no candidate on X axis in mTrackerOnY (that means 215 // each candidate's scroll-snap-align is `none` on X axis), but there's 216 // any candidate in mTrackerOnX, the closest candidates of mTrackerOnX 217 // should be used. 218 // 2) There's no candidate on Y axis in mTrackerOnX (that means 219 // each candidate's scroll-snap-align is `none` on Y axis), but there's 220 // any candidate in mTrackerOnY, the closest candidates of mTrackerOnY 221 // should be used. 222 // 3) There are candidates on both axes. Choosing a combination such as 223 // (mTrackerOnX.mBestEdges[i].mSnapPoint.mX, 224 // mTrackerOnY.mBestEdges[i].mSnapPoint.mY) 225 // would require us to iterate over the candidates again if the 226 // combination position is outside the snapport, which we don't want to 227 // do. Instead, we choose either one of the axis' candidates. 228 if ((minimumDistanceOnX == nscoord_MAX) && 229 minimumDistanceOnY != nscoord_MAX) { 230 bestCandidate.y = *mTrackerOnX.mBestEdges[minimumXIndex].mSnapPoint.mY; 231 return SnapDestination{bestCandidate, 232 ScrollSnapTargetIds{minimumDistanceTargetIdsOnX, 233 minimumDistanceTargetIdsOnX}}; 234 } 235 236 if (minimumDistanceOnX != nscoord_MAX && 237 minimumDistanceOnY == nscoord_MAX) { 238 bestCandidate.x = *mTrackerOnY.mBestEdges[minimumYIndex].mSnapPoint.mX; 239 return SnapDestination{bestCandidate, 240 ScrollSnapTargetIds{minimumDistanceTargetIdsOnY, 241 minimumDistanceTargetIdsOnY}}; 242 } 243 244 if (minimumDistanceOnX != nscoord_MAX && 245 minimumDistanceOnY != nscoord_MAX) { 246 // If we've found candidates on both axes, choose the closest point either 247 // on X axis or Y axis from the scroll destination. I.e. choose 248 // `minimumXIndex` one or `minimumYIndex` one to make at least one of 249 // snap target elements visible inside the snapport. 250 // 251 // For example, 252 // [bestCandidate.x, mTrackerOnX.mBestEdges[minimumXIndex].mSnapPoint.mY] 253 // is a candidate generated from a single element, thus snapping to the 254 // point would definitely make the element visible inside the snapport. 255 if (hypotf(NSCoordToFloat(mDestination.x - 256 mTrackerOnX.mBestEdges[0].mPosition), 257 NSCoordToFloat(minimumDistanceOnY)) < 258 hypotf(NSCoordToFloat(minimumDistanceOnX), 259 NSCoordToFloat(mDestination.y - 260 mTrackerOnY.mBestEdges[0].mPosition))) { 261 bestCandidate.y = *mTrackerOnX.mBestEdges[minimumXIndex].mSnapPoint.mY; 262 } else { 263 bestCandidate.x = *mTrackerOnY.mBestEdges[minimumYIndex].mSnapPoint.mX; 264 } 265 return SnapDestination{bestCandidate, 266 ScrollSnapTargetIds{minimumDistanceTargetIdsOnX, 267 minimumDistanceTargetIdsOnY}}; 268 } 269 MOZ_ASSERT_UNREACHABLE("There's at least one candidate on either axis"); 270 // `minimumDistanceOnX == nscoord_MAX && minimumDistanceOnY == nscoord_MAX` 271 // should not happen but we fall back for safety. 272 } 273 274 return SnapDestination{ 275 nsPoint( 276 mTrackerOnX.EdgeFound() ? mTrackerOnX.mBestEdges[0].mPosition 277 // In the case of IntendedEndPosition (i.e. the destination point is 278 // explicitely specied, e.g. scrollTo) use the destination point if we 279 // didn't find any candidates. 280 : !(mSnapFlags & ScrollSnapFlags::IntendedDirection) ? mDestination.x 281 : mStartPos.x, 282 mTrackerOnY.EdgeFound() ? mTrackerOnY.mBestEdges[0].mPosition 283 // Same as above X axis case, use the destination point if we didn't 284 // find any candidates. 285 : !(mSnapFlags & ScrollSnapFlags::IntendedDirection) ? mDestination.y 286 : mStartPos.y), 287 ScrollSnapTargetIds{mTrackerOnX.mTargetIds, mTrackerOnY.mTargetIds}}; 288 } 289 290 void CalcSnapPoints::AddHorizontalEdge(const SnapTarget& aTarget) { 291 MOZ_ASSERT(aTarget.mSnapPoint.mY); 292 AddEdge(SnapPosition{aTarget, *aTarget.mSnapPoint.mY, 293 aTarget.mSnapPoint.mX 294 ? std::abs(mDestination.x - *aTarget.mSnapPoint.mX) 295 : nscoord_MAX}, 296 mDestination.y, mStartPos.y, mScrollingDirection.y, &mTrackerOnY); 297 } 298 299 void CalcSnapPoints::AddVerticalEdge(const SnapTarget& aTarget) { 300 MOZ_ASSERT(aTarget.mSnapPoint.mX); 301 AddEdge(SnapPosition{aTarget, *aTarget.mSnapPoint.mX, 302 aTarget.mSnapPoint.mY 303 ? std::abs(mDestination.y - *aTarget.mSnapPoint.mY) 304 : nscoord_MAX}, 305 mDestination.x, mStartPos.x, mScrollingDirection.x, &mTrackerOnX); 306 } 307 308 void CalcSnapPoints::AddEdge(const SnapPosition& aEdge, nscoord aDestination, 309 nscoord aStartPos, nscoord aScrollingDirection, 310 CandidateTracker* aCandidateTracker) { 311 if (mSnapFlags & ScrollSnapFlags::IntendedDirection) { 312 // In the case of intended direction, we only want to snap to points ahead 313 // of the direction we are scrolling. 314 if (aScrollingDirection == 0 || 315 (aEdge.mPosition - aStartPos) * aScrollingDirection <= 0) { 316 // The scroll direction is neutral - will not hit a snap point, or the 317 // edge is not in the direction we are scrolling, skip it. 318 return; 319 } 320 } 321 322 if (!aCandidateTracker->EdgeFound()) { 323 aCandidateTracker->mBestEdges = AutoTArray<SnapPosition, 1>{aEdge}; 324 aCandidateTracker->mTargetIds = 325 AutoTArray<ScrollSnapTargetId, 1>{aEdge.mTargetId}; 326 return; 327 } 328 329 auto isPreferredStopAlways = [&](const SnapPosition& aSnapPosition) -> bool { 330 MOZ_ASSERT(mSnapFlags & ScrollSnapFlags::IntendedDirection); 331 // In the case of intended direction scroll operations, `scroll-snap-stop: 332 // always` snap points in between the start point and the scroll destination 333 // are preferable preferable. In other words any `scroll-snap-stop: always` 334 // snap points can be handled as if it's `scroll-snap-stop: normal`. 335 return aSnapPosition.mScrollSnapStop == StyleScrollSnapStop::Always && 336 std::abs(aSnapPosition.mPosition - aStartPos) < 337 std::abs(aDestination - aStartPos); 338 }; 339 340 const bool isOnOppositeSide = 341 ((aEdge.mPosition - aDestination) > 0) != 342 ((aCandidateTracker->mBestEdges[0].mPosition - aDestination) > 0); 343 const nscoord distanceFromStart = aEdge.mPosition - aStartPos; 344 // A utility function to update the best and the second best edges in the 345 // given conditions. 346 // |aIsCloserThanBest| True if the current candidate is closer than the best 347 // edge. 348 // |aIsCloserThanSecond| True if the current candidate is closer than 349 // the second best edge. 350 const nscoord distanceFromDestination = aEdge.mPosition - aDestination; 351 auto updateBestEdges = [&](bool aIsCloserThanBest, bool aIsCloserThanSecond) { 352 if (aIsCloserThanBest) { 353 if (mSnapFlags & ScrollSnapFlags::IntendedDirection && 354 isPreferredStopAlways(aEdge)) { 355 // In the case of intended direction scroll operations and the new best 356 // candidate is `scroll-snap-stop: always` and if it's closer to the 357 // start position than the destination, thus we won't use the second 358 // best edge since even if the snap port of the best edge covers entire 359 // snapport, the `scroll-snap-stop: always` snap point is preferred than 360 // any points. 361 // NOTE: We've already ignored snap points behind start points so that 362 // we can use std::abs here in the comparison. 363 // 364 // For example, if there's a `scroll-snap-stop: always` in between the 365 // start point and destination, no `snap-overflow` mechanism should 366 // happen, if there's `scroll-snap-stop: always` further than the 367 // destination, `snap-overflow` might happen something like below 368 // diagram. 369 // start always dest other always 370 // |------------|---------|------| 371 aCandidateTracker->mSecondBestEdge = aEdge.mPosition; 372 } else if (isOnOppositeSide) { 373 // Replace the second best edge with the current best edge only if the 374 // new best edge (aEdge) is on the opposite side of the current best 375 // edge. 376 aCandidateTracker->mSecondBestEdge = 377 aCandidateTracker->mBestEdges[0].mPosition; 378 } 379 aCandidateTracker->mBestEdges = AutoTArray<SnapPosition, 1>{aEdge}; 380 aCandidateTracker->mTargetIds = 381 AutoTArray<ScrollSnapTargetId, 1>{aEdge.mTargetId}; 382 } else { 383 if (aEdge.mPosition == aCandidateTracker->mBestEdges[0].mPosition) { 384 aCandidateTracker->mTargetIds.AppendElement(aEdge.mTargetId); 385 aCandidateTracker->mBestEdges.AppendElement(aEdge); 386 } 387 if (aIsCloserThanSecond && isOnOppositeSide) { 388 aCandidateTracker->mSecondBestEdge = aEdge.mPosition; 389 } 390 } 391 }; 392 393 bool isCandidateOfBest = false; 394 bool isCandidateOfSecondBest = false; 395 switch (mUnit) { 396 case ScrollUnit::DEVICE_PIXELS: 397 case ScrollUnit::LINES: 398 case ScrollUnit::WHOLE: { 399 isCandidateOfBest = 400 std::abs(distanceFromDestination) < 401 std::abs(aCandidateTracker->mBestEdges[0].mPosition - aDestination); 402 isCandidateOfSecondBest = 403 std::abs(distanceFromDestination) < 404 std::abs(NSCoordSaturatingSubtract(aCandidateTracker->mSecondBestEdge, 405 aDestination, nscoord_MAX)); 406 break; 407 } 408 case ScrollUnit::PAGES: { 409 // distance to the edge from the scrolling destination in the direction of 410 // scrolling 411 nscoord overshoot = distanceFromDestination * aScrollingDirection; 412 // distance to the current best edge from the scrolling destination in the 413 // direction of scrolling 414 nscoord curOvershoot = 415 (aCandidateTracker->mBestEdges[0].mPosition - aDestination) * 416 aScrollingDirection; 417 418 nscoord secondOvershoot = 419 NSCoordSaturatingSubtract(aCandidateTracker->mSecondBestEdge, 420 aDestination, nscoord_MAX) * 421 aScrollingDirection; 422 423 // edges between the current position and the scrolling destination are 424 // favoured to preserve context 425 if (overshoot < 0) { 426 isCandidateOfBest = overshoot > curOvershoot || curOvershoot >= 0; 427 isCandidateOfSecondBest = 428 overshoot > secondOvershoot || secondOvershoot >= 0; 429 } 430 // if there are no edges between the current position and the scrolling 431 // destination the closest edge beyond the destination is used 432 if (overshoot > 0) { 433 isCandidateOfBest = overshoot < curOvershoot; 434 isCandidateOfSecondBest = overshoot < secondOvershoot; 435 } 436 } 437 } 438 439 if (mSnapFlags & ScrollSnapFlags::IntendedDirection) { 440 if (isPreferredStopAlways(aEdge)) { 441 // If the given position is `scroll-snap-stop: always` and if the position 442 // is in between the start and the destination positions, update the best 443 // position based on the distance from the __start__ point. 444 isCandidateOfBest = 445 std::abs(distanceFromStart) < 446 std::abs(aCandidateTracker->mBestEdges[0].mPosition - aStartPos); 447 } else if (isPreferredStopAlways(aCandidateTracker->mBestEdges[0])) { 448 // If we've found a preferable `scroll-snap-stop:always` position as the 449 // best, do not update it unless the given position is also 450 // `scroll-snap-stop: always`. 451 isCandidateOfBest = false; 452 } 453 } 454 455 updateBestEdges(isCandidateOfBest, isCandidateOfSecondBest); 456 } 457 458 static void ProcessSnapPositions(CalcSnapPoints& aCalcSnapPoints, 459 const ScrollSnapInfo& aSnapInfo) { 460 aSnapInfo.ForEachValidTargetFor( 461 aCalcSnapPoints.Destination(), [&](const auto& aTarget) -> bool { 462 if (aTarget.mSnapPoint.mX && aSnapInfo.mScrollSnapStrictnessX != 463 StyleScrollSnapStrictness::None) { 464 aCalcSnapPoints.AddVerticalEdge(aTarget); 465 } 466 if (aTarget.mSnapPoint.mY && aSnapInfo.mScrollSnapStrictnessY != 467 StyleScrollSnapStrictness::None) { 468 aCalcSnapPoints.AddHorizontalEdge(aTarget); 469 } 470 return true; 471 }); 472 } 473 474 Maybe<SnapDestination> ScrollSnapUtils::GetSnapPointForDestination( 475 const ScrollSnapInfo& aSnapInfo, ScrollUnit aUnit, 476 ScrollSnapFlags aSnapFlags, const nsRect& aScrollRange, 477 const nsPoint& aStartPos, const nsPoint& aDestination) { 478 if (aSnapInfo.mScrollSnapStrictnessY == StyleScrollSnapStrictness::None && 479 aSnapInfo.mScrollSnapStrictnessX == StyleScrollSnapStrictness::None) { 480 return Nothing(); 481 } 482 483 if (!aSnapInfo.HasSnapPositions()) { 484 return Nothing(); 485 } 486 487 CalcSnapPoints calcSnapPoints(aUnit, aSnapFlags, aDestination, aStartPos); 488 489 ProcessSnapPositions(calcSnapPoints, aSnapInfo); 490 491 // If the distance between the first and the second candidate snap points 492 // is larger than the snapport size and the snapport is covered by larger 493 // elements, any points inside the covering area should be valid snap 494 // points. 495 // https://drafts.csswg.org/css-scroll-snap-1/#snap-overflow 496 // NOTE: |aDestination| sometimes points outside of the scroll range, e.g. 497 // by the APZC fling, so for the overflow checks we need to clamp it. 498 nsPoint clampedDestination = aScrollRange.ClampPoint(aDestination); 499 for (auto range : aSnapInfo.mXRangeWiderThanSnapport) { 500 if (range.IsValid(clampedDestination.x, aSnapInfo.mSnapportSize.width) && 501 calcSnapPoints.XDistanceBetweenBestAndSecondEdge() > 502 aSnapInfo.mSnapportSize.width) { 503 calcSnapPoints.AddVerticalEdge(ScrollSnapInfo::SnapTarget{ 504 Some(clampedDestination.x), Nothing(), range.mSnapArea, 505 StyleScrollSnapStop::Normal, ScrollSnapTargetId::None}); 506 break; 507 } 508 } 509 for (auto range : aSnapInfo.mYRangeWiderThanSnapport) { 510 if (range.IsValid(clampedDestination.y, aSnapInfo.mSnapportSize.height) && 511 calcSnapPoints.YDistanceBetweenBestAndSecondEdge() > 512 aSnapInfo.mSnapportSize.height) { 513 calcSnapPoints.AddHorizontalEdge(ScrollSnapInfo::SnapTarget{ 514 Nothing(), Some(clampedDestination.y), range.mSnapArea, 515 StyleScrollSnapStop::Normal, ScrollSnapTargetId::None}); 516 break; 517 } 518 } 519 520 bool snapped = false; 521 auto finalPos = calcSnapPoints.GetBestEdge(aSnapInfo.mSnapportSize); 522 523 // Check whether we will snap to the final position on the given axis or not, 524 // and if we will not, reset the final position to the original position so 525 // that even if we need to snap on an axis, but we don't need to on the other 526 // axis, the returned final position can be used as a valid destination. 527 auto checkSnapOnAxis = [&snapped](StyleScrollSnapStrictness aStrictness, 528 nscoord aDestination, nscoord aSnapportSize, 529 nscoord& aFinalPosition) { 530 // We used 0.3 proximity threshold which is what WebKit uses. 531 constexpr float proximityRatio = 0.3; 532 if (aStrictness == StyleScrollSnapStrictness::None || 533 (aStrictness == StyleScrollSnapStrictness::Proximity && 534 std::abs(aDestination - aFinalPosition) > 535 aSnapportSize * proximityRatio)) { 536 aFinalPosition = aDestination; 537 return; 538 } 539 snapped = true; 540 }; 541 542 checkSnapOnAxis(aSnapInfo.mScrollSnapStrictnessY, aDestination.y, 543 aSnapInfo.mSnapportSize.height, finalPos.mPosition.y); 544 checkSnapOnAxis(aSnapInfo.mScrollSnapStrictnessX, aDestination.x, 545 aSnapInfo.mSnapportSize.width, finalPos.mPosition.x); 546 547 return snapped ? Some(finalPos) : Nothing(); 548 } 549 550 ScrollSnapTargetId ScrollSnapUtils::GetTargetIdFor(const nsIFrame* aFrame) { 551 MOZ_ASSERT(aFrame && aFrame->GetContent()); 552 return ScrollSnapTargetId{reinterpret_cast<uintptr_t>(aFrame->GetContent())}; 553 } 554 555 static std::pair<Maybe<nscoord>, Maybe<nscoord>> GetCandidateInLastTargets( 556 const ScrollSnapInfo& aSnapInfo, const nsPoint& aCurrentPosition, 557 const UniquePtr<ScrollSnapTargetIds>& aLastSnapTargetIds, 558 const nsIContent* aFocusedContent) { 559 ScrollSnapTargetId targetIdForFocusedContent = ScrollSnapTargetId::None; 560 if (aFocusedContent && aFocusedContent->GetPrimaryFrame()) { 561 targetIdForFocusedContent = 562 ScrollSnapUtils::GetTargetIdFor(aFocusedContent->GetPrimaryFrame()); 563 } 564 565 // Note: Below algorithm doesn't care about cases where the last snap point 566 // was on an element larger than the snapport since it's not clear to us 567 // what we should do for now. 568 // https://github.com/w3c/csswg-drafts/issues/7438 569 const ScrollSnapInfo::SnapTarget* focusedTarget = nullptr; 570 Maybe<nscoord> x, y; 571 aSnapInfo.ForEachValidTargetFor( 572 aCurrentPosition, [&](const auto& aTarget) -> bool { 573 if (aTarget.mSnapPoint.mX && aSnapInfo.mScrollSnapStrictnessX != 574 StyleScrollSnapStrictness::None) { 575 if (aLastSnapTargetIds->mIdsOnX.Contains(aTarget.mTargetId)) { 576 if (targetIdForFocusedContent == aTarget.mTargetId) { 577 // If we've already found the candidate on Y axis, but if snapping 578 // to the point results this target is scrolled out, we can't use 579 // it. 580 if ((y && !aTarget.mSnapArea.Intersects( 581 nsRect(nsPoint(*aTarget.mSnapPoint.mX, *y), 582 aSnapInfo.mSnapportSize)))) { 583 y.reset(); 584 } 585 586 focusedTarget = &aTarget; 587 // If the focused one is valid, then it's the candidate. 588 x = aTarget.mSnapPoint.mX; 589 } 590 591 if (!x) { 592 // Update the candidate on X axis only if 593 // 1) we haven't yet found the candidate on Y axis 594 // 2) or if we've found the candiate on Y axis and if snapping to 595 // the 596 // candidate position result the target element is visible 597 // inside the snapport. 598 if (!y || (y && aTarget.mSnapArea.Intersects( 599 nsRect(nsPoint(*aTarget.mSnapPoint.mX, *y), 600 aSnapInfo.mSnapportSize)))) { 601 x = aTarget.mSnapPoint.mX; 602 } 603 } 604 } 605 } 606 if (aTarget.mSnapPoint.mY && aSnapInfo.mScrollSnapStrictnessY != 607 StyleScrollSnapStrictness::None) { 608 if (aLastSnapTargetIds->mIdsOnY.Contains(aTarget.mTargetId)) { 609 if (targetIdForFocusedContent == aTarget.mTargetId) { 610 NS_ASSERTION( 611 !focusedTarget || focusedTarget == &aTarget, 612 "If the focused target has been found on X axis, the " 613 "target should be same"); 614 // If we've already found the candidate on X axis other than the 615 // focused one, but if snapping to the point results this target 616 // is scrolled out, we can't use it. 617 if (!focusedTarget && 618 (x && !aTarget.mSnapArea.Intersects( 619 nsRect(nsPoint(*x, *aTarget.mSnapPoint.mY), 620 aSnapInfo.mSnapportSize)))) { 621 x.reset(); 622 } 623 624 focusedTarget = &aTarget; 625 y = aTarget.mSnapPoint.mY; 626 } 627 628 if (!y) { 629 if (!x || (x && aTarget.mSnapArea.Intersects( 630 nsRect(nsPoint(*x, *aTarget.mSnapPoint.mY), 631 aSnapInfo.mSnapportSize)))) { 632 y = aTarget.mSnapPoint.mY; 633 } 634 } 635 } 636 } 637 638 // If we found candidates on both axes, it's the one we need. 639 if (x && y && 640 // If we haven't found the focused target, it's possible that we 641 // haven't iterated it, don't break in such case. 642 (targetIdForFocusedContent == ScrollSnapTargetId::None || 643 focusedTarget)) { 644 return false; 645 } 646 return true; 647 }); 648 649 return {x, y}; 650 } 651 652 Maybe<SnapDestination> ScrollSnapUtils::GetSnapPointForResnap( 653 const ScrollSnapInfo& aSnapInfo, const nsRect& aScrollRange, 654 const nsPoint& aCurrentPosition, 655 const UniquePtr<ScrollSnapTargetIds>& aLastSnapTargetIds, 656 const nsIContent* aFocusedContent) { 657 if (!aLastSnapTargetIds) { 658 return GetSnapPointForDestination(aSnapInfo, ScrollUnit::DEVICE_PIXELS, 659 ScrollSnapFlags::IntendedEndPosition, 660 aScrollRange, aCurrentPosition, 661 aCurrentPosition); 662 } 663 664 auto [x, y] = GetCandidateInLastTargets(aSnapInfo, aCurrentPosition, 665 aLastSnapTargetIds, aFocusedContent); 666 if (!x && !y) { 667 // In the worst case there's no longer valid snap points previously snapped, 668 // try to find new valid snap points. 669 return GetSnapPointForDestination(aSnapInfo, ScrollUnit::DEVICE_PIXELS, 670 ScrollSnapFlags::IntendedEndPosition, 671 aScrollRange, aCurrentPosition, 672 aCurrentPosition); 673 } 674 675 // If there's no candidate on one of the axes in the last snap points, try 676 // to find a new candidate. 677 if (!x || !y) { 678 nsPoint newPosition = 679 nsPoint(x ? *x : aCurrentPosition.x, y ? *y : aCurrentPosition.y); 680 CalcSnapPoints calcSnapPoints(ScrollUnit::DEVICE_PIXELS, 681 ScrollSnapFlags::IntendedEndPosition, 682 newPosition, newPosition); 683 684 aSnapInfo.ForEachValidTargetFor( 685 newPosition, [&, &x = x, &y = y](const auto& aTarget) -> bool { 686 if (!x && aTarget.mSnapPoint.mX && 687 aSnapInfo.mScrollSnapStrictnessX != 688 StyleScrollSnapStrictness::None) { 689 calcSnapPoints.AddVerticalEdge(aTarget); 690 } 691 if (!y && aTarget.mSnapPoint.mY && 692 aSnapInfo.mScrollSnapStrictnessY != 693 StyleScrollSnapStrictness::None) { 694 calcSnapPoints.AddHorizontalEdge(aTarget); 695 } 696 return true; 697 }); 698 699 auto finalPos = calcSnapPoints.GetBestEdge(aSnapInfo.mSnapportSize); 700 if (!x) { 701 x = Some(finalPos.mPosition.x); 702 } 703 if (!y) { 704 y = Some(finalPos.mPosition.y); 705 } 706 } 707 708 SnapDestination snapTarget{nsPoint(*x, *y)}; 709 // Collect snap points where the position is still same as the new snap 710 // position. 711 aSnapInfo.ForEachValidTargetFor( 712 snapTarget.mPosition, [&, &x = x, &y = y](const auto& aTarget) -> bool { 713 if (aTarget.mSnapPoint.mX && 714 aSnapInfo.mScrollSnapStrictnessX != 715 StyleScrollSnapStrictness::None && 716 aTarget.mSnapPoint.mX == x) { 717 snapTarget.mTargetIds.mIdsOnX.AppendElement(aTarget.mTargetId); 718 } 719 720 if (aTarget.mSnapPoint.mY && 721 aSnapInfo.mScrollSnapStrictnessY != 722 StyleScrollSnapStrictness::None && 723 aTarget.mSnapPoint.mY == y) { 724 snapTarget.mTargetIds.mIdsOnY.AppendElement(aTarget.mTargetId); 725 } 726 return true; 727 }); 728 return Some(snapTarget); 729 } 730 731 void ScrollSnapUtils::PostPendingResnapIfNeededFor(nsIFrame* aFrame) { 732 ScrollSnapTargetId id = GetTargetIdFor(aFrame); 733 if (id == ScrollSnapTargetId::None) { 734 return; 735 } 736 737 if (ScrollContainerFrame* sf = nsLayoutUtils::GetNearestScrollContainerFrame( 738 aFrame, nsLayoutUtils::SCROLLABLE_SAME_DOC | 739 nsLayoutUtils::SCROLLABLE_INCLUDE_HIDDEN)) { 740 sf->PostPendingResnapIfNeeded(aFrame); 741 } 742 } 743 744 void ScrollSnapUtils::PostPendingResnapFor(nsIFrame* aFrame) { 745 if (ScrollContainerFrame* sf = nsLayoutUtils::GetNearestScrollContainerFrame( 746 aFrame, nsLayoutUtils::SCROLLABLE_SAME_DOC | 747 nsLayoutUtils::SCROLLABLE_INCLUDE_HIDDEN)) { 748 sf->PostPendingResnap(); 749 } 750 } 751 752 bool ScrollSnapUtils::NeedsToRespectTargetWritingMode( 753 const nsSize& aSnapAreaSize, const nsSize& aSnapportSize) { 754 // Use the writing-mode on the target element if the snap area is larger than 755 // the snapport. 756 // https://drafts.csswg.org/css-scroll-snap/#snap-scope 757 // 758 // It's unclear `larger` means that the size is larger than only on the target 759 // axis. If it doesn't, it will pick the same axis in the case where only one 760 // axis is larger. For example, if an element size is (200 x 10) and the 761 // snapport size is (100 x 100) and if the element's writing mode is different 762 // from the scroller's writing mode, then `scroll-snap-align: start start` 763 // will be conflict. 764 return aSnapAreaSize.width > aSnapportSize.width || 765 aSnapAreaSize.height > aSnapportSize.height; 766 } 767 768 static nsRect InflateByScrollMargin(const nsRect& aTargetRect, 769 const nsMargin& aScrollMargin, 770 const nsRect& aScrolledRect) { 771 // Inflate the rect by scroll-margin. 772 nsRect result = aTargetRect; 773 result.Inflate(aScrollMargin); 774 775 // But don't be beyond the limit boundary. 776 return result.Intersect(aScrolledRect); 777 } 778 779 nsRect ScrollSnapUtils::GetSnapAreaFor(const nsIFrame* aFrame, 780 const nsIFrame* aScrolledFrame, 781 const nsRect& aScrolledRect) { 782 nsRect targetRect = nsLayoutUtils::TransformFrameRectToAncestor( 783 aFrame, aFrame->GetRectRelativeToSelf(), aScrolledFrame); 784 785 // The snap area contains scroll-margin values. 786 // https://drafts.csswg.org/css-scroll-snap-1/#scroll-snap-area 787 nsMargin scrollMargin = aFrame->StyleMargin()->GetScrollMargin(); 788 return InflateByScrollMargin(targetRect, scrollMargin, aScrolledRect); 789 } 790 791 } // namespace mozilla