chromium/ash/system/holding_space/holding_space_tray_icon.cc

// Copyright 2020 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/system/holding_space/holding_space_tray_icon.h"

#include <vector>

#include "ash/public/cpp/holding_space/holding_space_constants.h"
#include "ash/public/cpp/holding_space/holding_space_item.h"
#include "ash/public/cpp/holding_space/holding_space_metrics.h"
#include "ash/public/cpp/holding_space/holding_space_prefs.h"
#include "ash/public/cpp/shelf_config.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/shelf/shelf.h"
#include "ash/shell.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/system/holding_space/holding_space_tray_icon_preview.h"
#include "ash/system/tray/tray_constants.h"
#include "base/barrier_closure.h"
#include "base/containers/adapters.h"
#include "base/containers/contains.h"
#include "base/containers/unique_ptr_adapters.h"
#include "base/functional/bind.h"
#include "base/i18n/rtl.h"
#include "base/memory/raw_ptr.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/compositor/compositor.h"
#include "ui/compositor/layer.h"
#include "ui/compositor/layer_animator.h"
#include "ui/compositor/scoped_animation_duration_scale_mode.h"
#include "ui/gfx/animation/slide_animation.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/views/animation/animation_delegate_views.h"
#include "ui/views/layout/fill_layout.h"
#include "ui/views/widget/widget.h"

namespace ash {

namespace {

// When in drop target state, previews are shifted to indices which are offset
// from their standard positions by this fixed amount.
constexpr int kPreviewIndexOffsetForDropTarget = 3;

// The previews are animated in and shifted with a delay that increases
// incrementally. This is the delay increment.
constexpr base::TimeDelta kPreviewItemUpdateDelayIncrement =
    base::Milliseconds(50);

// Helpers ---------------------------------------------------------------------

// Returns the size of previews given the current shelf configuration.
int GetPreviewSize() {
  ShelfConfig* const shelf_config = ShelfConfig::Get();
  return shelf_config->in_tablet_mode() && shelf_config->is_in_app()
             ? kHoldingSpaceTrayIconSmallPreviewSize
             : kHoldingSpaceTrayIconDefaultPreviewSize;
}

}  // namespace

// HoldingSpaceTrayIcon::ResizeAnimation ---------------------------------------

// Animation for resizing the previews icon. The animation updates the icon
// view's preferred size, which causes the status area (and the shelf) to
// re-layout.
class HoldingSpaceTrayIcon::ResizeAnimation
    : public views::AnimationDelegateViews {
 public:
  ResizeAnimation(HoldingSpaceTrayIcon* icon,
                  views::View* previews_container,
                  const gfx::Size& initial_size,
                  const gfx::Size& target_size)
      : views::AnimationDelegateViews(icon),
        icon_(icon),
        previews_container_(previews_container),
        initial_size_(initial_size),
        target_size_(target_size),
        animation_(this),
        animation_throughput_tracker_(
            icon->GetWidget()->GetCompositor()->RequestNewThroughputTracker()) {
    animation_.SetTweenType(gfx::Tween::FAST_OUT_SLOW_IN);
    animation_.SetSlideDuration(
        ui::ScopedAnimationDurationScaleMode::duration_multiplier() *
        base::Milliseconds(250));
  }
  ResizeAnimation(const ResizeAnimation&) = delete;
  ResizeAnimation operator=(const ResizeAnimation&) = delete;
  ~ResizeAnimation() override = default;

  // views::AnimationDelegateViews:
  void AnimationEnded(const gfx::Animation* animation) override {
    icon_->SetPreferredSize(target_size_);
    previews_container_->SetTransform(gfx::Transform());

    // Record animation smoothness.
    animation_throughput_tracker_.Stop();
  }

  void AnimationProgressed(const gfx::Animation* animation) override {
    const gfx::Size current_size = gfx::Tween::SizeValueBetween(
        animation->GetCurrentValue(), initial_size_, target_size_);

    // Bounds grow from start to the end by default - for holding space tray
    // icon, the bounds are expected to grow from end to start. To achieve
    // growth from end to start, the position of previews is adjusted for target
    // bounds, and the previews container is translated so previews position
    // remains constant relative to the end of the icon.
    const gfx::Vector2d offset(current_size.width() - target_size_.width(),
                               current_size.height() - target_size_.height());
    gfx::Transform transform;
    const int direction = base::i18n::IsRTL() ? -1 : 1;
    transform.Translate(direction * offset.x(), offset.y());
    previews_container_->SetTransform(transform);

    // This will update the shelf and status area layout.
    if (icon_->GetPreferredSize() != current_size)
      icon_->SetPreferredSize(current_size);
  }

  void Start() {
    animation_throughput_tracker_.Start(
        metrics_util::ForSmoothnessV3(base::BindRepeating(
            holding_space_metrics::RecordPodResizeAnimationSmoothness)));

    animation_.Show();
    AnimationProgressed(&animation_);
  }

  void AdvanceToEnd() { animation_.End(); }

 private:
  const raw_ptr<HoldingSpaceTrayIcon> icon_;
  const raw_ptr<views::View> previews_container_;
  const gfx::Size initial_size_;
  const gfx::Size target_size_;

  gfx::SlideAnimation animation_;
  ui::ThroughputTracker animation_throughput_tracker_;
};

// HoldingSpaceTrayIcon --------------------------------------------------------

HoldingSpaceTrayIcon::HoldingSpaceTrayIcon(Shelf* shelf) : shelf_(shelf) {
  SetID(kHoldingSpaceTrayPreviewsIconId);
  InitLayout();

  shell_observer_.Observe(Shell::Get());
  shelf_config_observer_.Observe(ShelfConfig::Get());
}

HoldingSpaceTrayIcon::~HoldingSpaceTrayIcon() = default;

void HoldingSpaceTrayIcon::Clear() {
  previews_update_weak_factory_.InvalidateWeakPtrs();
  item_ids_.clear();
  previews_by_id_.clear();
  removed_previews_.clear();
  SetPreferredSize(CalculatePreferredSize({}));
}

gfx::Size HoldingSpaceTrayIcon::CalculatePreferredSize(
    const views::SizeBounds& available_size) const {
  const int num_visible_previews =
      std::min(kHoldingSpaceTrayIconMaxVisiblePreviews,
               static_cast<int>(previews_by_id_.size()));
  const int preview_size = GetPreviewSize();

  int primary_axis_size = preview_size;
  if (num_visible_previews > 1)
    primary_axis_size += (num_visible_previews - 1) * preview_size / 2;

  return shelf_->PrimaryAxisValue(
      /*horizontal=*/gfx::Size(primary_axis_size, kTrayItemSize),
      /*vertical=*/gfx::Size(kTrayItemSize, primary_axis_size));
}

void HoldingSpaceTrayIcon::OnThemeChanged() {
  views::View::OnThemeChanged();

  for (auto& preview_by_id : previews_by_id_)
    preview_by_id.second->OnThemeChanged();

  for (auto& preview : removed_previews_)
    preview->OnThemeChanged();
}

void HoldingSpaceTrayIcon::InitLayout() {
  SetLayoutManager(std::make_unique<views::FillLayout>());

  const int preview_size = GetPreviewSize();
  SetPreferredSize(gfx::Size(preview_size, preview_size));

  SetPaintToLayer(ui::LAYER_NOT_DRAWN);
  layer()->SetFillsBoundsOpaquely(false);
  const int radius = ShelfConfig::Get()->control_border_radius();
  gfx::RoundedCornersF rounded_corners(radius);
  layer()->SetRoundedCornerRadius(rounded_corners);
  layer()->SetIsFastRoundedCorner(true);

  previews_container_ = AddChildView(std::make_unique<views::View>());
  // As holding space items are added to the model, child layers will be added
  // to `previews_container_` view's layer to represent them.
  previews_container_->SetPaintToLayer(ui::LAYER_NOT_DRAWN);
}

void HoldingSpaceTrayIcon::UpdateDropTargetState(bool is_drop_target,
                                                 bool did_drop_to_pin) {
  if (is_drop_target_ == is_drop_target)
    return;

  is_drop_target_ = is_drop_target;

  // If the user performed a drag-and-drop to pin action, no handling is needed
  // to transition the holding space tray icon out of drop target state. When
  // the model updates, an `UpdatePreviews()` event will follow which will
  // restore standard indexing to the new and existing previews.
  if (!is_drop_target_ && did_drop_to_pin)
    return;

  DCHECK(!did_drop_to_pin);

  for (size_t i = 0; i < item_ids_.size(); ++i) {
    auto* preview = previews_by_id_.find(item_ids_[i])->second.get();

    DCHECK(preview->index());
    DCHECK(!preview->pending_index());

    size_t pending_index = i;
    base::TimeDelta delay;

    if (is_drop_target_) {
      // When in drop target state, preview indices are offset from their
      // standard positions by a fixed amount.
      pending_index += kPreviewIndexOffsetForDropTarget;
    } else {
      // When transitioning into drop target state, all previews shift out in
      // sync. When transitioning out of drop target state, previews shift in
      // with incremental `delay`.
      delay = i * kPreviewItemUpdateDelayIncrement;
    }

    preview->set_pending_index(pending_index);
    preview->AnimateShift(delay);
  }

  EnsurePreviewLayerStackingOrder();
}

void HoldingSpaceTrayIcon::UpdatePreviews(
    const std::vector<const HoldingSpaceItem*> items) {
  // Cancel any in progress updates.
  previews_update_weak_factory_.InvalidateWeakPtrs();

  item_ids_.clear();

  // When in drop target state, indices are offset from their standard position.
  const int offset = is_drop_target_ ? kPreviewIndexOffsetForDropTarget : 0;

  // Go over the new item list, create previews for new items, and assign new
  // indices to existing items.
  std::set<std::string> item_ids;
  for (size_t index = 0; index < items.size(); ++index) {
    const HoldingSpaceItem* item = items[index];
    DCHECK(item->IsInitialized());

    item_ids.insert(item->id());
    item_ids_.push_back(item->id());

    auto preview_it = previews_by_id_.find(item->id());
    if (preview_it != previews_by_id_.end()) {
      preview_it->second->set_pending_index(index + offset);
      continue;
    }

    auto preview = std::make_unique<HoldingSpaceTrayIconPreview>(
        shelf_, previews_container_, item);
    preview->set_pending_index(index + offset);
    previews_by_id_.emplace(item->id(), std::move(preview));
  }

  // Collect the list of items that should be removed.
  std::vector<std::string> items_to_remove;
  for (const auto& preview_pair : previews_by_id_) {
    if (!base::Contains(item_ids, preview_pair.first))
      items_to_remove.push_back(preview_pair.first);
  }

  if (!should_animate_updates_) {
    for (auto& item_id : items_to_remove)
      previews_by_id_.erase(item_id);
    items_to_remove.clear();
  }

  if (items_to_remove.empty()) {
    OnOldItemsRemoved();
    return;
  }

  // Animate out all items that should be removed.
  base::RepeatingClosure items_removed_callback = base::BarrierClosure(
      items_to_remove.size(),
      base::BindOnce(&HoldingSpaceTrayIcon::OnOldItemsRemoved,
                     previews_update_weak_factory_.GetWeakPtr()));

  for (auto& item_id : items_to_remove) {
    auto preview_it = previews_by_id_.find(item_id);
    HoldingSpaceTrayIconPreview* preview_ptr = preview_it->second.get();
    removed_previews_.push_back(std::move(preview_it->second));
    previews_by_id_.erase(preview_it);

    preview_ptr->AnimateOut(base::BindOnce(
        &HoldingSpaceTrayIcon::OnOldItemAnimatedOut, base::Unretained(this),
        base::Unretained(preview_ptr), items_removed_callback));
  }
}

void HoldingSpaceTrayIcon::OnShellDestroying() {
  shell_observer_.Reset();
}

void HoldingSpaceTrayIcon::OnShelfAlignmentChanged(
    aura::Window* root_window,
    ShelfAlignment old_alignment) {
  // Each display has its own shelf. The shelf undergoing an alignment change
  // may not be the `shelf_` associated with this holding space tray icon.
  if (shelf_ != Shelf::ForWindow(root_window))
    return;

  if (!removed_previews_.empty()) {
    removed_previews_.clear();
    OnOldItemsRemoved();
  }

  for (const auto& preview : previews_by_id_)
    preview.second->OnShelfAlignmentChanged(old_alignment, shelf_->alignment());

  if (resize_animation_) {
    resize_animation_->AdvanceToEnd();
    resize_animation_.reset();
  }

  SetPreferredSize(CalculatePreferredSize({}));
  previews_container_->SetTransform(gfx::Transform());
}

void HoldingSpaceTrayIcon::OnShelfConfigUpdated() {
  if (!removed_previews_.empty()) {
    removed_previews_.clear();
    OnOldItemsRemoved();
  }

  for (const auto& preview : previews_by_id_)
    preview.second->OnShelfConfigChanged();

  if (resize_animation_) {
    resize_animation_->AdvanceToEnd();
    resize_animation_.reset();
  }

  SetPreferredSize(CalculatePreferredSize({}));
  previews_container_->SetTransform(gfx::Transform());
}

void HoldingSpaceTrayIcon::OnOldItemAnimatedOut(
    HoldingSpaceTrayIconPreview* preview,
    const base::RepeatingClosure& callback) {
  std::erase_if(removed_previews_, base::MatchesUniquePtr(preview));
  callback.Run();
}

void HoldingSpaceTrayIcon::OnOldItemsRemoved() {
  if (resize_animation_) {
    resize_animation_->AdvanceToEnd();
    resize_animation_.reset();
  }

  // Now that the old items have been removed, resize the icon, and update
  // previews position within the icon.
  const gfx::Size initial_size = size();
  const gfx::Size target_size = CalculatePreferredSize({});

  if (initial_size != target_size) {
    // Changing icon bounds changes the relative position of existing item
    // layers within the icon (as the icon origin moves). Adjust the
    // position of existing items to maintain their position relative to the
    // "end" visible bounds.
    gfx::Vector2d adjustment(target_size.width() - initial_size.width(),
                             target_size.height() - initial_size.height());
    for (auto& preview_pair : previews_by_id_)
      preview_pair.second->AdjustTransformForContainerSizeChange(adjustment);

    if (should_animate_updates_) {
      resize_animation_ = std::make_unique<ResizeAnimation>(
          this, previews_container_, initial_size, target_size);
      resize_animation_->Start();
    } else {
      SetPreferredSize(target_size);
    }
  }

  // Shift existing items to their new positions. Note that this must be done
  // *prior* to animating in new items as `CalculateAnimateShiftParams()` relies
  // on index being unset in order to distinguish new items from existing items.
  for (const PreviewAnimationParams& params : CalculateAnimateShiftParams()) {
    params.preview->AnimateShift(params.delay);
    if (!should_animate_updates_ && params.preview->layer())
      params.preview->layer()->GetAnimator()->StopAnimating();
  }

  // Add new items. Note that this must be done *after* animating existing items
  // as `CalculateAnimateInParams()` relies on index being unset in order to
  // distinguish new items from existing items.
  for (const PreviewAnimationParams& params : CalculateAnimateInParams()) {
    params.preview->AnimateIn(params.delay);
    if (!should_animate_updates_ && params.preview->layer())
      params.preview->layer()->GetAnimator()->StopAnimating();
  }

  EnsurePreviewLayerStackingOrder();
}

std::vector<HoldingSpaceTrayIcon::PreviewAnimationParams>
HoldingSpaceTrayIcon::CalculateAnimateShiftParams() {
  // Items shift with an incremental delay. For items shifting towards the end
  // of the icon, the delay should decrease with the index. For items shifting
  // towards the start of the icon, the delay should increase with the index.
  // This ensures items moving in the same direction do not fly over each other.
  std::vector<PreviewAnimationParams> animation_params;

  // Calculate the starting delay for items that will be shifting towards the
  // end of the icon.
  base::TimeDelta shift_out_delay;
  for (size_t i = 0; i < item_ids_.size(); ++i) {
    auto* preview = previews_by_id_.find(item_ids_[i])->second.get();
    if (!preview->index().has_value())
      continue;

    DCHECK(preview->pending_index());

    if (*preview->index() <= kHoldingSpaceTrayIconMaxVisiblePreviews &&
        *preview->index() < *preview->pending_index()) {
      shift_out_delay += kPreviewItemUpdateDelayIncrement;
    }
  }

  base::TimeDelta shift_in_delay;
  for (auto& item_id : item_ids_) {
    auto* preview = previews_by_id_.find(item_id)->second.get();

    // Existing items have current index set.
    if (preview->index().has_value()) {
      const bool shift_out = *preview->index() < *preview->pending_index();

      animation_params.emplace_back(PreviewAnimationParams{
          .preview = preview,
          .delay = shift_out ? shift_out_delay : shift_in_delay,
      });

      if (shift_out) {
        shift_out_delay -= kPreviewItemUpdateDelayIncrement;
        if (shift_out_delay < base::TimeDelta())
          shift_out_delay = base::TimeDelta();
      } else {
        shift_in_delay += kPreviewItemUpdateDelayIncrement;
      }
    }
  }

  return animation_params;
}

std::vector<HoldingSpaceTrayIcon::PreviewAnimationParams>
HoldingSpaceTrayIcon::CalculateAnimateInParams() {
  // Items animating in should do so with a delay that increases in order of
  // addition, which is reverse of order in `items_`.
  std::vector<PreviewAnimationParams> animation_params;

  // Calculate the max delay which will be used for the first item.
  // NOTE: When animating in, an extra preview may be visible before items
  // before it drops into their position.
  base::TimeDelta addition_delay;
  for (size_t i = 0;
       i < kHoldingSpaceTrayIconMaxVisiblePreviews + 1 && i < item_ids_.size();
       ++i) {
    auto* preview = previews_by_id_.find(item_ids_[i])->second.get();
    if (!preview->index().has_value())
      addition_delay += kPreviewItemUpdateDelayIncrement;
  }

  for (auto& item_id : item_ids_) {
    auto* preview = previews_by_id_.find(item_id)->second.get();

    // New items do not have current index set.
    if (!preview->index().has_value()) {
      animation_params.emplace_back(PreviewAnimationParams{
          .preview = preview,
          .delay = addition_delay,
      });

      addition_delay -= kPreviewItemUpdateDelayIncrement;
      if (addition_delay < base::TimeDelta())
        addition_delay = base::TimeDelta();
    }
  }

  return animation_params;
}

void HoldingSpaceTrayIcon::EnsurePreviewLayerStackingOrder() {
  for (const auto& item_id : item_ids_) {
    auto* preview = previews_by_id_.find(item_id)->second.get();
    if (preview->layer())
      previews_container_->layer()->StackAtBottom(preview->layer());
  }
}

BEGIN_METADATA(HoldingSpaceTrayIcon)
END_METADATA

}  // namespace ash