chromium/ash/wm/gestures/back_gesture/back_gesture_contextual_nudge.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/wm/gestures/back_gesture/back_gesture_contextual_nudge.h"

#include "ash/controls/contextual_tooltip.h"
#include "ash/public/cpp/shell_window_ids.h"
#include "ash/session/session_controller_impl.h"
#include "ash/shell.h"
#include "ash/strings/grit/ash_strings.h"
#include "ash/style/ash_color_id.h"
#include "ash/style/ash_color_provider.h"
#include "ash/wm/gestures/back_gesture/back_gesture_util.h"
#include "base/i18n/rtl.h"
#include "base/timer/timer.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_animation_observer.h"
#include "ui/compositor/scoped_layer_animation_settings.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/color_palette.h"
#include "ui/gfx/shadow_value.h"
#include "ui/gfx/skia_paint_util.h"
#include "ui/views/controls/label.h"
#include "ui/views/view.h"

namespace ash {

namespace {

// Width of the contextual nudge.
constexpr int kBackgroundWidth = 320;

// Radius of the circle in the middle of the contextual nudge.
constexpr int kCircleRadius = 20;

// Width of the circle that inside the screen at the beginning.
constexpr int kCircleInsideScreenWidth = 12;

// Padding between the circle and the label.
constexpr int kPaddingBetweenCircleAndLabel = 8;

// Line height of the label.
constexpr int kLabelLineHeight = 18;

// Corner radius for the label's background.
constexpr int kLabelCornerRadius = 16;

// Top and bottom inset of the label.
constexpr int kLabelTopBottomInset = 6;

// Duration of the pause before sliding in to show the nudge.
constexpr base::TimeDelta kPauseBeforeShowAnimationDuration = base::Seconds(10);

// Duration for the animation to show the nudge.
constexpr base::TimeDelta kNudgeShowAnimationDuration = base::Milliseconds(600);

// Duration for the animation to hide the nudge.
constexpr base::TimeDelta kNudgeHideAnimationDuration = base::Milliseconds(400);

// Duration for the animation to fade out the suggestion label and circle when
// the back nudge showing animation is interrupted and should be dismissed.
constexpr base::TimeDelta kSuggestionDismissDuration = base::Milliseconds(100);

// Duration for the animation of the suggestion part of the nudge.
constexpr base::TimeDelta kSuggestionBounceAnimationDuration =
    base::Milliseconds(600);

// Repeat bouncing times of the suggestion animation.
constexpr int kSuggestionAnimationRepeatTimes = 4;

std::unique_ptr<views::Widget> CreateWidget() {
  auto widget = std::make_unique<views::Widget>();
  views::Widget::InitParams params(
      views::Widget::InitParams::WIDGET_OWNS_NATIVE_WIDGET,
      views::Widget::InitParams::TYPE_WINDOW_FRAMELESS);
  params.opacity = views::Widget::InitParams::WindowOpacity::kTranslucent;
  params.z_order = ui::ZOrderLevel::kFloatingWindow;
  params.accept_events = false;
  params.activatable = views::Widget::InitParams::Activatable::kNo;
  params.name = "BackGestureContextualNudge";
  params.layer_type = ui::LAYER_NOT_DRAWN;
  params.parent = Shell::GetPrimaryRootWindow()->GetChildById(
      kShellWindowId_OverlayContainer);
  widget->Init(std::move(params));

  // TODO(crbug.com/40100889): Get the bounds of the display that should show
  // the nudge, which may based on the conditions to show the nudge.
  const gfx::Rect display_bounds =
      display::Screen::GetScreen()->GetPrimaryDisplay().bounds();
  gfx::Rect widget_bounds;
  if (base::i18n::IsRTL()) {
    widget_bounds = gfx::Rect(display_bounds.right(), display_bounds.y(),
                              kBackgroundWidth, display_bounds.height());
  } else {
    widget_bounds =
        gfx::Rect(display_bounds.x() - kBackgroundWidth, display_bounds.y(),
                  kBackgroundWidth, display_bounds.height());
  }
  widget->SetBounds(widget_bounds);
  return widget;
}

}  // namespace

class BackGestureContextualNudge::ContextualNudgeView
    : public views::View,
      public ui::ImplicitAnimationObserver {
  METADATA_HEADER(ContextualNudgeView, views::View)

 public:
  explicit ContextualNudgeView(base::OnceCallback<void(bool)> callback)
      : callback_(std::move(callback)) {
    SetPaintToLayer();
    layer()->SetFillsBoundsOpaquely(false);

    suggestion_view_ = AddChildView(std::make_unique<SuggestionView>(this));
    show_timer_.Start(
        FROM_HERE, kPauseBeforeShowAnimationDuration, this,
        &ContextualNudgeView::ScheduleOffScreenToStartPositionAnimation);
  }
  ContextualNudgeView(const ContextualNudgeView&) = delete;
  ContextualNudgeView& operator=(const ContextualNudgeView&) = delete;

  ~ContextualNudgeView() override { StopObservingImplicitAnimations(); }

  // Cancel in-waiting animation or in-progress animation.
  void CancelAnimationOrFadeOutToHide() {
    if (animation_stage_ == AnimationStage::kWaitingCancelled ||
        animation_stage_ == AnimationStage::kFadingOut) {
      return;
    }

    if (animation_stage_ == AnimationStage::kWaiting) {
      // Cancel the animation if it's waiting to be shown.
      animation_stage_ = AnimationStage::kWaitingCancelled;
      DCHECK(show_timer_.IsRunning());
      show_timer_.AbandonAndStop();
      std::move(callback_).Run(/*animation_completed=*/false);
    } else if (animation_stage_ == AnimationStage::kSlidingIn ||
               animation_stage_ == AnimationStage::kBouncing ||
               animation_stage_ == AnimationStage::kSlidingOut) {
      // Cancel previous animations and fade out the widget if it's animating.
      layer()->GetAnimator()->AbortAllAnimations();
      suggestion_view_->layer()->GetAnimator()->AbortAllAnimations();

      animation_stage_ = AnimationStage::kFadingOut;
      suggestion_view_->FadeOutForDismiss();
    }
  }

  void SetNudgeShownForTesting() { SetNudgeCountsAsShown(); }

  bool count_as_shown() const { return count_as_shown_; }

 private:
  enum class AnimationStage {
    kWaiting,     // Animation hasn't been started.
    kSlidingIn,   // Sliding in animation to show the affordance and label.
    kBouncing,    // Bouncing the affordance and label animation.
    kSlidingOut,  // Sliding out animation to hide the affordance and label.
    kWaitingCancelled,  // The in-waiting animation is cancelled.
    kFadingOut,  // Previous in-progress animations are cancelled, fading out
                 // the affordance and label.
  };

  // Used to show the suggestion information of the nudge, which includes the
  // affordance and a label.
  class SuggestionView : public views::View,
                         public ui::ImplicitAnimationObserver {
    METADATA_HEADER(SuggestionView, views::View)

   public:
    explicit SuggestionView(ContextualNudgeView* nudge_view)
        : nudge_view_(nudge_view) {
      SetPaintToLayer();
      layer()->SetFillsBoundsOpaquely(false);

      label_ = AddChildView(std::make_unique<views::Label>());
      label_->SetBackgroundColor(SK_ColorTRANSPARENT);
      label_->SetEnabledColor(AshColorProvider::Get()->GetContentLayerColor(
          AshColorProvider::ContentLayerType::kTextColorPrimary));
      label_->SetText(l10n_util::GetStringUTF16(
          base::i18n::IsRTL() ? IDS_ASH_BACK_GESTURE_CONTEXTUAL_NUDGE_RTL
                              : IDS_ASH_BACK_GESTURE_CONTEXTUAL_NUDGE));
      label_->SetLineHeight(kLabelLineHeight);
      label_->SetFontList(
          gfx::FontList().DeriveWithWeight(gfx::Font::Weight::MEDIUM));
    }
    SuggestionView(const SuggestionView&) = delete;
    SuggestionView& operator=(const SuggestionView&) = delete;

    ~SuggestionView() override { StopObservingImplicitAnimations(); }

    void ScheduleBounceAnimation() {
      const bool is_rtl = base::i18n::IsRTL();
      gfx::Transform transform;
      const int x_offset = kCircleRadius - kCircleInsideScreenWidth;
      transform.Translate(is_rtl ? x_offset : -x_offset, 0);
      ui::ScopedLayerAnimationSettings animation(layer()->GetAnimator());
      animation.AddObserver(this);
      animation.SetTransitionDuration(kSuggestionBounceAnimationDuration);
      animation.SetTweenType(gfx::Tween::EASE_IN_OUT_2);
      animation.SetPreemptionStrategy(
          ui::LayerAnimator::IMMEDIATELY_ANIMATE_TO_NEW_TARGET);
      layer()->SetTransform(transform);

      transform.Translate(is_rtl ? -x_offset : x_offset, 0);
      animation.SetTransitionDuration(kSuggestionBounceAnimationDuration);
      animation.SetTweenType(gfx::Tween::EASE_IN_OUT_2);
      animation.SetPreemptionStrategy(ui::LayerAnimator::ENQUEUE_NEW_ANIMATION);
      layer()->SetTransform(transform);
    }

    // Called when the in-progress animation is cancelled. The suggestion view
    // will fade out.
    void FadeOutForDismiss() {
      ui::ScopedLayerAnimationSettings animation(layer()->GetAnimator());
      animation.SetTransitionDuration(kSuggestionDismissDuration);
      animation.SetTweenType(gfx::Tween::LINEAR);
      animation.SetPreemptionStrategy(
          ui::LayerAnimator::IMMEDIATELY_ANIMATE_TO_NEW_TARGET);
      animation.AddObserver(nudge_view_);
      layer()->SetOpacity(0.f);
    }

   private:
    // views::View:
    void Layout(PassKey) override {
      const gfx::Rect bounds = GetLocalBounds();
      gfx::Rect label_rect(bounds);
      label_rect.ClampToCenteredSize(
          label_->GetPreferredSize(views::SizeBounds(label_->width(), {})));
      label_rect.set_x(bounds.x() + 2 * kCircleRadius +
                       kPaddingBetweenCircleAndLabel + kLabelCornerRadius);
      label_->SetBoundsRect(label_rect);
    }

    // views::View:
    void OnPaint(gfx::Canvas* canvas) override {
      const auto* color_provider = GetColorProvider();
      // Draw the circle.
      cc::PaintFlags circle_flags;
      circle_flags.setAntiAlias(true);
      circle_flags.setStyle(cc::PaintFlags::kFill_Style);
      circle_flags.setColor(
          color_provider->GetColor(kColorAshShieldAndBaseOpaque));

      gfx::PointF center_point;
      if (base::i18n::IsRTL()) {
        const gfx::Point right_center = GetLocalBounds().right_center();
        center_point =
            gfx::PointF(right_center.x() - kCircleRadius, right_center.y());
      } else {
        const gfx::Point left_center = GetLocalBounds().left_center();
        center_point =
            gfx::PointF(left_center.x() + kCircleRadius, left_center.y());
      }
      canvas->DrawCircle(center_point, kCircleRadius, circle_flags);

      // Draw highlight border circles for the affordance.
      DrawCircleHighlightBorder(this, canvas, center_point, kCircleRadius);

      // Draw the black round rectangle around the text.
      cc::PaintFlags round_rect_flags;
      round_rect_flags.setStyle(cc::PaintFlags::kFill_Style);
      round_rect_flags.setAntiAlias(true);
      round_rect_flags.setColor(
          color_provider->GetColor(kColorAshShieldAndBaseOpaque));
      gfx::Rect label_bounds(label_->GetMirroredBounds());
      label_bounds.Inset(
          gfx::Insets::VH(-kLabelTopBottomInset, -kLabelCornerRadius));
      canvas->DrawRoundRect(label_bounds, kLabelCornerRadius, round_rect_flags);

      // Draw highlight border for the black round rectangle around the text.
      DrawRoundRectHighlightBorder(this, canvas, label_bounds,
                                   kLabelCornerRadius);
    }

    // ui::ImplicitAnimationObserver:
    void OnImplicitAnimationsCompleted() override {
      // Do not do the following animation if the bouncing animation is aborted.
      if (WasAnimationAbortedForProperty(ui::LayerAnimationElement::TRANSFORM))
        return;

      if (current_animation_times_ < (kSuggestionAnimationRepeatTimes - 1)) {
        current_animation_times_++;
        ScheduleBounceAnimation();
      } else {
        nudge_view_->ScheduleStartPositionToOffScreenAnimation();
      }
    }

    raw_ptr<views::Label> label_ = nullptr;
    int current_animation_times_ = 0;
    raw_ptr<ContextualNudgeView> nudge_view_ = nullptr;  // Not owned.
  };

  // Showing contextual nudge from off screen to its start position.
  void ScheduleOffScreenToStartPositionAnimation() {
    animation_stage_ = AnimationStage::kSlidingIn;
    gfx::Transform transform;
    transform.Translate(base::i18n::IsRTL() ? -kBackgroundWidth + kCircleRadius
                                            : kBackgroundWidth - kCircleRadius,
                        0);
    ui::ScopedLayerAnimationSettings animation(layer()->GetAnimator());
    animation.AddObserver(this);
    animation.SetTransitionDuration(kNudgeShowAnimationDuration);
    animation.SetTweenType(gfx::Tween::LINEAR_OUT_SLOW_IN);
    layer()->SetTransform(transform);
  }

  // Hiding the contextual nudge from its current position to off screen.
  void ScheduleStartPositionToOffScreenAnimation() {
    animation_stage_ = AnimationStage::kSlidingOut;
    gfx::Transform transform;
    transform.Translate(base::i18n::IsRTL() ? kBackgroundWidth - kCircleRadius
                                            : -kBackgroundWidth + kCircleRadius,
                        0);
    ui::ScopedLayerAnimationSettings animation(layer()->GetAnimator());
    animation.SetTransitionDuration(kNudgeHideAnimationDuration);
    animation.SetTweenType(gfx::Tween::EASE_OUT_2);
    animation.AddObserver(this);
    layer()->SetTransform(transform);
  }

  void SetNudgeCountsAsShown() {
    count_as_shown_ = true;
    // Log nudge metrics right after it's shown.
    contextual_tooltip::HandleNudgeShown(
        Shell::Get()->session_controller()->GetActivePrefService(),
        contextual_tooltip::TooltipType::kBackGesture);
  }

  // views::View:
  void Layout(PassKey) override {
    suggestion_view_->SetBoundsRect(GetLocalBounds());
  }

  // ui::ImplicitAnimationObserver:
  void OnImplicitAnimationsCompleted() override {
    if (animation_stage_ == AnimationStage::kFadingOut ||
        (animation_stage_ == AnimationStage::kSlidingOut &&
         !WasAnimationAbortedForProperty(
             ui::LayerAnimationElement::TRANSFORM))) {
      std::move(callback_).Run(/*animation_completed=*/animation_stage_ ==
                               AnimationStage::kSlidingOut);
      return;
    }

    if (animation_stage_ == AnimationStage::kSlidingIn &&
        !WasAnimationAbortedForProperty(ui::LayerAnimationElement::TRANSFORM)) {
      // Only after the back nudge finishes sliding in animation, it counts as
      // a successful shown.
      SetNudgeCountsAsShown();
      animation_stage_ = AnimationStage::kBouncing;
      suggestion_view_->ScheduleBounceAnimation();
    }
  }

  // Created by ContextualNudgeView. Owned by views hierarchy.
  raw_ptr<SuggestionView> suggestion_view_ = nullptr;

  // Timer to start show the sliding in animation.
  base::OneShotTimer show_timer_;

  // Current animation stage;
  AnimationStage animation_stage_ = AnimationStage::kWaiting;

  // The nudge should be counted as shown if the nudge has finished its sliding-
  // in animation no matter whether its following animations get cancelled or
  // not.
  bool count_as_shown_ = false;

  // Callback function to be called after animation is cancelled or completed.
  // Count the nudge as shown successfully if |count_as_shown_| is true.
  base::OnceCallback<void(bool)> callback_;
};

BEGIN_METADATA(BackGestureContextualNudge, ContextualNudgeView)
END_METADATA

BEGIN_METADATA(BackGestureContextualNudge::ContextualNudgeView, SuggestionView)
END_METADATA

BackGestureContextualNudge::BackGestureContextualNudge(
    base::OnceCallback<void(bool)> callback) {
  widget_ = CreateWidget();
  nudge_view_ = widget_->SetContentsView(
      std::make_unique<ContextualNudgeView>(std::move(callback)));
  widget_->Show();
}

BackGestureContextualNudge::~BackGestureContextualNudge() = default;

void BackGestureContextualNudge::CancelAnimationOrFadeOutToHide() {
  nudge_view_->CancelAnimationOrFadeOutToHide();
}

bool BackGestureContextualNudge::ShouldNudgeCountAsShown() const {
  return nudge_view_->count_as_shown();
}

void BackGestureContextualNudge::SetNudgeShownForTesting() {
  nudge_view_->SetNudgeShownForTesting();
}

}  // namespace ash