Updated Interactive Scrollbars (#71664)
This commit is contained in:
parent
e92df4ff80
commit
770a9b25d9
@ -2,8 +2,6 @@
|
|||||||
// Use of this source code is governed by a BSD-style license that can be
|
// Use of this source code is governed by a BSD-style license that can be
|
||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/gestures.dart';
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@ -32,180 +30,70 @@ const double _kScrollbarCrossAxisMargin = 3.0;
|
|||||||
|
|
||||||
/// An iOS style scrollbar.
|
/// 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
|
/// To add a scrollbar to a [ScrollView], simply wrap the scroll view widget in
|
||||||
/// a [CupertinoScrollbar] widget.
|
/// a [CupertinoScrollbar] widget.
|
||||||
///
|
///
|
||||||
/// By default, the CupertinoScrollbar will be draggable (a feature introduced
|
/// {@macro flutter.widgets.Scrollbar}
|
||||||
/// in iOS 13), it uses the PrimaryScrollController. For multiple scrollbars, or
|
///
|
||||||
/// other more complicated situations, see the [controller] parameter.
|
/// 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:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * [ListView], which display a linear, scrollable list of children.
|
/// * [ListView], which displays a linear, scrollable list of children.
|
||||||
/// * [GridView], which display a 2 dimensional, scrollable array of children.
|
/// * [GridView], which displays a 2 dimensional, scrollable array of children.
|
||||||
/// * [Scrollbar], a Material Design scrollbar that dynamically adapts to the
|
/// * [Scrollbar], a Material Design scrollbar.
|
||||||
/// platform showing either an Android style or iOS style scrollbar.
|
/// * [RawScrollbar], a basic scrollbar that fades in and out, extended
|
||||||
class CupertinoScrollbar extends StatefulWidget {
|
/// by this class to add more animations and behaviors.
|
||||||
|
class CupertinoScrollbar extends RawScrollbar {
|
||||||
/// Creates an iOS style scrollbar that wraps the given [child].
|
/// Creates an iOS style scrollbar that wraps the given [child].
|
||||||
///
|
///
|
||||||
/// The [child] should be a source of [ScrollNotification] notifications,
|
/// The [child] should be a source of [ScrollNotification] notifications,
|
||||||
/// typically a [Scrollable] widget.
|
/// typically a [Scrollable] widget.
|
||||||
const CupertinoScrollbar({
|
const CupertinoScrollbar({
|
||||||
Key? key,
|
Key? key,
|
||||||
this.controller,
|
required Widget child,
|
||||||
this.isAlwaysShown = false,
|
ScrollController? controller,
|
||||||
this.thickness = defaultThickness,
|
bool isAlwaysShown = false,
|
||||||
|
double thickness = defaultThickness,
|
||||||
this.thicknessWhileDragging = defaultThicknessWhileDragging,
|
this.thicknessWhileDragging = defaultThicknessWhileDragging,
|
||||||
this.radius = defaultRadius,
|
Radius radius = defaultRadius,
|
||||||
this.radiusWhileDragging = defaultRadiusWhileDragging,
|
this.radiusWhileDragging = defaultRadiusWhileDragging,
|
||||||
required this.child,
|
|
||||||
}) : assert(thickness != null),
|
}) : assert(thickness != null),
|
||||||
assert(thickness < double.infinity),
|
assert(thickness < double.infinity),
|
||||||
assert(thicknessWhileDragging != null),
|
assert(thicknessWhileDragging != null),
|
||||||
assert(thicknessWhileDragging < double.infinity),
|
assert(thicknessWhileDragging < double.infinity),
|
||||||
assert(radius != null),
|
assert(radius != null),
|
||||||
assert(radiusWhileDragging != null),
|
assert(radiusWhileDragging != null),
|
||||||
assert(!isAlwaysShown || controller != null, 'When isAlwaysShown is true, must pass a controller that is attached to a scroll view'),
|
super(
|
||||||
super(key: key);
|
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;
|
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;
|
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);
|
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);
|
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.
|
/// The thickness of the scrollbar when it's being dragged by the user.
|
||||||
///
|
///
|
||||||
/// When the user starts dragging the scrollbar, the thickness will animate
|
/// When the user starts dragging the scrollbar, the thickness will animate
|
||||||
@ -213,14 +101,6 @@ class CupertinoScrollbar extends StatefulWidget {
|
|||||||
/// dragging the scrollbar.
|
/// dragging the scrollbar.
|
||||||
final double thicknessWhileDragging;
|
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 radius of the scrollbar edges when the scrollbar is being dragged by
|
||||||
/// the user.
|
/// the user.
|
||||||
///
|
///
|
||||||
@ -233,363 +113,100 @@ class CupertinoScrollbar extends StatefulWidget {
|
|||||||
_CupertinoScrollbarState createState() => _CupertinoScrollbarState();
|
_CupertinoScrollbarState createState() => _CupertinoScrollbarState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProviderStateMixin {
|
class _CupertinoScrollbarState extends RawScrollbarState<CupertinoScrollbar> {
|
||||||
final GlobalKey _customPaintKey = GlobalKey();
|
|
||||||
ScrollbarPainter? _painter;
|
|
||||||
|
|
||||||
late AnimationController _fadeoutAnimationController;
|
|
||||||
late Animation<double> _fadeoutOpacityAnimation;
|
|
||||||
late AnimationController _thicknessAnimationController;
|
late AnimationController _thicknessAnimationController;
|
||||||
Timer? _fadeoutTimer;
|
|
||||||
double? _dragScrollbarAxisPosition;
|
|
||||||
Drag? _drag;
|
|
||||||
|
|
||||||
double get _thickness {
|
double get _thickness {
|
||||||
return widget.thickness + _thicknessAnimationController.value * (widget.thicknessWhileDragging - widget.thickness);
|
return widget.thickness! + _thicknessAnimationController.value * (widget.thicknessWhileDragging - widget.thickness!);
|
||||||
}
|
}
|
||||||
|
|
||||||
Radius get _radius {
|
Radius get _radius {
|
||||||
return Radius.lerp(widget.radius, widget.radiusWhileDragging, _thicknessAnimationController.value)!;
|
return Radius.lerp(widget.radius, widget.radiusWhileDragging, _thicknessAnimationController.value)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollController? _currentController;
|
|
||||||
ScrollController? get _controller =>
|
|
||||||
widget.controller ?? PrimaryScrollController.of(context);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_fadeoutAnimationController = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: _kScrollbarFadeDuration,
|
|
||||||
);
|
|
||||||
_fadeoutOpacityAnimation = CurvedAnimation(
|
|
||||||
parent: _fadeoutAnimationController,
|
|
||||||
curve: Curves.fastOutSlowIn,
|
|
||||||
);
|
|
||||||
_thicknessAnimationController = AnimationController(
|
_thicknessAnimationController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: _kScrollbarResizeDuration,
|
duration: _kScrollbarResizeDuration,
|
||||||
);
|
);
|
||||||
_thicknessAnimationController.addListener(() {
|
_thicknessAnimationController.addListener(() {
|
||||||
_painter!.updateThickness(_thickness, _radius);
|
updateScrollbarPainter();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void updateScrollbarPainter() {
|
||||||
super.didChangeDependencies();
|
scrollbarPainter
|
||||||
if (_painter == null) {
|
..color = CupertinoDynamicColor.resolve(_kScrollbarColor, context)
|
||||||
_painter = _buildCupertinoScrollbarPainter(context);
|
..textDirection = Directionality.of(context)
|
||||||
} else {
|
..thickness = _thickness
|
||||||
_painter!
|
..mainAxisMargin = _kScrollbarMainAxisMargin
|
||||||
..textDirection = Directionality.of(context)
|
..crossAxisMargin = _kScrollbarCrossAxisMargin
|
||||||
..color = CupertinoDynamicColor.resolve(_kScrollbarColor, context)
|
..radius = _radius
|
||||||
..padding = MediaQuery.of(context).padding;
|
..padding = MediaQuery.of(context).padding
|
||||||
}
|
..minLength = _kScrollbarMinLength
|
||||||
_triggerScrollbar();
|
..minOverscrollLength = _kScrollbarMinOverscrollLength;
|
||||||
}
|
|
||||||
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
double _pressStartAxisPosition = 0.0;
|
double _pressStartAxisPosition = 0.0;
|
||||||
|
|
||||||
// Long press event callbacks handle the gesture where the user long presses
|
// Long press event callbacks handle the gesture where the user long presses
|
||||||
// on the scrollbar thumb and then drags the scrollbar without releasing.
|
// on the scrollbar thumb and then drags the scrollbar without releasing.
|
||||||
void _handleLongPressStart(LongPressStartDetails details) {
|
|
||||||
_currentController = _controller;
|
@override
|
||||||
final Axis? direction = _getDirection();
|
void handleThumbPressStart(Offset localPosition) {
|
||||||
if (direction == null) {
|
super.handleThumbPressStart(localPosition);
|
||||||
return;
|
final Axis direction = getScrollbarDirection()!;
|
||||||
}
|
|
||||||
_fadeoutTimer?.cancel();
|
|
||||||
_fadeoutAnimationController.forward();
|
|
||||||
switch (direction) {
|
switch (direction) {
|
||||||
case Axis.vertical:
|
case Axis.vertical:
|
||||||
_pressStartAxisPosition = details.localPosition.dy;
|
_pressStartAxisPosition = localPosition.dy;
|
||||||
_dragScrollbar(details.localPosition.dy);
|
|
||||||
_dragScrollbarAxisPosition = details.localPosition.dy;
|
|
||||||
break;
|
break;
|
||||||
case Axis.horizontal:
|
case Axis.horizontal:
|
||||||
_pressStartAxisPosition = details.localPosition.dx;
|
_pressStartAxisPosition = localPosition.dx;
|
||||||
_dragScrollbar(details.localPosition.dx);
|
|
||||||
_dragScrollbarAxisPosition = details.localPosition.dx;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleLongPress() {
|
@override
|
||||||
if (_getDirection() == null) {
|
void handleThumbPress() {
|
||||||
|
if (getScrollbarDirection() == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_fadeoutTimer?.cancel();
|
super.handleThumbPress();
|
||||||
_thicknessAnimationController.forward().then<void>(
|
_thicknessAnimationController.forward().then<void>(
|
||||||
(_) => HapticFeedback.mediumImpact(),
|
(_) => HapticFeedback.mediumImpact(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
|
@override
|
||||||
final Axis? direction = _getDirection();
|
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
|
||||||
|
final Axis? direction = getScrollbarDirection();
|
||||||
if (direction == null) {
|
if (direction == null) {
|
||||||
return;
|
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();
|
|
||||||
if (direction == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch(direction) {
|
|
||||||
case Axis.vertical:
|
|
||||||
_handleDragScrollEnd(details.velocity.pixelsPerSecond.dy, direction);
|
|
||||||
if (details.velocity.pixelsPerSecond.dy.abs() < 10 &&
|
|
||||||
(details.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) {
|
|
||||||
HapticFeedback.mediumImpact();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_currentController = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleDragScrollEnd(double trackVelocity, Axis direction) {
|
|
||||||
_startFadeoutTimer();
|
|
||||||
_thicknessAnimationController.reverse();
|
_thicknessAnimationController.reverse();
|
||||||
_dragScrollbarAxisPosition = null;
|
super.handleThumbPressEnd(localPosition, velocity);
|
||||||
final double scrollVelocity = _painter!.getTrackToScroll(trackVelocity);
|
switch(direction) {
|
||||||
_drag?.end(DragEndDetails(
|
case Axis.vertical:
|
||||||
primaryVelocity: -scrollVelocity,
|
if (velocity.pixelsPerSecond.dy.abs() < 10 &&
|
||||||
velocity: Velocity(
|
(localPosition.dy - _pressStartAxisPosition).abs() > 0) {
|
||||||
pixelsPerSecond: direction == Axis.vertical
|
HapticFeedback.mediumImpact();
|
||||||
? Offset(0.0, -scrollVelocity)
|
}
|
||||||
: Offset(-scrollVelocity, 0.0),
|
break;
|
||||||
),
|
case Axis.horizontal:
|
||||||
));
|
if (velocity.pixelsPerSecond.dx.abs() < 10 &&
|
||||||
_drag = null;
|
(localPosition.dx - _pressStartAxisPosition).abs() > 0) {
|
||||||
}
|
HapticFeedback.mediumImpact();
|
||||||
|
}
|
||||||
bool _handleScrollNotification(ScrollNotification notification) {
|
break;
|
||||||
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_fadeoutAnimationController.dispose();
|
|
||||||
_thicknessAnimationController.dispose();
|
_thicknessAnimationController.dispose();
|
||||||
_fadeoutTimer?.cancel();
|
|
||||||
_painter!.dispose();
|
|
||||||
super.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
|
// Use of this source code is governed by a BSD-style license that can be
|
||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'color_scheme.dart';
|
||||||
|
import 'material_state.dart';
|
||||||
import 'theme.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 _kScrollbarFadeDuration = Duration(milliseconds: 300);
|
||||||
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
|
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
|
||||||
|
|
||||||
/// A material design scrollbar.
|
/// A material design scrollbar.
|
||||||
///
|
///
|
||||||
/// A scrollbar indicates which portion of a [Scrollable] widget is actually
|
/// To add a scrollbar thumb to a [ScrollView], simply wrap the scroll view
|
||||||
/// visible.
|
/// widget in a [Scrollbar] widget.
|
||||||
///
|
///
|
||||||
/// Dynamically changes to an iOS style scrollbar that looks like
|
/// {@macro flutter.widgets.Scrollbar}
|
||||||
/// [CupertinoScrollbar] on the iOS platform.
|
|
||||||
///
|
///
|
||||||
/// To add a scrollbar to a [ScrollView], simply wrap the scroll view widget in
|
/// The color of the Scrollbar will change when dragged, as well as when
|
||||||
/// a [Scrollbar] widget.
|
/// 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].
|
||||||
|
///
|
||||||
|
// TODO(Piinks): Add code sample
|
||||||
///
|
///
|
||||||
/// See also:
|
/// See also:
|
||||||
///
|
///
|
||||||
/// * [ListView], which display a linear, scrollable list of children.
|
/// * [RawScrollbar], a basic scrollbar that fades in and out, extended
|
||||||
/// * [GridView], which display a 2 dimensional, scrollable array of children.
|
/// by this class to add more animations and behaviors.
|
||||||
class Scrollbar extends StatefulWidget {
|
/// * [CupertinoScrollbar], an iOS style scrollbar.
|
||||||
/// Creates a material design scrollbar that wraps the given [child].
|
/// * [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,
|
/// The [child] should be a source of [ScrollNotification] notifications,
|
||||||
/// typically a [Scrollable] widget.
|
/// typically a [Scrollable] widget.
|
||||||
|
///
|
||||||
|
/// If the [controller] is null, the default behavior is to
|
||||||
|
/// enable scrollbar dragging using the [PrimaryScrollController].
|
||||||
|
///
|
||||||
|
/// 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({
|
const Scrollbar({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.child,
|
required Widget child,
|
||||||
this.controller,
|
ScrollController? controller,
|
||||||
this.isAlwaysShown = false,
|
bool isAlwaysShown = false,
|
||||||
this.thickness,
|
this.showTrackOnHover = false,
|
||||||
this.radius,
|
this.hoverThickness,
|
||||||
}) : assert(!isAlwaysShown || controller != null, 'When isAlwaysShown is true, must pass a controller that is attached to a scroll view'),
|
double? thickness,
|
||||||
super(key: key);
|
Radius? radius,
|
||||||
|
}) : super(
|
||||||
|
key: key,
|
||||||
|
child: child,
|
||||||
|
controller: controller,
|
||||||
|
isAlwaysShown: isAlwaysShown,
|
||||||
|
thickness: thickness ?? _kScrollbarThickness,
|
||||||
|
radius: radius,
|
||||||
|
fadeDuration: _kScrollbarFadeDuration,
|
||||||
|
timeToFade: _kScrollbarTimeToFade,
|
||||||
|
pressDuration: Duration.zero,
|
||||||
|
);
|
||||||
|
|
||||||
/// The widget below this widget in the tree.
|
/// Controls if the track will show on hover and remain, including during drag.
|
||||||
///
|
///
|
||||||
/// The scrollbar will be stacked on top of this child. This child (and its
|
/// Defaults to false, cannot be null.
|
||||||
/// subtree) should include a source of [ScrollNotification] notifications.
|
final bool showTrackOnHover;
|
||||||
|
|
||||||
|
/// The thickness of the scrollbar when a hover state is active and
|
||||||
|
/// [showTrackOnHover] is true.
|
||||||
///
|
///
|
||||||
/// Typically a [ListView] or [CustomScrollView].
|
/// Defaults to 12.0 dp when null.
|
||||||
final Widget child;
|
final double? hoverThickness;
|
||||||
|
|
||||||
/// {@macro flutter.cupertino.cupertinoScrollbar.controller}
|
|
||||||
final ScrollController? controller;
|
|
||||||
|
|
||||||
/// {@macro flutter.cupertino.cupertinoScrollbar.isAlwaysShown}
|
|
||||||
final bool isAlwaysShown;
|
|
||||||
|
|
||||||
/// The thickness of the scrollbar.
|
|
||||||
///
|
|
||||||
/// 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.
|
|
||||||
///
|
|
||||||
/// 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;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_ScrollbarState createState() => _ScrollbarState();
|
_ScrollbarState createState() => _ScrollbarState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ScrollbarState extends State<Scrollbar> with SingleTickerProviderStateMixin {
|
class _ScrollbarState extends RawScrollbarState<Scrollbar> {
|
||||||
ScrollbarPainter? _materialPainter;
|
late AnimationController _hoverAnimationController;
|
||||||
late TextDirection _textDirection;
|
bool _dragIsActive = false;
|
||||||
late Color _themeColor;
|
bool _hoverIsActive = false;
|
||||||
late bool _useCupertinoScrollbar;
|
late ColorScheme _colorScheme;
|
||||||
late AnimationController _fadeoutAnimationController;
|
|
||||||
late Animation<double> _fadeoutOpacityAnimation;
|
Set<MaterialState> get _states => <MaterialState>{
|
||||||
Timer? _fadeoutTimer;
|
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;
|
||||||
|
return widget.thickness ?? _kScrollbarThickness;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_fadeoutAnimationController = AnimationController(
|
_hoverAnimationController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: _kScrollbarFadeDuration,
|
duration: const Duration(milliseconds: 200),
|
||||||
);
|
);
|
||||||
_fadeoutOpacityAnimation = CurvedAnimation(
|
_hoverAnimationController.addListener(() {
|
||||||
parent: _fadeoutAnimationController,
|
updateScrollbarPainter();
|
||||||
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;
|
|
||||||
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();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!_useCupertinoScrollbar) {
|
|
||||||
_materialPainter!
|
|
||||||
..thickness = widget.thickness ?? _kScrollbarThickness
|
|
||||||
..radius = widget.radius;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ScrollbarPainter _buildMaterialScrollbarPainter() {
|
@override
|
||||||
return ScrollbarPainter(
|
void updateScrollbarPainter() {
|
||||||
color: _themeColor,
|
_colorScheme = Theme.of(context).colorScheme;
|
||||||
textDirection: _textDirection,
|
scrollbarPainter
|
||||||
thickness: widget.thickness ?? _kScrollbarThickness,
|
..color = _thumbColor.resolve(_states)
|
||||||
radius: widget.radius,
|
..trackColor = _trackColor.resolve(_states)
|
||||||
fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
|
..trackBorderColor = _trackBorderColor.resolve(_states)
|
||||||
padding: MediaQuery.of(context).padding,
|
..textDirection = Directionality.of(context)
|
||||||
);
|
..thickness = _thickness.resolve(_states)
|
||||||
|
..radius = widget.radius ?? _kScrollbarRadius
|
||||||
|
..crossAxisMargin = _kScrollbarMargin
|
||||||
|
..minLength = _kScrollbarMinLength
|
||||||
|
..padding = MediaQuery.of(context).padding;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _handleScrollNotification(ScrollNotification notification) {
|
@override
|
||||||
final ScrollMetrics metrics = notification.metrics;
|
void handleThumbPressStart(Offset localPosition) {
|
||||||
if (metrics.maxScrollExtent <= metrics.minScrollExtent) {
|
super.handleThumbPressStart(localPosition);
|
||||||
return false;
|
setState(() { _dragIsActive = true; });
|
||||||
}
|
}
|
||||||
|
|
||||||
// iOS sub-delegates to the CupertinoScrollbar instead and doesn't handle
|
@override
|
||||||
// scroll notifications here.
|
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
|
||||||
if (!_useCupertinoScrollbar &&
|
super.handleThumbPressEnd(localPosition, velocity);
|
||||||
(notification is ScrollUpdateNotification ||
|
setState(() { _dragIsActive = false; });
|
||||||
notification is OverscrollNotification)) {
|
}
|
||||||
if (_fadeoutAnimationController.status != AnimationStatus.forward) {
|
|
||||||
_fadeoutAnimationController.forward();
|
|
||||||
}
|
|
||||||
|
|
||||||
_materialPainter!.update(
|
@override
|
||||||
notification.metrics,
|
void handleHover(PointerHoverEvent event) {
|
||||||
notification.metrics.axisDirection,
|
super.handleHover(event);
|
||||||
);
|
// Check if the position of the pointer falls over the painted scrollbar
|
||||||
if (!widget.isAlwaysShown) {
|
if (isPointerOverScrollbar(event.position)) {
|
||||||
_fadeoutTimer?.cancel();
|
// Pointer is hovering over the scrollbar
|
||||||
_fadeoutTimer = Timer(_kScrollbarTimeToFade, () {
|
setState(() { _hoverIsActive = true; });
|
||||||
_fadeoutAnimationController.reverse();
|
_hoverAnimationController.forward();
|
||||||
_fadeoutTimer = null;
|
} else if (_hoverIsActive) {
|
||||||
});
|
// Pointer was, but is no longer over painted scrollbar.
|
||||||
}
|
setState(() { _hoverIsActive = false; });
|
||||||
|
_hoverAnimationController.reverse();
|
||||||
}
|
}
|
||||||
return false;
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void handleHoverExit(PointerExitEvent event) {
|
||||||
|
super.handleHoverExit(event);
|
||||||
|
setState(() { _hoverIsActive = false; });
|
||||||
|
_hoverAnimationController.reverse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_fadeoutAnimationController.dispose();
|
_hoverAnimationController.dispose();
|
||||||
_fadeoutTimer?.cancel();
|
|
||||||
_materialPainter?.dispose();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -680,4 +680,63 @@ void main() {
|
|||||||
await tester.pump(_kScrollbarTimeToFade);
|
await tester.pump(_kScrollbarTimeToFade);
|
||||||
await tester.pump(_kScrollbarFadeDuration);
|
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()));
|
expect(find.byType(Scrollbar), isNot(paints..rect()));
|
||||||
await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0);
|
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 {
|
testWidgets('Viewport basic test (RTL)', (WidgetTester tester) async {
|
||||||
@ -40,7 +40,7 @@ void main() {
|
|||||||
));
|
));
|
||||||
expect(find.byType(Scrollbar), isNot(paints..rect()));
|
expect(find.byType(Scrollbar), isNot(paints..rect()));
|
||||||
await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0);
|
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 {
|
testWidgets('works with MaterialApp and Scaffold', (WidgetTester tester) async {
|
||||||
@ -69,11 +69,11 @@ void main() {
|
|||||||
|
|
||||||
expect(find.byType(Scrollbar), paints..rect(
|
expect(find.byType(Scrollbar), paints..rect(
|
||||||
rect: const Rect.fromLTWH(
|
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
|
0, // the paint area starts from the bottom of the app bar
|
||||||
6, // thickness
|
12, // thickness
|
||||||
// 56 being the height of the app bar
|
// 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
|
// Use of this source code is governed by a BSD-style license that can be
|
||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'dart:ui' as ui;
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import '../rendering/mock_canvas.dart';
|
import '../rendering/mock_canvas.dart';
|
||||||
|
|
||||||
|
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300);
|
||||||
|
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
|
||||||
|
|
||||||
class TestCanvas implements Canvas {
|
class TestCanvas implements Canvas {
|
||||||
final List<Invocation> invocations = <Invocation>[];
|
final List<Invocation> invocations = <Invocation>[];
|
||||||
|
|
||||||
@ -119,85 +122,6 @@ void main() {
|
|||||||
expect(canvas.invocations.isEmpty, isTrue);
|
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',
|
testWidgets('When isAlwaysShown is true, must pass a controller',
|
||||||
(WidgetTester tester) async {
|
(WidgetTester tester) async {
|
||||||
Widget viewWithScroll() {
|
Widget viewWithScroll() {
|
||||||
@ -546,14 +470,300 @@ void main() {
|
|||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
// Long press on the scrollbar thumb and expect it to grow
|
// Long press on the scrollbar thumb and expect it to grow
|
||||||
expect(find.byType(Scrollbar), paints..rect(
|
expect(find.byType(Scrollbar), paints..rrect(
|
||||||
rect: const Rect.fromLTWH(780, 0, 20, 300),
|
rrect: RRect.fromRectAndRadius(const Rect.fromLTWH(778, 0, 20, 300), const Radius.circular(8)),
|
||||||
));
|
));
|
||||||
await tester.pumpWidget(viewWithScroll(radius: const Radius.circular(10)));
|
await tester.pumpWidget(viewWithScroll(radius: const Radius.circular(10)));
|
||||||
expect(find.byType(Scrollbar), paints..rrect(
|
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();
|
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
|
// Use of this source code is governed by a BSD-style license that can be
|
||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/src/physics/utils.dart' show nearEqual;
|
import 'package:flutter/src/physics/utils.dart' show nearEqual;
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import '../flutter_test_alternative.dart' show Fake;
|
import '../flutter_test_alternative.dart' show Fake;
|
||||||
|
import '../rendering/mock_canvas.dart';
|
||||||
|
|
||||||
const Color _kScrollbarColor = Color(0xFF123456);
|
const Color _kScrollbarColor = Color(0xFF123456);
|
||||||
const double _kThickness = 2.5;
|
const double _kThickness = 2.5;
|
||||||
const double _kMinThumbExtent = 18.0;
|
const double _kMinThumbExtent = 18.0;
|
||||||
|
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300);
|
||||||
|
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
|
||||||
|
|
||||||
ScrollbarPainter _buildPainter({
|
ScrollbarPainter _buildPainter({
|
||||||
TextDirection textDirection = TextDirection.ltr,
|
TextDirection textDirection = TextDirection.ltr,
|
||||||
@ -45,6 +50,9 @@ class _DrawRectOnceCanvas extends Fake implements Canvas {
|
|||||||
void drawRect(Rect rect, Paint paint) {
|
void drawRect(Rect rect, Paint paint) {
|
||||||
rects.add(rect);
|
rects.add(rect);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void drawLine(Offset p1, Offset p2, Paint paint) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user