| // Copyright 2021 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/controls/rounded_scroll_bar.h" |
| |
| #include <limits> |
| |
| #include "ash/public/cpp/style/color_provider.h" |
| #include "ash/style/ash_color_id.h" |
| #include "base/functional/bind.h" |
| #include "base/memory/raw_ptr.h" |
| #include "base/numerics/ranges.h" |
| #include "base/time/time.h" |
| #include "cc/paint/paint_flags.h" |
| #include "chromeos/constants/chromeos_features.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/compositor/scoped_layer_animation_settings.h" |
| #include "ui/gfx/canvas.h" |
| #include "ui/gfx/geometry/dip_util.h" |
| #include "ui/gfx/scoped_canvas.h" |
| #include "ui/views/controls/button/button.h" |
| #include "ui/views/controls/scrollbar/base_scroll_bar_thumb.h" |
| |
| namespace ash { |
| namespace { |
| |
| // Thickness of scroll bar thumb. |
| constexpr int kScrollThumbThicknessDp = 8; |
| constexpr int kScrollThumbThicknessHoverInsets = 2; |
| constexpr int kScrollThumbOutlineTickness = 1; |
| // How long for the scrollbar to hide after no scroll events have been received? |
| constexpr base::TimeDelta kScrollThumbHideTimeout = base::Milliseconds(500); |
| // How long for the scrollbar to fade away? |
| constexpr base::TimeDelta kScrollThumbFadeDuration = base::Milliseconds(240); |
| // Opacity values from go/semantic-color-system for "Scrollbar". |
| constexpr float kDefaultOpacity = 0.38f; |
| constexpr float kActiveOpacity = 1.0f; |
| |
| // The active state is when the thumb is hovered or pressed. |
| bool IsActiveState(views::Button::ButtonState state) { |
| return state == views::Button::STATE_HOVERED || |
| state == views::Button::STATE_PRESSED; |
| } |
| |
| // Draws a fully rounded rectangle filling in the given bounds. |
| void DrawFullyRoundedRect(gfx::Canvas* canvas, |
| const gfx::RectF& bounds, |
| const cc::PaintFlags& flags) { |
| const SkScalar corner_radius = std::min(bounds.width(), bounds.height()) / 2; |
| SkPath rounded_rect; |
| rounded_rect.addRoundRect(gfx::RectFToSkRect(bounds), corner_radius, |
| corner_radius); |
| canvas->DrawPath(rounded_rect, flags); |
| } |
| |
| } // namespace |
| |
| // A scroll bar "thumb" that paints itself with rounded ends. |
| class RoundedScrollBar::Thumb : public views::BaseScrollBarThumb { |
| public: |
| explicit Thumb(RoundedScrollBar* scroll_bar) |
| : BaseScrollBarThumb(scroll_bar), scroll_bar_(scroll_bar) {} |
| Thumb(const Thumb&) = delete; |
| Thumb& operator=(const Thumb&) = delete; |
| ~Thumb() override = default; |
| |
| bool ShouldPaintAsActive() const { |
| // The thumb is active during hover and also when the user is dragging the |
| // thumb with the mouse. In the latter case, the mouse might be outside the |
| // scroll bar, due to mouse capture. |
| return IsActiveState(GetState()); |
| } |
| |
| int GetThumbThickness() const { |
| if (!chromeos::features::IsJellyrollEnabled() || ShouldPaintAsActive()) { |
| return kScrollThumbThicknessDp; |
| } |
| return kScrollThumbThicknessDp - kScrollThumbThicknessHoverInsets; |
| } |
| |
| // views::BaseScrollBarThumb: |
| gfx::Size CalculatePreferredSize() const override { |
| const int thickness = GetThumbThickness(); |
| return gfx::Size(thickness, thickness); |
| } |
| |
| void OnPaint(gfx::Canvas* canvas) override { |
| // Scale bounds with device scale factor to make sure border bounds match |
| // thumb bounds. |
| gfx::ScopedCanvas scoped_canvas(canvas); |
| const float dsf = canvas->UndoDeviceScaleFactor(); |
| const gfx::RectF local_bounds = |
| gfx::ConvertRectToPixels(GetLocalBounds(), dsf); |
| gfx::RectF thumb_bounds(local_bounds); |
| |
| // Can be nullptr in tests. |
| auto* color_provider = GetColorProvider(); |
| |
| const bool is_jellyroll_enabled = chromeos::features::IsJellyrollEnabled(); |
| if (is_jellyroll_enabled) { |
| // Paint outline. |
| cc::PaintFlags stroke_flags; |
| stroke_flags.setStyle(cc::PaintFlags::kStroke_Style); |
| if (color_provider) { |
| stroke_flags.setColor( |
| color_provider->GetColor(cros_tokens::kCrosSysScrollbarBorder)); |
| } |
| stroke_flags.setStrokeWidth(kScrollThumbOutlineTickness); |
| stroke_flags.setAntiAlias(true); |
| |
| gfx::RectF border_bounds = local_bounds; |
| border_bounds.Inset(kScrollThumbOutlineTickness / 2.0f); |
| |
| DrawFullyRoundedRect(canvas, border_bounds, stroke_flags); |
| |
| thumb_bounds.Inset(kScrollThumbOutlineTickness); |
| } |
| |
| // Paint thumb. |
| cc::PaintFlags fill_flags; |
| fill_flags.setStyle(cc::PaintFlags::kFill_Style); |
| fill_flags.setAntiAlias(true); |
| if (color_provider) { |
| fill_flags.setColor(color_provider->GetColor( |
| is_jellyroll_enabled |
| ? (ShouldPaintAsActive() ? cros_tokens::kCrosSysScrollbarHover |
| : cros_tokens::kCrosSysScrollbar) |
| : static_cast<ui::ColorId>(kColorAshScrollBarColor))); |
| } |
| |
| DrawFullyRoundedRect(canvas, thumb_bounds, fill_flags); |
| } |
| |
| void OnBoundsChanged(const gfx::Rect& previous_bounds) override { |
| scroll_bar_->OnThumbBoundsChanged(); |
| } |
| |
| void OnStateChanged() override { |
| scroll_bar_->OnThumbStateChanged(current_state_); |
| current_state_ = GetState(); |
| } |
| |
| private: |
| const raw_ptr<RoundedScrollBar> scroll_bar_; |
| views::Button::ButtonState current_state_ = views::Button::STATE_NORMAL; |
| }; |
| |
| RoundedScrollBar::RoundedScrollBar(Orientation orientation) |
| : ScrollBar(orientation), |
| thumb_(new Thumb(this)), // Owned by views hierarchy. |
| hide_scrollbar_timer_( |
| FROM_HERE, |
| kScrollThumbHideTimeout, |
| base::BindRepeating(&RoundedScrollBar::HideScrollBar, |
| base::Unretained(this))) { |
| // Moving the mouse directly into the thumb will also notify this view. |
| SetNotifyEnterExitOnChild(true); |
| |
| SetThumb(thumb_); |
| thumb_->SetPaintToLayer(); |
| thumb_->layer()->SetFillsBoundsOpaquely(false); |
| // The thumb is hidden by default. |
| thumb_->layer()->SetOpacity(0.f); |
| } |
| |
| RoundedScrollBar::~RoundedScrollBar() = default; |
| |
| void RoundedScrollBar::SetInsets(const gfx::Insets& insets) { |
| insets_ = insets; |
| } |
| |
| void RoundedScrollBar::SetSnapBackOnDragOutside(bool snap) { |
| thumb_->SetSnapBackOnDragOutside(snap); |
| } |
| |
| void RoundedScrollBar::SetShowOnThumbBoundsChanged(bool show) { |
| show_on_thumb_bounds_changed_ = show; |
| } |
| |
| gfx::Rect RoundedScrollBar::GetTrackBounds() const { |
| gfx::Rect bounds = GetLocalBounds(); |
| bounds.Inset(insets_); |
| return bounds; |
| } |
| |
| bool RoundedScrollBar::OverlapsContent() const { |
| return true; |
| } |
| |
| int RoundedScrollBar::GetThickness() const { |
| // Extend the thickness by the insets on the sides of the bar. |
| const int sides = GetOrientation() == Orientation::kHorizontal |
| ? insets_.top() + insets_.bottom() |
| : insets_.left() + insets_.right(); |
| return thumb_->GetThumbThickness() + sides; |
| } |
| |
| void RoundedScrollBar::OnMouseEntered(const ui::MouseEvent& event) { |
| ShowScrollbar(); |
| } |
| |
| void RoundedScrollBar::OnMouseExited(const ui::MouseEvent& event) { |
| if (!hide_scrollbar_timer_.IsRunning()) { |
| hide_scrollbar_timer_.Reset(); |
| } |
| } |
| |
| void RoundedScrollBar::ScrollToPosition(int position) { |
| ShowScrollbar(); |
| views::ScrollBar::ScrollToPosition(position); |
| } |
| |
| void RoundedScrollBar::ObserveScrollEvent(const ui::ScrollEvent& event) { |
| // Scroll fling events are generated by moving a single finger over the |
| // trackpad; do not show the scrollbar for these events. |
| if (event.type() == ui::ET_SCROLL_FLING_CANCEL) { |
| return; |
| } |
| ShowScrollbar(); |
| } |
| |
| views::BaseScrollBarThumb* RoundedScrollBar::GetThumbForTest() const { |
| return thumb_; |
| } |
| |
| void RoundedScrollBar::ShowScrollbar() { |
| if (!IsMouseHovered()) { |
| hide_scrollbar_timer_.Reset(); |
| } |
| |
| const float target_opacity = (chromeos::features::IsJellyrollEnabled() || |
| thumb_->ShouldPaintAsActive()) |
| ? kActiveOpacity |
| : kDefaultOpacity; |
| if (base::IsApproximatelyEqual(thumb_->layer()->GetTargetOpacity(), |
| target_opacity, |
| std::numeric_limits<float>::epsilon())) { |
| return; |
| } |
| ui::ScopedLayerAnimationSettings animation(thumb_->layer()->GetAnimator()); |
| animation.SetTransitionDuration(kScrollThumbFadeDuration); |
| thumb_->layer()->SetOpacity(target_opacity); |
| } |
| |
| void RoundedScrollBar::HideScrollBar() { |
| // Never hide the scrollbar if the mouse is over it. The auto-hide timer |
| // will be reset when the mouse leaves the scrollable area. |
| if (IsMouseHovered()) { |
| return; |
| } |
| |
| hide_scrollbar_timer_.Stop(); |
| ui::ScopedLayerAnimationSettings animation(thumb_->layer()->GetAnimator()); |
| animation.SetTransitionDuration(kScrollThumbFadeDuration); |
| thumb_->layer()->SetOpacity(0.f); |
| } |
| |
| void RoundedScrollBar::OnThumbStateChanged( |
| views::Button::ButtonState old_state) { |
| // Update the scroll bar track and thumb bounds as needed. This won't |
| // re-layout the scroll contents since the scroll bar overlaps the contents. |
| if (chromeos::features::IsJellyrollEnabled() && |
| IsActiveState(old_state) != thumb_->ShouldPaintAsActive()) { |
| PreferredSizeChanged(); |
| } |
| |
| // If the mouse is still in the scroll bar, the thumb hover state may have |
| // changed, so recompute opacity. |
| if (IsMouseHovered()) { |
| ShowScrollbar(); |
| } |
| } |
| |
| void RoundedScrollBar::OnThumbBoundsChanged() { |
| // Optionally show the scroll bar on thumb bounds changes (e.g. keyboard |
| // driven scroll position changes). |
| if (show_on_thumb_bounds_changed_) { |
| ShowScrollbar(); |
| } |
| } |
| |
| BEGIN_METADATA(RoundedScrollBar) |
| END_METADATA |
| |
| } // namespace ash |