| // 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/system/toast/system_nudge_view.h" |
| |
| #include <algorithm> |
| #include <string> |
| #include <utility> |
| #include <vector> |
| |
| #include "ash/public/cpp/ash_view_ids.h" |
| #include "ash/public/cpp/style/color_provider.h" |
| #include "ash/public/cpp/system/anchored_nudge_data.h" |
| #include "ash/resources/vector_icons/vector_icons.h" |
| #include "ash/shell.h" |
| #include "ash/strings/grit/ash_strings.h" |
| #include "ash/style/ash_color_id.h" |
| #include "ash/style/keyboard_shortcut_view.h" |
| #include "ash/style/pill_button.h" |
| #include "ash/style/system_shadow.h" |
| #include "ash/style/typography.h" |
| #include "ash/system/toast/nudge_constants.h" |
| #include "chromeos/constants/chromeos_features.h" |
| #include "ui/base/l10n/l10n_util.h" |
| #include "ui/base/metadata/metadata_impl_macros.h" |
| #include "ui/chromeos/styles/cros_tokens_color_mappings.h" |
| #include "ui/compositor/layer.h" |
| #include "ui/gfx/geometry/insets.h" |
| #include "ui/gfx/geometry/point.h" |
| #include "ui/gfx/geometry/rect.h" |
| #include "ui/views/background.h" |
| #include "ui/views/controls/button/image_button.h" |
| #include "ui/views/controls/image_view.h" |
| #include "ui/views/controls/label.h" |
| #include "ui/views/highlight_border.h" |
| #include "ui/views/layout/fill_layout.h" |
| #include "ui/views/layout/flex_layout.h" |
| #include "ui/views/layout/flex_layout_view.h" |
| #include "ui/views/view.h" |
| #include "ui/views/view_class_properties.h" |
| #include "ui/views/view_tracker.h" |
| |
| namespace ash { |
| |
| namespace { |
| |
| constexpr int kFocusableViewsGroupId = 1; |
| |
| // Nudge constants |
| constexpr gfx::Insets kNudgeInteriorMargin = gfx::Insets::VH(20, 20); |
| constexpr gfx::Insets kTextOnlyNudgeInteriorMargin = gfx::Insets::VH(12, 20); |
| |
| constexpr gfx::Insets kNudgeWithCloseButton_InteriorMargin = |
| gfx::Insets::TLBR(8, 20, 20, 8); |
| constexpr gfx::Insets |
| kNudgeWithCloseButton_ImageAndTextContainerInteriorMargin = |
| gfx::Insets::TLBR(12, 0, 0, 12); |
| constexpr gfx::Insets kNudgeWithCloseButton_ButtonContainerInteriorMargin = |
| gfx::Insets::TLBR(0, 0, 0, 12); |
| |
| constexpr float kNudgeCornerRadius = 24.0f; |
| constexpr float kNudgePointyCornerRadius = 4.0f; |
| |
| // Label constants |
| constexpr int kBodyLabelMaxLines = 3; |
| |
| // Image constants |
| constexpr int kImageViewSize = 60; |
| constexpr int kImageViewCornerRadius = 12; |
| |
| // Button constants |
| constexpr gfx::Insets kButtonsMargins = gfx::Insets::VH(0, 8); |
| |
| // Padding constants |
| constexpr int kButtonContainerTopPadding = 16; |
| constexpr int kImageViewTrailingPadding = 16; |
| constexpr int kTitleBottomPadding = 4; |
| |
| void AddPaddingView(views::View* parent, int width, int height) { |
| parent->AddChildView(std::make_unique<views::View>()) |
| ->SetPreferredSize(gfx::Size(width, height)); |
| } |
| |
| // Returns true if the provided arrow is located at a corner. |
| bool CalculateIsCornerAnchored(views::BubbleBorder::Arrow arrow) { |
| switch (arrow) { |
| case views::BubbleBorder::Arrow::TOP_LEFT: |
| case views::BubbleBorder::Arrow::TOP_RIGHT: |
| case views::BubbleBorder::Arrow::BOTTOM_LEFT: |
| case views::BubbleBorder::Arrow::BOTTOM_RIGHT: |
| case views::BubbleBorder::Arrow::LEFT_TOP: |
| case views::BubbleBorder::Arrow::RIGHT_TOP: |
| case views::BubbleBorder::Arrow::LEFT_BOTTOM: |
| case views::BubbleBorder::Arrow::RIGHT_BOTTOM: |
| return true; |
| default: |
| return false; |
| } |
| } |
| |
| // Returns a `gfx::RoundedCornersF` object that has a single pointy corner which |
| // is defined based on the nudge's position in relation to its anchor view. |
| gfx::RoundedCornersF CalculatePointyAnchoredNudgeCorners( |
| views::View* nudge_view, |
| views::View* anchor_view) { |
| auto nudge_bounds = nudge_view->GetBoundsInScreen(); |
| auto anchor_bounds = anchor_view->GetBoundsInScreen(); |
| |
| bool pointy_bottom = nudge_bounds.bottom() < anchor_bounds.bottom(); |
| bool pointy_right = nudge_bounds.right() < anchor_bounds.right(); |
| |
| auto top_left_radius = !pointy_bottom && !pointy_right |
| ? kNudgePointyCornerRadius |
| : kNudgeCornerRadius; |
| auto top_right_radius = !pointy_bottom && pointy_right |
| ? kNudgePointyCornerRadius |
| : kNudgeCornerRadius; |
| auto bottom_right_radius = pointy_bottom && pointy_right |
| ? kNudgePointyCornerRadius |
| : kNudgeCornerRadius; |
| auto bottom_left_radius = pointy_bottom && !pointy_right |
| ? kNudgePointyCornerRadius |
| : kNudgeCornerRadius; |
| |
| return gfx::RoundedCornersF(top_left_radius, top_right_radius, |
| bottom_right_radius, bottom_left_radius); |
| } |
| |
| } // namespace |
| |
| DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(SystemNudgeView, kBubbleIdForTesting); |
| DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(SystemNudgeView, |
| kPrimaryButtonIdForTesting); |
| DEFINE_CLASS_ELEMENT_IDENTIFIER_VALUE(SystemNudgeView, |
| kSecondaryButtonIdForTesting); |
| |
| // FocusableChildrenObserver -------------------------------------------------- |
| |
| // Observes child view's focus state changes. |
| class SystemNudgeView::FocusableChildrenObserver : public views::ViewObserver { |
| public: |
| FocusableChildrenObserver( |
| std::vector<views::View*> observed_children, |
| base::RepeatingCallback<void(/*has_focus=*/bool)> focus_callback) |
| : observed_children_(std::move(observed_children)), |
| focus_callback_(std::move(focus_callback)) { |
| for (views::View* observed_child : observed_children_) { |
| observed_child->AddObserver(this); |
| } |
| } |
| |
| FocusableChildrenObserver(const FocusableChildrenObserver&) = delete; |
| FocusableChildrenObserver& operator=(const FocusableChildrenObserver&) = |
| delete; |
| |
| ~FocusableChildrenObserver() override { |
| for (views::View* observed_child : observed_children_) { |
| observed_child->RemoveObserver(this); |
| } |
| } |
| |
| private: |
| // ViewObserver: |
| void OnViewFocused(views::View* observed_view) override { |
| focus_callback_.Run(/*has_focus=*/true); |
| } |
| |
| void OnViewBlurred(views::View* observed_view) override { |
| focus_callback_.Run(/*has_focus=*/false); |
| } |
| |
| const std::vector<views::View*> observed_children_; |
| |
| base::RepeatingCallback<void(/*has_focus=*/bool)> focus_callback_; |
| }; |
| |
| // SystemNudgeView ------------------------------------------------------------ |
| |
| SystemNudgeView::SystemNudgeView( |
| const AnchoredNudgeData& nudge_data, |
| base::RepeatingCallback<void(/*is_hovered_or_has_focus=*/bool)> |
| hover_or_focus_changed_callback) |
| : shadow_(SystemShadow::CreateShadowOnTextureLayer( |
| SystemShadow::Type::kElevation4)), |
| is_corner_anchored_(CalculateIsCornerAnchored(nudge_data.arrow)), |
| hover_changed_callback_(std::move(nudge_data.hover_changed_callback)), |
| hover_or_focus_changed_callback_( |
| std::move(hover_or_focus_changed_callback)) { |
| // Painted to layer so the view can be semi-transparent and set rounded |
| // corners. |
| SetPaintToLayer(); |
| if (chromeos::features::IsSystemBlurEnabled()) { |
| layer()->SetFillsBoundsOpaquely(false); |
| layer()->SetBackgroundBlur(ColorProvider::kBackgroundBlurSigma); |
| layer()->SetBackdropFilterQuality(ColorProvider::kBackgroundBlurQuality); |
| } |
| |
| const ui::ColorId default_background_color_id = |
| chromeos::features::IsSystemBlurEnabled() |
| ? static_cast<ui::ColorId>(kColorAshShieldAndBase80) |
| : cros_tokens::kCrosSysSystemOnBaseOpaque; |
| SetBackground(views::CreateSolidBackground( |
| nudge_data.background_color_id.value_or(default_background_color_id))); |
| SetNotifyEnterExitOnChild(true); |
| SetProperty(views::kElementIdentifierKey, kBubbleIdForTesting); |
| |
| // Cache the anchor view when the nudge anchors by its corner to set a pointy |
| // corner based on the nudge's position in relation to this anchor view. |
| if (nudge_data.is_anchored() && is_corner_anchored_) { |
| anchor_view_tracker_ = std::make_unique<views::ViewTracker>(); |
| anchor_view_tracker_->SetView(nudge_data.GetAnchorView()); |
| SetNudgeRoundedCornerRadius(CalculatePointyAnchoredNudgeCorners( |
| /*nudge_view=*/this, anchor_view_tracker_->view())); |
| } else { |
| SetNudgeRoundedCornerRadius(gfx::RoundedCornersF(kNudgeCornerRadius)); |
| } |
| |
| SetOrientation(views::LayoutOrientation::kVertical); |
| SetInteriorMargin(kNudgeInteriorMargin); |
| SetCrossAxisAlignment(views::LayoutAlignment::kStretch); |
| |
| // TODO(crbug.com/40232718): See View::SetLayoutManagerUseConstrainedSpace |
| SetLayoutManagerUseConstrainedSpace(false); |
| |
| const bool nudge_is_text_only = nudge_data.image_model.IsEmpty() && |
| nudge_data.title_text.empty() && |
| nudge_data.primary_button_text.empty() && |
| nudge_data.keyboard_codes.empty(); |
| |
| // Nudges without an anchor view that are not text-only will have a close |
| // button that is visible on view hovered. |
| const bool has_close_button = |
| !nudge_data.is_anchored() && !nudge_is_text_only; |
| |
| views::View* image_and_text_container; |
| auto image_and_text_container_unique = |
| views::Builder<views::FlexLayoutView>() |
| .SetOrientation(views::LayoutOrientation::kHorizontal) |
| .SetCrossAxisAlignment(views::LayoutAlignment::kStart) |
| .SetInteriorMargin( |
| has_close_button |
| ? kNudgeWithCloseButton_ImageAndTextContainerInteriorMargin |
| : gfx::Insets()) |
| .Build(); |
| |
| if (has_close_button) { |
| SetInteriorMargin(kNudgeWithCloseButton_InteriorMargin); |
| |
| // Set the `image_and_text_container` parent to use a `FillLayout` so it can |
| // allow for overlap with the close button. |
| auto* fill_layout_container = AddChildView(std::make_unique<views::View>()); |
| fill_layout_container->SetLayoutManager( |
| std::make_unique<views::FillLayout>()); |
| |
| image_and_text_container = fill_layout_container->AddChildView( |
| std::move(image_and_text_container_unique)); |
| |
| auto* close_button_container = fill_layout_container->AddChildView( |
| views::Builder<views::FlexLayoutView>() |
| .SetOrientation(views::LayoutOrientation::kHorizontal) |
| .SetMainAxisAlignment(views::LayoutAlignment::kEnd) |
| .SetCrossAxisAlignment(views::LayoutAlignment::kStart) |
| .Build()); |
| |
| close_button_ = close_button_container->AddChildView( |
| views::Builder<views::ImageButton>() |
| .SetID(VIEW_ID_SYSTEM_NUDGE_CLOSE_BUTTON) |
| .SetCallback(std::move(nudge_data.close_button_callback)) |
| .SetImageModel(views::Button::STATE_NORMAL, |
| ui::ImageModel::FromVectorIcon( |
| kCloseSmallIcon, cros_tokens::kCrosSysOnSurface)) |
| .SetTooltipText(l10n_util::GetStringUTF16( |
| IDS_ASH_SYSTEM_NUDGE_CLOSE_BUTTON_TOOLTIP)) |
| .SetVisible(false) |
| .Build()); |
| } else { |
| image_and_text_container = |
| AddChildView(std::move(image_and_text_container_unique)); |
| } |
| |
| const bool has_image = !nudge_data.image_model.IsEmpty(); |
| if (has_image) { |
| auto* image_view = image_and_text_container->AddChildView( |
| views::Builder<views::ImageView>() |
| .SetID(VIEW_ID_SYSTEM_NUDGE_IMAGE_VIEW) |
| .SetPreferredSize(gfx::Size(kImageViewSize, kImageViewSize)) |
| .SetImage(nudge_data.image_model) |
| // Painted to layer to set rounded corners. |
| .SetPaintToLayer() |
| .Build()); |
| // Certain `ImageModels` do not have the ability to set their size in the |
| // constructor, so instead we can do it here. |
| if (nudge_data.fill_image_size) { |
| image_view->SetImageSize(gfx::Size(kImageViewSize, kImageViewSize)); |
| } |
| image_view->layer()->SetFillsBoundsOpaquely(false); |
| image_view->layer()->SetRoundedCornerRadius( |
| gfx::RoundedCornersF(kImageViewCornerRadius)); |
| |
| if (nudge_data.image_background_color_id) { |
| image_view->SetBackground( |
| views::CreateSolidBackground(*nudge_data.image_background_color_id)); |
| } |
| |
| AddPaddingView(image_and_text_container, kImageViewTrailingPadding, |
| kImageViewSize); |
| } |
| |
| const bool has_title = !nudge_data.title_text.empty(); |
| auto* text_container = image_and_text_container->AddChildView( |
| views::Builder<views::FlexLayoutView>() |
| .SetOrientation(views::LayoutOrientation::kVertical) |
| .SetProperty( |
| views::kFlexBehaviorKey, |
| views::FlexSpecification(views::LayoutOrientation::kVertical, |
| views::MinimumFlexSizeRule::kPreferred, |
| views::MaximumFlexSizeRule::kUnbounded)) |
| // If the nudge has an image and no title, vertically center the text. |
| .SetMainAxisAlignment(has_image && !has_title |
| ? views::LayoutAlignment::kCenter |
| : views::LayoutAlignment::kStart) |
| .Build()); |
| |
| auto label_width = nudge_data.image_model.IsEmpty() |
| ? kNudgeLabelWidth_NudgeWithoutLeadingImage |
| : kNudgeLabelWidth_NudgeWithLeadingImage; |
| |
| if (has_title) { |
| auto* title_label = text_container->AddChildView( |
| views::Builder<views::Label>() |
| .SetID(VIEW_ID_SYSTEM_NUDGE_TITLE_LABEL) |
| .SetText(nudge_data.title_text) |
| .SetTooltipText(nudge_data.title_text) |
| .SetHorizontalAlignment(gfx::ALIGN_LEFT) |
| .SetEnabledColor(cros_tokens::kCrosSysOnSurface) |
| .SetAutoColorReadabilityEnabled(false) |
| .SetSubpixelRenderingEnabled(false) |
| .SetFontList(TypographyProvider::Get()->ResolveTypographyToken( |
| TypographyToken::kCrosButton1)) |
| .SetMaximumWidthSingleLine(label_width) |
| .Build()); |
| |
| AddPaddingView(text_container, title_label->width(), kTitleBottomPadding); |
| } |
| |
| auto* body_label = text_container->AddChildView( |
| views::Builder<views::Label>() |
| .SetID(VIEW_ID_SYSTEM_NUDGE_BODY_LABEL) |
| .SetText(nudge_data.body_text) |
| .SetTooltipText(nudge_data.body_text) |
| .SetHorizontalAlignment(gfx::ALIGN_LEFT) |
| .SetEnabledColor(cros_tokens::kCrosSysOnSurface) |
| .SetAutoColorReadabilityEnabled(false) |
| .SetSubpixelRenderingEnabled(false) |
| .SetFontList(TypographyProvider::Get()->ResolveTypographyToken( |
| TypographyToken::kCrosAnnotation1)) |
| .SetMultiLine(true) |
| .SetMaxLines(kBodyLabelMaxLines) |
| .SizeToFit(label_width) |
| .Build()); |
| |
| // TODO(b/302368860): Add support for a view to display keyboard shortcuts in |
| // the same style as the launcher and the new keyboard shortcut app. |
| if (!nudge_data.keyboard_codes.empty()) { |
| AddPaddingView(text_container, image_and_text_container->width(), |
| kTitleBottomPadding); |
| |
| text_container |
| ->AddChildView( |
| std::make_unique<KeyboardShortcutView>(nudge_data.keyboard_codes)) |
| ->SetID(VIEW_ID_SYSTEM_NUDGE_SHORTCUT_VIEW); |
| } |
| |
| // Return early if there are no buttons. |
| if (nudge_data.primary_button_text.empty()) { |
| CHECK(nudge_data.secondary_button_text.empty()); |
| |
| // Update nudge margins and body label max width if nudge only has text. |
| if (nudge_is_text_only) { |
| SetInteriorMargin(kTextOnlyNudgeInteriorMargin); |
| // `SizeToFit` is reset to zero so a maximum width can be set. |
| body_label->SizeToFit(0); |
| body_label->SetMaximumWidth(kNudgeLabelWidth_TextOnlyNudge); |
| } |
| return; |
| } |
| |
| // Add top padding for the buttons row. |
| AddPaddingView(this, image_and_text_container->width(), |
| kButtonContainerTopPadding); |
| |
| auto* buttons_container = AddChildView( |
| views::Builder<views::FlexLayoutView>() |
| .SetMainAxisAlignment(views::LayoutAlignment::kEnd) |
| .SetInteriorMargin( |
| has_close_button |
| ? kNudgeWithCloseButton_ButtonContainerInteriorMargin |
| : gfx::Insets()) |
| .SetIgnoreDefaultMainAxisMargins(true) |
| .SetCollapseMargins(true) |
| .Build()); |
| buttons_container->SetDefault(views::kMarginsKey, kButtonsMargins); |
| |
| std::vector<views::View*> focusable_children; |
| focusable_children.push_back(buttons_container->AddChildView( |
| views::Builder<PillButton>() |
| .SetID(VIEW_ID_SYSTEM_NUDGE_PRIMARY_BUTTON) |
| .SetGroup(kFocusableViewsGroupId) |
| .SetCallback(std::move(nudge_data.primary_button_callback)) |
| .SetText(nudge_data.primary_button_text) |
| .SetTooltipText(nudge_data.primary_button_text) |
| .SetPillButtonType(PillButton::Type::kPrimaryWithoutIcon) |
| .SetFocusBehavior(views::View::FocusBehavior::ALWAYS) |
| .SetProperty(views::kElementIdentifierKey, kPrimaryButtonIdForTesting) |
| .Build())); |
| |
| if (!nudge_data.secondary_button_text.empty()) { |
| focusable_children.push_back(buttons_container->AddChildViewAt( |
| views::Builder<PillButton>() |
| .SetID(VIEW_ID_SYSTEM_NUDGE_SECONDARY_BUTTON) |
| .SetGroup(kFocusableViewsGroupId) |
| .SetCallback(std::move(nudge_data.secondary_button_callback)) |
| .SetText(nudge_data.secondary_button_text) |
| .SetTooltipText(nudge_data.secondary_button_text) |
| .SetPillButtonType(PillButton::Type::kSecondaryWithoutIcon) |
| .SetFocusBehavior(views::View::FocusBehavior::ALWAYS) |
| .SetProperty(views::kElementIdentifierKey, |
| kSecondaryButtonIdForTesting) |
| .Build(), |
| 0)); |
| } |
| |
| focusable_children_observer_ = std::make_unique<FocusableChildrenObserver>( |
| std::move(focusable_children), |
| base::BindRepeating(&SystemNudgeView::HandleOnChildFocusStateChanged, |
| // Unretained is safe because `this` outlives the |
| // `FocusableChildrenObserver`. |
| base::Unretained(this))); |
| } |
| |
| SystemNudgeView::~SystemNudgeView() { |
| auto* widget = GetWidget(); |
| if (widget && widget->HasObserver(this)) { |
| widget->RemoveObserver(this); |
| } |
| } |
| |
| void SystemNudgeView::AddedToWidget() { |
| GetWidget()->AddObserver(this); |
| |
| // Attach the shadow at the bottom of the parent layer. |
| auto* shadow_layer = shadow_->GetLayer(); |
| auto* parent_layer = layer()->parent(); |
| parent_layer->Add(shadow_layer); |
| parent_layer->StackAtBottom(shadow_layer); |
| } |
| |
| void SystemNudgeView::RemovedFromWidget() { |
| auto* widget = GetWidget(); |
| if (widget && widget->HasObserver(this)) { |
| widget->RemoveObserver(this); |
| } |
| } |
| |
| void SystemNudgeView::OnMouseEntered(const ui::MouseEvent& event) { |
| HandleOnMouseHovered(/*mouse_entered=*/true); |
| } |
| |
| void SystemNudgeView::OnMouseExited(const ui::MouseEvent& event) { |
| HandleOnMouseHovered(/*mouse_entered=*/false); |
| } |
| |
| void SystemNudgeView::OnWidgetBoundsChanged(views::Widget* widget, |
| const gfx::Rect& new_bounds) { |
| // `shadow_` should have the same bounds as the view's layer. |
| shadow_->SetContentBounds(layer()->bounds()); |
| |
| if (anchor_view_tracker_ && anchor_view_tracker_->view() && |
| is_corner_anchored_) { |
| SetNudgeRoundedCornerRadius(CalculatePointyAnchoredNudgeCorners( |
| /*nudge_view=*/this, anchor_view_tracker_->view())); |
| } |
| } |
| |
| void SystemNudgeView::OnWidgetDestroying(views::Widget* widget) { |
| if (widget && widget->HasObserver(this)) { |
| widget->RemoveObserver(this); |
| } |
| } |
| |
| void SystemNudgeView::HandleOnChildFocusStateChanged(bool focus_entered) { |
| hover_or_focus_changed_callback_.Run(IsHoveredOrChildHasFocus()); |
| } |
| |
| void SystemNudgeView::HandleOnMouseHovered(bool mouse_entered) { |
| if (close_button_) { |
| close_button_->SetVisible(mouse_entered); |
| } |
| |
| if (hover_changed_callback_) { |
| hover_changed_callback_.Run(mouse_entered); |
| } |
| hover_or_focus_changed_callback_.Run(IsHoveredOrChildHasFocus()); |
| } |
| |
| bool SystemNudgeView::IsHoveredOrChildHasFocus() { |
| views::View::Views focusable_views; |
| GetViewsInGroup(kFocusableViewsGroupId, &focusable_views); |
| bool child_has_focus = |
| std::find(focusable_views.begin(), focusable_views.end(), |
| GetWidget()->GetFocusManager()->GetFocusedView()) != |
| focusable_views.end(); |
| |
| return IsMouseHovered() || child_has_focus; |
| } |
| |
| void SystemNudgeView::SetNudgeRoundedCornerRadius( |
| const gfx::RoundedCornersF& rounded_corners) { |
| layer()->SetRoundedCornerRadius(rounded_corners); |
| SetBorder(std::make_unique<views::HighlightBorder>( |
| rounded_corners, views::HighlightBorder::Type::kHighlightBorderOnShadow)); |
| shadow_->SetRoundedCorners(rounded_corners); |
| } |
| |
| BEGIN_METADATA(SystemNudgeView) |
| END_METADATA |
| |
| } // namespace ash |