blob: 70fb3d9292f81bcba71548e7535ef43e11f22a04 [file] [log] [blame]
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/widgets.dart';
import 'color_scheme.dart';
import 'material_state.dart';
import 'theme.dart';
const double _kScrollbarThickness = 8.0;
const double _kScrollbarThicknessWithTrack = 12.0;
const double _kScrollbarMargin = 2.0;
const double _kScrollbarMinLength = 48.0;
const Radius _kScrollbarRadius = Radius.circular(8.0);
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300);
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
/// A Material Design scrollbar.
///
/// To add a scrollbar to a [ScrollView], wrap the scroll view
/// widget in a [Scrollbar] widget.
///
/// {@macro flutter.widgets.Scrollbar}
///
/// The color of the Scrollbar will change when dragged. A hover animation is
/// also triggered when used on web and desktop platforms. A scrollbar track
/// can also been drawn when triggered by a hover event, which is controlled by
/// [showTrackOnHover]. The thickness of the track and scrollbar thumb will
/// become larger when hovering, unless overridden by [hoverThickness].
///
// TODO(Piinks): Add code sample
///
/// See also:
///
/// * [RawScrollbar], a basic scrollbar that fades in and out, extended
/// by this class to add more animations and behaviors.
/// * [CupertinoScrollbar], an iOS style scrollbar.
/// * [ListView], which displays a linear, scrollable list of children.
/// * [GridView], which displays a 2 dimensional, scrollable array of children.
class Scrollbar extends RawScrollbar {
/// Creates a material design scrollbar that by default will connect to the
/// closest Scrollable descendant of [child].
///
/// The [child] should be a source of [ScrollNotification] notifications,
/// typically a [Scrollable] widget.
///
/// If the [controller] is null, the default behavior is to
/// enable scrollbar dragging using the [PrimaryScrollController].
///
/// When null, [thickness] defaults to 8.0 pixels on desktop and web, and 4.0
/// pixels when on mobile platforms. A null [radius] will result in a default
/// of an 8.0 pixel circular radius about the corners of the scrollbar thumb,
/// except for when executing on [TargetPlatform.android], which will render the
/// thumb without a radius.
const Scrollbar({
Key? key,
required Widget child,
ScrollController? controller,
bool isAlwaysShown = false,
this.showTrackOnHover = false,
this.hoverThickness,
double? thickness,
Radius? radius,
}) : super(
key: key,
child: child,
controller: controller,
isAlwaysShown: isAlwaysShown,
thickness: thickness,
radius: radius,
fadeDuration: _kScrollbarFadeDuration,
timeToFade: _kScrollbarTimeToFade,
pressDuration: Duration.zero,
);
/// Controls if the track will show on hover and remain, including during drag.
///
/// Defaults to false, cannot be null.
final bool showTrackOnHover;
/// The thickness of the scrollbar when a hover state is active and
/// [showTrackOnHover] is true.
///
/// Defaults to 12.0 dp when null.
final double? hoverThickness;
@override
_ScrollbarState createState() => _ScrollbarState();
}
class _ScrollbarState extends RawScrollbarState<Scrollbar> {
late AnimationController _hoverAnimationController;
bool _dragIsActive = false;
bool _hoverIsActive = false;
late ColorScheme _colorScheme;
// On Android, scrollbars should match native appearance.
late bool _useAndroidScrollbar;
// Hover events should be ignored on mobile, the exit event cannot be
// triggered, but the enter event can on tap.
late bool _isMobile;
Set<MaterialState> get _states => <MaterialState>{
if (_dragIsActive) MaterialState.dragged,
if (_hoverIsActive) MaterialState.hovered,
};
MaterialStateProperty<Color> get _thumbColor {
final Color onSurface = _colorScheme.onSurface;
final Brightness brightness = _colorScheme.brightness;
late Color dragColor;
late Color hoverColor;
late Color idleColor;
switch (brightness) {
case Brightness.light:
dragColor = onSurface.withOpacity(0.6);
hoverColor = onSurface.withOpacity(0.5);
idleColor = onSurface.withOpacity(0.1);
break;
case Brightness.dark:
dragColor = onSurface.withOpacity(0.75);
hoverColor = onSurface.withOpacity(0.65);
idleColor = onSurface.withOpacity(0.3);
break;
}
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.dragged))
return dragColor;
// If the track is visible, the thumb color hover animation is ignored and
// changes immediately.
if (states.contains(MaterialState.hovered) && widget.showTrackOnHover)
return hoverColor;
return Color.lerp(
idleColor,
hoverColor,
_hoverAnimationController.value,
)!;
});
}
MaterialStateProperty<Color> get _trackColor {
final Color onSurface = _colorScheme.onSurface;
final Brightness brightness = _colorScheme.brightness;
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.hovered) && widget.showTrackOnHover) {
return brightness == Brightness.light
? onSurface.withOpacity(0.03)
: onSurface.withOpacity(0.05);
}
return const Color(0x00000000);
});
}
MaterialStateProperty<Color> get _trackBorderColor {
final Color onSurface = _colorScheme.onSurface;
final Brightness brightness = _colorScheme.brightness;
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.hovered) && widget.showTrackOnHover) {
return brightness == Brightness.light
? onSurface.withOpacity(0.1)
: onSurface.withOpacity(0.25);
}
return const Color(0x00000000);
});
}
MaterialStateProperty<double> get _thickness {
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.hovered) && widget.showTrackOnHover)
return widget.hoverThickness ?? _kScrollbarThicknessWithTrack;
// The default scrollbar thickness is smaller on mobile.
return widget.thickness ?? (_kScrollbarThickness / (_isMobile ? 2 : 1));
});
}
@override
void initState() {
super.initState();
_hoverAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
_hoverAnimationController.addListener(() {
updateScrollbarPainter();
});
}
@override
void didChangeDependencies() {
final ThemeData theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.android:
_useAndroidScrollbar = true;
_isMobile = true;
break;
case TargetPlatform.iOS:
_useAndroidScrollbar = false;
_isMobile = true;
break;
case TargetPlatform.linux:
case TargetPlatform.fuchsia:
case TargetPlatform.macOS:
case TargetPlatform.windows:
_useAndroidScrollbar = false;
_isMobile = false;
break;
}
super.didChangeDependencies();
}
@override
void updateScrollbarPainter() {
_colorScheme = Theme.of(context).colorScheme;
scrollbarPainter
..color = _thumbColor.resolve(_states)
..trackColor = _trackColor.resolve(_states)
..trackBorderColor = _trackBorderColor.resolve(_states)
..textDirection = Directionality.of(context)
..thickness = _thickness.resolve(_states)
..radius = widget.radius ?? (_useAndroidScrollbar ? null : _kScrollbarRadius)
..crossAxisMargin = (_useAndroidScrollbar ? 0.0 : _kScrollbarMargin)
..minLength = _kScrollbarMinLength
..padding = MediaQuery.of(context).padding;
}
@override
void handleThumbPressStart(Offset localPosition) {
super.handleThumbPressStart(localPosition);
setState(() { _dragIsActive = true; });
}
@override
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
super.handleThumbPressEnd(localPosition, velocity);
setState(() { _dragIsActive = false; });
}
@override
void handleHover(PointerHoverEvent event) {
// Hover events should not be triggered on mobile.
assert(!_isMobile);
super.handleHover(event);
// Check if the position of the pointer falls over the painted scrollbar
if (isPointerOverScrollbar(event.position)) {
// Pointer is hovering over the scrollbar
setState(() { _hoverIsActive = true; });
_hoverAnimationController.forward();
} else if (_hoverIsActive) {
// Pointer was, but is no longer over painted scrollbar.
setState(() { _hoverIsActive = false; });
_hoverAnimationController.reverse();
}
}
@override
void handleHoverExit(PointerExitEvent event) {
// Hover events should not be triggered on mobile.
assert(!_isMobile);
super.handleHoverExit(event);
setState(() { _hoverIsActive = false; });
_hoverAnimationController.reverse();
}
@override
void dispose() {
_hoverAnimationController.dispose();
super.dispose();
}
}