Unverified Commit 770a9b25 authored by Kate Lovett's avatar Kate Lovett Committed by GitHub

Updated Interactive Scrollbars (#71664)

parent e92df4ff
......@@ -2,8 +2,6 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
......@@ -32,180 +30,70 @@ const double _kScrollbarCrossAxisMargin = 3.0;
/// An iOS style scrollbar.
///
/// A scrollbar indicates which portion of a [Scrollable] widget is actually
/// visible.
///
/// To add a scrollbar to a [ScrollView], simply wrap the scroll view widget in
/// a [CupertinoScrollbar] widget.
///
/// By default, the CupertinoScrollbar will be draggable (a feature introduced
/// in iOS 13), it uses the PrimaryScrollController. For multiple scrollbars, or
/// other more complicated situations, see the [controller] parameter.
/// {@macro flutter.widgets.Scrollbar}
///
/// When dragging a [CupertinoScrollbar] thumb, the thickness and radius will
/// animate from [thickness] and [radius] to [thicknessWhileDragging] and
/// [radiusWhileDragging], respectively.
///
// TODO(Piinks): Add code sample
///
/// See also:
///
/// * [ListView], which display a linear, scrollable list of children.
/// * [GridView], which display a 2 dimensional, scrollable array of children.
/// * [Scrollbar], a Material Design scrollbar that dynamically adapts to the
/// platform showing either an Android style or iOS style scrollbar.
class CupertinoScrollbar extends StatefulWidget {
/// * [ListView], which displays a linear, scrollable list of children.
/// * [GridView], which displays a 2 dimensional, scrollable array of children.
/// * [Scrollbar], a Material Design scrollbar.
/// * [RawScrollbar], a basic scrollbar that fades in and out, extended
/// by this class to add more animations and behaviors.
class CupertinoScrollbar extends RawScrollbar {
/// Creates an iOS style scrollbar that wraps the given [child].
///
/// The [child] should be a source of [ScrollNotification] notifications,
/// typically a [Scrollable] widget.
const CupertinoScrollbar({
Key? key,
this.controller,
this.isAlwaysShown = false,
this.thickness = defaultThickness,
required Widget child,
ScrollController? controller,
bool isAlwaysShown = false,
double thickness = defaultThickness,
this.thicknessWhileDragging = defaultThicknessWhileDragging,
this.radius = defaultRadius,
Radius radius = defaultRadius,
this.radiusWhileDragging = defaultRadiusWhileDragging,
required this.child,
}) : assert(thickness != null),
assert(thickness < double.infinity),
assert(thicknessWhileDragging != null),
assert(thicknessWhileDragging < double.infinity),
assert(radius != null),
assert(radiusWhileDragging != null),
assert(!isAlwaysShown || controller != null, 'When isAlwaysShown is true, must pass a controller that is attached to a scroll view'),
super(key: key);
super(
key: key,
child: child,
controller: controller,
isAlwaysShown: isAlwaysShown,
thickness: thickness,
radius: radius,
fadeDuration: _kScrollbarFadeDuration,
timeToFade: _kScrollbarTimeToFade,
pressDuration: const Duration(milliseconds: 100),
);
/// Default value for [thickness] if it's not specified in [new CupertinoScrollbar].
/// Default value for [thickness] if it's not specified in [CupertinoScrollbar].
static const double defaultThickness = 3;
/// Default value for [thicknessWhileDragging] if it's not specified in [new CupertinoScrollbar].
/// Default value for [thicknessWhileDragging] if it's not specified in
/// [CupertinoScrollbar].
static const double defaultThicknessWhileDragging = 8.0;
/// Default value for [radius] if it's not specified in [new CupertinoScrollbar].
/// Default value for [radius] if it's not specified in [CupertinoScrollbar].
static const Radius defaultRadius = Radius.circular(1.5);
/// Default value for [radiusWhileDragging] if it's not specified in [new CupertinoScrollbar].
/// Default value for [radiusWhileDragging] if it's not specified in
/// [CupertinoScrollbar].
static const Radius defaultRadiusWhileDragging = Radius.circular(4.0);
/// The subtree to place inside the [CupertinoScrollbar].
///
/// This should include a source of [ScrollNotification] notifications,
/// typically a [Scrollable] widget.
final Widget child;
/// {@template flutter.cupertino.cupertinoScrollbar.controller}
/// The [ScrollController] used to implement Scrollbar dragging.
///
/// introduced in iOS 13.
///
/// If nothing is passed to controller, the default behavior is to automatically
/// enable scrollbar dragging on the nearest ScrollController using
/// [PrimaryScrollController.of].
///
/// If a ScrollController is passed, then scrollbar dragging will be enabled on
/// the given ScrollController. A stateful ancestor of this CupertinoScrollbar
/// needs to manage the ScrollController and either pass it to a scrollable
/// descendant or use a PrimaryScrollController to share it.
///
/// Here is an example of using the `controller` parameter to enable
/// scrollbar dragging for multiple independent ListViews:
///
/// {@tool snippet}
///
/// ```dart
/// final ScrollController _controllerOne = ScrollController();
/// final ScrollController _controllerTwo = ScrollController();
///
/// build(BuildContext context) {
/// return Column(
/// children: <Widget>[
/// Container(
/// height: 200,
/// child: CupertinoScrollbar(
/// controller: _controllerOne,
/// child: ListView.builder(
/// controller: _controllerOne,
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index) => Text('item $index'),
/// ),
/// ),
/// ),
/// Container(
/// height: 200,
/// child: CupertinoScrollbar(
/// controller: _controllerTwo,
/// child: ListView.builder(
/// controller: _controllerTwo,
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index) => Text('list 2 item $index'),
/// ),
/// ),
/// ),
/// ],
/// );
/// }
/// ```
/// {@end-tool}
/// {@endtemplate}
final ScrollController? controller;
/// {@template flutter.cupertino.cupertinoScrollbar.isAlwaysShown}
/// Indicates whether the [Scrollbar] should always be visible.
///
/// When false, the scrollbar will be shown during scrolling
/// and will fade out otherwise.
///
/// When true, the scrollbar will always be visible and never fade out.
///
/// The [controller] property must be set in this case.
/// It should be passed the relevant [Scrollable]'s [ScrollController].
///
/// Defaults to false.
///
/// {@tool snippet}
///
/// ```dart
/// final ScrollController _controllerOne = ScrollController();
/// final ScrollController _controllerTwo = ScrollController();
///
/// build(BuildContext context) {
/// return Column(
/// children: <Widget>[
/// Container(
/// height: 200,
/// child: Scrollbar(
/// isAlwaysShown: true,
/// controller: _controllerOne,
/// child: ListView.builder(
/// controller: _controllerOne,
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index)
/// => Text('item $index'),
/// ),
/// ),
/// ),
/// Container(
/// height: 200,
/// child: CupertinoScrollbar(
/// isAlwaysShown: true,
/// controller: _controllerTwo,
/// child: SingleChildScrollView(
/// controller: _controllerTwo,
/// child: SizedBox(height: 2000, width: 500,),
/// ),
/// ),
/// ),
/// ],
/// );
/// }
/// ```
/// {@end-tool}
/// {@endtemplate}
final bool isAlwaysShown;
/// The thickness of the scrollbar when it's not being dragged by the user.
///
/// When the user starts dragging the scrollbar, the thickness will animate
/// to [thicknessWhileDragging], then animate back when the user stops
/// dragging the scrollbar.
final double thickness;
/// The thickness of the scrollbar when it's being dragged by the user.
///
/// When the user starts dragging the scrollbar, the thickness will animate
......@@ -213,14 +101,6 @@ class CupertinoScrollbar extends StatefulWidget {
/// dragging the scrollbar.
final double thicknessWhileDragging;
/// The radius of the scrollbar edges when the scrollbar is not being dragged
/// by the user.
///
/// When the user starts dragging the scrollbar, the radius will animate
/// to [radiusWhileDragging], then animate back when the user stops dragging
/// the scrollbar.
final Radius radius;
/// The radius of the scrollbar edges when the scrollbar is being dragged by
/// the user.
///
......@@ -233,363 +113,100 @@ class CupertinoScrollbar extends StatefulWidget {
_CupertinoScrollbarState createState() => _CupertinoScrollbarState();
}
class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProviderStateMixin {
final GlobalKey _customPaintKey = GlobalKey();
ScrollbarPainter? _painter;
late AnimationController _fadeoutAnimationController;
late Animation<double> _fadeoutOpacityAnimation;
class _CupertinoScrollbarState extends RawScrollbarState<CupertinoScrollbar> {
late AnimationController _thicknessAnimationController;
Timer? _fadeoutTimer;
double? _dragScrollbarAxisPosition;
Drag? _drag;
double get _thickness {
return widget.thickness + _thicknessAnimationController.value * (widget.thicknessWhileDragging - widget.thickness);
return widget.thickness! + _thicknessAnimationController.value * (widget.thicknessWhileDragging - widget.thickness!);
}
Radius get _radius {
return Radius.lerp(widget.radius, widget.radiusWhileDragging, _thicknessAnimationController.value)!;
}
ScrollController? _currentController;
ScrollController? get _controller =>
widget.controller ?? PrimaryScrollController.of(context);
@override
void initState() {
super.initState();
_fadeoutAnimationController = AnimationController(
vsync: this,
duration: _kScrollbarFadeDuration,
);
_fadeoutOpacityAnimation = CurvedAnimation(
parent: _fadeoutAnimationController,
curve: Curves.fastOutSlowIn,
);
_thicknessAnimationController = AnimationController(
vsync: this,
duration: _kScrollbarResizeDuration,
);
_thicknessAnimationController.addListener(() {
_painter!.updateThickness(_thickness, _radius);
updateScrollbarPainter();
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_painter == null) {
_painter = _buildCupertinoScrollbarPainter(context);
} else {
_painter!
..textDirection = Directionality.of(context)
void updateScrollbarPainter() {
scrollbarPainter
..color = CupertinoDynamicColor.resolve(_kScrollbarColor, context)
..padding = MediaQuery.of(context).padding;
}
_triggerScrollbar();
}
@override
void didUpdateWidget(CupertinoScrollbar oldWidget) {
super.didUpdateWidget(oldWidget);
assert(_painter != null);
_painter!.updateThickness(_thickness, _radius);
if (widget.isAlwaysShown != oldWidget.isAlwaysShown) {
if (widget.isAlwaysShown == true) {
_triggerScrollbar();
_fadeoutAnimationController.animateTo(1.0);
} else {
_fadeoutAnimationController.reverse();
}
}
}
/// Returns a [ScrollbarPainter] visually styled like the iOS scrollbar.
ScrollbarPainter _buildCupertinoScrollbarPainter(BuildContext context) {
return ScrollbarPainter(
color: CupertinoDynamicColor.resolve(_kScrollbarColor, context),
textDirection: Directionality.of(context),
thickness: _thickness,
fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
mainAxisMargin: _kScrollbarMainAxisMargin,
crossAxisMargin: _kScrollbarCrossAxisMargin,
radius: _radius,
padding: MediaQuery.of(context).padding,
minLength: _kScrollbarMinLength,
minOverscrollLength: _kScrollbarMinOverscrollLength,
);
}
// Wait one frame and cause an empty scroll event. This allows the thumb to
// show immediately when isAlwaysShown is true. A scroll event is required in
// order to paint the thumb.
void _triggerScrollbar() {
WidgetsBinding.instance!.addPostFrameCallback((Duration duration) {
if (widget.isAlwaysShown) {
_fadeoutTimer?.cancel();
widget.controller!.position.didUpdateScrollPositionBy(0);
}
});
}
// Handle a gesture that drags the scrollbar by the given amount.
void _dragScrollbar(double primaryDelta) {
assert(_currentController != null);
// Convert primaryDelta, the amount that the scrollbar moved since the last
// time _dragScrollbar was called, into the coordinate space of the scroll
// position, and create/update the drag event with that position.
final double scrollOffsetLocal = _painter!.getTrackToScroll(primaryDelta);
final double scrollOffsetGlobal = scrollOffsetLocal + _currentController!.position.pixels;
final Axis direction = _currentController!.position.axis;
if (_drag == null) {
_drag = _currentController!.position.drag(
DragStartDetails(
globalPosition: direction == Axis.vertical
? Offset(0.0, scrollOffsetGlobal)
: Offset(scrollOffsetGlobal, 0.0),
),
() {},
);
} else {
_drag!.update(DragUpdateDetails(
globalPosition: direction == Axis.vertical
? Offset(0.0, scrollOffsetGlobal)
: Offset(scrollOffsetGlobal, 0.0),
delta: direction == Axis.vertical
? Offset(0.0, -scrollOffsetLocal)
: Offset(-scrollOffsetLocal, 0.0),
primaryDelta: -scrollOffsetLocal,
));
}
}
void _startFadeoutTimer() {
if (!widget.isAlwaysShown) {
_fadeoutTimer?.cancel();
_fadeoutTimer = Timer(_kScrollbarTimeToFade, () {
_fadeoutAnimationController.reverse();
_fadeoutTimer = null;
});
}
}
Axis? _getDirection() {
try {
return _currentController!.position.axis;
} catch (_) {
// Ignore the gesture if we cannot determine the direction.
return null;
}
..textDirection = Directionality.of(context)
..thickness = _thickness
..mainAxisMargin = _kScrollbarMainAxisMargin
..crossAxisMargin = _kScrollbarCrossAxisMargin
..radius = _radius
..padding = MediaQuery.of(context).padding
..minLength = _kScrollbarMinLength
..minOverscrollLength = _kScrollbarMinOverscrollLength;
}
double _pressStartAxisPosition = 0.0;
// Long press event callbacks handle the gesture where the user long presses
// on the scrollbar thumb and then drags the scrollbar without releasing.
void _handleLongPressStart(LongPressStartDetails details) {
_currentController = _controller;
final Axis? direction = _getDirection();
if (direction == null) {
return;
}
_fadeoutTimer?.cancel();
_fadeoutAnimationController.forward();
@override
void handleThumbPressStart(Offset localPosition) {
super.handleThumbPressStart(localPosition);
final Axis direction = getScrollbarDirection()!;
switch (direction) {
case Axis.vertical:
_pressStartAxisPosition = details.localPosition.dy;
_dragScrollbar(details.localPosition.dy);
_dragScrollbarAxisPosition = details.localPosition.dy;
_pressStartAxisPosition = localPosition.dy;
break;
case Axis.horizontal:
_pressStartAxisPosition = details.localPosition.dx;
_dragScrollbar(details.localPosition.dx);
_dragScrollbarAxisPosition = details.localPosition.dx;
_pressStartAxisPosition = localPosition.dx;
break;
}
}
void _handleLongPress() {
if (_getDirection() == null) {
@override
void handleThumbPress() {
if (getScrollbarDirection() == null) {
return;
}
_fadeoutTimer?.cancel();
super.handleThumbPress();
_thicknessAnimationController.forward().then<void>(
(_) => HapticFeedback.mediumImpact(),
);
}
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
final Axis? direction = _getDirection();
if (direction == null) {
return;
}
switch(direction) {
case Axis.vertical:
_dragScrollbar(details.localPosition.dy - _dragScrollbarAxisPosition!);
_dragScrollbarAxisPosition = details.localPosition.dy;
break;
case Axis.horizontal:
_dragScrollbar(details.localPosition.dx - _dragScrollbarAxisPosition!);
_dragScrollbarAxisPosition = details.localPosition.dx;
break;
}
}
void _handleLongPressEnd(LongPressEndDetails details) {
final Axis? direction = _getDirection();
@override
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
final Axis? direction = getScrollbarDirection();
if (direction == null) {
return;
}
_thicknessAnimationController.reverse();
super.handleThumbPressEnd(localPosition, velocity);
switch(direction) {
case Axis.vertical:
_handleDragScrollEnd(details.velocity.pixelsPerSecond.dy, direction);
if (details.velocity.pixelsPerSecond.dy.abs() < 10 &&
(details.localPosition.dy - _pressStartAxisPosition).abs() > 0) {
if (velocity.pixelsPerSecond.dy.abs() < 10 &&
(localPosition.dy - _pressStartAxisPosition).abs() > 0) {
HapticFeedback.mediumImpact();
}
break;
case Axis.horizontal:
_handleDragScrollEnd(details.velocity.pixelsPerSecond.dx, direction);
if (details.velocity.pixelsPerSecond.dx.abs() < 10 &&
(details.localPosition.dx - _pressStartAxisPosition).abs() > 0) {
if (velocity.pixelsPerSecond.dx.abs() < 10 &&
(localPosition.dx - _pressStartAxisPosition).abs() > 0) {
HapticFeedback.mediumImpact();
}
break;
}
_currentController = null;
}
void _handleDragScrollEnd(double trackVelocity, Axis direction) {
_startFadeoutTimer();
_thicknessAnimationController.reverse();
_dragScrollbarAxisPosition = null;
final double scrollVelocity = _painter!.getTrackToScroll(trackVelocity);
_drag?.end(DragEndDetails(
primaryVelocity: -scrollVelocity,
velocity: Velocity(
pixelsPerSecond: direction == Axis.vertical
? Offset(0.0, -scrollVelocity)
: Offset(-scrollVelocity, 0.0),
),
));
_drag = null;
}
bool _handleScrollNotification(ScrollNotification notification) {
final ScrollMetrics metrics = notification.metrics;
if (metrics.maxScrollExtent <= metrics.minScrollExtent) {
return false;
}
if (notification is ScrollUpdateNotification ||
notification is OverscrollNotification) {
// Any movements always makes the scrollbar start showing up.
if (_fadeoutAnimationController.status != AnimationStatus.forward) {
_fadeoutAnimationController.forward();
}
_fadeoutTimer?.cancel();
_painter!.update(notification.metrics, notification.metrics.axisDirection);
} else if (notification is ScrollEndNotification) {
// On iOS, the scrollbar can only go away once the user lifted the finger.
if (_dragScrollbarAxisPosition == null) {
_startFadeoutTimer();
}
}
return false;
}
// Get the GestureRecognizerFactories used to detect gestures on the scrollbar
// thumb.
Map<Type, GestureRecognizerFactory> get _gestures {
final Map<Type, GestureRecognizerFactory> gestures =
<Type, GestureRecognizerFactory>{};
gestures[_ThumbPressGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<_ThumbPressGestureRecognizer>(
() => _ThumbPressGestureRecognizer(
debugOwner: this,
customPaintKey: _customPaintKey,
),
(_ThumbPressGestureRecognizer instance) {
instance
..onLongPressStart = _handleLongPressStart
..onLongPress = _handleLongPress
..onLongPressMoveUpdate = _handleLongPressMoveUpdate
..onLongPressEnd = _handleLongPressEnd;
},
);
return gestures;
}
@override
void dispose() {
_fadeoutAnimationController.dispose();
_thicknessAnimationController.dispose();
_fadeoutTimer?.cancel();
_painter!.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: RepaintBoundary(
child: RawGestureDetector(
gestures: _gestures,
child: CustomPaint(
key: _customPaintKey,
foregroundPainter: _painter,
child: RepaintBoundary(child: widget.child),
),
),
),
);
}
}
// A longpress gesture detector that only responds to events on the scrollbar's
// thumb and ignores everything else.
class _ThumbPressGestureRecognizer extends LongPressGestureRecognizer {
_ThumbPressGestureRecognizer({
double? postAcceptSlopTolerance,
PointerDeviceKind? kind,
required Object debugOwner,
required GlobalKey customPaintKey,
}) : _customPaintKey = customPaintKey,
super(
postAcceptSlopTolerance: postAcceptSlopTolerance,
kind: kind,
debugOwner: debugOwner,
duration: const Duration(milliseconds: 100),
);
final GlobalKey _customPaintKey;
@override
bool isPointerAllowed(PointerDownEvent event) {
if (!_hitTestInteractive(_customPaintKey, event.position)) {
return false;
}
return super.isPointerAllowed(event);
}
}
// foregroundPainter also hit tests its children by default, but the
// scrollbar should only respond to a gesture directly on its thumb, so
// manually check for a hit on the thumb here.
bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset) {
if (customPaintKey.currentContext == null) {
return false;
}
final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint;
final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter;
final RenderBox renderBox = customPaintKey.currentContext!.findRenderObject()! as RenderBox;
final Offset localOffset = renderBox.globalToLocal(offset);
return painter.hitTestInteractive(localOffset);
}
......@@ -2,236 +2,237 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
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 = 6.0;
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.
///
/// A scrollbar indicates which portion of a [Scrollable] widget is actually
/// visible.
/// To add a scrollbar thumb to a [ScrollView], simply wrap the scroll view
/// widget in a [Scrollbar] widget.
///
/// {@macro flutter.widgets.Scrollbar}
///
/// Dynamically changes to an iOS style scrollbar that looks like
/// [CupertinoScrollbar] on the iOS platform.
/// The color of the Scrollbar will change when dragged, as well as when
/// hovered over. 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].
///
/// To add a scrollbar to a [ScrollView], simply wrap the scroll view widget in
/// a [Scrollbar] widget.
// TODO(Piinks): Add code sample
///
/// See also:
///
/// * [ListView], which display a linear, scrollable list of children.
/// * [GridView], which display a 2 dimensional, scrollable array of children.
class Scrollbar extends StatefulWidget {
/// Creates a material design scrollbar that wraps the given [child].
/// * [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.
const Scrollbar({
Key? key,
required this.child,
this.controller,
this.isAlwaysShown = false,
this.thickness,
this.radius,
}) : assert(!isAlwaysShown || controller != null, 'When isAlwaysShown is true, must pass a controller that is attached to a scroll view'),
super(key: key);
/// The widget below this widget in the tree.
///
/// The scrollbar will be stacked on top of this child. This child (and its
/// subtree) should include a source of [ScrollNotification] notifications.
/// If the [controller] is null, the default behavior is to
/// enable scrollbar dragging using the [PrimaryScrollController].
///
/// Typically a [ListView] or [CustomScrollView].
final Widget child;
/// {@macro flutter.cupertino.cupertinoScrollbar.controller}
final ScrollController? controller;
/// {@macro flutter.cupertino.cupertinoScrollbar.isAlwaysShown}
final bool isAlwaysShown;
/// When null, [thickness] and [radius] defaults will result in a rounded
/// rectangular thumb that is 8.0 dp wide with a radius of 8.0 pixels.
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 ?? _kScrollbarThickness,
radius: radius,
fadeDuration: _kScrollbarFadeDuration,
timeToFade: _kScrollbarTimeToFade,
pressDuration: Duration.zero,
);
/// The thickness of the scrollbar.
/// Controls if the track will show on hover and remain, including during drag.
///
/// If this is non-null, it will be used as the thickness of the scrollbar on
/// all platforms, whether the scrollbar is being dragged by the user or not.
/// By default (if this is left null), each platform will get a thickness
/// that matches the look and feel of the platform, and the thickness may
/// grow while the scrollbar is being dragged if the platform look and feel
/// calls for such behavior.
final double? thickness;
/// The radius of the corners of the scrollbar.
/// Defaults to false, cannot be null.
final bool showTrackOnHover;
/// The thickness of the scrollbar when a hover state is active and
/// [showTrackOnHover] is true.
///
/// If this is non-null, it will be used as the fixed radius of the scrollbar
/// on all platforms, whether the scrollbar is being dragged by the user or
/// not. By default (if this is left null), each platform will get a radius
/// that matches the look and feel of the platform, and the radius may
/// change while the scrollbar is being dragged if the platform look and feel
/// calls for such behavior.
final Radius? radius;
/// Defaults to 12.0 dp when null.
final double? hoverThickness;
@override
_ScrollbarState createState() => _ScrollbarState();
}
class _ScrollbarState extends State<Scrollbar> with SingleTickerProviderStateMixin {
ScrollbarPainter? _materialPainter;
late TextDirection _textDirection;
late Color _themeColor;
late bool _useCupertinoScrollbar;
late AnimationController _fadeoutAnimationController;
late Animation<double> _fadeoutOpacityAnimation;
Timer? _fadeoutTimer;
@override
void initState() {
super.initState();
_fadeoutAnimationController = AnimationController(
vsync: this,
duration: _kScrollbarFadeDuration,
);
_fadeoutOpacityAnimation = CurvedAnimation(
parent: _fadeoutAnimationController,
curve: Curves.fastOutSlowIn,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final ThemeData theme = Theme.of(context);
switch (theme.platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
// On iOS, stop all local animations. CupertinoScrollbar has its own
// animations.
_fadeoutTimer?.cancel();
_fadeoutTimer = null;
_fadeoutAnimationController.reset();
_useCupertinoScrollbar = true;
class _ScrollbarState extends RawScrollbarState<Scrollbar> {
late AnimationController _hoverAnimationController;
bool _dragIsActive = false;
bool _hoverIsActive = false;
late ColorScheme _colorScheme;
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 TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
_themeColor = theme.highlightColor.withOpacity(1.0);
_textDirection = Directionality.of(context);
_materialPainter = _buildMaterialScrollbarPainter();
_useCupertinoScrollbar = false;
_triggerScrollbar();
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,
)!;
});
}
@override
void didUpdateWidget(Scrollbar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isAlwaysShown != oldWidget.isAlwaysShown) {
if (widget.isAlwaysShown == false) {
_fadeoutAnimationController.reverse();
} else {
_triggerScrollbar();
_fadeoutAnimationController.animateTo(1.0);
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);
});
}
if (!_useCupertinoScrollbar) {
_materialPainter!
..thickness = widget.thickness ?? _kScrollbarThickness
..radius = widget.radius;
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);
});
}
// Wait one frame and cause an empty scroll event. This allows the thumb to
// show immediately when isAlwaysShown is true. A scroll event is required in
// order to paint the thumb.
void _triggerScrollbar() {
WidgetsBinding.instance!.addPostFrameCallback((Duration duration) {
if (widget.isAlwaysShown) {
_fadeoutTimer?.cancel();
widget.controller!.position.didUpdateScrollPositionBy(0);
}
MaterialStateProperty<double> get _thickness {
return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
if (states.contains(MaterialState.hovered) && widget.showTrackOnHover)
return widget.hoverThickness ?? _kScrollbarThicknessWithTrack;
return widget.thickness ?? _kScrollbarThickness;
});
}
ScrollbarPainter _buildMaterialScrollbarPainter() {
return ScrollbarPainter(
color: _themeColor,
textDirection: _textDirection,
thickness: widget.thickness ?? _kScrollbarThickness,
radius: widget.radius,
fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
padding: MediaQuery.of(context).padding,
@override
void initState() {
super.initState();
_hoverAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
_hoverAnimationController.addListener(() {
updateScrollbarPainter();
});
}
bool _handleScrollNotification(ScrollNotification notification) {
final ScrollMetrics metrics = notification.metrics;
if (metrics.maxScrollExtent <= metrics.minScrollExtent) {
return false;
@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 ?? _kScrollbarRadius
..crossAxisMargin = _kScrollbarMargin
..minLength = _kScrollbarMinLength
..padding = MediaQuery.of(context).padding;
}
// iOS sub-delegates to the CupertinoScrollbar instead and doesn't handle
// scroll notifications here.
if (!_useCupertinoScrollbar &&
(notification is ScrollUpdateNotification ||
notification is OverscrollNotification)) {
if (_fadeoutAnimationController.status != AnimationStatus.forward) {
_fadeoutAnimationController.forward();
@override
void handleThumbPressStart(Offset localPosition) {
super.handleThumbPressStart(localPosition);
setState(() { _dragIsActive = true; });
}
_materialPainter!.update(
notification.metrics,
notification.metrics.axisDirection,
);
if (!widget.isAlwaysShown) {
_fadeoutTimer?.cancel();
_fadeoutTimer = Timer(_kScrollbarTimeToFade, () {
_fadeoutAnimationController.reverse();
_fadeoutTimer = null;
});
@override
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
super.handleThumbPressEnd(localPosition, velocity);
setState(() { _dragIsActive = false; });
}
@override
void handleHover(PointerHoverEvent event) {
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();
}
return false;
}
@override
void dispose() {
_fadeoutAnimationController.dispose();
_fadeoutTimer?.cancel();
_materialPainter?.dispose();
super.dispose();
void handleHoverExit(PointerExitEvent event) {
super.handleHoverExit(event);
setState(() { _hoverIsActive = false; });
_hoverAnimationController.reverse();
}
@override
Widget build(BuildContext context) {
if (_useCupertinoScrollbar) {
return CupertinoScrollbar(
child: widget.child,
isAlwaysShown: widget.isAlwaysShown,
thickness: widget.thickness ?? CupertinoScrollbar.defaultThickness,
thicknessWhileDragging: widget.thickness ?? CupertinoScrollbar.defaultThicknessWhileDragging,
radius: widget.radius ?? CupertinoScrollbar.defaultRadius,
radiusWhileDragging: widget.radius ?? CupertinoScrollbar.defaultRadiusWhileDragging,
controller: widget.controller,
);
}
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: RepaintBoundary(
child: CustomPaint(
foregroundPainter: _materialPainter,
child: RepaintBoundary(
child: widget.child,
),
),
),
);
void dispose() {
_hoverAnimationController.dispose();
super.dispose();
}
}
......@@ -2,18 +2,34 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'basic.dart';
import 'binding.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'media_query.dart';
import 'notification_listener.dart';
import 'primary_scroll_controller.dart';
import 'scroll_controller.dart';
import 'scroll_metrics.dart';
import 'scroll_notification.dart';
import 'scrollable.dart';
import 'ticker_provider.dart';
const double _kMinThumbExtent = 18.0;
const double _kMinInteractiveSize = 48.0;
const double _kScrollbarThickness = 6.0;
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300);
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
/// A [CustomPainter] for painting scrollbars.
/// Paints a scrollbar's track and thumb.
///
/// The size of the scrollbar along its scroll direction is typically
/// proportional to the percentage of content completely visible on screen,
......@@ -45,17 +61,18 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
/// Creates a scrollbar with customizations given by construction arguments.
ScrollbarPainter({
required Color color,
required TextDirection textDirection,
required this.thickness,
required this.fadeoutOpacityAnimation,
Color trackColor = const Color(0x00000000),
Color trackBorderColor = const Color(0x00000000),
TextDirection? textDirection,
double thickness = _kScrollbarThickness,
EdgeInsets padding = EdgeInsets.zero,
this.mainAxisMargin = 0.0,
this.crossAxisMargin = 0.0,
this.radius,
this.minLength = _kMinThumbExtent,
double mainAxisMargin = 0.0,
double crossAxisMargin = 0.0,
Radius? radius,
double minLength = _kMinThumbExtent,
double? minOverscrollLength,
}) : assert(color != null),
assert(textDirection != null),
assert(thickness != null),
assert(fadeoutOpacityAnimation != null),
assert(mainAxisMargin != null),
......@@ -68,8 +85,15 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
assert(padding.isNonNegative),
_color = color,
_textDirection = textDirection,
_thickness = thickness,
_radius = radius,
_padding = padding,
minOverscrollLength = minOverscrollLength ?? minLength {
_mainAxisMargin = mainAxisMargin,
_crossAxisMargin = crossAxisMargin,
_minLength = minLength,
_trackColor = trackColor,
_trackBorderColor = trackBorderColor,
_minOverscrollLength = minOverscrollLength ?? minLength {
fadeoutOpacityAnimation.addListener(notifyListeners);
}
......@@ -85,11 +109,36 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
notifyListeners();
}
/// [Color] of the track. Mustn't be null.
Color get trackColor => _trackColor;
Color _trackColor;
set trackColor(Color value) {
assert(value != null);
if (trackColor == value)
return;
_trackColor = value;
notifyListeners();
}
/// [Color] of the track border. Mustn't be null.
Color get trackBorderColor => _trackBorderColor;
Color _trackBorderColor;
set trackBorderColor(Color value) {
assert(value != null);
if (trackBorderColor == value)
return;
_trackBorderColor = value;
notifyListeners();
}
/// [TextDirection] of the [BuildContext] which dictates the side of the
/// screen the scrollbar appears in (the trailing side). Mustn't be null.
TextDirection get textDirection => _textDirection;
TextDirection _textDirection;
set textDirection(TextDirection value) {
/// screen the scrollbar appears in (the trailing side). Must be set prior to
/// calling paint.
TextDirection? get textDirection => _textDirection;
TextDirection? _textDirection;
set textDirection(TextDirection? value) {
assert(value != null);
if (textDirection == value)
return;
......@@ -99,7 +148,16 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
}
/// Thickness of the scrollbar in its cross-axis in logical pixels. Mustn't be null.
double thickness;
double get thickness => _thickness;
double _thickness;
set thickness(double value) {
assert(value != null);
if (thickness == value)
return;
_thickness = value;
notifyListeners();
}
/// An opacity [Animation] that dictates the opacity of the thumb.
/// Changes in value of this [Listenable] will automatically trigger repaints.
......@@ -110,17 +168,43 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
/// in logical pixels. It affects the amount of available paint area.
///
/// Mustn't be null and defaults to 0.
final double mainAxisMargin;
double get mainAxisMargin => _mainAxisMargin;
double _mainAxisMargin;
set mainAxisMargin(double value) {
assert(value != null);
if (mainAxisMargin == value)
return;
_mainAxisMargin = value;
notifyListeners();
}
/// Distance from the scrollbar's side to the nearest edge in logical pixels.
///
/// Must not be null and defaults to 0.
final double crossAxisMargin;
double get crossAxisMargin => _crossAxisMargin;
double _crossAxisMargin;
set crossAxisMargin(double value) {
assert(value != null);
if (crossAxisMargin == value)
return;
_crossAxisMargin = value;
notifyListeners();
}
/// [Radius] of corners if the scrollbar should have rounded corners.
///
/// Scrollbar will be rectangular if [radius] is null.
Radius? radius;
Radius? get radius => _radius;
Radius? _radius;
set radius(Radius? value) {
if (radius == value)
return;
_radius = value;
notifyListeners();
}
/// The amount of space by which to inset the scrollbar's start and end, as
/// well as its side to the nearest edge, in logical pixels.
......@@ -154,7 +238,16 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
///
/// Mustn't be null and the value has to be within the range of 0 to
/// [minOverscrollLength], inclusive. Defaults to 18.0.
final double minLength;
double get minLength => _minLength;
double _minLength;
set minLength(double value) {
assert(value != null);
if (minLength == value)
return;
_minLength = value;
notifyListeners();
}
/// The preferred smallest size the scrollbar can shrink to when viewport is
/// overscrolled.
......@@ -166,11 +259,22 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
///
/// The value is less than or equal to [minLength] and greater than or equal to 0.
/// If unspecified or set to null, it will defaults to the value of [minLength].
final double minOverscrollLength;
double get minOverscrollLength => _minOverscrollLength;
double _minOverscrollLength;
set minOverscrollLength(double value) {
assert(value != null);
if (minOverscrollLength == value)
return;
_minOverscrollLength = value;
notifyListeners();
}
ScrollMetrics? _lastMetrics;
AxisDirection? _lastAxisDirection;
Rect? _thumbRect;
Rect? _trackRect;
late double _thumbOffset;
/// Update with new [ScrollMetrics]. The scrollbar will show and redraw itself
/// based on these new metrics.
......@@ -189,50 +293,82 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
void updateThickness(double nextThickness, Radius nextRadius) {
thickness = nextThickness;
radius = nextRadius;
notifyListeners();
}
Paint get _paint {
Paint get _paintThumb {
return Paint()
..color = color.withOpacity(color.opacity * fadeoutOpacityAnimation.value);
}
void _paintThumbCrossAxis(Canvas canvas, Size size, double thumbOffset, double thumbExtent, AxisDirection direction) {
Paint _paintTrack({ bool isBorder = false }) {
if (isBorder) {
return Paint()
..color = trackBorderColor.withOpacity(trackBorderColor.opacity * fadeoutOpacityAnimation.value)
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
}
return Paint()
..color = trackColor.withOpacity(trackColor.opacity * fadeoutOpacityAnimation.value);
}
void _paintScrollbar(Canvas canvas, Size size, double thumbExtent, AxisDirection direction) {
assert(
textDirection != null,
'A TextDirection must be provided before a Scrollbar can be painted.',
);
final double x, y;
final Size thumbSize;
final Size thumbSize, trackSize;
final Offset trackOffset;
switch (direction) {
case AxisDirection.down:
thumbSize = Size(thickness, thumbExtent);
trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent);
x = textDirection == TextDirection.rtl
? crossAxisMargin + padding.left
: size.width - thickness - crossAxisMargin - padding.right;
y = thumbOffset;
y = _thumbOffset;
trackOffset = Offset(x - crossAxisMargin, 0.0);
break;
case AxisDirection.up:
thumbSize = Size(thickness, thumbExtent);
trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent);
x = textDirection == TextDirection.rtl
? crossAxisMargin + padding.left
: size.width - thickness - crossAxisMargin - padding.right;
y = thumbOffset;
y = _thumbOffset;
trackOffset = Offset(x - crossAxisMargin, 0.0);
break;
case AxisDirection.left:
thumbSize = Size(thumbExtent, thickness);
x = thumbOffset;
x = _thumbOffset;
y = size.height - thickness - crossAxisMargin - padding.bottom;
trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin);
trackOffset = Offset(0.0, y - crossAxisMargin);
break;
case AxisDirection.right:
thumbSize = Size(thumbExtent, thickness);
x = thumbOffset;
trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin);
x = _thumbOffset;
y = size.height - thickness - crossAxisMargin - padding.bottom;
trackOffset = Offset(0.0, y - crossAxisMargin);
break;
}
_trackRect = trackOffset & trackSize;
canvas.drawRect(_trackRect!, _paintTrack());
canvas.drawLine(
trackOffset,
Offset(trackOffset.dx, trackOffset.dy + _trackExtent),
_paintTrack(isBorder: true),
);
_thumbRect = Offset(x, y) & thumbSize;
if (radius == null)
canvas.drawRect(_thumbRect!, _paint);
canvas.drawRect(_thumbRect!, _paintThumb);
else
canvas.drawRRect(RRect.fromRectAndRadius(_thumbRect!, radius!), _paint);
canvas.drawRRect(RRect.fromRectAndRadius(_thumbRect!, radius!), _paintThumb);
}
double _thumbExtent() {
......@@ -332,14 +468,39 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
final double beforePadding = _isVertical ? padding.top : padding.left;
final double thumbExtent = _thumbExtent();
final double thumbOffsetLocal = _getScrollToTrack(_lastMetrics!, thumbExtent);
final double thumbOffset = thumbOffsetLocal + mainAxisMargin + beforePadding;
_thumbOffset = thumbOffsetLocal + mainAxisMargin + beforePadding;
// Do not paint a scrollbar if the scroll view is infinitely long.
// TODO(Piinks): Special handling for infinite scroll views, https://github.com/flutter/flutter/issues/41434
if (_lastMetrics!.maxScrollExtent.isInfinite)
return;
return _paintThumbCrossAxis(canvas, size, thumbOffset, thumbExtent, _lastAxisDirection!);
return _paintScrollbar(canvas, size, thumbExtent, _lastAxisDirection!);
}
/// Same as hitTest, but includes some padding to make sure that the region
/// isn't too small to be interacted with by the user.
bool hitTestInteractive(Offset position) {
if (_thumbRect == null) {
return false;
}
// The scrollbar is not able to be hit when transparent.
if (fadeoutOpacityAnimation.value == 0.0) {
return false;
}
final Rect interactiveScrollbarRect = _trackRect == null
? _thumbRect!.expandToInclude(
Rect.fromCircle(center: _thumbRect!.center, radius: _kMinInteractiveSize / 2),
)
: _trackRect!.expandToInclude(
Rect.fromCircle(center: _thumbRect!.center, radius: _kMinInteractiveSize / 2),
);
return interactiveScrollbarRect.contains(position);
}
/// Same as hitTestInteractive, but excludes the track portion of the scrollbar.
/// Used to evaluate interactions with only the scrollbar thumb.
bool hitTestOnlyThumbInteractive(Offset position) {
if (_thumbRect == null) {
return false;
}
......@@ -353,7 +514,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
return interactiveThumbRect.contains(position);
}
// Scrollbars can be interactive in Cupertino.
// Scrollbars are interactive.
@override
bool? hitTest(Offset? position) {
if (_thumbRect == null) {
......@@ -370,6 +531,8 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
bool shouldRepaint(ScrollbarPainter old) {
// Should repaint if any properties changed.
return color != old.color
|| trackColor != old.trackColor
|| trackBorderColor != old.trackBorderColor
|| textDirection != old.textDirection
|| thickness != old.thickness
|| fadeoutOpacityAnimation != old.fadeoutOpacityAnimation
......@@ -377,7 +540,8 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
|| crossAxisMargin != old.crossAxisMargin
|| radius != old.radius
|| minLength != old.minLength
|| padding != old.padding;
|| padding != old.padding
|| minOverscrollLength != old.minOverscrollLength;
}
@override
......@@ -386,3 +550,686 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
@override
SemanticsBuilderCallback? get semanticsBuilder => null;
}
/// An extendable base class for building scrollbars that fade in and out.
///
/// To add a scrollbar to a [ScrollView], like a [ListView] or a
/// [CustomScrollView], wrap the scroll view widget in a [RawScrollbar] widget.
///
/// {@template flutter.widgets.Scrollbar}
/// A scrollbar thumb indicates which portion of a [ScrollView] is actually
/// visible.
///
/// By default, the thumb will fade in and out as the child scroll view
/// scrolls. When [isAlwaysShown] is true, and a [controller] is specified, the
/// scrollbar thumb will remain visible without the fade animation.
///
/// Scrollbars are interactive and will use the [PrimaryScrollController] if
/// a [controller] is not set. Scrollbar thumbs can be dragged along the main axis
/// of the [ScrollView] to change the [ScrollPosition]. Tapping along the track
/// exclusive of the thumb will trigger a [ScrollIncrementType.page] based on
/// the relative position to the thumb.
///
/// If the child [ScrollView] is infinitely long, the [RawScrollbar] will not be
/// painted. In this case, the scrollbar cannot accurately represent the
/// relative location of the visible area, or calculate the accurate delta to
/// apply when dragging on the thumb or tapping on the track.
/// {@endtemplate}
///
// TODO(Piinks): Add code sample
///
/// See also:
///
/// * [ListView], which displays a linear, scrollable list of children.
/// * [GridView], which displays a 2 dimensional, scrollable array of children.
// TODO(Piinks): Add support for passing a shape instead of thickness/radius.
// Will need to update painter to support as well.
// Also, expose helpful properties like main/crossAxis margins, minThumbLength,
// etc. on the RawScrollbar in follow-up changes
// part of https://github.com/flutter/flutter/issues/13253
class RawScrollbar extends StatefulWidget {
/// Creates a basic raw scrollbar that wraps the given [child].
///
/// The [child], or a descendant of the [child], should be a source of
/// [ScrollNotification] notifications, typically a [Scrollable] widget.
///
/// The [child], [thickness], [thumbColor], [isAlwaysShown], [fadeDuration],
/// and [timeToFade] arguments must not be null.
const RawScrollbar({
Key? key,
required this.child,
this.controller,
this.isAlwaysShown = false,
this.radius,
this.thickness,
this.thumbColor,
this.fadeDuration = _kScrollbarFadeDuration,
this.timeToFade = _kScrollbarTimeToFade,
this.pressDuration = Duration.zero,
}) : assert(
!isAlwaysShown || controller != null,
'When isAlwaysShown is true, a ScrollController must be provided.',
),
assert(child != null),
assert(fadeDuration != null),
assert(timeToFade != null),
assert(pressDuration != null),
super(key: key);
/// The widget below this widget in the tree.
///
/// The scrollbar will be stacked on top of this child. This child (and its
/// subtree) should include a source of [ScrollNotification] notifications.
///
/// Typically a [ListView] or [CustomScrollView].
final Widget child;
/// The [ScrollController] used to implement Scrollbar dragging.
///
/// If nothing is passed to controller, the default behavior is to automatically
/// enable scrollbar dragging on the nearest ScrollController using
/// [PrimaryScrollController.of].
///
/// If a ScrollController is passed, then dragging on the scrollbar thumb will
/// update the [ScrollPosition] attached to the controller. A stateful ancestor
/// of this widget needs to manage the ScrollController and either pass it to
/// a scrollable descendant or use a PrimaryScrollController to share it.
///
/// {@tool snippet}
/// Here is an example of using the `controller` parameter to enable
/// scrollbar dragging for multiple independent ListViews:
///
/// ```dart
/// final ScrollController _controllerOne = ScrollController();
/// final ScrollController _controllerTwo = ScrollController();
///
/// build(BuildContext context) {
/// return Column(
/// children: <Widget>[
/// Container(
/// height: 200,
/// child: CupertinoScrollbar(
/// controller: _controllerOne,
/// child: ListView.builder(
/// controller: _controllerOne,
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index) => Text('item $index'),
/// ),
/// ),
/// ),
/// Container(
/// height: 200,
/// child: CupertinoScrollbar(
/// controller: _controllerTwo,
/// child: ListView.builder(
/// controller: _controllerTwo,
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index) => Text('list 2 item $index'),
/// ),
/// ),
/// ),
/// ],
/// );
/// }
/// ```
/// {@end-tool}
final ScrollController? controller;
/// Indicates that the scrollbar should be visible, even when a scroll is not
/// underway.
///
/// When false, the scrollbar will be shown during scrolling
/// and will fade out otherwise.
///
/// When true, the scrollbar will always be visible and never fade out. The
/// [controller] property must be set in this case.
///
/// Defaults to false.
///
/// {@tool snippet}
///
/// ```dart
/// final ScrollController _controllerOne = ScrollController();
/// final ScrollController _controllerTwo = ScrollController();
///
/// build(BuildContext context) {
/// return Column(
/// children: <Widget>[
/// SizedBox(
/// height: 200,
/// child: Scrollbar(
/// isAlwaysShown: true,
/// controller: _controllerOne,
/// child: ListView.builder(
/// controller: _controllerOne,
/// itemCount: 120,
/// itemBuilder: (BuildContext context, int index) {
/// return Text('item $index');
/// },
/// ),
/// ),
/// ),
/// SizedBox(
/// height: 200,
/// child: CupertinoScrollbar(
/// isAlwaysShown: true,
/// controller: _controllerTwo,
/// child: SingleChildScrollView(
/// controller: _controllerTwo,
/// child: SizedBox(
/// height: 2000,
/// width: 500,
/// child: Placeholder(),
/// ),
/// ),
/// ),
/// ),
/// ],
/// );
/// }
/// ```
/// {@end-tool}
final bool isAlwaysShown;
/// The [Radius] of the scrollbar thumb's rounded rectangle corners.
///
/// Scrollbar will be rectangular if [radius] is null, which is the default
/// behavior.
final Radius? radius;
/// The thickness of the scrollbar in the cross axis of the scrollable.
///
/// If null, will default to 6.0 pixels.
final double? thickness;
/// The color of the scrollbar thumb.
///
/// If null, defaults to Color(0x66BCBCBC).
final Color? thumbColor;
/// The [Duration] of the fade animation.
///
/// Cannot be null, defaults to a [Duration] of 300 milliseconds.
final Duration fadeDuration;
/// The [Duration] of time until the fade animation begins.
///
/// Cannot be null, defaults to a [Duration] of 600 milliseconds.
final Duration timeToFade;
/// The [Duration] of time that a LongPress will trigger the drag gesture of
/// the scrollbar thumb.
///
/// Cannot be null, defaults to [Duration.zero].
final Duration pressDuration;
@override
RawScrollbarState<RawScrollbar> createState() => RawScrollbarState<RawScrollbar>();
}
/// The state for a [RawScrollbar] widget, also shared by the [Scrollbar] and
/// [CupertinoScrollbar] widgets.
///
/// Controls the animation that fades a scrollbar's thumb in and out of view.
///
/// Provides defaults gestures for dragging the scrollbar thumb and tapping on the
/// scrollbar track.
class RawScrollbarState<T extends RawScrollbar> extends State<T> with TickerProviderStateMixin<T> {
double? _dragScrollbarAxisPosition;
ScrollController? _currentController;
Timer? _fadeoutTimer;
late AnimationController _fadeoutAnimationController;
late Animation<double> _fadeoutOpacityAnimation;
final GlobalKey _scrollbarPainterKey = GlobalKey();
bool _hoverIsActive = false;
/// Used to paint the scrollbar.
///
/// Can be customized by subclasses to change scrollbar behavior by overriding
/// [updateScrollbarPainter].
@protected
late final ScrollbarPainter scrollbarPainter;
@override
void initState() {
super.initState();
_fadeoutAnimationController = AnimationController(
vsync: this,
duration: widget.fadeDuration,
);
_fadeoutOpacityAnimation = CurvedAnimation(
parent: _fadeoutAnimationController,
curve: Curves.fastOutSlowIn,
);
scrollbarPainter = ScrollbarPainter(
color: widget.thumbColor ?? const Color(0x66BCBCBC),
thickness: widget.thickness ?? _kScrollbarThickness,
fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_maybeTriggerScrollbar();
}
// Waits one frame and cause an empty scroll event (zero delta pixels).
//
// This allows the thumb to show immediately when isAlwaysShown is true.
// A scroll event is required in order to paint the thumb.
void _maybeTriggerScrollbar() {
WidgetsBinding.instance!.addPostFrameCallback((Duration duration) {
if (widget.isAlwaysShown) {
_fadeoutTimer?.cancel();
// Wait one frame and cause an empty scroll event. This allows the
// thumb to show immediately when isAlwaysShown is true. A scroll
// event is required in order to paint the thumb.
widget.controller!.position.didUpdateScrollPositionBy(0);
}
});
}
/// This method is responsible for configuring the [scrollbarPainter]
/// according to the [widget]'s properties and any inherited widgets the
/// painter depends on, like [Directionality] and [MediaQuery].
///
/// Subclasses can override to configure the [scrollbarPainter].
@protected
void updateScrollbarPainter() {
scrollbarPainter
..color = widget.thumbColor ?? const Color(0x66BCBCBC)
..textDirection = Directionality.of(context)
..thickness = widget.thickness ?? _kScrollbarThickness
..radius = widget.radius
..padding = MediaQuery.of(context).padding;
}
@override
void didUpdateWidget(T oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isAlwaysShown != oldWidget.isAlwaysShown) {
if (widget.isAlwaysShown == true) {
_maybeTriggerScrollbar();
_fadeoutAnimationController.animateTo(1.0);
} else {
_fadeoutAnimationController.reverse();
}
}
}
void _updateScrollPosition(double primaryDelta) {
assert(_currentController != null);
// Convert primaryDelta, the amount that the scrollbar moved since the last
// time _dragScrollbar was called, into the coordinate space of the scroll
// position, and jump to that position.
final double scrollOffsetLocal = scrollbarPainter.getTrackToScroll(primaryDelta);
final double scrollOffsetGlobal = scrollOffsetLocal + _currentController!.position.pixels;
_currentController!.position.jumpTo(scrollOffsetGlobal);
}
void _maybeStartFadeoutTimer() {
if (!widget.isAlwaysShown) {
_fadeoutTimer?.cancel();
_fadeoutTimer = Timer(widget.timeToFade, () {
_fadeoutAnimationController.reverse();
_fadeoutTimer = null;
});
}
}
/// Returns the [Axis] of the child scroll view, or null if the current scroll
/// controller does not have any attached positions.
@protected
Axis? getScrollbarDirection() {
assert(_currentController != null);
if (_currentController!.hasClients)
return _currentController!.position.axis;
return null;
}
/// Handler called when a press on the scrollbar thumb has been recognized.
///
/// Cancels the [Timer] associated with the fade animation of the scrollbar.
@protected
@mustCallSuper
void handleThumbPress() {
if (getScrollbarDirection() == null) {
return;
}
_fadeoutTimer?.cancel();
}
/// Handler called when a long press gesture has started.
///
/// Begins the fade out animation and initializes dragging the scrollbar thumb.
@protected
@mustCallSuper
void handleThumbPressStart(Offset localPosition) {
_currentController = widget.controller ?? PrimaryScrollController.of(context);
final Axis? direction = getScrollbarDirection();
if (direction == null) {
return;
}
_fadeoutTimer?.cancel();
_fadeoutAnimationController.forward();
switch (direction) {
case Axis.vertical:
_dragScrollbarAxisPosition = localPosition.dy;
break;
case Axis.horizontal:
_dragScrollbarAxisPosition = localPosition.dx;
break;
}
}
/// Handler called when a currently active long press gesture moves.
///
/// Updates the position of the child scrollable.
@protected
@mustCallSuper
void handleThumbPressUpdate(Offset localPosition) {
final Axis? direction = getScrollbarDirection();
if (direction == null) {
return;
}
switch(direction) {
case Axis.vertical:
_updateScrollPosition(localPosition.dy - _dragScrollbarAxisPosition!);
_dragScrollbarAxisPosition = localPosition.dy;
break;
case Axis.horizontal:
_updateScrollPosition(localPosition.dx - _dragScrollbarAxisPosition!);
_dragScrollbarAxisPosition = localPosition.dx;
break;
}
}
/// Handler called when a long press has ended.
@protected
@mustCallSuper
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
final Axis? direction = getScrollbarDirection();
if (direction == null)
return;
_maybeStartFadeoutTimer();
_dragScrollbarAxisPosition = null;
_currentController = null;
}
void _handleTrackTapDown(TapDownDetails details) {
// The Scrollbar should page towards the position of the tap on the track.
_currentController = widget.controller ?? PrimaryScrollController.of(context);
double scrollIncrement;
// Is an increment calculator available?
final ScrollIncrementCalculator? calculator = Scrollable.of(
_currentController!.position.context.notificationContext!
)?.widget.incrementCalculator;
if (calculator != null) {
scrollIncrement = calculator(
ScrollIncrementDetails(
type: ScrollIncrementType.page,
metrics: _currentController!.position,
)
);
} else {
// Default page increment
scrollIncrement = 0.8 * _currentController!.position.viewportDimension;
}
// Adjust scrollIncrement for direction
switch (_currentController!.position.axisDirection) {
case AxisDirection.up:
if (details.localPosition.dy > scrollbarPainter._thumbOffset)
scrollIncrement = -scrollIncrement;
break;
case AxisDirection.down:
if (details.localPosition.dy < scrollbarPainter._thumbOffset)
scrollIncrement = -scrollIncrement;
break;
case AxisDirection.right:
if (details.localPosition.dx < scrollbarPainter._thumbOffset)
scrollIncrement = -scrollIncrement;
break;
case AxisDirection.left:
if (details.localPosition.dx > scrollbarPainter._thumbOffset)
scrollIncrement = -scrollIncrement;
break;
}
_currentController!.position.moveTo(
_currentController!.position.pixels + scrollIncrement,
duration: const Duration(milliseconds: 100),
curve: Curves.easeInOut,
);
}
bool _handleScrollNotification(ScrollNotification notification) {
final ScrollMetrics metrics = notification.metrics;
if (metrics.maxScrollExtent <= metrics.minScrollExtent)
return false;
if (notification is ScrollUpdateNotification ||
notification is OverscrollNotification) {
// Any movements always makes the scrollbar start showing up.
if (_fadeoutAnimationController.status != AnimationStatus.forward)
_fadeoutAnimationController.forward();
_fadeoutTimer?.cancel();
scrollbarPainter.update(notification.metrics, notification.metrics.axisDirection);
} else if (notification is ScrollEndNotification) {
if (_dragScrollbarAxisPosition == null)
_maybeStartFadeoutTimer();
}
return false;
}
Map<Type, GestureRecognizerFactory> get _gestures {
final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
final ScrollController? controller = widget.controller ?? PrimaryScrollController.of(context);
if (controller == null)
return gestures;
gestures[_ThumbPressGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<_ThumbPressGestureRecognizer>(
() => _ThumbPressGestureRecognizer(
debugOwner: this,
customPaintKey: _scrollbarPainterKey,
pressDuration: widget.pressDuration,
),
(_ThumbPressGestureRecognizer instance) {
instance.onLongPress = handleThumbPress;
instance.onLongPressStart = (LongPressStartDetails details) => handleThumbPressStart(details.localPosition);
instance.onLongPressMoveUpdate = (LongPressMoveUpdateDetails details) => handleThumbPressUpdate(details.localPosition);
instance.onLongPressEnd = (LongPressEndDetails details) => handleThumbPressEnd(details.localPosition, details.velocity);
},
);
gestures[_TrackTapGestureRecognizer] =
GestureRecognizerFactoryWithHandlers<_TrackTapGestureRecognizer>(
() => _TrackTapGestureRecognizer(
debugOwner: this,
customPaintKey: _scrollbarPainterKey,
),
(_TrackTapGestureRecognizer instance) {
instance.onTapDown = _handleTrackTapDown;
},
);
return gestures;
}
/// Returns true if the provided [Offset] is located over the track of the
/// [RawScrollbar].
///
/// Excludes the [RawScrollbar] thumb.
@protected
bool isPointerOverTrack(Offset position) {
if (_scrollbarPainterKey.currentContext == null) {
return false;
}
final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position);
return scrollbarPainter.hitTestInteractive(localOffset)
&& !scrollbarPainter.hitTestOnlyThumbInteractive(localOffset);
}
/// Returns true if the provided [Offset] is located over the thumb of the
/// [RawScrollbar].
@protected
bool isPointerOverThumb(Offset position) {
if (_scrollbarPainterKey.currentContext == null) {
return false;
}
final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position);
return scrollbarPainter.hitTestOnlyThumbInteractive(localOffset);
}
/// Returns true if the provided [Offset] is located over the track or thumb
/// of the [RawScrollbar].
@protected
bool isPointerOverScrollbar(Offset position) {
if (_scrollbarPainterKey.currentContext == null) {
return false;
}
final Offset localOffset = _getLocalOffset(_scrollbarPainterKey, position);
return scrollbarPainter.hitTestInteractive(localOffset);
}
/// Cancels the fade out animation so the scrollbar will remain visible for
/// interaction.
///
/// Can be overridden by subclasses to respond to a [PointerHoverEvent].
///
/// Helper methods [isPointerOverScrollbar], [isPointerOverThumb], and
/// [isPointerOverTrack] can be used to determine the location of the pointer
/// relative to the painter scrollbar elements.
@protected
@mustCallSuper
void handleHover(PointerHoverEvent event) {
// Check if the position of the pointer falls over the painted scrollbar
if (isPointerOverScrollbar(event.position)) {
_hoverIsActive = true;
_fadeoutTimer?.cancel();
} else if (_hoverIsActive) {
// Pointer is not over painted scrollbar.
_hoverIsActive = false;
_maybeStartFadeoutTimer();
}
}
/// Initiates the fade out animation.
///
/// Can be overridden by subclasses to respond to a [PointerExitEvent].
@protected
@mustCallSuper
void handleHoverExit(PointerExitEvent event) {
_hoverIsActive = false;
_maybeStartFadeoutTimer();
}
@override
void dispose() {
_fadeoutAnimationController.dispose();
_fadeoutTimer?.cancel();
scrollbarPainter.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
updateScrollbarPainter();
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: RepaintBoundary(
child: RawGestureDetector(
gestures: _gestures,
child: MouseRegion(
onExit: handleHoverExit,
onHover: handleHover,
child: CustomPaint(
key: _scrollbarPainterKey,
foregroundPainter: scrollbarPainter,
child: RepaintBoundary(child: widget.child),
),
),
),
),
);
}
}
// A long press gesture detector that only responds to events on the scrollbar's
// thumb and ignores everything else.
class _ThumbPressGestureRecognizer extends LongPressGestureRecognizer {
_ThumbPressGestureRecognizer({
double? postAcceptSlopTolerance,
PointerDeviceKind? kind,
required Object debugOwner,
required GlobalKey customPaintKey,
required Duration pressDuration,
}) : _customPaintKey = customPaintKey,
super(
postAcceptSlopTolerance: postAcceptSlopTolerance,
kind: kind,
debugOwner: debugOwner,
duration: pressDuration,
);
final GlobalKey _customPaintKey;
@override
bool isPointerAllowed(PointerDownEvent event) {
if (!_hitTestInteractive(_customPaintKey, event.position)) {
return false;
}
return super.isPointerAllowed(event);
}
bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset) {
if (customPaintKey.currentContext == null) {
return false;
}
final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint;
final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter;
final Offset localOffset = _getLocalOffset(customPaintKey, offset);
return painter.hitTestOnlyThumbInteractive(localOffset);
}
}
// A tap gesture detector that only responds to events on the scrollbar's
// track and ignores everything else, including the thumb.
class _TrackTapGestureRecognizer extends TapGestureRecognizer {
_TrackTapGestureRecognizer({
required Object debugOwner,
required GlobalKey customPaintKey,
}) : _customPaintKey = customPaintKey,
super(debugOwner: debugOwner);
final GlobalKey _customPaintKey;
@override
bool isPointerAllowed(PointerDownEvent event) {
if (!_hitTestInteractive(_customPaintKey, event.position)) {
return false;
}
return super.isPointerAllowed(event);
}
bool _hitTestInteractive(GlobalKey customPaintKey, Offset offset) {
if (customPaintKey.currentContext == null) {
return false;
}
final CustomPaint customPaint = customPaintKey.currentContext!.widget as CustomPaint;
final ScrollbarPainter painter = customPaint.foregroundPainter! as ScrollbarPainter;
final Offset localOffset = _getLocalOffset(customPaintKey, offset);
// We only receive track taps that are not on the thumb.
return painter.hitTestInteractive(localOffset) && !painter.hitTestOnlyThumbInteractive(localOffset);
}
}
Offset _getLocalOffset(GlobalKey scrollbarPainterKey, Offset position) {
final RenderBox renderBox = scrollbarPainterKey.currentContext!.findRenderObject()! as RenderBox;
return renderBox.globalToLocal(position);
}
......@@ -680,4 +680,63 @@ void main() {
await tester.pump(_kScrollbarTimeToFade);
await tester.pump(_kScrollbarFadeDuration);
});
testWidgets('Tapping the track area pages the Scroll View', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
child: CupertinoScrollbar(
isAlwaysShown: true,
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
child: const SizedBox(width: 1000.0, height: 1000.0),
),
),
),
),
);
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(
find.byType(CupertinoScrollbar),
paints..rrect(
color: _kScrollbarColor.color,
rrect: RRect.fromLTRBR(794.0, 3.0, 797.0, 359.4, const Radius.circular(1.5)),
)
);
// Tap on the track area below the thumb.
await tester.tapAt(const Offset(796.0, 550.0));
await tester.pumpAndSettle();
expect(scrollController.offset, 400.0);
expect(
find.byType(CupertinoScrollbar),
paints..rrect(
color: _kScrollbarColor.color,
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(794.0, 240.6, 797.0, 597.0),
const Radius.circular(1.5),
),
)
);
// Tap on the track area above the thumb.
await tester.tapAt(const Offset(796.0, 50.0));
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(
find.byType(CupertinoScrollbar),
paints..rrect(
color: _kScrollbarColor.color,
rrect: RRect.fromLTRBR(794.0, 3.0, 797.0, 359.4, const Radius.circular(1.5)),
)
);
});
}
......@@ -30,7 +30,7 @@ void main() {
));
expect(find.byType(Scrollbar), isNot(paints..rect()));
await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0);
expect(find.byType(Scrollbar), paints..rect(rect: const Rect.fromLTRB(800.0 - 6.0, 1.5, 800.0, 91.5)));
expect(find.byType(Scrollbar), paints..rect(rect: const Rect.fromLTRB(800.0 - 12.0, 0.0, 800.0, 600.0)));
});
testWidgets('Viewport basic test (RTL)', (WidgetTester tester) async {
......@@ -40,7 +40,7 @@ void main() {
));
expect(find.byType(Scrollbar), isNot(paints..rect()));
await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0);
expect(find.byType(Scrollbar), paints..rect(rect: const Rect.fromLTRB(0.0, 1.5, 6.0, 91.5)));
expect(find.byType(Scrollbar), paints..rect(rect: const Rect.fromLTRB(0.0, 0.0, 12.0, 600.0)));
});
testWidgets('works with MaterialApp and Scaffold', (WidgetTester tester) async {
......@@ -69,11 +69,11 @@ void main() {
expect(find.byType(Scrollbar), paints..rect(
rect: const Rect.fromLTWH(
800.0 - 6, // screen width - thickness
800.0 - 12, // screen width - default thickness and margin
0, // the paint area starts from the bottom of the app bar
6, // thickness
12, // thickness
// 56 being the height of the app bar
(600.0 - 56 - 34 - 20) / 4000 * (600 - 56 - 34 - 20),
600.0 - 56 - 34 - 20,
),
));
});
......
......@@ -2,14 +2,17 @@
// 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/foundation.dart';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300);
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
class TestCanvas implements Canvas {
final List<Invocation> invocations = <Invocation>[];
......@@ -119,85 +122,6 @@ void main() {
expect(canvas.invocations.isEmpty, isTrue);
});
testWidgets('Adaptive scrollbar', (WidgetTester tester) async {
Widget viewWithScroll(TargetPlatform platform) {
return _buildBoilerplate(
child: Theme(
data: ThemeData(
platform: platform
),
child: const Scrollbar(
child: SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0),
),
),
),
);
}
await tester.pumpWidget(viewWithScroll(TargetPlatform.android));
await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0));
await tester.pump();
// Scrollbar fully showing
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(Scrollbar), paints..rect());
await tester.pumpWidget(viewWithScroll(TargetPlatform.iOS));
final TestGesture gesture = await tester.startGesture(
tester.getCenter(find.byType(SingleChildScrollView))
);
await gesture.moveBy(const Offset(0.0, -10.0));
await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.byType(Scrollbar), paints..rrect());
expect(find.byType(CupertinoScrollbar), paints..rrect());
await gesture.up();
await tester.pumpAndSettle();
await tester.pumpWidget(viewWithScroll(TargetPlatform.macOS));
await gesture.down(
tester.getCenter(find.byType(SingleChildScrollView)),
);
await gesture.moveBy(const Offset(0.0, -10.0));
await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.byType(Scrollbar), paints..rrect());
expect(find.byType(CupertinoScrollbar), paints..rrect());
});
testWidgets('Scrollbar passes controller to CupertinoScrollbar', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
Widget viewWithScroll(TargetPlatform? platform) {
return _buildBoilerplate(
child: Theme(
data: ThemeData(
platform: platform
),
child: Scrollbar(
controller: controller,
child: const SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0),
),
),
),
);
}
await tester.pumpWidget(viewWithScroll(debugDefaultTargetPlatformOverride));
final TestGesture gesture = await tester.startGesture(
tester.getCenter(find.byType(SingleChildScrollView))
);
await gesture.moveBy(const Offset(0.0, -10.0));
await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.byType(CupertinoScrollbar), paints..rrect());
final CupertinoScrollbar scrollbar = find.byType(CupertinoScrollbar).evaluate().first.widget as CupertinoScrollbar;
expect(scrollbar.controller, isNotNull);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
testWidgets('When isAlwaysShown is true, must pass a controller',
(WidgetTester tester) async {
Widget viewWithScroll() {
......@@ -546,14 +470,300 @@ void main() {
await tester.pump();
// Long press on the scrollbar thumb and expect it to grow
expect(find.byType(Scrollbar), paints..rect(
rect: const Rect.fromLTWH(780, 0, 20, 300),
expect(find.byType(Scrollbar), paints..rrect(
rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(778, 0, 20, 300), const Radius.circular(8)),
));
await tester.pumpWidget(viewWithScroll(radius: const Radius.circular(10)));
expect(find.byType(Scrollbar), paints..rrect(
rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(780, 0, 20, 300), const Radius.circular(10)),
rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(778, 0, 20, 300), const Radius.circular(10)),
));
await tester.pumpAndSettle();
});
testWidgets('Tapping the track area pages the Scroll View', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
child: Scrollbar(
isAlwaysShown: true,
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
child: const SizedBox(width: 1000.0, height: 1000.0),
),
),
),
),
);
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(
find.byType(Scrollbar),
paints..rrect(
rrect: RRect.fromLTRBR(790.0, 0.0, 798.0, 360.0, const Radius.circular(8.0)),
)
);
// Tap on the track area below the thumb.
await tester.tapAt(const Offset(796.0, 550.0));
await tester.pumpAndSettle();
expect(scrollController.offset, 400.0);
expect(
find.byType(Scrollbar),
paints..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(790.0, 240.0, 798.0, 600.0),
const Radius.circular(8.0),
),
)
);
// Tap on the track area above the thumb.
await tester.tapAt(const Offset(796.0, 50.0));
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(
find.byType(Scrollbar),
paints..rrect(
rrect: RRect.fromLTRBR(790.0, 0.0, 798.0, 360.0, const Radius.circular(8.0)),
)
);
});
testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scrollbar(
child: SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0)
),
),
),
);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView)));
await gesture.moveBy(const Offset(0.0, -20.0));
await tester.pump();
// Scrollbar fully showing
await tester.pump(const Duration(milliseconds: 500));
expect(
find.byType(Scrollbar),
paints..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(790.0, 3.0, 798.0, 93.0),
const Radius.circular(8.0),
),
color: const Color(0x1a000000),
),
);
await tester.pump(const Duration(seconds: 3));
await tester.pump(const Duration(seconds: 3));
// Still there.
expect(
find.byType(Scrollbar),
paints..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(790.0, 3.0, 798.0, 93.0),
const Radius.circular(8.0),
),
color: const Color(0x1a000000),
),
);
await gesture.up();
await tester.pump(_kScrollbarTimeToFade);
await tester.pump(_kScrollbarFadeDuration * 0.5);
// Opacity going down now.
expect(
find.byType(Scrollbar),
paints..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(790.0, 3.0, 798.0, 93.0),
const Radius.circular(8.0),
),
color: const Color(0x14000000),
),
);
});
testWidgets('Scrollbar thumb can be dragged', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
await tester.pumpWidget(
MaterialApp(
home: PrimaryScrollController(
controller: scrollController,
child: Scrollbar(
isAlwaysShown: true,
controller: scrollController,
child: const SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0)
),
),
),
),
);
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(
find.byType(Scrollbar),
paints..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(790.0, 0.0, 798.0, 90.0),
const Radius.circular(8.0),
),
color: const Color(0x1a000000),
),
);
// Drag the thumb down to scroll down.
const double scrollAmount = 10.0;
final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0));
await tester.pumpAndSettle();
expect(
find.byType(Scrollbar),
paints..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(790.0, 0.0, 798.0, 90.0),
const Radius.circular(8.0),
),
// Drag color
color: const Color(0x99000000),
),
);
await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
await tester.pumpAndSettle();
await dragScrollbarGesture.up();
await tester.pumpAndSettle();
// The view has scrolled more than it would have by a swipe gesture of the
// same distance.
expect(scrollController.offset, greaterThan(scrollAmount * 2));
expect(
find.byType(Scrollbar),
paints..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(790.0, 10.0, 798.0, 100.0),
const Radius.circular(8.0),
),
color: const Color(0x1a000000),
),
);
});
testWidgets('Scrollbar thumb color completes a hover animation', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
await tester.pumpWidget(
MaterialApp(
home: PrimaryScrollController(
controller: scrollController,
child: Scrollbar(
isAlwaysShown: true,
controller: scrollController,
child: const SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0)
),
),
),
),
);
await tester.pumpAndSettle();
expect(
find.byType(Scrollbar),
paints..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(790.0, 0.0, 798.0, 90.0),
const Radius.circular(8.0),
),
color: const Color(0x1a000000),
),
);
final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
await gesture.moveTo(const Offset(794.0, 5.0));
await tester.pumpAndSettle();
expect(
find.byType(Scrollbar),
paints..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(790.0, 0.0, 798.0, 90.0),
const Radius.circular(8.0),
),
// Hover color
color: const Color(0x80000000),
),
);
});
testWidgets('Scrollbar showTrackOnHover', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
await tester.pumpWidget(
MaterialApp(
home: PrimaryScrollController(
controller: scrollController,
child: Scrollbar(
isAlwaysShown: true,
showTrackOnHover: true,
controller: scrollController,
child: const SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0)
),
),
),
),
);
await tester.pumpAndSettle();
expect(
find.byType(Scrollbar),
paints..rrect(
rrect: RRect.fromRectAndRadius(
const Rect.fromLTRB(790.0, 0.0, 798.0, 90.0),
const Radius.circular(8.0),
),
color: const Color(0x1a000000),
),
);
final TestGesture gesture = await tester.createGesture(kind: ui.PointerDeviceKind.mouse);
await gesture.addPointer();
addTearDown(gesture.removePointer);
await gesture.moveTo(const Offset(794.0, 5.0));
await tester.pump();
expect(
find.byType(Scrollbar),
paints
..rect(
rect: const Rect.fromLTRB(784.0, 0.0, 800.0, 600.0),
color: const Color(0x08000000),
)
..line(
p1: const Offset(784.0, 0.0),
p2: const Offset(784.0, 600.0),
strokeWidth: 1.0,
color: const Color(0x1a000000),
)
..rrect(
rrect: RRect.fromRectAndRadius(
// Scrollbar thumb is larger
const Rect.fromLTRB(786.0, 0.0, 798.0, 90.0),
const Radius.circular(8.0),
),
// Hover color
color: const Color(0x80000000),
),
);
});
}
......@@ -2,15 +2,20 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' as ui;
import 'package:flutter/src/physics/utils.dart' show nearEqual;
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import '../flutter_test_alternative.dart' show Fake;
import '../rendering/mock_canvas.dart';
const Color _kScrollbarColor = Color(0xFF123456);
const double _kThickness = 2.5;
const double _kMinThumbExtent = 18.0;
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300);
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
ScrollbarPainter _buildPainter({
TextDirection textDirection = TextDirection.ltr,
......@@ -45,6 +50,9 @@ class _DrawRectOnceCanvas extends Fake implements Canvas {
void drawRect(Rect rect, Paint paint) {
rects.add(rect);
}
@override
void drawLine(Offset p1, Offset p2, Paint paint) {}
}
void main() {
......@@ -503,4 +511,245 @@ void main() {
}
},
);
testWidgets('ScrollbarPainter asserts if no TextDirection has been provided', (WidgetTester tester) async {
final ScrollbarPainter painter = ScrollbarPainter(
color: _kScrollbarColor,
fadeoutOpacityAnimation: kAlwaysCompleteAnimation,
);
const Size size = Size(60, 80);
final ScrollMetrics scrollMetrics = defaultMetrics.copyWith(
maxScrollExtent: 100000,
viewportDimension: size.height,
);
painter.update(scrollMetrics, scrollMetrics.axisDirection);
// Try to paint the scrollbar
try {
painter.paint(testCanvas, size);
} on AssertionError catch (error) {
expect(error.message, 'A TextDirection must be provided before a Scrollbar can be painted.');
}
});
testWidgets('Tapping the track area pages the Scroll View', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
child: RawScrollbar(
isAlwaysShown: true,
controller: scrollController,
child: SingleChildScrollView(
controller: scrollController,
child: const SizedBox(width: 1000.0, height: 1000.0),
),
),
),
),
);
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(
find.byType(RawScrollbar),
paints
..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
..rect(
rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 360.0),
color: const Color(0x66BCBCBC),
)
);
// Tap on the track area below the thumb.
await tester.tapAt(const Offset(796.0, 550.0));
await tester.pumpAndSettle();
expect(scrollController.offset, 400.0);
expect(
find.byType(RawScrollbar),
paints
..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
..rect(
rect: const Rect.fromLTRB(794.0, 240.0, 800.0, 600.0),
color: const Color(0x66BCBCBC),
)
);
// Tap on the track area above the thumb.
await tester.tapAt(const Offset(796.0, 50.0));
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(
find.byType(RawScrollbar),
paints
..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
..rect(
rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 360.0),
color: const Color(0x66BCBCBC),
)
);
});
testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async {
await tester.pumpWidget(
const Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData(),
child: RawScrollbar(
child: SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0)
),
),
),
),
);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView)));
await gesture.moveBy(const Offset(0.0, -20.0));
await tester.pump();
// Scrollbar fully showing
await tester.pump(const Duration(milliseconds: 500));
expect(
find.byType(RawScrollbar),
paints
..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
..rect(
rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
color: const Color(0x66BCBCBC),
),
);
await tester.pump(const Duration(seconds: 3));
await tester.pump(const Duration(seconds: 3));
// Still there.
expect(
find.byType(RawScrollbar),
paints
..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
..rect(
rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
color: const Color(0x66BCBCBC),
),
);
await gesture.up();
await tester.pump(_kScrollbarTimeToFade);
await tester.pump(_kScrollbarFadeDuration * 0.5);
// Opacity going down now.
expect(
find.byType(RawScrollbar),
paints
..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
..rect(
rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
color: const Color(0x4fbcbcbc),
),
);
});
testWidgets('Scrollbar does not fade away while hovering', (WidgetTester tester) async {
await tester.pumpWidget(
const Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData(),
child: RawScrollbar(
child: SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0)
),
),
),
),
);
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView)));
await gesture.moveBy(const Offset(0.0, -20.0));
await tester.pump();
// Scrollbar fully showing
await tester.pump(const Duration(milliseconds: 500));
expect(
find.byType(RawScrollbar),
paints
..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
..rect(
rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
color: const Color(0x66BCBCBC),
),
);
final TestPointer testPointer = TestPointer(1, ui.PointerDeviceKind.mouse);
// Hover over the thumb to prevent the scrollbar from fading out.
testPointer.hover(const Offset(790.0, 5.0));
await gesture.up();
await tester.pump(const Duration(seconds: 3));
// Still there.
expect(
find.byType(RawScrollbar),
paints
..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
..rect(
rect: const Rect.fromLTRB(794.0, 3.0, 800.0, 93.0),
color: const Color(0x66BCBCBC),
),
);
});
testWidgets('Scrollbar thumb can be dragged', (WidgetTester tester) async {
final ScrollController scrollController = ScrollController();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
child: PrimaryScrollController(
controller: scrollController,
child: RawScrollbar(
isAlwaysShown: true,
controller: scrollController,
child: const SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0)
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(
find.byType(RawScrollbar),
paints
..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
..rect(
rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 90.0),
color: const Color(0x66BCBCBC),
),
);
// Drag the thumb down to scroll down.
const double scrollAmount = 10.0;
final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(797.0, 45.0));
await tester.pumpAndSettle();
await dragScrollbarGesture.moveBy(const Offset(0.0, scrollAmount));
await tester.pumpAndSettle();
await dragScrollbarGesture.up();
await tester.pumpAndSettle();
// The view has scrolled more than it would have by a swipe gesture of the
// same distance.
expect(scrollController.offset, greaterThan(scrollAmount * 2));
expect(
find.byType(RawScrollbar),
paints
..rect(rect: const Rect.fromLTRB(794.0, 0.0, 800.0, 600.0))
..rect(
rect: const Rect.fromLTRB(794.0, 10.0, 800.0, 100.0),
color: const Color(0x66BCBCBC),
),
);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment