Unverified Commit 1840b712 authored by Todd Volkert's avatar Todd Volkert Committed by GitHub

Make scrollbar thickness and radius customizable (#61401)

* Make scrollbar thickness and radius customizable

https://github.com/flutter/flutter/issues/29576
https://github.com/flutter/flutter/issues/36412

* Add docs for constants

* No more magic numbers in test
parent 5587ca17
...@@ -24,10 +24,6 @@ const Color _kScrollbarColor = CupertinoDynamicColor.withBrightness( ...@@ -24,10 +24,6 @@ const Color _kScrollbarColor = CupertinoDynamicColor.withBrightness(
color: Color(0x59000000), color: Color(0x59000000),
darkColor: Color(0x80FFFFFF), darkColor: Color(0x80FFFFFF),
); );
const double _kScrollbarThickness = 3;
const double _kScrollbarThicknessDragging = 8.0;
const Radius _kScrollbarRadius = Radius.circular(1.5);
const Radius _kScrollbarRadiusDragging = Radius.circular(4.0);
// This is the amount of space from the top of a vertical scrollbar to the // This is the amount of space from the top of a vertical scrollbar to the
// top edge of the scrollable, measured when the vertical scrollbar overscrolls // top edge of the scrollable, measured when the vertical scrollbar overscrolls
...@@ -63,10 +59,32 @@ class CupertinoScrollbar extends StatefulWidget { ...@@ -63,10 +59,32 @@ class CupertinoScrollbar extends StatefulWidget {
Key key, Key key,
this.controller, this.controller,
this.isAlwaysShown = false, this.isAlwaysShown = false,
this.thickness = defaultThickness,
this.thicknessWhileDragging = defaultThicknessWhileDragging,
this.radius = defaultRadius,
this.radiusWhileDragging = defaultRadiusWhileDragging,
@required this.child, @required this.child,
}) : assert(!isAlwaysShown || controller != null, 'When isAlwaysShown is true, must pass a controller that is attached to a scroll view'), }) : 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);
/// Default value for [thickness] if it's not specified in [new CupertinoScrollbar].
static const double defaultThickness = 3;
/// Default value for [thicknessWhileDragging] if it's not specified in [new CupertinoScrollbar].
static const double defaultThicknessWhileDragging = 8.0;
/// Default value for [radius] if it's not specified in [new CupertinoScrollbar].
static const Radius defaultRadius = Radius.circular(1.5);
/// Default value for [radiusWhileDragging] if it's not specified in [new CupertinoScrollbar].
static const Radius defaultRadiusWhileDragging = Radius.circular(4.0);
/// The subtree to place inside the [CupertinoScrollbar]. /// The subtree to place inside the [CupertinoScrollbar].
/// ///
/// This should include a source of [ScrollNotification] notifications, /// This should include a source of [ScrollNotification] notifications,
...@@ -183,6 +201,36 @@ class CupertinoScrollbar extends StatefulWidget { ...@@ -183,6 +201,36 @@ class CupertinoScrollbar extends StatefulWidget {
/// {@endtemplate} /// {@endtemplate}
final bool isAlwaysShown; 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
/// from [thickness] to this value, then animate back when the user stops
/// 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.
///
/// When the user starts dragging the scrollbar, the radius will animate
/// from [radius] to this value, then animate back when the user stops
/// dragging the scrollbar.
final Radius radiusWhileDragging;
@override @override
_CupertinoScrollbarState createState() => _CupertinoScrollbarState(); _CupertinoScrollbarState createState() => _CupertinoScrollbarState();
} }
...@@ -199,11 +247,11 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv ...@@ -199,11 +247,11 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
Drag _drag; Drag _drag;
double get _thickness { double get _thickness {
return _kScrollbarThickness + _thicknessAnimationController.value * (_kScrollbarThicknessDragging - _kScrollbarThickness); return widget.thickness + _thicknessAnimationController.value * (widget.thicknessWhileDragging - widget.thickness);
} }
Radius get _radius { Radius get _radius {
return Radius.lerp(_kScrollbarRadius, _kScrollbarRadiusDragging, _thicknessAnimationController.value); return Radius.lerp(widget.radius, widget.radiusWhileDragging, _thicknessAnimationController.value);
} }
ScrollController _currentController; ScrollController _currentController;
...@@ -247,6 +295,8 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv ...@@ -247,6 +295,8 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
@override @override
void didUpdateWidget(CupertinoScrollbar oldWidget) { void didUpdateWidget(CupertinoScrollbar oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
assert(_painter != null);
_painter.updateThickness(_thickness, _radius);
if (widget.isAlwaysShown != oldWidget.isAlwaysShown) { if (widget.isAlwaysShown != oldWidget.isAlwaysShown) {
if (widget.isAlwaysShown == true) { if (widget.isAlwaysShown == true) {
_triggerScrollbar(); _triggerScrollbar();
......
...@@ -40,6 +40,8 @@ class Scrollbar extends StatefulWidget { ...@@ -40,6 +40,8 @@ class Scrollbar extends StatefulWidget {
@required this.child, @required this.child,
this.controller, this.controller,
this.isAlwaysShown = false, 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'), }) : 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);
...@@ -57,6 +59,26 @@ class Scrollbar extends StatefulWidget { ...@@ -57,6 +59,26 @@ class Scrollbar extends StatefulWidget {
/// {@macro flutter.cupertino.cupertinoScrollbar.isAlwaysShown} /// {@macro flutter.cupertino.cupertinoScrollbar.isAlwaysShown}
final bool 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();
} }
...@@ -126,6 +148,12 @@ class _ScrollbarState extends State<Scrollbar> with TickerProviderStateMixin { ...@@ -126,6 +148,12 @@ class _ScrollbarState extends State<Scrollbar> with TickerProviderStateMixin {
_fadeoutAnimationController.animateTo(1.0); _fadeoutAnimationController.animateTo(1.0);
} }
} }
if (!_useCupertinoScrollbar) {
assert(_materialPainter != null);
_materialPainter
..thickness = widget.thickness ?? _kScrollbarThickness
..radius = widget.radius;
}
} }
// Wait one frame and cause an empty scroll event. This allows the thumb to // Wait one frame and cause an empty scroll event. This allows the thumb to
...@@ -144,7 +172,8 @@ class _ScrollbarState extends State<Scrollbar> with TickerProviderStateMixin { ...@@ -144,7 +172,8 @@ class _ScrollbarState extends State<Scrollbar> with TickerProviderStateMixin {
return ScrollbarPainter( return ScrollbarPainter(
color: _themeColor, color: _themeColor,
textDirection: _textDirection, textDirection: _textDirection,
thickness: _kScrollbarThickness, thickness: widget.thickness ?? _kScrollbarThickness,
radius: widget.radius,
fadeoutOpacityAnimation: _fadeoutOpacityAnimation, fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
padding: MediaQuery.of(context).padding, padding: MediaQuery.of(context).padding,
); );
...@@ -194,6 +223,10 @@ class _ScrollbarState extends State<Scrollbar> with TickerProviderStateMixin { ...@@ -194,6 +223,10 @@ class _ScrollbarState extends State<Scrollbar> with TickerProviderStateMixin {
return CupertinoScrollbar( return CupertinoScrollbar(
child: widget.child, child: widget.child,
isAlwaysShown: widget.isAlwaysShown, 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, controller: widget.controller,
); );
} }
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
// @dart = 2.8 // @dart = 2.8
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -19,6 +20,7 @@ void main() { ...@@ -19,6 +20,7 @@ void main() {
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200); const Duration _kScrollbarTimeToFade = Duration(milliseconds: 1200);
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250); const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250);
const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100); const Duration _kScrollbarResizeDuration = Duration(milliseconds: 100);
const Duration _kLongPressDuration = Duration(milliseconds: 100);
testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async { testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async {
await tester.pumpWidget( await tester.pumpWidget(
...@@ -137,10 +139,10 @@ void main() { ...@@ -137,10 +139,10 @@ void main() {
} }
}); });
// Longpress on the scrollbar thumb and expect a vibration after it resizes. // Long press on the scrollbar thumb and expect a vibration after it resizes.
expect(hapticFeedbackCalls, 0); expect(hapticFeedbackCalls, 0);
final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(796.0, 50.0)); final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(796.0, 50.0));
await tester.pump(const Duration(milliseconds: 100)); await tester.pump(_kLongPressDuration);
expect(hapticFeedbackCalls, 0); expect(hapticFeedbackCalls, 0);
await tester.pump(_kScrollbarResizeDuration); await tester.pump(_kScrollbarResizeDuration);
// Allow the haptic feedback some slack. // Allow the haptic feedback some slack.
...@@ -166,6 +168,103 @@ void main() { ...@@ -166,6 +168,103 @@ void main() {
await tester.pump(_kScrollbarFadeDuration); await tester.pump(_kScrollbarFadeDuration);
}); });
testWidgets('Scrollbar changes thickness and radius when dragged', (WidgetTester tester) async {
const double thickness = 20;
const double thicknessWhileDragging = 40;
const double radius = 10;
const double radiusWhileDragging = 20;
const double inset = 3;
const double scaleFactor = 2;
final Size screenSize = tester.binding.window.physicalSize / tester.binding.window.devicePixelRatio;
final ScrollController scrollController = ScrollController();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: const MediaQueryData(),
child: PrimaryScrollController(
controller: scrollController,
child: CupertinoScrollbar(
thickness: thickness,
thicknessWhileDragging: thicknessWhileDragging,
radius: const Radius.circular(radius),
radiusWhileDragging: const Radius.circular(radiusWhileDragging),
child: SingleChildScrollView(
child: SizedBox(
width: screenSize.width * scaleFactor,
height: screenSize.height * scaleFactor,
),
),
),
),
),
),
);
expect(scrollController.offset, 0.0);
// Scroll a bit to cause the scrollbar thumb to be shown;
// undo the scroll to put the thumb back at the top.
const double scrollAmount = 10.0;
final TestGesture scrollGesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView)));
await scrollGesture.moveBy(const Offset(0.0, -scrollAmount));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await scrollGesture.moveBy(const Offset(0.0, scrollAmount));
await tester.pump();
await scrollGesture.up();
await tester.pump();
// Long press on the scrollbar thumb and expect it to grow
final TestGesture dragScrollbarGesture = await tester.startGesture(const Offset(780.0, 50.0));
await tester.pump(_kLongPressDuration);
expect(find.byType(CupertinoScrollbar), paints..rrect(
rrect: RRect.fromRectAndRadius(
Rect.fromLTWH(
screenSize.width - inset - thickness,
inset,
thickness,
(screenSize.height - 2 * inset) / scaleFactor,
),
const Radius.circular(radius),
),
));
await tester.pump(_kScrollbarResizeDuration ~/ 2);
const double midpointThickness = (thickness + thicknessWhileDragging) / 2;
const double midpointRadius = (radius + radiusWhileDragging) / 2;
expect(find.byType(CupertinoScrollbar), paints..rrect(
rrect: RRect.fromRectAndRadius(
Rect.fromLTWH(
screenSize.width - inset - midpointThickness,
inset,
midpointThickness,
(screenSize.height - 2 * inset) / scaleFactor,
),
const Radius.circular(midpointRadius),
),
));
await tester.pump(_kScrollbarResizeDuration ~/ 2);
expect(find.byType(CupertinoScrollbar), paints..rrect(
rrect: RRect.fromRectAndRadius(
Rect.fromLTWH(
screenSize.width - inset - thicknessWhileDragging,
inset,
thicknessWhileDragging,
(screenSize.height - 2 * inset) / scaleFactor,
),
const Radius.circular(radiusWhileDragging),
),
));
// Let the thumb fade out so all timers have resolved.
await dragScrollbarGesture.up();
await tester.pumpAndSettle();
await tester.pump(_kScrollbarTimeToFade);
await tester.pump(_kScrollbarFadeDuration);
});
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() {
......
...@@ -514,4 +514,51 @@ void main() { ...@@ -514,4 +514,51 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(materialScrollbar, isNot(paints..rect())); expect(materialScrollbar, isNot(paints..rect()));
}); });
testWidgets('Scrollbar respects thickness and radius', (WidgetTester tester) async {
final ScrollController controller = ScrollController();
Widget viewWithScroll({Radius radius}) {
return _buildBoilerplate(
child: Theme(
data: ThemeData(),
child: Scrollbar(
controller: controller,
thickness: 20,
radius: radius,
child: SingleChildScrollView(
controller: controller,
child: const SizedBox(
width: 1600.0,
height: 1200.0,
),
),
),
),
);
}
// Scroll a bit to cause the scrollbar thumb to be shown;
// undo the scroll to put the thumb back at the top.
await tester.pumpWidget(viewWithScroll());
const double scrollAmount = 10.0;
final TestGesture scrollGesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView)));
await scrollGesture.moveBy(const Offset(0.0, -scrollAmount));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await scrollGesture.moveBy(const Offset(0.0, scrollAmount));
await tester.pump();
await scrollGesture.up();
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),
));
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)),
));
await tester.pumpAndSettle();
});
} }
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