// Copyright 2024 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/app_list/views/app_list_bubble_apps_collections_page.h"
#include <limits>
#include <memory>
#include <utility>
#include <vector>
#include "ash/app_list/app_collections_constants.h"
#include "ash/app_list/app_list_metrics.h"
#include "ash/app_list/app_list_view_delegate.h"
#include "ash/app_list/apps_collections_controller.h"
#include "ash/app_list/views/app_list_keyboard_controller.h"
#include "ash/app_list/views/app_list_nudge_controller.h"
#include "ash/app_list/views/app_list_toast_container_view.h"
#include "ash/app_list/views/apps_collection_section_view.h"
#include "ash/app_list/views/apps_collections_dismiss_dialog.h"
#include "ash/app_list/views/apps_grid_context_menu.h"
#include "ash/app_list/views/search_result_page_dialog_controller.h"
#include "ash/controls/rounded_scroll_bar.h"
#include "ash/public/cpp/app_list/app_list_config.h"
#include "ash/public/cpp/app_list/app_list_features.h"
#include "ash/public/cpp/app_list/app_list_types.h"
#include "ash/public/cpp/app_menu_constants.h"
#include "ash/resources/vector_icons/vector_icons.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/pill_button.h"
#include "base/functional/bind.h"
#include "base/functional/callback_forward.h"
#include "base/functional/callback_helpers.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/base/metadata/metadata_header_macros.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/compositor/layer.h"
#include "ui/compositor/layer_animator.h"
#include "ui/compositor/layer_type.h"
#include "ui/compositor/scoped_animation_duration_scale_mode.h"
#include "ui/views/animation/animation_builder.h"
#include "ui/views/controls/scroll_view.h"
#include "ui/views/layout/box_layout.h"
#include "ui/views/layout/box_layout_view.h"
#include "ui/views/view.h"
#include "ui/views/view_utils.h"
namespace ash {
namespace {
// This ID is from //chrome/browser/web_applications/web_app_id_constants.h.
constexpr char kHelpAppId[] = "nbljnnecbjbmifnoehiemkgefbnpoeak";
// Insets for the vertical scroll bar. The bottom is pushed up slightly to keep
// the scroll bar from being clipped by the rounded corners.
constexpr auto kVerticalScrollInsets = gfx::Insets::TLBR(1, 0, 16, 1);
// The padding between different sections within the apps collections page. Also
// used for interior page container margin.
constexpr int kVerticalPaddingBetweenSections = 8;
constexpr int kVerticalPaddingBetweenNudgeAndSections = 8;
// The horizontal interior margin for the apps page container - i.e. the margin
// between the page bounds and the page content.
constexpr int kHorizontalInteriorMargin = 16;
// TODO(anasalazar): Update the animation details when a motion spec is set.
// Right now we are using the same transition as the apps page. The spec says
// "Down 40 -> 0, duration 250ms" with no delay, but the opacity animation has a
// 50ms delay that causes the first 50ms to be invisible. Just animate the 200ms
// visible part, which is 32 dips. This ensures the search page hide animation
// doesn't play at the same time as the apps page show animation.
constexpr int kShowPageAnimationVerticalOffset = 32;
constexpr base::TimeDelta kShowPageAnimationTransformDuration =
base::Milliseconds(200);
// Delay for the show page transform and opacity animations.
constexpr base::TimeDelta kShowPageAnimationDelay = base::Milliseconds(50);
// Duration of the show page opacity animation.
constexpr base::TimeDelta kShowPageAnimationOpacityDuration =
base::Milliseconds(100);
} // namespace
AppListBubbleAppsCollectionsPage::AppListBubbleAppsCollectionsPage(
AppListViewDelegate* view_delegate,
AppListConfig* app_list_config,
AppListA11yAnnouncer* a11y_announcer,
SearchResultPageDialogController* dialog_controller,
base::OnceClosure exit_page_callback)
: view_delegate_(view_delegate),
app_list_config_(app_list_config),
dialog_controller_(dialog_controller),
app_list_nudge_controller_(std::make_unique<AppListNudgeController>()),
exit_page_callback_(std::move(exit_page_callback)) {
AppListModelProvider::Get()->AddObserver(this);
SetUseDefaultFillLayout(true);
// The entire page scrolls.
scroll_view_ = AddChildView(std::make_unique<views::ScrollView>(
views::ScrollView::ScrollWithLayers::kEnabled));
scroll_view_->ClipHeightTo(0, std::numeric_limits<int>::max());
scroll_view_->SetDrawOverflowIndicator(false);
// Don't paint a background. The bubble already has one.
scroll_view_->SetBackgroundColor(std::nullopt);
// Arrow keys are used to select app icons.
scroll_view_->SetAllowKeyboardScrolling(false);
// Scroll view will have a gradient mask layer, and is animated during
// hide/show.
scroll_view_->SetPaintToLayer();
scroll_view_->layer()->SetFillsBoundsOpaquely(false);
// Set up scroll bars.
scroll_view_->SetHorizontalScrollBarMode(
views::ScrollView::ScrollBarMode::kDisabled);
auto vertical_scroll = std::make_unique<RoundedScrollBar>(
RoundedScrollBar::Orientation::kVertical);
vertical_scroll->SetInsets(kVerticalScrollInsets);
vertical_scroll->SetSnapBackOnDragOutside(false);
scroll_bar_ = vertical_scroll.get();
scroll_view_->SetVerticalScrollBar(std::move(vertical_scroll));
auto scroll_contents = std::make_unique<views::View>();
auto* layout =
scroll_contents->SetLayoutManager(std::make_unique<views::BoxLayout>(
views::BoxLayout::Orientation::kVertical,
gfx::Insets::VH(kVerticalPaddingBetweenNudgeAndSections,
kHorizontalInteriorMargin),
kVerticalPaddingBetweenNudgeAndSections));
layout->set_cross_axis_alignment(
views::BoxLayout::CrossAxisAlignment::kStretch);
// Add a empty container view. A toast view should be added to
// `toast_container_` for user ed.
toast_container_ =
scroll_contents->AddChildView(std::make_unique<AppListToastContainerView>(
app_list_nudge_controller_.get(), /*keyboard_controller=*/nullptr,
a11y_announcer, view_delegate,
/*delegate=*/this,
/*tablet_mode=*/false));
AppListModel* const model = AppListModelProvider::Get()->model();
sections_container_ =
scroll_contents->AddChildView(std::make_unique<views::View>());
sections_container_->SetLayoutManager(std::make_unique<views::BoxLayout>(
views::BoxLayout::Orientation::kVertical,
gfx::Insets::VH(kVerticalPaddingBetweenSections, 0),
kVerticalPaddingBetweenSections));
PopulateCollections(model);
views::BoxLayoutView* discovery_chip_container =
scroll_contents->AddChildView(std::make_unique<views::BoxLayoutView>());
discovery_chip_container->SetMainAxisAlignment(
views::BoxLayout::MainAxisAlignment::kCenter);
discovery_chip_container->SetCrossAxisAlignment(
views::BoxLayout::CrossAxisAlignment::kCenter);
discovery_chip_ =
discovery_chip_container->AddChildView(std::make_unique<ash::PillButton>(
base::BindRepeating(
&AppListBubbleAppsCollectionsPage::OnDiscoveryChipPressed,
base::Unretained(this)),
l10n_util::GetStringUTF16(
IDS_ASH_LAUNCHER_APPS_COLLECTIONS_DISCOVERY_CHIP_LABEL),
ash::PillButton::kDefaultWithIconLeading, &kDiscoveryChipIcon));
scroll_view_->SetContents(std::move(scroll_contents));
toast_container_->CreateTutorialNudgeView();
toast_container_->UpdateVisibilityState(
AppListToastContainerView::VisibilityState::kShown);
context_menu_ =
std::make_unique<AppsGridContextMenu>(GetGridTypeForContextMenu());
set_context_menu_controller(context_menu_.get());
on_contents_scrolled_subscription_ =
scroll_view_->AddContentsScrolledCallback(
base::BindRepeating(&AppListBubbleAppsCollectionsPage::OnPageScrolled,
base::Unretained(this)));
AppsCollectionsController::Get()->SetReorderCallback(
base::BindRepeating(&AppListBubbleAppsCollectionsPage::RequestAppReorder,
weak_factory_.GetWeakPtr()));
}
AppListBubbleAppsCollectionsPage::~AppListBubbleAppsCollectionsPage() {
AppListModelProvider::Get()->RemoveObserver(this);
}
void AppListBubbleAppsCollectionsPage::OnDiscoveryChipPressed() {
view_delegate_->ActivateItem(
kHelpAppId,
/*event_flags=*/0, ash::AppListLaunchedFrom::kLaunchedFromDiscoveryChip,
/*is_app_above_the_fold=*/false);
}
void AppListBubbleAppsCollectionsPage::AnimateShowPage() {
// If skipping animations, just update visibility.
if (ui::ScopedAnimationDurationScaleMode::is_zero()) {
SetVisible(true);
return;
}
// Ensure any in-progress animations have their cleanup callbacks called.
// Note that this might call SetVisible(false) from the hide animation.
AbortAllAnimations();
// Ensure the view is visible.
SetVisible(true);
ui::Layer* scroll_view_layer = scroll_view_->layer();
DCHECK(scroll_view_layer);
DCHECK_EQ(scroll_view_layer->type(), ui::LAYER_TEXTURED);
gfx::Transform translate_down;
translate_down.Translate(0, kShowPageAnimationVerticalOffset);
views::AnimationBuilder()
.SetPreemptionStrategy(
ui::LayerAnimator::IMMEDIATELY_ANIMATE_TO_NEW_TARGET)
.OnEnded(base::BindRepeating(
&AppListBubbleAppsCollectionsPage::SetVisibilityAfterAnimation,
weak_factory_.GetWeakPtr(), /* visible= */ true))
.OnAborted(base::BindRepeating(
&AppListBubbleAppsCollectionsPage::SetVisibilityAfterAnimation,
weak_factory_.GetWeakPtr(), /* visible= */ true))
.Once()
.SetOpacity(scroll_view_layer, 0.f)
.SetTransform(scroll_view_layer, translate_down)
.At(kShowPageAnimationDelay)
.SetDuration(kShowPageAnimationTransformDuration)
.SetTransform(scroll_view_layer, gfx::Transform(),
gfx::Tween::LINEAR_OUT_SLOW_IN)
.At(kShowPageAnimationDelay)
.SetDuration(kShowPageAnimationOpacityDuration)
.SetOpacity(scroll_view_layer, 1.f);
}
void AppListBubbleAppsCollectionsPage::AnimateHidePage() {
// If skipping animations, just update visibility.
if (ui::ScopedAnimationDurationScaleMode::is_zero()) {
SetVisible(false);
return;
}
ui::Layer* scroll_view_layer = scroll_view_->layer();
DCHECK(scroll_view_layer);
DCHECK_EQ(scroll_view_layer->type(), ui::LAYER_TEXTURED);
// The animation spec says 40 dips down over 250ms, but the opacity animation
// renders the view invisible after 50ms, so animate the visible fraction.
gfx::Transform translate_down;
constexpr int kVerticalOffset = 40 * 50 / 250;
translate_down.Translate(0, kVerticalOffset);
// Opacity: 100% -> 0%, duration 50ms
views::AnimationBuilder()
.SetPreemptionStrategy(
ui::LayerAnimator::IMMEDIATELY_ANIMATE_TO_NEW_TARGET)
.OnEnded(base::BindRepeating(
&AppListBubbleAppsCollectionsPage::SetVisibilityAfterAnimation,
weak_factory_.GetWeakPtr(), /* visible= */ false))
.OnAborted(base::BindRepeating(
&AppListBubbleAppsCollectionsPage::SetVisibilityAfterAnimation,
weak_factory_.GetWeakPtr(), /* visible= */ false))
.Once()
.SetDuration(base::Milliseconds(50))
.SetOpacity(scroll_view_layer, 0.f)
.SetTransform(scroll_view_layer, translate_down);
}
void AppListBubbleAppsCollectionsPage::AbortAllAnimations() {
auto abort_animations = [](views::View* view) {
if (view->layer()) {
view->layer()->GetAnimator()->AbortAllAnimations();
}
};
abort_animations(scroll_view_);
if (toast_container_) {
abort_animations(toast_container_);
}
abort_animations(sections_container_);
}
void AppListBubbleAppsCollectionsPage::OnNudgeRemoved() {
AppsCollectionsController::Get()->SetAppsCollectionDismissed(
AppsCollectionsController::DismissReason::kExitNudge);
CHECK(exit_page_callback_);
std::move(exit_page_callback_).Run();
}
AppsGridContextMenu::GridType
AppListBubbleAppsCollectionsPage::GetGridTypeForContextMenu() {
return AppsGridContextMenu::GridType::kAppsCollectionsGrid;
}
ui::Layer* AppListBubbleAppsCollectionsPage::GetPageAnimationLayerForTest() {
// Animating the `scroll_view_`'s content layer can have its transform
// animations interrupted when the content layer's transforms get set due to
// rtl specific transforms in ScrollView code. Use the `scroll_view_` layer
// for animations to avoid this.
return scroll_view_->layer();
}
AppListToastContainerView*
AppListBubbleAppsCollectionsPage::GetToastContainerViewForTest() {
return toast_container_;
}
void AppListBubbleAppsCollectionsPage::SetVisibilityAfterAnimation(
bool visible) {
// Ensure the view has the correct opacity and transform when the animation is
// aborted.
SetVisible(visible);
ui::Layer* layer = scroll_view()->layer();
layer->SetOpacity(1.f);
layer->SetTransform(gfx::Transform());
}
void AppListBubbleAppsCollectionsPage::OnActiveAppListModelsChanged(
AppListModel* model,
SearchModel* search_model) {
PopulateCollections(model);
}
bool AppListBubbleAppsCollectionsPage::IsInFolder() const {
return false;
}
void AppListBubbleAppsCollectionsPage::SetSelectedView(AppListItemView* view) {
selected_view_ = view;
}
void AppListBubbleAppsCollectionsPage::ClearSelectedView() {
selected_view_ = nullptr;
}
bool AppListBubbleAppsCollectionsPage::IsSelectedView(
const AppListItemView* view) const {
return view == selected_view_;
}
bool AppListBubbleAppsCollectionsPage::InitiateDrag(
AppListItemView* view,
const gfx::Point& location,
const gfx::Point& root_location,
base::OnceClosure drag_start_callback,
base::OnceClosure drag_end_callback) {
return false;
}
void AppListBubbleAppsCollectionsPage::
StartDragAndDropHostDragAfterLongPress() {}
bool AppListBubbleAppsCollectionsPage::UpdateDragFromItem(
bool is_touch,
const ui::LocatedEvent& event) {
return false;
}
void AppListBubbleAppsCollectionsPage::EndDrag(bool cancel) {}
void AppListBubbleAppsCollectionsPage::OnAppListItemViewActivated(
AppListItemView* pressed_item_view,
const ui::Event& event) {
const std::string id = pressed_item_view->item()->id();
view_delegate_->ActivateItem(
id, event.flags(), AppListLaunchedFrom::kLaunchedFromAppsCollections,
IsAboveTheFold(pressed_item_view));
RecordAppListByCollectionLaunched(pressed_item_view->item()->collection_id(),
/*is_apps_collections_page=*/true);
// `this` may be deleted.
}
bool AppListBubbleAppsCollectionsPage::IsAboveTheFold(
AppListItemView* item_view) {
gfx::Rect item_bounds_in_scroll_view = views::View::ConvertRectToTarget(
item_view, scroll_view_->contents(), item_view->GetLocalBounds());
return item_bounds_in_scroll_view.bottom() <
scroll_view_->GetVisibleRect().height();
}
void AppListBubbleAppsCollectionsPage::SetDialogController(
SearchResultPageDialogController* dialog_controller) {
dialog_controller_ = dialog_controller;
}
void AppListBubbleAppsCollectionsPage::RecordAboveTheFoldMetrics() {
std::vector<std::string> apps_above_the_fold = {};
std::vector<std::string> apps_below_the_fold = {};
for (views::View* child_view : sections_container_->children()) {
AppsCollectionSectionView* collection_view =
views::AsViewClass<AppsCollectionSectionView>(child_view);
for (size_t i = 0; i < collection_view->item_views()->view_size(); ++i) {
AppListItemView* app_view = collection_view->item_views()->view_at(i);
if (IsAboveTheFold(app_view)) {
apps_above_the_fold.push_back(app_view->item()->id());
} else {
apps_below_the_fold.push_back(app_view->item()->id());
}
}
}
view_delegate_->RecordAppsDefaultVisibility(
apps_above_the_fold, apps_below_the_fold,
/*is_apps_collections_page=*/true);
}
void AppListBubbleAppsCollectionsPage::PopulateCollections(
AppListModel* model) {
sections_container_->RemoveAllChildViews();
if (!model) {
return;
}
std::vector<AppCollection> available_collections = GetAppCollections();
for (AppCollection collection : available_collections) {
AppsCollectionSectionView* collection_view =
sections_container_->AddChildView(
std::make_unique<AppsCollectionSectionView>(collection,
view_delegate_, this));
collection_view->UpdateAppListConfig(app_list_config_);
collection_view->SetModel(model);
}
}
void AppListBubbleAppsCollectionsPage::RequestAppReorder(
AppListSortOrder order) {
CHECK(dialog_controller_);
std::unique_ptr<views::WidgetDelegate> dialog =
std::make_unique<AppsCollectionsDismissDialog>(base::BindOnce(
&AppListBubbleAppsCollectionsPage::DismissPageAndReorder,
weak_factory_.GetWeakPtr(), order));
dialog_controller_->Show(std::move(dialog));
}
void AppListBubbleAppsCollectionsPage::DismissPageAndReorder(
AppListSortOrder order) {
AppListModelProvider::Get()->model()->delegate()->RequestAppListSort(order);
AppsCollectionsController::Get()->SetAppsCollectionDismissed(
AppsCollectionsController::DismissReason::kSorting);
CHECK(exit_page_callback_);
std::move(exit_page_callback_).Run();
}
void AppListBubbleAppsCollectionsPage::OnPageScrolled() {
const gfx::Rect visible_bounds = scroll_view_->GetVisibleRect();
const gfx::Rect contents_bounds = scroll_view_->contents()->bounds();
// Do not log anything if the contents are not scrolled.
if (visible_bounds.y() == contents_bounds.y()) {
last_bottom_scroll_offset_.reset();
return;
}
const int bottom_scroll_offset =
contents_bounds.bottom() - visible_bounds.bottom();
const int buffer =
kVerticalPaddingBetweenSections + kVerticalPaddingBetweenNudgeAndSections;
if (bottom_scroll_offset <= buffer &&
(!last_bottom_scroll_offset_ || last_bottom_scroll_offset_ > buffer)) {
RecordLauncherWorkflowMetrics(
AppListUserAction::kNavigatedToBottomOfAppList,
/*is_tablet_mode = */ false, std::nullopt);
}
last_bottom_scroll_offset_ = bottom_scroll_offset;
}
BEGIN_METADATA(AppListBubbleAppsCollectionsPage)
END_METADATA
} // namespace ash