blob: dc38a9007ff0501ac9f5b6181835c9ffa7138c60 [file] [log] [blame]
// Copyright 2017 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "cc/input/scroll_snap_data.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <memory>
#include <optional>
#include "base/auto_reset.h"
#include "base/check.h"
#include "base/notreached.h"
#include "cc/input/snap_selection_strategy.h"
#include "ui/gfx/geometry/vector2d_f.h"
namespace cc {
namespace {
gfx::Vector2dF DistanceFromCorridor(double dx,
double dy,
const gfx::RectF& area) {
gfx::Vector2dF distance;
if (dx < 0)
distance.set_x(-dx);
else if (dx > area.width())
distance.set_x(dx - area.width());
else
distance.set_x(0);
if (dy < 0)
distance.set_y(-dy);
else if (dy > area.height())
distance.set_y(dy - area.height());
else
distance.set_y(0);
return distance;
}
bool IsMutualVisible(const SnapSearchResult& a, const SnapSearchResult& b) {
return gfx::RangeF(b.snap_offset()).IsBoundedBy(a.visible_range()) &&
gfx::RangeF(a.snap_offset()).IsBoundedBy(b.visible_range());
}
void SetOrUpdateResult(const SnapSearchResult& candidate,
std::optional<SnapSearchResult>* result) {
if (result->has_value()) {
result->value().Union(candidate);
if (candidate.has_focus_within()) {
result->value().set_element_id(candidate.element_id());
}
} else {
*result = candidate;
}
}
const std::optional<SnapSearchResult>& ClosestSearchResult(
const gfx::PointF reference_point,
SearchAxis axis,
const std::optional<SnapSearchResult>& a,
const std::optional<SnapSearchResult>& b) {
if (!a.has_value())
return b;
if (!b.has_value())
return a;
float reference_position =
axis == SearchAxis::kX ? reference_point.x() : reference_point.y();
float position_a = a.value().snap_offset();
float position_b = b.value().snap_offset();
DCHECK(
(reference_position <= position_a && reference_position <= position_b) ||
(reference_position >= position_a && reference_position >= position_b));
float distance_a = std::abs(position_a - reference_position);
float distance_b = std::abs(position_b - reference_position);
return distance_a < distance_b ? a : b;
}
std::optional<SnapSearchResult> SearchResultForDodgingRange(
const gfx::RangeF& area_range,
const gfx::RangeF& dodging_range,
const SnapSearchResult& aligned_candidate,
float preferred_offset,
float scroll_padding,
float snapport_size,
SnapAlignment alignment) {
if (dodging_range.is_empty() || dodging_range.is_reversed()) {
return std::nullopt;
}
// Use aligned_candidate as a template (we will override snap_offset and
// covered_range).
SnapSearchResult result = aligned_candidate;
float min_offset = dodging_range.start() - scroll_padding;
float max_offset = dodging_range.end() - scroll_padding - snapport_size;
if (max_offset > min_offset) {
result.set_snap_offset(
std::clamp(preferred_offset, min_offset, max_offset));
result.set_covered_range(gfx::RangeF(min_offset, max_offset));
return result;
}
// The scrollport does not fit in the dodging range, but we should still
// return a snap position so that the content inside the dodging range is not
// unreachable. Choose a position by applying the snap area's alignment.
float offset;
switch (alignment) {
case SnapAlignment::kStart:
offset = min_offset;
break;
case SnapAlignment::kCenter:
offset = (min_offset + max_offset) / 2;
break;
case SnapAlignment::kEnd:
offset = max_offset;
break;
default:
NOTREACHED();
}
min_offset = area_range.start() - scroll_padding;
max_offset = area_range.end() - scroll_padding - snapport_size;
if (max_offset < min_offset) {
return std::nullopt;
}
result.set_snap_offset(std::clamp(offset, min_offset, max_offset));
return result;
}
bool CanCoverSnapportOnAxis(SearchAxis axis,
const gfx::RectF& container_rect,
const gfx::RectF& area_rect) {
return (axis == SearchAxis::kY &&
area_rect.height() >= container_rect.height()) ||
(axis == SearchAxis::kX &&
area_rect.width() >= container_rect.width());
}
} // namespace
SnapSearchResult::SnapSearchResult(float offset,
SearchAxis axis,
gfx::RangeF snapport_visible_range,
float snapport_max_visible)
: snap_offset_(offset),
axis_(axis),
snapport_visible_range_(snapport_visible_range),
snapport_max_visible_(snapport_max_visible) {}
void SnapSearchResult::Clip(float max_snap) {
snap_offset_ = std::clamp(snap_offset_, 0.0f, max_snap);
}
void SnapSearchResult::Union(const SnapSearchResult& other) {
DCHECK(snap_offset_ == other.snap_offset_);
DCHECK(rect_.has_value() && other.rect().has_value());
if (rect_ && other.rect().has_value()) {
rect_->Union(other.rect().value());
}
}
SnapContainerData::SnapContainerData()
: proximity_range_(gfx::PointF(std::numeric_limits<float>::max(),
std::numeric_limits<float>::max())) {}
SnapContainerData::SnapContainerData(ScrollSnapType type)
: scroll_snap_type_(type),
proximity_range_(gfx::PointF(std::numeric_limits<float>::max(),
std::numeric_limits<float>::max())) {}
SnapContainerData::SnapContainerData(ScrollSnapType type,
const gfx::RectF& rect,
const gfx::PointF& max)
: scroll_snap_type_(type),
rect_(rect),
max_position_(max),
proximity_range_(gfx::PointF(std::numeric_limits<float>::max(),
std::numeric_limits<float>::max())) {}
SnapContainerData::SnapContainerData(const SnapContainerData& other) = default;
SnapContainerData::SnapContainerData(SnapContainerData&& other) = default;
SnapContainerData::~SnapContainerData() = default;
SnapContainerData& SnapContainerData::operator=(
const SnapContainerData& other) = default;
SnapContainerData& SnapContainerData::operator=(SnapContainerData&& other) =
default;
void SnapContainerData::AddSnapAreaData(SnapAreaData snap_area_data) {
snap_area_list_.push_back(snap_area_data);
}
SnapPositionData SnapContainerData::FindSnapPositionWithViewportAdjustment(
const SnapSelectionStrategy& strategy,
double snapport_height_adjustment) {
base::AutoReset<double> resetter{&snapport_height_adjustment_,
snapport_height_adjustment};
return FindSnapPosition(strategy);
}
SnapPositionData SnapContainerData::FindSnapPosition(
const SnapSelectionStrategy& strategy) const {
SnapPositionData result;
result.target_element_ids = TargetSnapAreaElementIds();
if (scroll_snap_type_.is_none)
return result;
gfx::PointF base_position = strategy.base_position();
SnapAxis axis = scroll_snap_type_.axis;
bool should_snap_on_x = strategy.ShouldSnapOnX() &&
(axis == SnapAxis::kX || axis == SnapAxis::kBoth);
bool should_snap_on_y = strategy.ShouldSnapOnY() &&
(axis == SnapAxis::kY || axis == SnapAxis::kBoth);
if (!should_snap_on_x && !should_snap_on_y) {
// We may arrive here because the strategy wants to snap in an axis in
// which we do not snap, and doesn't want to snap in an axis in which we do
// snap. Ensure that we retain the id of the target in any axis where we are
// snapped.
if (axis == SnapAxis::kY) {
result.target_element_ids.y = target_snap_area_element_ids_.y;
} else {
result.target_element_ids.x = target_snap_area_element_ids_.x;
}
return result;
}
bool should_prioritize_x_target =
strategy.ShouldPrioritizeSnapTargets() &&
target_snap_area_element_ids_.x != ElementId();
bool should_prioritize_y_target =
strategy.ShouldPrioritizeSnapTargets() &&
target_snap_area_element_ids_.y != ElementId();
std::optional<SnapSearchResult> selected_x, selected_y;
if (should_snap_on_x) {
// Start from current position in the cross axis. The search algorithm
// expects the cross axis position to be inside scroller bounds. But since
// we cannot always assume that the incoming value fits this criteria we
// clamp it to the bounds to ensure this variant.
SnapSearchResult initial_snap_position_y = {
std::clamp(base_position.y(), 0.f, max_position_.y()), SearchAxis::kY,
gfx::RangeF(rect_.x(), rect_.right()), max_position_.x()};
if (should_prioritize_x_target) {
selected_x = GetTargetSnapAreaSearchResult(strategy, SearchAxis::kX,
initial_snap_position_y);
}
if (!selected_x) {
selected_x = FindClosestValidArea(SearchAxis::kX, strategy,
initial_snap_position_y);
}
}
if (should_snap_on_y) {
SnapSearchResult initial_snap_position_x = {
std::clamp(base_position.x(), 0.f, max_position_.x()), SearchAxis::kX,
gfx::RangeF(rect_.y(), rect_.bottom()), max_position_.y()};
if (should_prioritize_y_target) {
selected_y = GetTargetSnapAreaSearchResult(strategy, SearchAxis::kY,
initial_snap_position_x);
}
if (!selected_y) {
selected_y = FindClosestValidArea(SearchAxis::kY, strategy,
initial_snap_position_x);
}
}
if (!selected_x.has_value() && !selected_y.has_value()) {
// Searching along each axis separately can miss valid snap positions if
// snapping along both axes and the snap positions are off screen.
if (should_snap_on_x && should_snap_on_y &&
!strategy.ShouldRespectSnapStop() &&
FindSnapPositionForMutualSnap(strategy, &result.position)) {
result.type = SnapPositionData::Type::kAligned;
}
return result;
}
if (selected_x.has_value() && selected_y.has_value() &&
!IsMutualVisible(selected_x.value(), selected_y.value())) {
SnapAxis axis_to_follow = SelectAxisToFollowForMutualVisibility(
strategy, selected_x.value(), selected_y.value());
if (axis_to_follow == SnapAxis::kX) {
selected_y =
FindClosestValidArea(SearchAxis::kY, strategy, selected_x.value());
} else {
selected_x =
FindClosestValidArea(SearchAxis::kX, strategy, selected_y.value());
}
}
// For each axis, the alternative makes a better selection if it is also
// aligned in the cross axis.
if (selected_y && selected_y->alternative()) {
SelectAlternativeIdForSearchResult(*selected_y, selected_x,
strategy.current_position().x(),
max_position_.x());
}
if (selected_x && selected_x->alternative()) {
SelectAlternativeIdForSearchResult(*selected_x, selected_y,
strategy.current_position().y(),
max_position_.y());
}
result.type = SnapPositionData::Type::kAligned;
result.position = strategy.current_position();
// Make sure that |result| retains what we are currently snapped to in each
// axis in case this search had no result for one axis. This ensures we don't
// incorrectly trigger a snap event. Don't retain ids of areas that may no
// longer exist.
for (const auto& area : snap_area_list_) {
if (area.element_id == target_snap_area_element_ids_.x) {
result.target_element_ids.x = target_snap_area_element_ids_.x;
}
if (area.element_id == target_snap_area_element_ids_.y) {
result.target_element_ids.y = target_snap_area_element_ids_.y;
}
}
if (selected_x) {
result.position.set_x(selected_x->snap_offset());
result.target_element_ids.x = selected_x->element_id();
result.covered_range_x = selected_x->covered_range();
}
if (selected_y) {
result.position.set_y(selected_y->snap_offset());
result.target_element_ids.y = selected_y->element_id();
result.covered_range_y = selected_y->covered_range();
}
if ((!selected_x || result.covered_range_x) &&
(!selected_y || result.covered_range_y)) {
result.type = SnapPositionData::Type::kCovered;
}
return result;
}
// This method is called only if the preferred algorithm fails to find either an
// x or a y snap position.
// The base algorithm searches on x (if appropriate) and then y (if
// appropriate). Each search is along the corridor in the search direction.
// For a search in the x-direction, areas as excluded from consideration if the
// range in the y-direction does not overlap the y base position (i.e. can
// scroll-snap in the x-direction without scrolling in the y-direction). Rules
// for scroll-snap in the y-direction are symmetric. This is the preferred
// approach, though the ordering of the searches should perhaps be determined
// based on axis locking.
// In cases where no valid snap points are found via searches along the axis
// corridors, the snap selection strategy allows for selection of areas outside
// of the corridors.
bool SnapContainerData::FindSnapPositionForMutualSnap(
const SnapSelectionStrategy& strategy,
gfx::PointF* snap_position) const {
DCHECK(strategy.ShouldSnapOnX() && strategy.ShouldSnapOnY());
bool found = false;
gfx::Vector2dF smallest_distance(std::numeric_limits<float>::max(),
std::numeric_limits<float>::max());
// Snap to same element for x & y if possible.
for (const SnapAreaData& area : snap_area_list_) {
if (!strategy.IsValidSnapArea(SearchAxis::kX, area))
continue;
if (!strategy.IsValidSnapArea(SearchAxis::kY, area))
continue;
SnapSearchResult x_candidate = GetSnapSearchResult(SearchAxis::kX, area);
float dx = x_candidate.snap_offset() - strategy.current_position().x();
if (std::abs(dx) > proximity_range_.x())
continue;
SnapSearchResult y_candidate = GetSnapSearchResult(SearchAxis::kY, area);
float dy = y_candidate.snap_offset() - strategy.current_position().y();
if (std::abs(dy) > proximity_range_.y())
continue;
// Preferentially minimize block scrolling distance. Ties in block scrolling
// distance are resolved by considering inline scrolling distance.
gfx::Vector2dF distance = DistanceFromCorridor(dx, dy, snapport());
if (distance.y() < smallest_distance.y() ||
(distance.y() == smallest_distance.y() &&
distance.x() < smallest_distance.x())) {
smallest_distance = distance;
snap_position->set_x(x_candidate.snap_offset());
snap_position->set_y(y_candidate.snap_offset());
found = true;
}
}
return found;
}
std::optional<SnapSearchResult>
SnapContainerData::GetTargetSnapAreaSearchResult(
const SnapSelectionStrategy& strategy,
SearchAxis axis,
SnapSearchResult cross_axis_snap_result) const {
ElementId target_id = axis == SearchAxis::kX
? target_snap_area_element_ids_.x
: target_snap_area_element_ids_.y;
if (target_id == ElementId())
return std::nullopt;
for (const SnapAreaData& area : snap_area_list_) {
if (area.element_id == target_id && strategy.IsValidSnapArea(axis, area)) {
auto aligned_result = GetSnapSearchResult(axis, area);
if (CanCoverSnapportOnAxis(axis, snapport(), area.rect)) {
// This code path handles snapping after layout changes. If the
// target snap area is larger than the snapport, we need to consider
// snap areas nested within it, which may themselves be large snap areas
// containing nested snap areas.
gfx::RangeF area_range =
axis == SearchAxis::kX
? gfx::RangeF(area.rect.x(), area.rect.right())
: gfx::RangeF(area.rect.y(), area.rect.bottom());
auto covering_result = FindClosestValidAreaInternal(
axis, strategy, cross_axis_snap_result, true, area_range);
return covering_result.has_value() ? covering_result.value()
: aligned_result;
}
return aligned_result;
}
}
return std::nullopt;
}
void SnapContainerData::UpdateSnapAreaForTesting(ElementId element_id,
SnapAreaData snap_area_data) {
for (SnapAreaData& area : snap_area_list_) {
if (area.element_id == element_id) {
area = snap_area_data;
}
}
}
const TargetSnapAreaElementIds& SnapContainerData::GetTargetSnapAreaElementIds()
const {
return target_snap_area_element_ids_;
}
bool SnapContainerData::SetTargetSnapAreaElementIds(
TargetSnapAreaElementIds ids) {
if (target_snap_area_element_ids_ == ids)
return false;
target_snap_area_element_ids_ = ids;
return true;
}
std::optional<SnapSearchResult> SnapContainerData::FindClosestValidArea(
SearchAxis axis,
const SnapSelectionStrategy& strategy,
const SnapSearchResult& cross_axis_snap_result) const {
std::optional<SnapSearchResult> result =
FindClosestValidAreaInternal(axis, strategy, cross_axis_snap_result);
// For EndAndDirectionStrategy, if there is a snap area with snap-stop:always,
// and is between the starting position and the above result, we should choose
// the first snap area with snap-stop:always.
// This additional search is executed only if we found a result, while the
// additional search for the relaxed_strategy is executed only if we didn't
// find a result. So we put this search first so we can return early if we
// could find a result.
if (result.has_value() && strategy.ShouldRespectSnapStop()) {
std::unique_ptr<SnapSelectionStrategy> must_only_strategy =
SnapSelectionStrategy::CreateForDirection(
strategy.current_position(),
strategy.intended_position() - strategy.current_position(),
strategy.UsingFractionalOffsets(), SnapStopAlwaysFilter::kRequire);
std::optional<SnapSearchResult> must_only_result =
FindClosestValidAreaInternal(axis, *must_only_strategy,
cross_axis_snap_result, false);
result = ClosestSearchResult(strategy.current_position(), axis, result,
must_only_result);
}
// Our current direction based strategies are too strict ignoring the other
// directions even when we have no candidate in the given direction. This is
// particularly problematic with mandatory snap points and for fling
// gestures. To counteract this, if the direction based strategy finds no
// candidates, we do a second search ignoring the direction (this is
// implemented by using an equivalent EndPosition strategy).
if (result.has_value() ||
scroll_snap_type_.strictness == SnapStrictness::kProximity ||
!strategy.HasIntendedDirection())
return result;
std::unique_ptr<SnapSelectionStrategy> relaxed_strategy =
SnapSelectionStrategy::CreateForEndPosition(strategy.current_position(),
strategy.ShouldSnapOnX(),
strategy.ShouldSnapOnY());
return FindClosestValidAreaInternal(axis, *relaxed_strategy,
cross_axis_snap_result);
}
std::optional<SnapSearchResult> SnapContainerData::FindClosestValidAreaInternal(
SearchAxis axis,
const SnapSelectionStrategy& strategy,
const SnapSearchResult& cross_axis_snap_result,
bool should_consider_covering,
std::optional<gfx::RangeF> active_element_range) const {
bool horiz = axis == SearchAxis::kX;
// The cross axis result is expected to be within bounds otherwise no snap
// area will meet the mutual visibility requirement.
DCHECK(cross_axis_snap_result.snap_offset() >= 0 &&
cross_axis_snap_result.snap_offset() <=
(horiz ? max_position_.y() : max_position_.x()));
// The search result from the snap area that's closest to the search origin.
std::optional<SnapSearchResult> closest;
// The search result with the intended position if it makes a snap area cover
// the snapport.
std::optional<SnapSearchResult> covering_intended;
// The intended position of the scroll operation if there's no snap. This
// scroll position becomes the covering candidate if there is a snap area that
// fully covers the snapport if this position is scrolled to.
float intended_position = horiz ? strategy.intended_position().x()
: strategy.intended_position().y();
// The position from which we search for the closest snap position.
float base_position =
horiz ? strategy.base_position().x() : strategy.base_position().y();
// True if we have found a "preferred" candidate.
bool preferred_candidate = false;
float smallest_distance = horiz ? proximity_range_.x() : proximity_range_.y();
float proximity_distance =
horiz ? proximity_range_.x() : proximity_range_.y();
auto evaluate = [&](const SnapSearchResult& candidate,
const SnapAreaData& area) {
if (!IsMutualVisible(candidate, cross_axis_snap_result)) {
return;
}
if (!strategy.IsValidSnapPosition(axis, candidate.snap_offset())) {
return;
}
float distance = std::abs(candidate.snap_offset() - base_position);
if (distance > proximity_distance) {
return;
}
bool is_preferred_candidate =
strategy.IsPreferredSnapPosition(axis, candidate.snap_offset());
// If we have a preferred candidate, skip those which are not preferred.
if (preferred_candidate && !is_preferred_candidate) {
return;
}
// If this snap area is further away from the best candidate, and
// we either already have a preferred candidate or this candidate is not
// preferred, then skip it.
if (distance > smallest_distance &&
(preferred_candidate || !is_preferred_candidate)) {
return;
}
// Aligned snap areas that have focus should be given preference when
// selecting snap targets.
if (distance < smallest_distance ||
(is_preferred_candidate &&
(!preferred_candidate || candidate.has_focus_within()))) {
smallest_distance = distance;
closest = candidate;
preferred_candidate = is_preferred_candidate;
} else if (closest && !closest->has_focus_within()) {
if (closest->element_id() == targeted_area_id_) {
return;
}
if (candidate.element_id() == targeted_area_id_) {
closest = candidate;
preferred_candidate = is_preferred_candidate;
return;
}
const auto candidate_rect = candidate.rect();
const auto closest_rect = closest->rect();
// Prefer snapping to innermost elements when nesting snap areas.
// RectF::Contains allows equality but the candidate should only prevail
// if it is smaller.
DCHECK(closest_rect && candidate_rect);
if (closest_rect && candidate_rect &&
closest_rect->Contains(candidate_rect.value()) &&
closest_rect != candidate_rect) {
smallest_distance = distance;
closest = candidate;
preferred_candidate = is_preferred_candidate;
} else if ((scroll_snap_type_.axis == SnapAxis::kBoth) &&
(area.scroll_snap_align.alignment_block !=
SnapAlignment::kNone) &&
(area.scroll_snap_align.alignment_inline !=
SnapAlignment::kNone) &&
is_preferred_candidate == preferred_candidate) {
// This candidate is equally aligned with the current closest. Since it
// can be snapped to in both axes, designate it a potential alternative
// if we don't already have a potential alternative or it is a better
// alternative than the current one.
UpdateSearchAlternative(*closest, candidate, area, strategy);
}
}
};
for (const SnapAreaData& area : snap_area_list_) {
if (!strategy.IsValidSnapArea(axis, area))
continue;
if (active_element_range) {
gfx::RangeF area_range =
horiz ? gfx::RangeF(area.rect.x(), area.rect.right())
: gfx::RangeF(area.rect.y(), area.rect.bottom());
if (!active_element_range->Intersects(area_range)) {
continue;
}
}
SnapSearchResult candidate = GetSnapSearchResult(axis, area);
evaluate(candidate, area);
if (should_consider_covering &&
CanCoverSnapportOnAxis(axis, snapport(), area.rect)) {
if (std::optional<SnapSearchResult> covering =
FindCoveringCandidate(area, axis, candidate, intended_position)) {
covering->set_has_focus_within(area.has_focus_within);
covering->set_rect(area.rect);
if (covering->snap_offset() == intended_position) {
SetOrUpdateResult(*covering, &covering_intended);
} else {
// A covering candidate that is displaced from the intended position
// should behave similarly to an aligned snap position, competing on
// distance with other aligned snap positions - unlike a covering
// candidate at the intended position which may be given a higher
// priority in ScrollSnapStrategy::PickBestResult.
evaluate(*covering, area);
}
}
}
// Even if a snap area covers the snapport, we need to continue this
// search to find previous and next snap positions and also to have
// alternative snap candidates if this covering candidate is ultimately
// rejected. And this covering snap area has its own alignment that may
// generates a snap position rejecting the current inplace candidate.
}
const std::optional<SnapSearchResult>& picked =
strategy.PickBestResult(closest, covering_intended);
return picked;
}
SnapSearchResult SnapContainerData::GetSnapSearchResult(
SearchAxis axis,
const SnapAreaData& area) const {
SnapSearchResult result;
gfx::RectF rect = snapport();
if (axis == SearchAxis::kX) {
// https://www.w3.org/TR/css-scroll-snap-1/#scroll-snap-align
// Snap alignment has been normalized for a horizontal left to right and top
// to bottom writing mode.
switch (area.scroll_snap_align.alignment_inline) {
case SnapAlignment::kStart:
result.set_snap_offset(area.rect.x() - rect.x());
break;
case SnapAlignment::kCenter:
result.set_snap_offset(area.rect.CenterPoint().x() -
rect.CenterPoint().x());
break;
case SnapAlignment::kEnd:
result.set_snap_offset(area.rect.right() - rect.right());
break;
default:
NOTREACHED();
}
result.Clip(max_position_.x());
result.set_snapport_max_visible(max_position_.y());
result.set_snapport_visible_range(gfx::RangeF(rect.y(), rect.bottom()));
} else {
switch (area.scroll_snap_align.alignment_block) {
case SnapAlignment::kStart:
result.set_snap_offset(area.rect.y() - rect.y());
break;
case SnapAlignment::kCenter:
result.set_snap_offset(area.rect.CenterPoint().y() -
rect.CenterPoint().y());
break;
case SnapAlignment::kEnd:
result.set_snap_offset(area.rect.bottom() - rect.bottom());
break;
default:
NOTREACHED();
}
result.Clip(max_position_.y());
result.set_snapport_max_visible(max_position_.x());
result.set_snapport_visible_range(gfx::RangeF(rect.x(), rect.right()));
}
result.set_axis(axis);
result.set_rect(area.rect);
result.set_has_focus_within(area.has_focus_within);
result.set_element_id(area.element_id);
return result;
}
std::optional<SnapSearchResult> SnapContainerData::FindCoveringCandidate(
const SnapAreaData& area,
SearchAxis axis,
const SnapSearchResult& aligned_candidate,
float intended_position) const {
bool horiz = axis == SearchAxis::kX;
gfx::RectF rect = snapport();
float scroll_padding = horiz ? rect.x() : rect.y();
float snapport_size = horiz ? rect.width() : rect.height();
SnapAlignment alignment = horiz ? area.scroll_snap_align.alignment_inline
: area.scroll_snap_align.alignment_block;
gfx::RangeF area_range = horiz
? gfx::RangeF(area.rect.x(), area.rect.right())
: gfx::RangeF(area.rect.y(), area.rect.bottom());
gfx::RangeF preferred_snapport(
intended_position + scroll_padding,
intended_position + scroll_padding + snapport_size);
gfx::RangeF backward_dodging_range = area_range;
gfx::RangeF middle_dodging_range = area_range;
gfx::RangeF forward_dodging_range = area_range;
for (const SnapAreaData& intruder : snap_area_list_) {
gfx::RangeF intruder_range =
horiz ? gfx::RangeF(intruder.rect.x(), intruder.rect.right())
: gfx::RangeF(intruder.rect.y(), intruder.rect.bottom());
if (intruder_range.start() > area_range.end() ||
intruder_range.end() < area_range.start()) {
// Does not intrude.
continue;
}
if (intruder_range.start() <= area_range.start() &&
intruder_range.end() >= area_range.end()) {
// Superset of `area` also not treated as an intruder.
continue;
}
// Try three ways of dodging the intruders.
// In full generality this requires an interval tree. But we can simplify
// somewhat because we only care about a dodging range that is potentially
// closer than an aligned snap position, which each intruder also
// produces. For example, given:
// |---A---| |---preferred snapport---|
// |---B---|
// We do not care about the dodging range before the start of A.
// backward_dodging_range finds a dodging range that is above any intruder
// that intersects the snapport.
if (intruder_range.end() < preferred_snapport.start()) {
backward_dodging_range.set_start(
std::max(backward_dodging_range.start(), intruder_range.end()));
} else {
backward_dodging_range.set_end(
std::min(backward_dodging_range.end(), intruder_range.start()));
}
// forward_dodging_range finds a dodging range that is below any intruder
// that intersects the snapport.
if (intruder_range.start() > preferred_snapport.end()) {
forward_dodging_range.set_end(
std::min(forward_dodging_range.end(), intruder_range.start()));
} else {
forward_dodging_range.set_start(
std::max(forward_dodging_range.start(), intruder_range.end()));
}
// middle_dodging_range finds a dodging range inside the snapport, if there
// are intruders from above and below.
if (intruder_range.Contains(preferred_snapport) ||
preferred_snapport.Contains(intruder_range)) {
middle_dodging_range = gfx::RangeF();
} else if (intruder_range.start() <= preferred_snapport.start()) {
middle_dodging_range.set_start(
std::max(middle_dodging_range.start(), intruder_range.end()));
} else {
DCHECK(intruder_range.end() >= preferred_snapport.end());
middle_dodging_range.set_end(
std::min(middle_dodging_range.end(), intruder_range.start()));
}
}
std::optional<SnapSearchResult> middle_candidate =
SearchResultForDodgingRange(area_range, middle_dodging_range,
aligned_candidate, intended_position,
scroll_padding, snapport_size, alignment);
if (middle_candidate) {
return middle_candidate;
}
std::optional<SnapSearchResult> backward_candidate =
SearchResultForDodgingRange(area_range, backward_dodging_range,
aligned_candidate, intended_position,
scroll_padding, snapport_size, alignment);
std::optional<SnapSearchResult> forward_candidate =
SearchResultForDodgingRange(area_range, forward_dodging_range,
aligned_candidate, intended_position,
scroll_padding, snapport_size, alignment);
if (!backward_candidate) {
return forward_candidate;
}
if (!forward_candidate) {
return backward_candidate;
}
float backward_distance =
std::abs(backward_candidate->snap_offset() - intended_position);
float forward_distance =
std::abs(forward_candidate->snap_offset() - intended_position);
return backward_distance < forward_distance ? backward_candidate
: forward_candidate;
}
constexpr float kSnapportCoveredTolerance = 0.5;
bool SnapContainerData::IsSnapportCoveredOnAxis(
SearchAxis axis,
float current_offset,
const gfx::RectF& area_rect) const {
// We expand the range that SnapContainerData considers covering the snapport
// by kSnapportCoveredTolerance to handle offsets at the boundaries of
// the snap container. At the boundaries, |current_offset| might be a rounded
// int coming from ScrollTree::ClampScrollOffsetToLimits which uses
// ScrollNode::bounds which is a gfx::Size which stores ints.
// See crbug.com/1468412.
gfx::RectF rect = snapport();
if (axis == SearchAxis::kX) {
if (area_rect.width() < rect.width()) {
return false;
}
float left = area_rect.x() - rect.x();
float right = area_rect.right() - rect.right();
return current_offset >= left - kSnapportCoveredTolerance &&
current_offset <= right + kSnapportCoveredTolerance;
} else {
if (area_rect.height() < rect.height()) {
return false;
}
float top = area_rect.y() - rect.y();
float bottom = area_rect.bottom() - rect.bottom();
return current_offset >= top - kSnapportCoveredTolerance &&
current_offset <= bottom + kSnapportCoveredTolerance;
}
}
// TODO(crbug.com/40941354): Use tolerance value less than 1.
// It is currently set to 1 because of differences in the way Blink and cc
// currently handle fractional offsets when snapping.
constexpr float kSnappedToTolerance = 1.0;
bool SnapContainerData::IsSnappedToArea(
const SnapAreaData& area,
const gfx::PointF& scroll_offset) const {
bool covered_on_y =
IsSnapportCoveredOnAxis(SearchAxis::kY, scroll_offset.y(), area.rect);
bool covered_on_x =
IsSnapportCoveredOnAxis(SearchAxis::kX, scroll_offset.x(), area.rect);
bool snaps_on_x = scroll_snap_type_.axis == SnapAxis::kX ||
scroll_snap_type_.axis == SnapAxis::kBoth;
bool snaps_on_y = scroll_snap_type_.axis == SnapAxis::kY ||
scroll_snap_type_.axis == SnapAxis::kBoth;
if ((snaps_on_x && covered_on_x) && (snaps_on_y && covered_on_y)) {
return true;
}
if (snaps_on_y &&
area.scroll_snap_align.alignment_block != SnapAlignment::kNone) {
SnapSearchResult snap_result_y = GetSnapSearchResult(SearchAxis::kY, area);
if (((std::abs(snap_result_y.snap_offset() - scroll_offset.y()) <=
kSnappedToTolerance) ||
covered_on_y) &&
gfx::RangeF(scroll_offset.x())
.IsBoundedBy(snap_result_y.visible_range())) {
return true;
}
}
if (snaps_on_x &&
area.scroll_snap_align.alignment_inline != SnapAlignment::kNone) {
SnapSearchResult snap_result_x = GetSnapSearchResult(SearchAxis::kX, area);
if (((std::abs(snap_result_x.snap_offset() - scroll_offset.x()) <=
kSnappedToTolerance) ||
covered_on_x) &&
gfx::RangeF(scroll_offset.y())
.IsBoundedBy(snap_result_x.visible_range())) {
return true;
}
}
return false;
}
gfx::RectF SnapContainerData::snapport() const {
if (!snapport_height_adjustment_) {
return rect_;
}
gfx::RectF adjusted = rect_;
// The top visible point is not changed by showing / hiding the top controls;
// they only expand the visible rect from that anchor point.
adjusted.set_height(adjusted.height() + snapport_height_adjustment_);
return adjusted;
}
void SnapContainerData::UpdateSearchAlternative(
SnapSearchResult& current_result,
const SnapSearchResult& candidate_result,
const SnapAreaData& candidate_area,
const SnapSelectionStrategy& strategy) const {
bool horiz = current_result.axis() == SearchAxis::kX;
const auto candidate_cross_axis_aligned_result = GetSnapSearchResult(
horiz ? SearchAxis::kY : SearchAxis::kX, candidate_area);
const auto candidate_rect = candidate_result.rect();
const auto current_result_rect = current_result.rect();
DCHECK(candidate_rect && current_result_rect);
if (!candidate_rect || !current_result_rect ||
candidate_rect->Contains(*current_result_rect)) {
return;
}
if (auto alt = current_result.alternative()) {
float cross_axis_base_position =
horiz ? strategy.base_position().y() : strategy.base_position().x();
float candidate_cross_axis_distance =
std::abs(cross_axis_base_position -
candidate_cross_axis_aligned_result.snap_offset());
float alt_cross_axis_distance =
std::abs(cross_axis_base_position - alt->cross_axis_snap_offset);
if (candidate_cross_axis_distance > alt_cross_axis_distance) {
return;
}
const auto alt_rect = alt->area_rect;
// This candidate beats our current alternative if it is closer to the
// base position in the cross axis than our current alternative,
// or if it is tied with the current alternative and is nested within
// the current alternative (inner targets are preferred to outer targets).
if (candidate_cross_axis_distance < alt_cross_axis_distance ||
(alt_rect != *candidate_rect && alt_rect.Contains(*candidate_rect))) {
current_result.set_alternative(
candidate_area.element_id, *candidate_rect,
candidate_cross_axis_aligned_result.snap_offset());
}
} else {
// We did not have an alternative before now, make the current
// candidate our alternative.
current_result.set_alternative(
candidate_area.element_id, *candidate_rect,
candidate_cross_axis_aligned_result.snap_offset());
}
}
void SnapContainerData::SelectAlternativeIdForSearchResult(
SnapSearchResult& selection,
const std::optional<SnapSearchResult>& cross_selection,
float cross_current_position,
float cross_max_position) const {
const auto within_snapped_tolerance = [](float v1, float v2) {
return std::abs(v1 - v2) <= kSnappedToTolerance;
};
if (cross_selection) {
if (within_snapped_tolerance(
cross_selection->snap_offset(),
selection.alternative()->cross_axis_snap_offset)) {
selection.set_element_id(selection.alternative()->element_id);
}
} else {
if (within_snapped_tolerance(
std::clamp(cross_current_position, 0.0f, cross_max_position),
selection.alternative()->cross_axis_snap_offset)) {
selection.set_element_id(selection.alternative()->element_id);
}
}
}
SnapAxis SnapContainerData::SelectAxisToFollowForMutualVisibility(
const SnapSelectionStrategy& strategy,
const SnapSearchResult& selected_x,
const SnapSearchResult& selected_y) const {
// If snapping in one axis pushes off-screen the other snap area, this snap
// position is invalid. https://drafts.csswg.org/css-scroll-snap-1/#snap-scope
// In this case, first check if we need to prioritize snapping to the most
// recent snap targets in each axis and prioritize one axis over the other
// according to the following order:
// 1. an axis with the focused area.
// 2. an axis with the targeted [1] area.
// 3. the block axis.
// (See step 8 at
// https://github.com/w3c/csswg-drafts/issues/9622#issue-2006578282)
// [1]https://drafts.csswg.org/selectors/#the-target-pseudo
// If we don't prioritize snapping to the most recent snap targets, we choose
// the axis whose snap area is closer. Then find a new snap area on the other
// axis that is mutually visible with the selected axis' snap area.
if (strategy.ShouldPrioritizeSnapTargets()) {
// If we we're previously snapped in one axis but not the other, follow the
// axis we we're previously snapped in.
if (target_snap_area_element_ids_.x == ElementId()) {
return SnapAxis::kY;
} else if (target_snap_area_element_ids_.y == ElementId()) {
return SnapAxis::kX;
}
// Focused, then targeted snap areas should be followed.
if (selected_x.has_focus_within()) {
return SnapAxis::kX;
} else if (selected_y.has_focus_within()) {
return SnapAxis::kY;
} else if (selected_x.element_id() == targeted_area_id_) {
return SnapAxis::kX;
} else if (selected_y.element_id() == targeted_area_id_) {
return SnapAxis::kY;
}
// Follow the block axis target.
return has_horizontal_writing_mode_ ? SnapAxis::kY : SnapAxis::kX;
}
return (
std::abs(selected_x.snap_offset() - strategy.base_position().x()) <=
std::abs(selected_y.snap_offset() - strategy.base_position().y())
? SnapAxis::kX
: SnapAxis::kY);
}
std::ostream& operator<<(std::ostream& ostream, const SnapAreaData& area_data) {
return ostream << area_data.rect.ToString();
}
std::ostream& operator<<(std::ostream& ostream,
const SnapContainerData& container_data) {
ostream << "container_rect: " << container_data.rect().ToString();
ostream << "area_rects: ";
for (size_t i = 0; i < container_data.size(); ++i) {
ostream << container_data.at(i) << "\n";
}
return ostream;
}
} // namespace cc