chromium/ui/views/controls/scrollbar/cocoa_scroll_bar.mm

// Copyright 2016 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "base/memory/raw_ptr.h"

#import "ui/views/controls/scrollbar/cocoa_scroll_bar.h"

#include "base/functional/bind.h"
#include "base/i18n/rtl.h"
#include "cc/paint/paint_shader.h"
#include "third_party/abseil-cpp/absl/types/variant.h"
#include "third_party/skia/include/core/SkColor.h"
#include "third_party/skia/include/effects/SkGradientShader.h"
#include "ui/base/metadata/metadata_impl_macros.h"
#include "ui/compositor/layer.h"
#include "ui/compositor/layer_animator.h"
#include "ui/compositor/scoped_layer_animation_settings.h"
#include "ui/gfx/canvas.h"
#include "ui/views/controls/scrollbar/base_scroll_bar_thumb.h"

namespace views {

namespace {

// The thickness of the normal, overlay, and expanded overlay scrollbars.
constexpr int kScrollbarThickness = 15;
constexpr int kOverlayScrollbarThickness = 12;
constexpr int kExpandedOverlayScrollbarThickness = 16;

// Opacity of the overlay scrollbar.
constexpr float kOverlayOpacity = 0.8f;

}  // namespace

//////////////////////////////////////////////////////////////////
// CocoaScrollBarThumb

class CocoaScrollBarThumb : public BaseScrollBarThumb {
 public:
  explicit CocoaScrollBarThumb(CocoaScrollBar* scroll_bar);

  CocoaScrollBarThumb(const CocoaScrollBarThumb&) = delete;
  CocoaScrollBarThumb& operator=(const CocoaScrollBarThumb&) = delete;

  ~CocoaScrollBarThumb() override;

  // Returns true if the thumb is in hovered state.
  bool IsStateHovered() const;

  // Returns true if the thumb is in pressed state.
  bool IsStatePressed() const;

  void UpdateIsMouseOverTrack(bool mouse_over_track);

 protected:
  // View:
  gfx::Size CalculatePreferredSize(
      const SizeBounds& /*available_size*/) const override;
  void OnPaint(gfx::Canvas* canvas) override;
  bool OnMousePressed(const ui::MouseEvent& event) override;
  void OnMouseReleased(const ui::MouseEvent& event) override;
  void OnMouseEntered(const ui::MouseEvent& event) override;
  void OnMouseExited(const ui::MouseEvent& event) override;

 private:
  // The CocoaScrollBar that owns us.
  raw_ptr<CocoaScrollBar> cocoa_scroll_bar_;  // weak.
};

CocoaScrollBarThumb::CocoaScrollBarThumb(CocoaScrollBar* scroll_bar)
    : BaseScrollBarThumb(scroll_bar), cocoa_scroll_bar_(scroll_bar) {
  DCHECK(scroll_bar);

  // This is necessary, otherwise the thumb will be rendered below the views if
  // those views paint to their own layers.
  SetPaintToLayer();
  layer()->SetFillsBoundsOpaquely(false);
}

CocoaScrollBarThumb::~CocoaScrollBarThumb() = default;

bool CocoaScrollBarThumb::IsStateHovered() const {
  return GetState() == Button::STATE_HOVERED;
}

bool CocoaScrollBarThumb::IsStatePressed() const {
  return GetState() == Button::STATE_PRESSED;
}

void CocoaScrollBarThumb::UpdateIsMouseOverTrack(bool mouse_over_track) {
  // The state should not change if the thumb is pressed. The thumb will be
  // set back to its hover or normal state when the mouse is released.
  if (IsStatePressed())
    return;

  SetState(mouse_over_track ? Button::STATE_HOVERED : Button::STATE_NORMAL);
}

gfx::Size CocoaScrollBarThumb::CalculatePreferredSize(
    const SizeBounds& /*available_size*/) const {
  int thickness = cocoa_scroll_bar_->ScrollbarThickness();
  return gfx::Size(thickness, thickness);
}

void CocoaScrollBarThumb::OnPaint(gfx::Canvas* canvas) {
  auto params = cocoa_scroll_bar_->GetPainterParams();
  auto& scrollbar = absl::get<ui::NativeTheme::ScrollbarExtraParams>(params);
  // Set the hover state based only on the thumb.
  scrollbar.is_hovering = IsStateHovered() || IsStatePressed();
  ui::NativeTheme::Part thumb_part =
      scrollbar.orientation ==
              ui::NativeTheme::ScrollbarOrientation::kHorizontal
          ? ui::NativeTheme::kScrollbarHorizontalThumb
          : ui::NativeTheme::kScrollbarVerticalThumb;
  GetNativeTheme()->Paint(canvas->sk_canvas(), GetColorProvider(), thumb_part,
                          ui::NativeTheme::kNormal, GetLocalBounds(), params);
}

bool CocoaScrollBarThumb::OnMousePressed(const ui::MouseEvent& event) {
  // Ignore the mouse press if the scrollbar is hidden.
  if (cocoa_scroll_bar_->IsScrollbarFullyHidden())
    return false;

  return BaseScrollBarThumb::OnMousePressed(event);
}

void CocoaScrollBarThumb::OnMouseReleased(const ui::MouseEvent& event) {
  BaseScrollBarThumb::OnMouseReleased(event);
  scroll_bar()->OnMouseReleased(event);
}

void CocoaScrollBarThumb::OnMouseEntered(const ui::MouseEvent& event) {
  BaseScrollBarThumb::OnMouseEntered(event);
  scroll_bar()->OnMouseEntered(event);
}

void CocoaScrollBarThumb::OnMouseExited(const ui::MouseEvent& event) {
  // The thumb should remain pressed when dragged, even if the mouse leaves
  // the scrollview. The thumb will be set back to its hover or normal state
  // when the mouse is released.
  if (GetState() != Button::STATE_PRESSED)
    SetState(Button::STATE_NORMAL);
}

//////////////////////////////////////////////////////////////////
// CocoaScrollBar class

CocoaScrollBar::CocoaScrollBar(ScrollBar::Orientation orientation)
    : ScrollBar(orientation),
      hide_scrollbar_timer_(FROM_HERE,
                            base::Milliseconds(500),
                            base::BindRepeating(&CocoaScrollBar::HideScrollbar,
                                                base::Unretained(this))),
      thickness_animation_(this) {
  SetThumb(new CocoaScrollBarThumb(this));
  bridge_ = [[ViewsScrollbarBridge alloc] initWithDelegate:this];
  scroller_style_ = [ViewsScrollbarBridge preferredScrollerStyle];

  thickness_animation_.SetSlideDuration(base::Milliseconds(240));

  SetPaintToLayer();
  has_scrolltrack_ = scroller_style_ == NSScrollerStyleLegacy;
  layer()->SetOpacity(scroller_style_ == NSScrollerStyleOverlay ? 0.0f : 1.0f);
}

CocoaScrollBar::~CocoaScrollBar() {
  [bridge_ clearDelegate];
}

//////////////////////////////////////////////////////////////////
// CocoaScrollBar, ScrollBar:

gfx::Rect CocoaScrollBar::GetTrackBounds() const {
  return GetLocalBounds();
}

//////////////////////////////////////////////////////////////////
// CocoaScrollBar, ScrollBar:

int CocoaScrollBar::GetThickness() const {
  return ScrollbarThickness();
}

bool CocoaScrollBar::OverlapsContent() const {
  return scroller_style_ == NSScrollerStyleOverlay;
}

//////////////////////////////////////////////////////////////////
// CocoaScrollBar::View:

void CocoaScrollBar::Layout(PassKey) {
  // Set the thickness of the thumb according to the track bounds.
  // The length of the thumb is set by ScrollBar::Update().
  gfx::Rect thumb_bounds(GetThumb()->bounds());
  gfx::Rect track_bounds(GetTrackBounds());
  if (GetOrientation() == Orientation::kHorizontal) {
    GetThumb()->SetBounds(thumb_bounds.x(),
                          track_bounds.y(),
                          thumb_bounds.width(),
                          track_bounds.height());
  } else {
    GetThumb()->SetBounds(track_bounds.x(),
                          thumb_bounds.y(),
                          track_bounds.width(),
                          thumb_bounds.height());
  }
}

gfx::Size CocoaScrollBar::CalculatePreferredSize(
    const SizeBounds& /*available_size*/) const {
  return gfx::Size();
}

void CocoaScrollBar::OnPaint(gfx::Canvas* canvas) {
  if (!has_scrolltrack_)
    return;
  auto params = GetPainterParams();
  auto& scrollbar = absl::get<ui::NativeTheme::ScrollbarExtraParams>(params);
  // Transparency of the track is handled by the View opacity, so always draw
  // using the non-overlay path.
  scrollbar.is_overlay = false;
  ui::NativeTheme::Part track_part =
      scrollbar.orientation ==
              ui::NativeTheme::ScrollbarOrientation::kHorizontal
          ? ui::NativeTheme::kScrollbarHorizontalTrack
          : ui::NativeTheme::kScrollbarVerticalTrack;
  GetNativeTheme()->Paint(canvas->sk_canvas(), GetColorProvider(), track_part,
                          ui::NativeTheme::kNormal, GetLocalBounds(), params);
}

bool CocoaScrollBar::GetCanProcessEventsWithinSubtree() const {
  // If using overlay scrollbars, do not process events when fully hidden.
  return scroller_style_ == NSScrollerStyleOverlay
             ? !IsScrollbarFullyHidden()
             : ScrollBar::GetCanProcessEventsWithinSubtree();
}

bool CocoaScrollBar::OnMousePressed(const ui::MouseEvent& event) {
  // Ignore the mouse press if the scrollbar is hidden.
  if (IsScrollbarFullyHidden())
    return false;

  return ScrollBar::OnMousePressed(event);
}

void CocoaScrollBar::OnMouseReleased(const ui::MouseEvent& event) {
  ResetOverlayScrollbar();
  ScrollBar::OnMouseReleased(event);
}

void CocoaScrollBar::OnMouseEntered(const ui::MouseEvent& event) {
  GetCocoaScrollBarThumb()->UpdateIsMouseOverTrack(true);

  if (scroller_style_ == NSScrollerStyleLegacy)
    return;

  // If the scrollbar thumb did not completely fade away, then reshow it when
  // the mouse enters the scrollbar thumb.
  if (!IsScrollbarFullyHidden())
    ShowScrollbar();

  // Expand the scrollbar. If the scrollbar is hidden, don't animate it.
  if (!is_expanded_) {
    SetScrolltrackVisible(true);
    is_expanded_ = true;
    if (IsScrollbarFullyHidden()) {
      thickness_animation_.Reset(1.0);
      UpdateScrollbarThickness();
    } else {
      thickness_animation_.Show();
    }
  }

  hide_scrollbar_timer_.Reset();
}

void CocoaScrollBar::OnMouseExited(const ui::MouseEvent& event) {
  GetCocoaScrollBarThumb()->UpdateIsMouseOverTrack(false);
  ResetOverlayScrollbar();
}

//////////////////////////////////////////////////////////////////
// CocoaScrollBar::ScrollBar:

void CocoaScrollBar::Update(int viewport_size,
                            int content_size,
                            int contents_scroll_offset) {
  // TODO(tapted): Pass in overscroll amounts from the Layer and "Squish" the
  // scroller thumb accordingly.
  ScrollBar::Update(viewport_size, content_size, contents_scroll_offset);

  // Only reveal the scroller when |contents_scroll_offset| changes. Note this
  // is different to GetPosition() which can change due to layout. A layout
  // change can also change the offset; show the scroller in these cases. This
  // is consistent with WebContents (Cocoa will also show a scroller with any
  // mouse-initiated layout, but not programmatic size changes).
  if (contents_scroll_offset == last_contents_scroll_offset_)
    return;

  last_contents_scroll_offset_ = contents_scroll_offset;

  if (GetCocoaScrollBarThumb()->IsStatePressed())
    did_start_dragging_ = true;

  if (scroller_style_ == NSScrollerStyleOverlay) {
    ShowScrollbar();
    hide_scrollbar_timer_.Reset();
  }
}

void CocoaScrollBar::ObserveScrollEvent(const ui::ScrollEvent& event) {
  // Do nothing if the delayed hide timer is running. This means there has been
  // some recent scrolling in this direction already.
  if (scroller_style_ != NSScrollerStyleOverlay ||
      hide_scrollbar_timer_.IsRunning()) {
    return;
  }

  // Otherwise, when starting the event stream, show an overlay scrollbar to
  // indicate possible scroll directions, but do not start the hide timer.
  if (event.momentum_phase() == ui::EventMomentumPhase::MAY_BEGIN) {
    // Show only if the direction isn't yet known.
    if (event.x_offset() == 0 && event.y_offset() == 0)
      ShowScrollbar();
    return;
  }

  // If the direction matches, do nothing. This is needed in addition to the
  // hide timer check because Update() is called asynchronously, after event
  // processing. So when |event| is the first event in a particular direction
  // the hide timer will not have started.
  if ((GetOrientation() == Orientation::kHorizontal ? event.x_offset()
                                                    : event.y_offset()) != 0) {
    return;
  }

  // Otherwise, scrolling has started, but not in this scroller direction. If
  // already faded out, don't start another fade animation since that would
  // immediately finish the first fade animation.
  if (layer()->GetTargetOpacity() != 0) {
    // If canceling rather than picking a direction, fade out after a delay.
    if (event.momentum_phase() == ui::EventMomentumPhase::END)
      hide_scrollbar_timer_.Reset();
    else
      HideScrollbar();  // Fade out immediately.
  }
}

//////////////////////////////////////////////////////////////////
// CocoaScrollBar::ViewsScrollbarBridge:

void CocoaScrollBar::OnScrollerStyleChanged() {
  NSScrollerStyle scroller_style =
      [ViewsScrollbarBridge preferredScrollerStyle];
  if (scroller_style_ == scroller_style)
    return;

  // Cancel all of the animations.
  thickness_animation_.Reset();
  layer()->GetAnimator()->AbortAllAnimations();

  scroller_style_ = scroller_style;

  // Ensure that the ScrollView updates the scrollbar's layout.
  if (parent())
    parent()->InvalidateLayout();

  if (scroller_style_ == NSScrollerStyleOverlay) {
    // Hide the scrollbar, but don't fade out.
    layer()->SetOpacity(0.0f);
    ResetOverlayScrollbar();
    GetThumb()->SchedulePaint();
  } else {
    is_expanded_ = false;
    SetScrolltrackVisible(true);
    ShowScrollbar();
  }
}

//////////////////////////////////////////////////////////////////
// CocoaScrollBar::ImplicitAnimationObserver:

void CocoaScrollBar::OnImplicitAnimationsCompleted() {
  DCHECK_EQ(scroller_style_, NSScrollerStyleOverlay);
  ResetOverlayScrollbar();
}

//////////////////////////////////////////////////////////////////
// CocoaScrollBar::AnimationDelegate:

void CocoaScrollBar::AnimationProgressed(const gfx::Animation* animation) {
  DCHECK(is_expanded_);
  UpdateScrollbarThickness();
}

void CocoaScrollBar::AnimationEnded(const gfx::Animation* animation) {
  // Remove the scrolltrack and set |is_expanded| to false at the end of
  // the shrink animation.
  if (!thickness_animation_.IsShowing()) {
    is_expanded_ = false;
    SetScrolltrackVisible(false);
  }
}

//////////////////////////////////////////////////////////////////
// CocoaScrollBar, public:

int CocoaScrollBar::ScrollbarThickness() const {
  if (scroller_style_ == NSScrollerStyleLegacy)
    return kScrollbarThickness;

  return thickness_animation_.CurrentValueBetween(
      kOverlayScrollbarThickness, kExpandedOverlayScrollbarThickness);
}

bool CocoaScrollBar::IsScrollbarFullyHidden() const {
  return layer()->opacity() == 0.0f;
}

ui::NativeTheme::ExtraParams CocoaScrollBar::GetPainterParams() const {
  ui::NativeTheme::ScrollbarExtraParams scrollbar;
  if (GetOrientation() == Orientation::kHorizontal) {
    scrollbar.orientation = ui::NativeTheme::ScrollbarOrientation::kHorizontal;
  } else if (base::i18n::IsRTL()) {
    scrollbar.orientation =
        ui::NativeTheme::ScrollbarOrientation::kVerticalOnLeft;
  } else {
    scrollbar.orientation =
        ui::NativeTheme::ScrollbarOrientation::kVerticalOnRight;
  }
  scrollbar.is_overlay = GetScrollerStyle() == NSScrollerStyleOverlay;
  scrollbar.scale_from_dip = 1.0f;
  return ui::NativeTheme::ExtraParams(scrollbar);
}

//////////////////////////////////////////////////////////////////
// CocoaScrollBar, private:

void CocoaScrollBar::HideScrollbar() {
  DCHECK_EQ(scroller_style_, NSScrollerStyleOverlay);

  // Don't disappear if the scrollbar is hovered, or pressed but not dragged.
  // This behavior matches the Cocoa scrollbars, but differs from the Blink
  // scrollbars which would just disappear.
  CocoaScrollBarThumb* thumb = GetCocoaScrollBarThumb();
  if (IsMouseHovered() || thumb->IsStateHovered() ||
      (thumb->IsStatePressed() && !did_start_dragging_)) {
    hide_scrollbar_timer_.Reset();
    return;
  }

  did_start_dragging_ = false;

  ui::ScopedLayerAnimationSettings animation(layer()->GetAnimator());
  animation.SetTransitionDuration(base::Milliseconds(240));
  animation.AddObserver(this);
  layer()->SetOpacity(0.0f);
}

void CocoaScrollBar::ShowScrollbar() {
  // If the scrollbar is still expanded but has not completely faded away,
  // then shrink it back to its original state.
  if (is_expanded_ && !IsHoverOrPressedState() &&
      layer()->GetAnimator()->IsAnimatingProperty(
          ui::LayerAnimationElement::OPACITY)) {
    DCHECK_EQ(scroller_style_, NSScrollerStyleOverlay);
    thickness_animation_.Hide();
  }

  // Updates the scrolltrack and repaint it, if necessary.
  double opacity =
      scroller_style_ == NSScrollerStyleOverlay ? kOverlayOpacity : 1.0f;
  layer()->SetOpacity(opacity);
  hide_scrollbar_timer_.Stop();
}

bool CocoaScrollBar::IsHoverOrPressedState() const {
  CocoaScrollBarThumb* thumb = GetCocoaScrollBarThumb();
  return thumb->IsStateHovered() ||
         thumb->IsStatePressed() ||
         IsMouseHovered();
}

void CocoaScrollBar::UpdateScrollbarThickness() {
  int thickness = ScrollbarThickness();
  if (GetOrientation() == Orientation::kHorizontal) {
    SetBounds(x(), bounds().bottom() - thickness, width(), thickness);
  } else {
    SetBounds(bounds().right() - thickness, y(), thickness, height());
  }
}

void CocoaScrollBar::ResetOverlayScrollbar() {
  if (!IsHoverOrPressedState() && IsScrollbarFullyHidden() &&
      !thickness_animation_.IsClosing()) {
    if (is_expanded_) {
      is_expanded_ = false;
      thickness_animation_.Reset();
      UpdateScrollbarThickness();
    }
    SetScrolltrackVisible(false);
  }
}

void CocoaScrollBar::SetScrolltrackVisible(bool visible) {
  has_scrolltrack_ = visible;
  SchedulePaint();
}

CocoaScrollBarThumb* CocoaScrollBar::GetCocoaScrollBarThumb() const {
  return static_cast<CocoaScrollBarThumb*>(GetThumb());
}

// static
base::RetainingOneShotTimer* ScrollBar::GetHideTimerForTesting(
    ScrollBar* scroll_bar) {
  return &static_cast<CocoaScrollBar*>(scroll_bar)->hide_scrollbar_timer_;
}

BEGIN_METADATA(CocoaScrollBar)
END_METADATA

}  // namespace views