/* * Copyright (C) 2014-2015 Apple Inc. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF * THE POSSIBILITY OF SUCH DAMAGE. */ #include "config.h" #include "AxisScrollSnapOffsets.h" #include "ElementChildIterator.h" #include "HTMLCollection.h" #include "HTMLElement.h" #include "Length.h" #include "Logging.h" #include "RenderBox.h" #include "RenderView.h" #include "ScrollableArea.h" #include "StyleScrollSnapPoints.h" #if ENABLE(CSS_SCROLL_SNAP) namespace WebCore { enum class InsetOrOutset { Inset, Outset }; static LayoutRect computeScrollSnapPortOrAreaRect(const LayoutRect& rect, const LengthBox& insetOrOutsetBox, InsetOrOutset insetOrOutset) { LayoutBoxExtent extents(valueForLength(insetOrOutsetBox.top(), rect.height()), valueForLength(insetOrOutsetBox.right(), rect.width()), valueForLength(insetOrOutsetBox.bottom(), rect.height()), valueForLength(insetOrOutsetBox.left(), rect.width())); auto snapPortOrArea(rect); if (insetOrOutset == InsetOrOutset::Inset) snapPortOrArea.contract(extents); else snapPortOrArea.expand(extents); return snapPortOrArea; } static LayoutUnit computeScrollSnapAlignOffset(const LayoutUnit& leftOrTop, const LayoutUnit& widthOrHeight, ScrollSnapAxisAlignType alignment) { switch (alignment) { case ScrollSnapAxisAlignType::Start: return leftOrTop; case ScrollSnapAxisAlignType::Center: return leftOrTop + widthOrHeight / 2; case ScrollSnapAxisAlignType::End: return leftOrTop + widthOrHeight; default: ASSERT_NOT_REACHED(); return 0; } } #if !LOG_DISABLED static String snapOffsetsToString(const Vector& snapOffsets) { StringBuilder s; s.append("[ "); for (auto offset : snapOffsets) s.append(String::format("%.1f ", offset.toFloat())); s.append("]"); return s.toString(); } static String snapOffsetRangesToString(const Vector>& ranges) { StringBuilder s; s.append("[ "); for (auto range : ranges) s.append(String::format("(%.1f, %.1f) ", range.start.toFloat(), range.end.toFloat())); s.append("]"); return s.toString(); } static String snapPortOrAreaToString(const LayoutRect& rect) { return String::format("{{%.1f, %.1f} {%.1f, %.1f}}", rect.x().toFloat(), rect.y().toFloat(), rect.width().toFloat(), rect.height().toFloat()); } #endif template static void indicesOfNearestSnapOffsetRanges(LayoutType offset, const Vector>& snapOffsetRanges, unsigned& lowerIndex, unsigned& upperIndex) { if (snapOffsetRanges.isEmpty()) { lowerIndex = invalidSnapOffsetIndex; upperIndex = invalidSnapOffsetIndex; return; } int lowerIndexAsInt = -1; int upperIndexAsInt = snapOffsetRanges.size(); do { int middleIndex = (lowerIndexAsInt + upperIndexAsInt) / 2; auto& range = snapOffsetRanges[middleIndex]; if (range.start < offset && offset < range.end) { lowerIndexAsInt = middleIndex; upperIndexAsInt = middleIndex; break; } if (offset > range.end) lowerIndexAsInt = middleIndex; else upperIndexAsInt = middleIndex; } while (lowerIndexAsInt < upperIndexAsInt - 1); if (offset <= snapOffsetRanges.first().start) lowerIndex = invalidSnapOffsetIndex; else lowerIndex = lowerIndexAsInt; if (offset >= snapOffsetRanges.last().end) upperIndex = invalidSnapOffsetIndex; else upperIndex = upperIndexAsInt; } template static void indicesOfNearestSnapOffsets(LayoutType offset, const Vector& snapOffsets, unsigned& lowerIndex, unsigned& upperIndex) { lowerIndex = 0; upperIndex = snapOffsets.size() - 1; while (lowerIndex < upperIndex - 1) { int middleIndex = (lowerIndex + upperIndex) / 2; auto middleOffset = snapOffsets[middleIndex]; if (offset == middleOffset) { upperIndex = middleIndex; lowerIndex = middleIndex; break; } if (offset > middleOffset) lowerIndex = middleIndex; else upperIndex = middleIndex; } } static void adjustAxisSnapOffsetsForScrollExtent(Vector& snapOffsets, float maxScrollExtent) { if (snapOffsets.isEmpty()) return; std::sort(snapOffsets.begin(), snapOffsets.end()); if (snapOffsets.last() != maxScrollExtent) snapOffsets.append(maxScrollExtent); if (snapOffsets.first()) snapOffsets.insert(0, 0); } static void computeAxisProximitySnapOffsetRanges(const Vector& snapOffsets, Vector>& offsetRanges, LayoutUnit scrollPortAxisLength) { // This is an arbitrary choice for what it means to be "in proximity" of a snap offset. We should play around with // this and see what feels best. static const float ratioOfScrollPortAxisLengthToBeConsideredForProximity = 0.3; if (snapOffsets.size() < 2) return; // The extra rule accounting for scroll offset ranges in between the scroll destination and a potential snap offset // handles the corner case where the user scrolls with momentum very lightly away from a snap offset, such that the // predicted scroll destination is still within proximity of the snap offset. In this case, the regular (mandatory // scroll snapping) behavior would be to snap to the next offset in the direction of momentum scrolling, but // instead, it is more intuitive to either return to the original snap position (which we arbitrarily choose here) // or scroll just outside of the snap offset range. This is another minor behavior tweak that we should play around // with to see what feels best. LayoutUnit proximityDistance = ratioOfScrollPortAxisLengthToBeConsideredForProximity * scrollPortAxisLength; for (size_t index = 1; index < snapOffsets.size(); ++index) { auto startOffset = snapOffsets[index - 1] + proximityDistance; auto endOffset = snapOffsets[index] - proximityDistance; if (startOffset < endOffset) offsetRanges.append({ startOffset, endOffset }); } } void updateSnapOffsetsForScrollableArea(ScrollableArea& scrollableArea, HTMLElement& scrollingElement, const RenderBox& scrollingElementBox, const RenderStyle& scrollingElementStyle) { auto* scrollContainer = scrollingElement.renderer(); auto scrollSnapType = scrollingElementStyle.scrollSnapType(); if (!scrollContainer || scrollSnapType.strictness == ScrollSnapStrictness::None || scrollContainer->view().boxesWithScrollSnapPositions().isEmpty()) { scrollableArea.clearHorizontalSnapOffsets(); scrollableArea.clearVerticalSnapOffsets(); return; } Vector verticalSnapOffsets; Vector horizontalSnapOffsets; Vector> verticalSnapOffsetRanges; Vector> horizontalSnapOffsetRanges; HashSet seenVerticalSnapOffsets; HashSet seenHorizontalSnapOffsets; bool hasHorizontalSnapOffsets = scrollSnapType.axis == ScrollSnapAxis::Both || scrollSnapType.axis == ScrollSnapAxis::XAxis || scrollSnapType.axis == ScrollSnapAxis::Inline; bool hasVerticalSnapOffsets = scrollSnapType.axis == ScrollSnapAxis::Both || scrollSnapType.axis == ScrollSnapAxis::YAxis || scrollSnapType.axis == ScrollSnapAxis::Block; auto maxScrollLeft = scrollingElementBox.scrollWidth() - scrollingElementBox.contentWidth(); auto maxScrollTop = scrollingElementBox.scrollHeight() - scrollingElementBox.contentHeight(); LayoutPoint containerScrollOffset(scrollingElementBox.scrollLeft(), scrollingElementBox.scrollTop()); // The bounds of the scrolling container's snap port, where the top left of the scrolling container's border box is the origin. auto scrollSnapPort = computeScrollSnapPortOrAreaRect(scrollingElementBox.paddingBoxRect(), scrollingElementStyle.scrollPadding(), InsetOrOutset::Inset); #if !LOG_DISABLED LOG(Scrolling, "Computing scroll snap offsets in snap port: %s", snapPortOrAreaToString(scrollSnapPort).utf8().data()); #endif for (auto* child : scrollContainer->view().boxesWithScrollSnapPositions()) { if (child->findEnclosingScrollableContainer() != scrollContainer) continue; // The bounds of the child element's snap area, where the top left of the scrolling container's border box is the origin. // The snap area is the bounding box of the child element's border box, after applying transformations. auto scrollSnapArea = LayoutRect(child->localToContainerQuad(FloatQuad(child->borderBoundingBox()), scrollingElement.renderBox()).boundingBox()); scrollSnapArea.moveBy(containerScrollOffset); scrollSnapArea = computeScrollSnapPortOrAreaRect(scrollSnapArea, child->style().scrollSnapMargin(), InsetOrOutset::Outset); #if !LOG_DISABLED LOG(Scrolling, " Considering scroll snap area: %s", snapPortOrAreaToString(scrollSnapArea).utf8().data()); #endif auto alignment = child->style().scrollSnapAlign(); if (hasHorizontalSnapOffsets && alignment.x != ScrollSnapAxisAlignType::None) { auto absoluteScrollOffset = clampTo(computeScrollSnapAlignOffset(scrollSnapArea.x(), scrollSnapArea.width(), alignment.x) - computeScrollSnapAlignOffset(scrollSnapPort.x(), scrollSnapPort.width(), alignment.x), 0, maxScrollLeft); if (!seenHorizontalSnapOffsets.contains(absoluteScrollOffset)) { seenHorizontalSnapOffsets.add(absoluteScrollOffset); horizontalSnapOffsets.append(absoluteScrollOffset); } } if (hasVerticalSnapOffsets && alignment.y != ScrollSnapAxisAlignType::None) { auto absoluteScrollOffset = clampTo(computeScrollSnapAlignOffset(scrollSnapArea.y(), scrollSnapArea.height(), alignment.y) - computeScrollSnapAlignOffset(scrollSnapPort.y(), scrollSnapPort.height(), alignment.y), 0, maxScrollTop); if (!seenVerticalSnapOffsets.contains(absoluteScrollOffset)) { seenVerticalSnapOffsets.add(absoluteScrollOffset); verticalSnapOffsets.append(absoluteScrollOffset); } } } if (!horizontalSnapOffsets.isEmpty()) { adjustAxisSnapOffsetsForScrollExtent(horizontalSnapOffsets, maxScrollLeft); #if !LOG_DISABLED LOG(Scrolling, " => Computed horizontal scroll snap offsets: %s", snapOffsetsToString(horizontalSnapOffsets).utf8().data()); LOG(Scrolling, " => Computed horizontal scroll snap offset ranges: %s", snapOffsetRangesToString(horizontalSnapOffsetRanges).utf8().data()); #endif if (scrollSnapType.strictness == ScrollSnapStrictness::Proximity) computeAxisProximitySnapOffsetRanges(horizontalSnapOffsets, horizontalSnapOffsetRanges, scrollSnapPort.width()); scrollableArea.setHorizontalSnapOffsets(horizontalSnapOffsets); scrollableArea.setHorizontalSnapOffsetRanges(horizontalSnapOffsetRanges); } else scrollableArea.clearHorizontalSnapOffsets(); if (!verticalSnapOffsets.isEmpty()) { adjustAxisSnapOffsetsForScrollExtent(verticalSnapOffsets, maxScrollTop); #if !LOG_DISABLED LOG(Scrolling, " => Computed vertical scroll snap offsets: %s", snapOffsetsToString(verticalSnapOffsets).utf8().data()); LOG(Scrolling, " => Computed vertical scroll snap offset ranges: %s", snapOffsetRangesToString(verticalSnapOffsetRanges).utf8().data()); #endif if (scrollSnapType.strictness == ScrollSnapStrictness::Proximity) computeAxisProximitySnapOffsetRanges(verticalSnapOffsets, verticalSnapOffsetRanges, scrollSnapPort.height()); scrollableArea.setVerticalSnapOffsets(verticalSnapOffsets); scrollableArea.setVerticalSnapOffsetRanges(verticalSnapOffsetRanges); } else scrollableArea.clearVerticalSnapOffsets(); } template LayoutType closestSnapOffset(const Vector& snapOffsets, const Vector>& snapOffsetRanges, LayoutType scrollDestination, float velocity, unsigned& activeSnapIndex) { ASSERT(snapOffsets.size()); activeSnapIndex = 0; unsigned lowerSnapOffsetRangeIndex; unsigned upperSnapOffsetRangeIndex; indicesOfNearestSnapOffsetRanges(scrollDestination, snapOffsetRanges, lowerSnapOffsetRangeIndex, upperSnapOffsetRangeIndex); if (lowerSnapOffsetRangeIndex == upperSnapOffsetRangeIndex && upperSnapOffsetRangeIndex != invalidSnapOffsetIndex) { activeSnapIndex = invalidSnapOffsetIndex; return scrollDestination; } if (scrollDestination <= snapOffsets.first()) return snapOffsets.first(); activeSnapIndex = snapOffsets.size() - 1; if (scrollDestination >= snapOffsets.last()) return snapOffsets.last(); unsigned lowerIndex; unsigned upperIndex; indicesOfNearestSnapOffsets(scrollDestination, snapOffsets, lowerIndex, upperIndex); LayoutType lowerSnapPosition = snapOffsets[lowerIndex]; LayoutType upperSnapPosition = snapOffsets[upperIndex]; if (!std::abs(velocity)) { bool isCloserToLowerSnapPosition = scrollDestination - lowerSnapPosition <= upperSnapPosition - scrollDestination; activeSnapIndex = isCloserToLowerSnapPosition ? lowerIndex : upperIndex; return isCloserToLowerSnapPosition ? lowerSnapPosition : upperSnapPosition; } // Non-zero velocity indicates a flick gesture. Even if another snap point is closer, we should choose the one in the direction of the flick gesture // as long as a scroll snap offset range does not lie between the scroll destination and the targeted snap offset. if (velocity < 0) { if (lowerSnapOffsetRangeIndex != invalidSnapOffsetIndex && lowerSnapPosition < snapOffsetRanges[lowerSnapOffsetRangeIndex].end) { activeSnapIndex = upperIndex; return upperSnapPosition; } activeSnapIndex = lowerIndex; return lowerSnapPosition; } if (upperSnapOffsetRangeIndex != invalidSnapOffsetIndex && snapOffsetRanges[upperSnapOffsetRangeIndex].start < upperSnapPosition) { activeSnapIndex = lowerIndex; return lowerSnapPosition; } activeSnapIndex = upperIndex; return upperSnapPosition; } LayoutUnit closestSnapOffset(const Vector& snapOffsets, const Vector>& snapOffsetRanges, LayoutUnit scrollDestination, float velocity, unsigned& activeSnapIndex) { return closestSnapOffset(snapOffsets, snapOffsetRanges, scrollDestination, velocity, activeSnapIndex); } float closestSnapOffset(const Vector& snapOffsets, const Vector>& snapOffsetRanges, float scrollDestination, float velocity, unsigned& activeSnapIndex) { return closestSnapOffset(snapOffsets, snapOffsetRanges, scrollDestination, velocity, activeSnapIndex); } } // namespace WebCore #endif // CSS_SCROLL_SNAP