chromium/ash/style/pagination_view.cc

// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "ash/style/pagination_view.h"

#include <optional>
#include <utility>

#include "ash/public/cpp/pagination/pagination_model.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/style_util.h"
#include "base/i18n/number_formatting.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/base/models/image_model.h"
#include "ui/chromeos/styles/cros_tokens_color_mappings.h"
#include "ui/compositor/layer.h"
#include "ui/gfx/animation/tween.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/geometry/rounded_corners_f.h"
#include "ui/views/accessibility/view_accessibility.h"
#include "ui/views/background.h"
#include "ui/views/controls/button/image_button.h"
#include "ui/views/controls/scroll_view.h"
#include "ui/views/layout/box_layout_view.h"
#include "ui/views/view_class_properties.h"

namespace ash {

namespace {

// The minimum number of pages to show the pagination view.
constexpr int kMinNumPages = 2;

// Attributes of arrow buttons.
constexpr int kArrowButtonIconSize = 20;
constexpr ui::ColorId kArrowButtonColorId = cros_tokens::kCrosSysSecondary;
constexpr int kArrowIndicatorSpacing = 2;

// Attributes of indicator.
constexpr int kIndicatorButtonSize = 20;
constexpr int kIndicatorRadius = 4;
constexpr int kIndicatorStrokeWidth = 1;
constexpr int kIndicatorSpacing = 2;
constexpr ui::ColorId kIndicatorColorId = cros_tokens::kCrosSysPrimary;
constexpr int kMaxNumVisibleIndicators = 5;

// Get the width of the indicator container.
int GetIndicatorContainerWidth(int total_pages) {
  if (total_pages < kMinNumPages) {
    return 0;
  }

  const int visible_num = std::min(total_pages, kMaxNumVisibleIndicators);
  return visible_num * kIndicatorButtonSize +
         (visible_num - 1) * kIndicatorSpacing;
}

// A structure holds the info needed by interpolation.
template <typename T>
struct InterpolationInterval {
  // The start time and value.
  double start_time;
  T start_value;
  // The end time and value.
  double end_time;
  T target_value;
};

// IndicatorButton:
// A button with a hollow circle in the center.
class IndicatorButton : public views::Button {
  METADATA_HEADER(IndicatorButton, views::Button)

 public:
  IndicatorButton(PressedCallback callback,
                  const std::u16string& accessible_name)
      : views::Button(std::move(callback)) {
    SetFocusBehavior(views::View::FocusBehavior::ACCESSIBLE_ONLY);
    GetViewAccessibility().SetName(accessible_name);
  }

  IndicatorButton(const IndicatorButton&) = delete;
  IndicatorButton& operator=(const IndicatorButton&) = delete;
  ~IndicatorButton() override = default;

  // Gets the bounds of the circle in the center.
  gfx::Rect GetIndicatorBounds() const {
    gfx::Rect indicator_bounds = bounds();
    indicator_bounds.Inset(
        gfx::Insets(0.5 * kIndicatorButtonSize - kIndicatorRadius));
    return indicator_bounds;
  }

  // views::Button:
  gfx::Size CalculatePreferredSize(
      const views::SizeBounds& available_size) const override {
    return gfx::Size(kIndicatorButtonSize, kIndicatorButtonSize);
  }

  void PaintButtonContents(gfx::Canvas* canvas) override {
    cc::PaintFlags flags;
    flags.setAntiAlias(true);
    flags.setColor(GetColorProvider()->GetColor(kIndicatorColorId));
    flags.setStyle(cc::PaintFlags::kStroke_Style);
    flags.setStrokeWidth(kIndicatorStrokeWidth);
    // Do inner stroke.
    canvas->DrawCircle(GetLocalBounds().CenterPoint(),
                       kIndicatorRadius - 0.5f * kIndicatorStrokeWidth, flags);
  }
};

BEGIN_METADATA(IndicatorButton)
END_METADATA

}  // namespace

//------------------------------------------------------------------------------
// PaginationView::SelectorDotView:
// A solid circle that performs deformation with the pace of page transition.
class PaginationView::SelectorDotView : public views::View {
  METADATA_HEADER(SelectorDotView, views::View)

 public:
  using DeformInterval = InterpolationInterval<gfx::Rect>;

  SelectorDotView() {
    SetBackground(
        StyleUtil::CreateThemedFullyRoundedRectBackground(kIndicatorColorId));
    SetPaintToLayer();
    layer()->SetFillsBoundsOpaquely(false);
    // Set selector dot ignored by layout since it will follow selected
    // indicator and deform on page transition.
    SetProperty(views::kViewIgnoredByLayoutKey, true);
  }

  SelectorDotView(const SelectorDotView&) = delete;
  SelectorDotView& operator=(const SelectorDotView&) = delete;
  ~SelectorDotView() override = default;

  // Adds a new deform interval.
  void AddDeformInterval(DeformInterval interval) {
    DCHECK_LT(interval.start_time, interval.end_time);
    deform_intervals_.push_back(interval);
    // Sort the intervals according to the start time in ascending order.
    std::sort(
        deform_intervals_.begin(), deform_intervals_.end(),
        [](const DeformInterval& interval_1, const DeformInterval& interval_2) {
          return interval_1.start_time < interval_2.start_time;
        });
  }

  // Performs deformation according to the given progress within deform
  // intervals.
  void Deform(double progress) {
    if (deform_intervals_.empty()) {
      return;
    }

    auto iter = std::find_if(deform_intervals_.begin(), deform_intervals_.end(),
                             [&](DeformInterval& interval) {
                               return interval.start_time <= progress &&
                                      interval.end_time >= progress;
                             });

    if (iter == deform_intervals_.end()) {
      return;
    }

    // Get intermediate bounds by interpolating the origin and target bounds.
    const gfx::Rect intermediate_bounds = gfx::Tween::RectValueBetween(
        (progress - iter->start_time) / (iter->end_time - iter->start_time),
        iter->start_value, iter->target_value);
    SetBoundsRect(intermediate_bounds);
  }

  void ResetDeform(bool canceled) {
    if (!deform_intervals_.empty()) {
      SetBoundsRect(canceled ? deform_intervals_.front().start_value
                             : deform_intervals_.back().target_value);
    }
    deform_intervals_.clear();
  }

  // Returns true if deformation is still in progress.
  bool DeformingInProgress() const { return !deform_intervals_.empty(); }

 private:
  std::vector<DeformInterval> deform_intervals_;
};

BEGIN_METADATA(PaginationView, SelectorDotView)
END_METADATA

//------------------------------------------------------------------------------
// PaginationView::IndicatorContainer:
// The container of indicators. If the indicator to be selected is not visible,
// the container will scroll with the pace of pagination transition.
class PaginationView::IndicatorContainer : public views::BoxLayoutView {
  METADATA_HEADER(IndicatorContainer, views::BoxLayoutView)

 public:
  explicit IndicatorContainer(views::BoxLayout::Orientation orientation) {
    SetOrientation(orientation);
    SetMainAxisAlignment(views::BoxLayout::MainAxisAlignment::kCenter);
    SetCrossAxisAlignment(views::BoxLayout::CrossAxisAlignment::kCenter);
    SetBetweenChildSpacing(kIndicatorSpacing);
  }

  IndicatorContainer(const IndicatorContainer&) = delete;
  IndicatorContainer& operator=(const IndicatorContainer&) = delete;
  ~IndicatorContainer() override = default;

  // Attaches an indicator to the end of container.
  void PushIndicator(PaginationModel* model) {
    const int page = buttons_.size();
    // Since the selector dot will also be added in the container, we should use
    // `AddChildViewAt` to ensure the indicator is in the expected position in
    // the child views.
    auto* indicator_button = AddChildViewAt(
        std::make_unique<IndicatorButton>(
            base::BindRepeating(
                [](PaginationModel* model, int page, const ui::Event& event) {
                  model->SelectPage(page, /*animate=*/true);
                },
                model, page),
            l10n_util::GetStringFUTF16(
                IDS_APP_LIST_PAGE_SWITCHER, base::FormatNumber(page + 1),
                base::FormatNumber(model->total_pages()))),
        page);
    buttons_.emplace_back(indicator_button);
  }

  // Discards the indicator at the end of the container.
  void PopIndicator() {
    DCHECK(buttons_.size());
    auto indicator_button = buttons_.back();
    buttons_.pop_back();
    RemoveChildViewT(std::exchange(indicator_button, nullptr));
  }

  // Gets indicator corresponding to the given page.
  IndicatorButton* GetIndicatorByPage(int page) {
    DCHECK_GE(page, 0);
    DCHECK_LT(page, static_cast<int>(buttons_.size()));
    return buttons_[page].get();
  }

  int GetNumberOfIndicators() const { return buttons_.size(); }

  // Sets up scrolling if an invisible page is selected.
  void StartScroll(int start_page, int target_page) {
    // Scroll the indicator container by the distance of a indicator button size
    // plus button spacing to reveal the next/previous indicator.
    // TODO(zxdan): settings bounds at each step will cause repainting which is
    // expensive. However, using transform sometimes makes the stroke of
    // indicator circle become thicker. Will investigate the cause latter.
    const bool forward = start_page < target_page;
    const int start_page_offset =
        forward ? kMaxNumVisibleIndicators - start_page - 1 : -start_page;
    const int target_page_offset =
        forward ? kMaxNumVisibleIndicators - target_page - 1 : -target_page;
    const int scroll_unit = kIndicatorButtonSize + kIndicatorSpacing;
    scroll_interval_ = {0.0, start_page_offset * scroll_unit, 1.0,
                        target_page_offset * scroll_unit};
  }

  // Scrolls the indicator container according to the given progress value.
  void Scroll(double progress) {
    if (!scroll_interval_) {
      return;
    }

    // Interpolate the scroll interval to get current container bounds.
    ScrollWithOffset(
        gfx::Tween::IntValueBetween(progress, scroll_interval_->start_value,
                                    scroll_interval_->target_value));
  }

  void ResetScroll(bool canceled) {
    if (scroll_interval_) {
      ScrollWithOffset(canceled ? scroll_interval_->start_value
                                : scroll_interval_->target_value);
    }
    scroll_interval_ = std::nullopt;
  }

  // Returns true if the scrolling is in progress.
  bool ScrollingInProgress() { return !!scroll_interval_; }

 private:
  // Scroll horizontally or vertically with given offset.
  void ScrollWithOffset(int offset) {
    if (GetOrientation() == views::BoxLayout::Orientation::kHorizontal) {
      SetX(offset);
    } else {
      SetY(offset);
    }
  }

  std::vector<raw_ptr<IndicatorButton>> buttons_;
  std::optional<InterpolationInterval<int>> scroll_interval_;
};

BEGIN_METADATA(PaginationView, IndicatorContainer)
END_METADATA

//------------------------------------------------------------------------------
// PaginationView:
PaginationView::PaginationView(PaginationModel* model, Orientation orientation)
    : model_(model),
      orientation_(orientation),
      indicator_scroll_view_(
          AddChildView(std::make_unique<views::ScrollView>())),
      indicator_container_(indicator_scroll_view_->SetContents(
          std::make_unique<IndicatorContainer>(
              orientation == Orientation::kHorizontal
                  ? views::BoxLayout::Orientation::kHorizontal
                  : views::BoxLayout::Orientation::kVertical))) {
  DCHECK(model_);
  model_observation_.Observe(model_.get());

  // Remove the default background color.
  indicator_scroll_view_->SetBackgroundColor(std::nullopt);

  // The scroll view does not accept any scroll event.
  indicator_scroll_view_->SetHorizontalScrollBarMode(
      views::ScrollView::ScrollBarMode::kDisabled);
  indicator_scroll_view_->SetVerticalScrollBarMode(
      views::ScrollView::ScrollBarMode::kDisabled);

  if (model_->total_pages() >= kMinNumPages) {
    TotalPagesChanged(0, model_->total_pages());
  }

  if (ShouldShowSelectorDot()) {
    CreateSelectorDot();
  }
}

PaginationView::~PaginationView() = default;

gfx::Size PaginationView::CalculatePreferredSize(
    const views::SizeBounds& available_size) const {
  const int total_pages = model_->total_pages();
  if (total_pages < kMinNumPages) {
    return gfx::Size();
  }

  // Initialize container size with indicator container size.
  int container_size = GetIndicatorContainerWidth(total_pages);
  if (total_pages > kMaxNumVisibleIndicators) {
    // If the number of total pages exceeds visible maximum, add arrow buttons.
    container_size += 2 * (kArrowButtonIconSize + kArrowIndicatorSpacing);
  }

  return (orientation_ == Orientation::kHorizontal)
             ? gfx::Size(container_size, kIndicatorButtonSize)
             : gfx::Size(kIndicatorButtonSize, container_size);
}

void PaginationView::Layout(PassKey) {
  const bool horizontal = (orientation_ == Orientation::kHorizontal);
  int offset = 0;

  // A callback to set the bounds of given arrow button if it exists. Return the
  // button size if the button exists. Otherwise, return 0.
  auto set_arrow_button = [&](views::ImageButton* arrow_button) -> int {
    if (arrow_button) {
      gfx::Point origin =
          horizontal ? gfx::Point(offset, 0) : gfx::Point(0, offset);
      arrow_button->SetBoundsRect(gfx::Rect(
          origin, gfx::Size(kArrowButtonIconSize, kArrowButtonIconSize)));
      return kArrowButtonIconSize;
    }
    return 0;
  };

  // Set the backward arrow button if exists.
  offset += set_arrow_button(backward_arrow_button_);

  // Set the indicator container.
  indicator_container_->SizeToPreferredSize();
  const int scroll_view_size =
      GetIndicatorContainerWidth(model_->total_pages());
  if (horizontal) {
    indicator_scroll_view_->SetBounds(offset, 0, scroll_view_size,
                                      kIndicatorButtonSize);
  } else {
    indicator_scroll_view_->SetBounds(0, offset, kIndicatorButtonSize,
                                      scroll_view_size);
  }
  offset += scroll_view_size + kArrowIndicatorSpacing;

  // Set the right arrow button if exists.
  set_arrow_button(forward_arrow_button_);

  // Update arrow button visibility and selector dot position.
  UpdateArrowButtonsVisiblity();
  UpdateSelectorDot();
}

void PaginationView::CreateArrowButtons() {
  const bool horizontal = (orientation_ == Orientation::kHorizontal);
  for (bool forward : {true, false}) {
    auto arrow_button = std::make_unique<views::ImageButton>(
        base::BindRepeating(&PaginationView::OnArrowButtonPressed,
                            base::Unretained(this), forward));

    arrow_button->SetImageModel(
        views::ImageButton::ButtonState::STATE_NORMAL,
        ui::ImageModel::FromVectorIcon(
            forward
                ? (horizontal ? kOverflowShelfRightIcon : kChevronDownSmallIcon)
                : (horizontal ? kOverflowShelfLeftIcon : kChevronUpSmallIcon),
            kArrowButtonColorId, kArrowButtonIconSize));

    if (forward) {
      arrow_button->SetTooltipText(
          l10n_util::GetStringUTF16(IDS_ASH_PAGINATION_FORWARD_ARROW_TOOLTIP));
      forward_arrow_button_ = AddChildView(std::move(arrow_button));
    } else {
      arrow_button->SetTooltipText(
          l10n_util::GetStringUTF16(IDS_ASH_PAGINATION_BACKWARD_ARROW_TOOLTIP));
      backward_arrow_button_ = AddChildView(std::move(arrow_button));
    }
  }
}

void PaginationView::RemoveArrowButtons() {
  RemoveChildViewT(std::exchange(backward_arrow_button_, nullptr));
  RemoveChildViewT(std::exchange(forward_arrow_button_, nullptr));
}

void PaginationView::UpdateArrowButtonsVisiblity() {
  // If the first page indicator is visible, hide the left arrow button.
  if (backward_arrow_button_) {
    backward_arrow_button_->SetVisible(!IsIndicatorVisible(0));
  }

  // If the last page indicator is visible, hide the right arrow button.
  if (forward_arrow_button_) {
    forward_arrow_button_->SetVisible(
        !IsIndicatorVisible(model_->total_pages() - 1));
  }
}

void PaginationView::OnArrowButtonPressed(bool forward,
                                          const ui::Event& event) {
  const int page_offset = forward ? 1 : -1;
  model_->SelectPage(model_->selected_page() + page_offset, /*animate=*/true);
}

void PaginationView::MaybeSetUpScroll() {
  const int current_page = model_->selected_page();
  const int target_page = model_->transition().target_page;
  if (!model_->is_valid_page(current_page) ||
      !model_->is_valid_page(target_page)) {
    return;
  }

  // If the target page indicator is not in visible area, scroll the container.
  if (!IsIndicatorVisible(target_page)) {
    indicator_container_->StartScroll(current_page, target_page);
  }
}

bool PaginationView::ShouldShowSelectorDot() const {
  return model_->total_pages() >= kMinNumPages &&
         model_->is_valid_page(model_->selected_page());
}

void PaginationView::CreateSelectorDot() {
  if (selector_dot_) {
    return;
  }

  selector_dot_ =
      indicator_container_->AddChildView(std::make_unique<SelectorDotView>());
  UpdateSelectorDot();
}

void PaginationView::RemoveSelectorDot() {
  if (!selector_dot_) {
    return;
  }

  indicator_container_->RemoveChildViewT(std::exchange(selector_dot_, nullptr));
}

void PaginationView::UpdateSelectorDot() {
  if (!selector_dot_) {
    return;
  }

  // The selected page may become invalid when total pages is changing.
  const int selected_page = model_->selected_page();
  if (!model_->is_valid_page(selected_page)) {
    return;
  }

  // Move the selector dot to the position of selected page indicator if the
  // selector dot is not deforming.
  if (!selector_dot_->DeformingInProgress()) {
    selector_dot_->SetBoundsRect(
        indicator_container_->GetIndicatorByPage(selected_page)
            ->GetIndicatorBounds());
  }
}

void PaginationView::SetUpSelectorDotDeformation() {
  CHECK(selector_dot_);
  CHECK(!selector_dot_->DeformingInProgress());

  const int current_page = model_->selected_page();
  const int target_page = model_->transition().target_page;

  if (!model_->is_valid_page(current_page) ||
      !model_->is_valid_page(target_page)) {
    return;
  }

  const gfx::Rect current_bounds =
      indicator_container_->GetIndicatorByPage(current_page)
          ->GetIndicatorBounds();
  const gfx::Rect target_bounds =
      indicator_container_->GetIndicatorByPage(target_page)
          ->GetIndicatorBounds();
  // If moves to a neighbor page, the selector dot will first be stretched into
  // a pill shape until it connects the current indicator to the target
  // indicator, and then shrink back to a circle at the target indicator
  // position.
  if (std::abs(target_page - current_page) == 1) {
    const gfx::Rect intermediate_bounds =
        gfx::UnionRects(current_bounds, target_bounds);
    selector_dot_->AddDeformInterval(
        {0.0, current_bounds, 0.5, intermediate_bounds});
    selector_dot_->AddDeformInterval(
        {0.5, intermediate_bounds, 1.0, target_bounds});
    return;
  }

  // If jumps across multiple pages, the selector dot will first shrink at the
  // current indicator position, and then expand at the target indicator
  // position.
  selector_dot_->AddDeformInterval(
      {0.0, current_bounds, 0.5,
       gfx::Rect(current_bounds.CenterPoint(), gfx::Size())});
  selector_dot_->AddDeformInterval(
      {0.5, gfx::Rect(target_bounds.CenterPoint(), gfx::Size()), 1.0,
       target_bounds});
}

bool PaginationView::IsIndicatorVisible(int page) const {
  // Check if the indicator is in the visible rect of the scroll view.
  return indicator_scroll_view_->GetVisibleRect().Contains(
      indicator_container_->GetIndicatorByPage(page)->bounds());
}

void PaginationView::SelectedPageChanged(int old_selected, int new_selected) {
  // Update selector dot position and arrow buttons visibility.
  if (ShouldShowSelectorDot()) {
    if (!selector_dot_) {
      CreateSelectorDot();
    } else {
      // Finish and reset ongoing deformation.
      selector_dot_->ResetDeform(/*canceled=*/false);
    }
  } else {
    RemoveSelectorDot();
  }

  // Finish and reset ongoing indicator container scrolling.
  if (indicator_container_->ScrollingInProgress()) {
    indicator_container_->ResetScroll(/*canceled=*/false);
    UpdateArrowButtonsVisiblity();
  }
}

void PaginationView::TotalPagesChanged(int previous_page_count,
                                       int new_page_count) {
  const int current_indicator_num =
      indicator_container_->GetNumberOfIndicators();
  new_page_count = new_page_count < kMinNumPages ? 0 : new_page_count;
  if (current_indicator_num == new_page_count) {
    return;
  }

  if (current_indicator_num < new_page_count) {
    // Add more indicators at the end of container.
    for (int i = current_indicator_num; i < new_page_count; i++) {
      indicator_container_->PushIndicator(model_.get());
    }

    // Add arrow buttons if the number of total pages exceeds the visible
    // maximum.
    if (current_indicator_num <= kMaxNumVisibleIndicators &&
        new_page_count > kMaxNumVisibleIndicators) {
      CreateArrowButtons();
    }

    // Create selector dot if the number of total pages exceeds the minimum
    // number of pages.
    if (new_page_count >= kMinNumPages && !selector_dot_) {
      CreateSelectorDot();
    }
  } else {
    // Remove indicators from the end of the container.
    for (int i = current_indicator_num; i > new_page_count; i--) {
      indicator_container_->PopIndicator();
    }

    // Remove arrow buttons if the number of total pages does not exceed the
    // visible maximum.
    if (current_indicator_num > kMaxNumVisibleIndicators &&
        new_page_count <= kMaxNumVisibleIndicators) {
      RemoveArrowButtons();
    }

    // Remove the selector dot if the number of pages is less than the minimum
    // number of pages.
    if (new_page_count < kMinNumPages && selector_dot_) {
      RemoveSelectorDot();
    }
  }

  DeprecatedLayoutImmediately();
}

void PaginationView::TransitionChanged() {
  if (!selector_dot_) {
    return;
  }

  // If there is no transition, reset and cancel current selector dot
  // deformation and indicator container scrolling.
  if (!model_->has_transition()) {
    selector_dot_->ResetDeform(/*canceled=*/true);
    indicator_container_->ResetScroll(/*canceled=*/true);
    return;
  }

  const double progress = model_->transition().progress;

  // Scroll the indicator container if needed.
  if (!indicator_container_->ScrollingInProgress()) {
    MaybeSetUpScroll();
  }
  indicator_container_->Scroll(progress);

  // Deform the selector dot.
  if (!selector_dot_->DeformingInProgress()) {
    SetUpSelectorDotDeformation();
  }
  // Deform the selector dot.
  selector_dot_->Deform(progress);
}

BEGIN_METADATA(PaginationView)
END_METADATA
}  // namespace ash