| // 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 "chrome/browser/ui/toasts/toast_view.h" |
| |
| #include <climits> |
| #include <memory> |
| |
| #include "chrome/browser/ui/browser_element_identifiers.h" |
| #include "chrome/browser/ui/views/chrome_layout_provider.h" |
| #include "components/strings/grit/components_strings.h" |
| #include "components/vector_icons/vector_icons.h" |
| #include "ui/base/interaction/element_identifier.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/base/metadata/metadata_impl_macros.h" |
| #include "ui/compositor/layer.h" |
| #include "ui/views/accessibility/view_accessibility.h" |
| #include "ui/views/animation/animation_builder.h" |
| #include "ui/views/controls/button/image_button.h" |
| #include "ui/views/controls/button/image_button_factory.h" |
| #include "ui/views/controls/highlight_path_generator.h" |
| #include "ui/views/view_class_properties.h" |
| #include "ui/views/widget/widget.h" |
| #include "ui/views/window/dialog_client_view.h" |
| #include "ui/views/window/dialog_delegate.h" |
| |
| namespace { |
| constexpr int kAnimationEntryDuration = 300; |
| constexpr int kAnimationExitDuration = 150; |
| constexpr int kAnimationHeightOffset = 50; |
| constexpr float kAnimationHeightScale = 0.5; |
| |
| gfx::Transform GetScaleTransformation(gfx::Rect bounds) { |
| gfx::Transform transform; |
| transform.Translate(0, |
| bounds.CenterPoint().y() * (1 - kAnimationHeightScale)); |
| transform.Scale(1, kAnimationHeightScale); |
| return transform; |
| } |
| } // namespace |
| |
| namespace toasts { |
| DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(ToastView, kToastViewId); |
| |
| ToastView::ToastView(views::View* anchor_view, |
| const std::u16string& toast_text, |
| const gfx::VectorIcon& icon, |
| bool has_close_button, |
| bool render_toast_over_web_contents) |
| : BubbleDialogDelegateView(anchor_view, views::BubbleBorder::NONE), |
| AnimationDelegateViews(this), |
| toast_text_(toast_text), |
| icon_(icon), |
| has_close_button_(has_close_button), |
| render_toast_over_web_contents_(render_toast_over_web_contents) { |
| SetShowCloseButton(false); |
| DialogDelegate::SetButtons(static_cast<int>(ui::mojom::DialogButton::kNone)); |
| set_corner_radius(ChromeLayoutProvider::Get()->GetDistanceMetric( |
| DISTANCE_TOAST_BUBBLE_HEIGHT)); |
| SetProperty(views::kElementIdentifierKey, kToastElementId); |
| set_close_on_deactivate(false); |
| SetProperty(views::kElementIdentifierKey, kToastViewId); |
| SetAccessibleWindowRole(ax::mojom::Role::kAlert); |
| SetAccessibleTitle(toast_text_); |
| } |
| |
| ToastView::~ToastView() = default; |
| |
| void ToastView::AddActionButton(const std::u16string& action_button_text, |
| base::RepeatingClosure action_button_callback) { |
| CHECK(!has_action_button_); |
| has_action_button_ = true; |
| action_button_text_ = action_button_text; |
| action_button_callback_ = std::move(action_button_callback); |
| } |
| |
| void ToastView::Init() { |
| ChromeLayoutProvider* lp = ChromeLayoutProvider::Get(); |
| SetLayoutManager( |
| std::make_unique<views::BoxLayout>( |
| views::BoxLayout::Orientation::kHorizontal, gfx::Insets())) |
| ->set_between_child_spacing( |
| lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_BETWEEN_CHILD_SPACING)); |
| |
| icon_view_ = AddChildView(std::make_unique<views::ImageView>()); |
| |
| label_ = AddChildView(std::make_unique<views::Label>( |
| toast_text_, views::style::CONTEXT_BUTTON, views::style::STYLE_PRIMARY)); |
| label_->SetEnabledColorId(ui::kColorToastForeground); |
| label_->SetMultiLine(false); |
| label_->SetHorizontalAlignment(gfx::ALIGN_LEFT); |
| label_->SetAllowCharacterBreak(false); |
| label_->SetAutoColorReadabilityEnabled(false); |
| label_->SetLineHeight( |
| lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_HEIGHT_CONTENT)); |
| |
| if (has_action_button_) { |
| label_->SetProperty( |
| views::kMarginsKey, |
| gfx::Insets::TLBR( |
| 0, 0, 0, |
| lp->GetDistanceMetric( |
| DISTANCE_TOAST_BUBBLE_BETWEEN_LABEL_ACTION_BUTTON_SPACING) - |
| lp->GetDistanceMetric( |
| DISTANCE_TOAST_BUBBLE_BETWEEN_CHILD_SPACING))); |
| |
| action_button_ = AddChildView(std::make_unique<views::MdTextButton>( |
| action_button_callback_.Then( |
| base::BindRepeating(&ToastView::Close, base::Unretained(this), |
| ToastCloseReason::kActionButton)), |
| action_button_text_)); |
| action_button_->SetEnabledTextColorIds(ui::kColorToastButton); |
| action_button_->SetBgColorIdOverride(ui::kColorToastBackgroundProminent); |
| action_button_->SetStrokeColorIdOverride(ui::kColorToastButton); |
| action_button_->SetPreferredSize(gfx::Size( |
| action_button_->GetPreferredSize().width(), |
| lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_HEIGHT_ACTION_BUTTON))); |
| action_button_->SetStyle(ui::ButtonStyle::kProminent); |
| action_button_->GetViewAccessibility().SetRole(ax::mojom::Role::kAlert); |
| SetInitiallyFocusedView(action_button_); |
| } |
| |
| if (has_close_button_) { |
| close_button_ = AddChildView(views::CreateVectorImageButtonWithNativeTheme( |
| base::BindRepeating(&ToastView::Close, base::Unretained(this), |
| ToastCloseReason::kCloseButton), |
| vector_icons::kCloseIcon, |
| lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_HEIGHT_CONTENT) - |
| lp->GetInsetsMetric(views::INSETS_VECTOR_IMAGE_BUTTON).height(), |
| ui::kColorToastForeground)); |
| views::InstallCircleHighlightPathGenerator(close_button_); |
| close_button_->SetAccessibleName(l10n_util::GetStringUTF16(IDS_CLOSE)); |
| if (!HasConfiguredInitiallyFocusedView()) { |
| SetInitiallyFocusedView(close_button_); |
| } |
| } |
| |
| // Height of the toast is set implicitly by adding margins depending on the |
| // height of the tallest child. |
| const int total_vertical_margins = |
| lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_HEIGHT) - |
| lp->GetDistanceMetric(action_button_ |
| ? DISTANCE_TOAST_BUBBLE_HEIGHT_ACTION_BUTTON |
| : DISTANCE_TOAST_BUBBLE_HEIGHT_CONTENT); |
| const int top_margin = total_vertical_margins / 2; |
| const int right_margin = lp->GetDistanceMetric( |
| close_button_ ? DISTANCE_TOAST_BUBBLE_MARGIN_RIGHT_CLOSE_BUTTON |
| : action_button_ ? DISTANCE_TOAST_BUBBLE_MARGIN_RIGHT_ACTION_BUTTON |
| : DISTANCE_TOAST_BUBBLE_MARGIN_RIGHT_LABEL); |
| set_margins(gfx::Insets::TLBR( |
| top_margin, lp->GetDistanceMetric(DISTANCE_TOAST_BUBBLE_MARGIN_LEFT), |
| total_vertical_margins - top_margin, right_margin)); |
| |
| if (has_action_button_ || has_close_button_) { |
| SetFocusTraversesOut(true); |
| } else { |
| set_focus_traversable_from_anchor_view(false); |
| SetCanActivate(false); |
| } |
| } |
| |
| void ToastView::AnimationProgressed(const gfx::Animation* animation) { |
| const double value = gfx::Tween::CalculateValue( |
| height_animation_tween_, height_animation_.GetCurrentValue()); |
| const gfx::Rect current_bounds = gfx::Tween::RectValueBetween( |
| value, starting_widget_bounds_, target_widget_bounds_); |
| GetWidget()->SetBounds(current_bounds); |
| } |
| |
| void ToastView::AnimateIn() { |
| if (!gfx::Animation::ShouldRenderRichAnimation()) { |
| return; |
| } |
| |
| target_widget_bounds_ = GetWidget()->GetWindowBoundsInScreen(); |
| starting_widget_bounds_ = |
| target_widget_bounds_ - gfx::Vector2d{0, kAnimationHeightOffset}; |
| height_animation_tween_ = gfx::Tween::ACCEL_5_70_DECEL_90; |
| height_animation_.SetDuration(base::Milliseconds(kAnimationEntryDuration)); |
| height_animation_.Start(); |
| |
| views::View* const bubble_frame_view = GetBubbleFrameView(); |
| bubble_frame_view->SetPaintToLayer(); |
| bubble_frame_view->layer()->SetFillsBoundsOpaquely(false); |
| bubble_frame_view->SetTransform( |
| GetScaleTransformation(bubble_frame_view->bounds())); |
| bubble_frame_view->layer()->SetOpacity(0); |
| GetDialogClientView()->SetBackground( |
| views::CreateThemedSolidBackground(ui::kColorToastBackgroundProminent)); |
| GetDialogClientView()->SetPaintToLayer(); |
| GetDialogClientView()->layer()->SetOpacity(0); |
| views::AnimationBuilder() |
| .Once() |
| .SetDuration(base::Milliseconds(kAnimationEntryDuration)) |
| .SetTransform(bubble_frame_view, gfx::Transform(), |
| height_animation_tween_) |
| .At(base::TimeDelta()) |
| .SetDuration(base::Milliseconds(50)) |
| .SetOpacity(bubble_frame_view, 1) |
| .Then() |
| .SetDuration(base::Milliseconds(150)) |
| .SetOpacity(GetDialogClientView(), 1); |
| } |
| |
| void ToastView::Close(ToastCloseReason reason) { |
| // TODO(crbug.com/358610872): Log toast close reason metric. |
| views::Widget::ClosedReason widget_closed_reason = |
| views::Widget::ClosedReason::kUnspecified; |
| switch (reason) { |
| case ToastCloseReason::kCloseButton: |
| widget_closed_reason = views::Widget::ClosedReason::kCloseButtonClicked; |
| break; |
| case ToastCloseReason::kActionButton: |
| widget_closed_reason = views::Widget::ClosedReason::kAcceptButtonClicked; |
| break; |
| default: |
| break; |
| } |
| |
| if (GetWidget()->IsVisible()) { |
| AnimateOut( |
| base::BindOnce(&views::Widget::CloseWithReason, |
| base::Unretained(GetWidget()), widget_closed_reason), |
| reason != ToastCloseReason::kPreempted); |
| } else { |
| GetWidget()->CloseWithReason(widget_closed_reason); |
| } |
| } |
| |
| void ToastView::UpdateRenderToastOverWebContentsAndPaint( |
| const bool render_toast_over_web_contents) { |
| render_toast_over_web_contents_ = render_toast_over_web_contents; |
| SizeToContents(); |
| } |
| |
| gfx::Rect ToastView::GetBubbleBounds() { |
| views::View* anchor_view = GetAnchorView(); |
| if (!anchor_view) { |
| return gfx::Rect(); |
| } |
| |
| const gfx::Size bubble_size = |
| GetWidget()->GetContentsView()->GetPreferredSize(); |
| const gfx::Rect anchor_bounds = anchor_view->GetBoundsInScreen(); |
| const int x = |
| anchor_bounds.x() + (anchor_bounds.width() - bubble_size.width()) / 2; |
| // Take bubble out of its original bounds to cross "line of death", unless in |
| // fullscreen mode where the top container isn't rendered. |
| const int y = anchor_bounds.bottom() - (render_toast_over_web_contents_ |
| ? views::BubbleBorder::kShadowBlur |
| : (bubble_size.height() / 2)); |
| return gfx::Rect(x, y, bubble_size.width(), bubble_size.height()); |
| } |
| |
| void ToastView::OnThemeChanged() { |
| BubbleDialogDelegateView::OnThemeChanged(); |
| const auto* color_provider = GetColorProvider(); |
| set_color(color_provider->GetColor(ui::kColorToastBackgroundProminent)); |
| icon_view_->SetImage(ui::ImageModel::FromVectorIcon( |
| *icon_, color_provider->GetColor(ui::kColorToastForeground), |
| ChromeLayoutProvider::Get()->GetDistanceMetric( |
| DISTANCE_TOAST_BUBBLE_HEIGHT_CONTENT))); |
| } |
| |
| void ToastView::AnimateOut(base::OnceClosure callback, |
| bool show_height_animation) { |
| if (!gfx::Animation::ShouldRenderRichAnimation()) { |
| std::move(callback).Run(); |
| return; |
| } |
| |
| views::View* const bubble_frame_view = GetBubbleFrameView(); |
| |
| if (show_height_animation) { |
| starting_widget_bounds_ = GetWidget()->GetWindowBoundsInScreen(); |
| target_widget_bounds_ = |
| starting_widget_bounds_ - gfx::Vector2d{0, kAnimationHeightOffset}; |
| height_animation_tween_ = gfx::Tween::ACCEL_30_DECEL_20_85; |
| height_animation_.SetDuration(base::Milliseconds(kAnimationExitDuration)); |
| height_animation_.Start(); |
| |
| views::AnimationBuilder() |
| .Once() |
| .SetDuration(base::Milliseconds(kAnimationExitDuration)) |
| .SetTransform(bubble_frame_view, |
| GetScaleTransformation(bubble_frame_view->bounds()), |
| height_animation_tween_); |
| } |
| |
| views::AnimationBuilder() |
| .OnEnded(std::move(callback)) |
| .Once() |
| .SetDuration(base::Milliseconds(100)) |
| .SetOpacity(GetDialogClientView(), 0) |
| .Then() |
| .SetDuration(base::Milliseconds(50)) |
| .SetOpacity(bubble_frame_view, 0); |
| } |
| |
| BEGIN_METADATA(ToastView) |
| END_METADATA |
| |
| } // namespace toasts |