chromium/ash/wm/gestures/back_gesture/back_gesture_affordance.cc

// Copyright 2019 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_affordance.h"

#include <algorithm>

#include "ash/display/screen_orientation_controller.h"
#include "ash/public/cpp/shell_window_ids.h"
#include "ash/style/ash_color_id.h"
#include "ash/style/color_util.h"
#include "ash/wm/gestures/back_gesture/back_gesture_util.h"
#include "ash/wm/splitview/split_view_controller.h"
#include "ash/wm/splitview/split_view_divider.h"
#include "ash/wm/splitview/split_view_types.h"
#include "ash/wm/window_util.h"
#include "base/i18n/rtl.h"
#include "components/vector_icons/vector_icons.h"
#include "ui/aura/window.h"
#include "ui/compositor/layer.h"
#include "ui/compositor/paint_recorder.h"
#include "ui/display/screen.h"
#include "ui/events/event.h"
#include "ui/gfx/animation/tween.h"
#include "ui/gfx/canvas.h"
#include "ui/gfx/color_palette.h"
#include "ui/gfx/geometry/point_f.h"
#include "ui/gfx/paint_vector_icon.h"
#include "ui/gfx/scoped_canvas.h"
#include "ui/gfx/skia_paint_util.h"
#include "ui/views/highlight_border.h"
#include "ui/views/view.h"
#include "ui/wm/core/window_animations.h"

namespace ash {

namespace {

// Distance from the arrow to the drag touch point. The arrow is usually
// above the touch point but will be put below the touch point if the affordance
// is outside of the display.
constexpr int kDistanceFromArrowToTouchPoint = 64;

constexpr int kArrowSize = 20;
constexpr int kBackgroundRadius = 20;

// Radius of the ripple while x-axis movement of the affordance achieves
// |kDistanceForFullRadius|.
constexpr int kFullRippleRadius = 32;

// Radius of the ripple while x-axis movement of the affordance achieves
// |kDistanceForMaxRadius|.
constexpr int kMaxRippleRadius = 40;

// Maximium burst ripple radius while release the drag with COMPLETING state.
constexpr int kMaxBurstRippleRadius = 48;

// X-axis movement of the affordance to achieve full ripple radius. Note, the
// movement equals to drag distance while in this range.
constexpr float kDistanceForFullRadius = 100.f;

// |kDistanceForFullRadius| plus the x-axis movement of the affordance for
// ripple to increase from |kFullRippleRadius| to |kMaxRippleRadius|.
constexpr float kDistanceForMaxRadius = 116.f;

// Drag distance of the gesture events for the ripple radius to achieve
// |kMaxRippleRadius|.
constexpr float kDragDistanceForMaxRadius = 260.f;

constexpr base::TimeDelta kAbortAnimationTimeout = base::Milliseconds(300);
constexpr base::TimeDelta kCompleteAnimationTimeout = base::Milliseconds(200);

// Y-axis drag distance to achieve full y drag progress.
constexpr float kDistanceForFullYProgress = 80.f;

// Distance of the affordance that beyond the left of display or splitview
// divider.
constexpr int kDistanceBeyondLeftOrSplitvieDivider =
    kMaxBurstRippleRadius + kBackgroundRadius;

// Maximium y-axis movement of the affordance. Note, the affordance can move
// both up and down.
constexpr float kMaxYMovement = 8.f;

class AffordanceView : public views::View {
 public:
  AffordanceView() {
    SetPaintToLayer();
    layer()->SetFillsBoundsOpaquely(false);
  }
  AffordanceView(AffordanceView&) = delete;
  AffordanceView& operator=(AffordanceView&) = delete;
  ~AffordanceView() override = default;

  // Schedule painting on given |affordance_progress|, |complete_progress| and
  // |state|.
  void Paint(float complete_progress,
             BackGestureAffordance::State state,
             float x_offset) {
    complete_progress_ = complete_progress;
    state_ = state;
    x_offset_ = x_offset;
    SchedulePaint();
  }

 private:
  // views::View:
  void OnPaint(gfx::Canvas* canvas) override {
    views::View::OnPaint(canvas);

    const gfx::PointF center_point(kMaxBurstRippleRadius,
                                   kMaxBurstRippleRadius);

    // Draw the ripple.
    cc::PaintFlags ripple_flags;
    ripple_flags.setAntiAlias(true);
    ripple_flags.setStyle(cc::PaintFlags::kFill_Style);
    ripple_flags.setColor(ColorUtil::GetSecondToneColor(
        GetColorProvider()->GetColor(kColorAshControlBackgroundColorActive)));

    float ripple_radius = 0.f;
    if (state_ == BackGestureAffordance::State::COMPLETING) {
      const float burst_progress = gfx::Tween::CalculateValue(
          gfx::Tween::FAST_OUT_SLOW_IN, complete_progress_);
      ripple_radius =
          kMaxRippleRadius +
          burst_progress * (kMaxBurstRippleRadius - kMaxRippleRadius);
    } else if (x_offset_ >= kDistanceForFullRadius) {
      const float factor = (kMaxRippleRadius - kFullRippleRadius) /
                           (kDistanceForMaxRadius - kDistanceForFullRadius);
      ripple_radius = (kFullRippleRadius - factor * kDistanceForFullRadius) +
                      factor * x_offset_;
    } else {
      const float factor =
          (kFullRippleRadius - kBackgroundRadius) / kDistanceForFullRadius;
      ripple_radius = kBackgroundRadius + factor * x_offset_;
    }
    canvas->DrawCircle(center_point, ripple_radius, ripple_flags);

    const bool is_activated =
        x_offset_ >= kDistanceForFullRadius ||
        state_ == BackGestureAffordance::State::COMPLETING;

    // Draw highlight border circles.
    DrawCircleHighlightBorder(this, canvas, center_point, kBackgroundRadius);

    // Draw the arrow background circle.
    cc::PaintFlags bg_flags;
    bg_flags.setAntiAlias(true);
    bg_flags.setStyle(cc::PaintFlags::kFill_Style);

    const auto* color_provider = GetColorProvider();
    bg_flags.setColor(color_provider->GetColor(
        is_activated ? kColorAshControlBackgroundColorActive
                     : kColorAshShieldAndBaseOpaque));
    canvas->DrawCircle(center_point, kBackgroundRadius, bg_flags);

    // Draw the arrow.
    const float arrow_x = center_point.x() - kArrowSize / 2.f;
    const float arrow_y = center_point.y() - kArrowSize / 2.f;
    const bool is_rtl = base::i18n::IsRTL();
    if (is_activated) {
      canvas->DrawImageInt(
          gfx::CreateVectorIcon(
              is_rtl ? vector_icons::kForwardArrowIcon
                     : vector_icons::kBackArrowIcon,
              kArrowSize,
              color_provider->GetColor(kColorAshButtonIconColorPrimary)),
          static_cast<int>(arrow_x), static_cast<int>(arrow_y));
    } else {
      canvas->DrawImageInt(
          gfx::CreateVectorIcon(
              is_rtl ? vector_icons::kForwardArrowIcon
                     : vector_icons::kBackArrowIcon,
              kArrowSize, color_provider->GetColor(kColorAshButtonIconColor)),
          static_cast<int>(arrow_x), static_cast<int>(arrow_y));
    }
  }

  float complete_progress_ = 0.f;
  BackGestureAffordance::State state_ = BackGestureAffordance::State::DRAGGING;
  float x_offset_ = 0;
};

gfx::Rect GetSplitViewDividerBoundsInScreen(const gfx::Point& location) {
  auto* split_view_controller =
      SplitViewController::Get(window_util::GetRootWindowAt(location));
  if (!split_view_controller->InTabletSplitViewMode())
    return gfx::Rect();

  return split_view_controller->split_view_divider()->GetDividerBoundsInScreen(
      /*is_dragging=*/false);
}

// Return true if |origin_y| is above the bottom of the splitview divider while
// in portrait screen orientation.
bool AboveBottomOfSplitViewDivider(const gfx::Point& location, int origin_y) {
  auto* split_view_controller =
      SplitViewController::Get(window_util::GetRootWindowAt(location));
  if (!split_view_controller->InTabletSplitViewMode() ||
      IsCurrentScreenOrientationLandscape()) {
    return false;
  }

  const gfx::Rect bounds_of_bottom_snapped_window =
      split_view_controller->GetSnappedWindowBoundsInScreen(
          IsCurrentScreenOrientationPrimary() ? SnapPosition::kSecondary
                                              : SnapPosition::kPrimary,
          /*window_for_minimum_size=*/nullptr, chromeos::kDefaultSnapRatio,
          /*account_for_divider_width=*/true);
  return bounds_of_bottom_snapped_window.Contains(location) &&
         origin_y < GetSplitViewDividerBoundsInScreen(location).bottom();
}

gfx::Rect GetAffordanceBounds(const gfx::Point& location,
                              bool dragged_from_splitview_divider) {
  gfx::Rect bounds(
      gfx::Rect(2 * kMaxBurstRippleRadius, 2 * kMaxBurstRippleRadius));

  gfx::Point origin;
  // X origin of the affordance is always beyond the left of the screen. We'll
  // apply translation to the affordance to put it in the right place during
  // dragging.
  const gfx::Rect work_area = display::Screen::GetScreen()
                                  ->GetDisplayNearestPoint(location)
                                  .work_area();
  origin.set_x(work_area.x() - kDistanceBeyondLeftOrSplitvieDivider);

  int origin_y =
      location.y() - kDistanceFromArrowToTouchPoint - kMaxBurstRippleRadius;
  // Put the affordance below the start |location| if |origin_y| exceeds the
  // top of the display or bottom of the splitview divider.
  if (origin_y < 0 || AboveBottomOfSplitViewDivider(location, origin_y)) {
    origin_y =
        location.y() + kDistanceFromArrowToTouchPoint - kMaxBurstRippleRadius;
  }
  origin.set_y(origin_y);
  bounds.set_origin(origin);
  return bounds;
}

// Returns the mirrored location of |location| if we're in rtl setting. If
// |dragged_from_splitview_divider| is true,  it will return the mirrored
// location against the center x position of the divider bar, otherwise, it will
// return the mirrored location against the center x position of the screen.
gfx::Point ToMirrorLocationIfRTL(const gfx::Point& location,
                                 bool dragged_from_splitview_divider) {
  if (!base::i18n::IsRTL())
    return location;

  const gfx::Rect work_area = display::Screen::GetScreen()
                                  ->GetDisplayNearestPoint(location)
                                  .work_area();
  if (!dragged_from_splitview_divider) {
    return gfx::Point(work_area.right() + work_area.x() - location.x(),
                      location.y());
  }

  const gfx::Rect divider_bounds = GetSplitViewDividerBoundsInScreen(location);
  return gfx::Point(2 * divider_bounds.CenterPoint().x() - location.x(),
                    location.y());
}

}  // namespace

BackGestureAffordance::BackGestureAffordance(
    const gfx::Point& location,
    bool dragged_from_splitview_divider)
    : dragged_from_splitview_divider_(dragged_from_splitview_divider) {
  CreateAffordanceWidget(
      ToMirrorLocationIfRTL(location, dragged_from_splitview_divider));
}

BackGestureAffordance::~BackGestureAffordance() {}

void BackGestureAffordance::Update(int x_drag_amount,
                                   int y_drag_amount,
                                   bool during_reverse_dragging) {
  DCHECK_EQ(State::DRAGGING, state_);

  // Since affordance is put outside of the display, add the distance from its
  // center point to the left edge of the display to be the actual drag
  // distance.
  x_drag_amount_ = (base::i18n::IsRTL() ? -x_drag_amount : x_drag_amount) +
                   kBackgroundRadius;

  float y_progress = y_drag_amount / kDistanceForFullYProgress;
  y_drag_progress_ = std::clamp(y_progress, -1.0f, 1.0f);

  during_reverse_dragging_ = during_reverse_dragging;

  UpdateTransform();
  SchedulePaint();
}

void BackGestureAffordance::Abort() {
  DCHECK_EQ(State::DRAGGING, state_);

  state_ = State::ABORTING;
  started_reverse_ = false;
  x_drag_amount_ = current_offset_;
  animation_ = std::make_unique<gfx::LinearAnimation>(
      GetAffordanceProgress() * kAbortAnimationTimeout,
      gfx::LinearAnimation::kDefaultFrameRate, this);
  animation_->Start();
}

void BackGestureAffordance::Complete() {
  CHECK_EQ(State::DRAGGING, state_);
  state_ = State::COMPLETING;

  animation_ = std::make_unique<gfx::LinearAnimation>(
      kCompleteAnimationTimeout, gfx::LinearAnimation::kDefaultFrameRate, this);
  animation_->Start();
}

bool BackGestureAffordance::IsActivated() const {
  return current_offset_ >= kDistanceForFullRadius ||
         state_ == State::COMPLETING;
}

void BackGestureAffordance::CreateAffordanceWidget(const gfx::Point& location) {
  affordance_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.accept_events = true;
  params.name = "BackGestureAffordance";
  params.activatable = views::Widget::InitParams::Activatable::kNo;
  params.parent = window_util::GetRootWindowAt(location)->GetChildById(
      kShellWindowId_OverlayContainer);

  affordance_widget_->Init(std::move(params));
  affordance_widget_->SetContentsView(std::make_unique<AffordanceView>());
  // We've got our own custom show/hide animation, so the default is unneeded.
  ::wm::SetWindowVisibilityAnimationTransition(
      affordance_widget_->GetNativeWindow(), ::wm::ANIMATE_NONE);

  const gfx::Rect widget_bounds =
      GetAffordanceBounds(location, dragged_from_splitview_divider_);
  affordance_widget_->SetBounds(widget_bounds);
  if (dragged_from_splitview_divider_) {
    // Clip the affordance to make sure it will only be visible inside the
    // snapped window's bounds. Note, |clip_bounds| is the area that the
    // affordance will be visible, and it is based on the layer's coordinate.
    gfx::Rect clip_bounds;
    const gfx::Rect divider_bounds =
        GetSplitViewDividerBoundsInScreen(location);
    const gfx::Rect work_area = display::Screen::GetScreen()
                                    ->GetDisplayNearestPoint(location)
                                    .work_area();
    if (base::i18n::IsRTL()) {
      clip_bounds = gfx::Rect(divider_bounds.x() - kDistanceForMaxRadius -
                                  kMaxBurstRippleRadius - widget_bounds.x(),
                              0, kDistanceForMaxRadius + kMaxBurstRippleRadius,
                              widget_bounds.height());
    } else {
      clip_bounds = gfx::Rect(divider_bounds.right() - work_area.x() +
                                  kDistanceBeyondLeftOrSplitvieDivider,
                              0, kDistanceForMaxRadius + kMaxBurstRippleRadius,
                              widget_bounds.height());
    }
    affordance_widget_->GetLayer()->SetClipRect(clip_bounds);
  }
  affordance_widget_->Show();
  affordance_widget_->SetOpacity(1.f);
}

void BackGestureAffordance::UpdateTransform() {
  float offset = 0.f;
  if (started_reverse_) {
    offset = x_drag_amount_ -
             (x_drag_amount_on_start_reverse_ - offset_on_start_reverse_);
    // Reset the previous started reverse drag if back to its started position.
    if (!during_reverse_dragging_ &&
        x_drag_amount_ >= x_drag_amount_on_start_reverse_) {
      started_reverse_ = false;
    }
  } else {
    if (x_drag_amount_ <= kDistanceForFullRadius) {
      offset = GetAffordanceProgress() * kDistanceForFullRadius;
    } else {
      if (during_reverse_dragging_) {
        started_reverse_ = true;
        offset_on_start_reverse_ = current_offset_;
        x_drag_amount_on_start_reverse_ = x_drag_amount_;
      }
      const float factor = (kDistanceForMaxRadius - kDistanceForFullRadius) /
                           (kDragDistanceForMaxRadius - kDistanceForFullRadius);
      offset = (kDistanceForFullRadius - kDistanceForFullRadius * factor) +
               factor * x_drag_amount_;
    }
  }
  offset = std::fmin(kDistanceForMaxRadius, std::fmax(0, offset));
  current_offset_ = offset;

  // Adjusting the affordance offset based on different configurations (e.g.,
  // drag from split view divider bar or rtl language) so that affordance can
  // remain under or above the finger.
  const gfx::Rect work_area =
      display::Screen::GetScreen()
          ->GetDisplayNearestWindow(affordance_widget_->GetNativeWindow())
          .work_area();
  if (dragged_from_splitview_divider_) {
    auto* split_view_controller = SplitViewController::Get(
        affordance_widget_->GetNativeWindow()->GetRootWindow());
    const gfx::Rect divider_bounds =
        split_view_controller->split_view_divider()->GetDividerBoundsInScreen(
            /*is_dragging=*/false);
    if (base::i18n::IsRTL()) {
      offset = divider_bounds.right() - work_area.x() - offset +
               kMaxBurstRippleRadius;
    } else {
      offset += divider_bounds.right() - work_area.x();
    }
  } else if (base::i18n::IsRTL()) {
    offset = work_area.width() - offset + kMaxBurstRippleRadius;
  }

  float y_offset = kMaxYMovement * y_drag_progress_;
  gfx::Transform transform;
  transform.Translate(offset, y_offset);
  affordance_widget_->GetContentsView()->SetTransform(transform);
}

void BackGestureAffordance::SchedulePaint() {
  static_cast<AffordanceView*>(affordance_widget_->GetContentsView())
      ->Paint(complete_progress_, state_, current_offset_);
}

void BackGestureAffordance::SetAbortProgress(float progress) {
  DCHECK_EQ(State::ABORTING, state_);
  DCHECK_LE(0.f, progress);
  DCHECK_GE(1.f, progress);

  if (abort_progress_ == progress)
    return;
  abort_progress_ = progress;

  UpdateTransform();
  SchedulePaint();
}

void BackGestureAffordance::SetCompleteProgress(float progress) {
  DCHECK_EQ(State::COMPLETING, state_);
  DCHECK_LE(0.f, progress);
  DCHECK_GE(1.f, progress);

  if (complete_progress_ == progress)
    return;
  complete_progress_ = progress;

  affordance_widget_->GetContentsView()->layer()->SetOpacity(
      gfx::Tween::CalculateValue(gfx::Tween::FAST_OUT_SLOW_IN,
                                 1 - complete_progress_));

  SchedulePaint();
}

float BackGestureAffordance::GetAffordanceProgress() const {
  return (x_drag_amount_ / kDistanceForFullRadius) *
         (1 - gfx::Tween::CalculateValue(gfx::Tween::FAST_OUT_SLOW_IN,
                                         abort_progress_));
}

void BackGestureAffordance::AnimationEnded(const gfx::Animation* animation) {
  affordance_widget_->Hide();
}

void BackGestureAffordance::AnimationProgressed(
    const gfx::Animation* animation) {
  switch (state_) {
    case State::DRAGGING:
      NOTREACHED();
    case State::ABORTING:
      SetAbortProgress(animation->GetCurrentValue());
      break;
    case State::COMPLETING:
      SetCompleteProgress(animation->GetCurrentValue());
      break;
  }
}

void BackGestureAffordance::AnimationCanceled(const gfx::Animation* animation) {
  NOTREACHED();
}

}  // namespace ash