From ac3b77bdaca677bbea58be11d22317879d363813 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Onat=20=C3=87ipli?= Date: Sat, 28 Mar 2020 00:51:03 +0300 Subject: [PATCH] Scrollbar display always (#50752) --- .../flutter/lib/src/cupertino/scrollbar.dart | 91 ++++++- .../flutter/lib/src/material/scrollbar.dart | 47 +++- .../test/cupertino/scrollbar_test.dart | 233 ++++++++++++++++++ .../flutter/test/material/scrollbar_test.dart | 207 ++++++++++++++++ 4 files changed, 565 insertions(+), 13 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/scrollbar.dart b/packages/flutter/lib/src/cupertino/scrollbar.dart index 3395d248ad..2143dc7b26 100644 --- a/packages/flutter/lib/src/cupertino/scrollbar.dart +++ b/packages/flutter/lib/src/cupertino/scrollbar.dart @@ -60,6 +60,7 @@ class CupertinoScrollbar extends StatefulWidget { const CupertinoScrollbar({ Key key, this.controller, + this.isAlwaysShown = false, @required this.child, }) : super(key: key); @@ -125,6 +126,60 @@ class CupertinoScrollbar extends StatefulWidget { /// {@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: [ + /// 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; + @override _CupertinoScrollbarState createState() => _CupertinoScrollbarState(); } @@ -183,6 +238,28 @@ class _CupertinoScrollbarState extends State with TickerProv ..color = CupertinoDynamicColor.resolve(_kScrollbarColor, context) ..padding = MediaQuery.of(context).padding; } + WidgetsBinding.instance.addPostFrameCallback((Duration duration) { + if (widget.isAlwaysShown) { + assert(widget.controller != null); + // Wait one frame and cause an empty scroll event. This allows the + // thumb to show immediately when isAlwaysShown is true. A scroll + // event is required in order to paint the thumb. + widget.controller.position.didUpdateScrollPositionBy(0); + } + }); + } + + @override + void didUpdateWidget(CupertinoScrollbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isAlwaysShown != oldWidget.isAlwaysShown) { + if (widget.isAlwaysShown == true) { + assert(widget.controller != null); + _fadeoutAnimationController.animateTo(1.0); + } else { + _fadeoutAnimationController.reverse(); + } + } } /// Returns a [ScrollbarPainter] visually styled like the iOS scrollbar. @@ -228,11 +305,13 @@ class _CupertinoScrollbarState extends State with TickerProv } void _startFadeoutTimer() { - _fadeoutTimer?.cancel(); - _fadeoutTimer = Timer(_kScrollbarTimeToFade, () { - _fadeoutAnimationController.reverse(); - _fadeoutTimer = null; - }); + if (!widget.isAlwaysShown) { + _fadeoutTimer?.cancel(); + _fadeoutTimer = Timer(_kScrollbarTimeToFade, () { + _fadeoutAnimationController.reverse(); + _fadeoutTimer = null; + }); + } } bool _checkVertical() { @@ -267,7 +346,7 @@ class _CupertinoScrollbarState extends State with TickerProv _fadeoutTimer?.cancel(); _thicknessAnimationController.forward().then( (_) => HapticFeedback.mediumImpact(), - ); + ); } void _handleLongPressMoveUpdate(LongPressMoveUpdateDetails details) { diff --git a/packages/flutter/lib/src/material/scrollbar.dart b/packages/flutter/lib/src/material/scrollbar.dart index e77c5172b0..e7ca4964a5 100644 --- a/packages/flutter/lib/src/material/scrollbar.dart +++ b/packages/flutter/lib/src/material/scrollbar.dart @@ -37,6 +37,7 @@ class Scrollbar extends StatefulWidget { Key key, @required this.child, this.controller, + this.isAlwaysShown = false, }) : super(key: key); /// The widget below this widget in the tree. @@ -50,6 +51,9 @@ class Scrollbar extends StatefulWidget { /// {@macro flutter.cupertino.cupertinoScrollbar.controller} final ScrollController controller; + /// {@macro flutter.cupertino.cupertinoScrollbar.isAlwaysShown} + final bool isAlwaysShown; + @override _ScrollbarState createState() => _ScrollbarState(); } @@ -102,11 +106,33 @@ class _ScrollbarState extends State with TickerProviderStateMixin { _textDirection = Directionality.of(context); _materialPainter = _buildMaterialScrollbarPainter(); _useCupertinoScrollbar = false; + WidgetsBinding.instance.addPostFrameCallback((Duration duration) { + if (widget.isAlwaysShown) { + assert(widget.controller != null); + // Wait one frame and cause an empty scroll event. This allows the + // thumb to show immediately when isAlwaysShown is true. A scroll + // event is required in order to paint the thumb. + widget.controller.position.didUpdateScrollPositionBy(0); + } + }); break; } assert(_useCupertinoScrollbar != null); } + @override + void didUpdateWidget(Scrollbar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isAlwaysShown != oldWidget.isAlwaysShown) { + assert(widget.controller != null); + if (widget.isAlwaysShown == false) { + _fadeoutAnimationController.reverse(); + } else { + _fadeoutAnimationController.animateTo(1.0); + } + } + } + ScrollbarPainter _buildMaterialScrollbarPainter() { return ScrollbarPainter( color: _themeColor, @@ -126,17 +152,23 @@ class _ScrollbarState extends State with TickerProviderStateMixin { // iOS sub-delegates to the CupertinoScrollbar instead and doesn't handle // scroll notifications here. if (!_useCupertinoScrollbar && - (notification is ScrollUpdateNotification || notification is OverscrollNotification)) { + (notification is ScrollUpdateNotification || + notification is OverscrollNotification)) { if (_fadeoutAnimationController.status != AnimationStatus.forward) { _fadeoutAnimationController.forward(); } - _materialPainter.update(notification.metrics, notification.metrics.axisDirection); - _fadeoutTimer?.cancel(); - _fadeoutTimer = Timer(_kScrollbarTimeToFade, () { - _fadeoutAnimationController.reverse(); - _fadeoutTimer = null; - }); + _materialPainter.update( + notification.metrics, + notification.metrics.axisDirection, + ); + if (!widget.isAlwaysShown) { + _fadeoutTimer?.cancel(); + _fadeoutTimer = Timer(_kScrollbarTimeToFade, () { + _fadeoutAnimationController.reverse(); + _fadeoutTimer = null; + }); + } } return false; } @@ -154,6 +186,7 @@ class _ScrollbarState extends State with TickerProviderStateMixin { if (_useCupertinoScrollbar) { return CupertinoScrollbar( child: widget.child, + isAlwaysShown: widget.isAlwaysShown, controller: widget.controller, ); } diff --git a/packages/flutter/test/cupertino/scrollbar_test.dart b/packages/flutter/test/cupertino/scrollbar_test.dart index 9706149768..017fd9f4cc 100644 --- a/packages/flutter/test/cupertino/scrollbar_test.dart +++ b/packages/flutter/test/cupertino/scrollbar_test.dart @@ -163,4 +163,237 @@ void main() { await tester.pump(_kScrollbarTimeToFade); await tester.pump(_kScrollbarFadeDuration); }); + + testWidgets('On first render with isAlwaysShown: true, the thumb shows', + (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + Widget viewWithScroll() { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: controller, + child: CupertinoScrollbar( + isAlwaysShown: true, + controller: controller, + child: const SingleChildScrollView( + child: SizedBox( + width: 4000.0, + height: 4000.0, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + // The scrollbar measures its size on the first frame + // and renders starting in the second, + // + // so pumpAndSettle a frame to allow it to appear. + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + }); + + testWidgets('On first render with isAlwaysShown: false, the thumb is hidden', + (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + Widget viewWithScroll() { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: PrimaryScrollController( + controller: controller, + child: CupertinoScrollbar( + isAlwaysShown: false, + controller: controller, + child: const SingleChildScrollView( + child: SizedBox( + width: 4000.0, + height: 4000.0, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), isNot(paints..rect())); + }); + + testWidgets( + 'With isAlwaysShown: true, fling a scroll. While it is still scrolling, set isAlwaysShown: false. The thumb should not fade out until the scrolling stops.', + (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + bool isAlwaysShown = true; + Widget viewWithScroll() { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Stack( + children: [ + CupertinoScrollbar( + isAlwaysShown: isAlwaysShown, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox( + width: 4000.0, + height: 4000.0, + ), + ), + ), + Positioned( + bottom: 10, + child: CupertinoButton( + onPressed: () { + setState(() { + isAlwaysShown = !isAlwaysShown; + }); + }, + child: const Text('change isAlwaysShown'), + ), + ) + ], + ), + ), + ); + }, + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + await tester.fling( + find.byType(SingleChildScrollView), + const Offset(0.0, -10.0), + 10, + ); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); + }); + + testWidgets( + 'With isAlwaysShown: false, fling a scroll. While it is still scrolling, set isAlwaysShown: true. The thumb should not fade even after the scrolling stops', + (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + bool isAlwaysShown = false; + Widget viewWithScroll() { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Stack( + children: [ + CupertinoScrollbar( + isAlwaysShown: isAlwaysShown, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox( + width: 4000.0, + height: 4000.0, + ), + ), + ), + Positioned( + bottom: 10, + child: CupertinoButton( + onPressed: () { + setState(() { + isAlwaysShown = !isAlwaysShown; + }); + }, + child: const Text('change isAlwaysShown'), + ), + ) + ], + ), + ), + ); + }, + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + await tester.fling( + find.byType(SingleChildScrollView), + const Offset(0.0, -10.0), + 10, + ); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + }); + + testWidgets( + 'Toggling isAlwaysShown while not scrolling fades the thumb in/out. This works even when you have never scrolled at all yet', + (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + bool isAlwaysShown = true; + Widget viewWithScroll() { + return StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: const MediaQueryData(), + child: Stack( + children: [ + CupertinoScrollbar( + isAlwaysShown: isAlwaysShown, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox( + width: 4000.0, + height: 4000.0, + ), + ), + ), + Positioned( + bottom: 10, + child: CupertinoButton( + onPressed: () { + setState(() { + isAlwaysShown = !isAlwaysShown; + }); + }, + child: const Text('change isAlwaysShown'), + ), + ) + ], + ), + ), + ); + }, + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), paints..rrect()); + + await tester.tap(find.byType(CupertinoButton)); + await tester.pumpAndSettle(); + expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); + }); } diff --git a/packages/flutter/test/material/scrollbar_test.dart b/packages/flutter/test/material/scrollbar_test.dart index 9ebbe9cac8..1bbd53a36c 100644 --- a/packages/flutter/test/material/scrollbar_test.dart +++ b/packages/flutter/test/material/scrollbar_test.dart @@ -201,4 +201,211 @@ void main() { expect(scrollbar.controller, isNotNull); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + testWidgets('On first render with isAlwaysShown: true, the thumb shows', + (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + Widget viewWithScroll() { + return _buildBoilerplate( + child: Theme( + data: ThemeData(), + child: Scrollbar( + isAlwaysShown: true, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox( + width: 4000.0, + height: 4000.0, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(Scrollbar), paints..rect()); + }); + + testWidgets('On first render with isAlwaysShown: false, the thumb is hidden', + (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + Widget viewWithScroll() { + return _buildBoilerplate( + child: Theme( + data: ThemeData(), + child: Scrollbar( + isAlwaysShown: false, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox( + width: 4000.0, + height: 4000.0, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + expect(find.byType(Scrollbar), isNot(paints..rect())); + }); + + testWidgets( + 'With isAlwaysShown: true, fling a scroll. While it is still scrolling, set isAlwaysShown: false. The thumb should not fade out until the scrolling stops.', + (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + bool isAlwaysShown = true; + Widget viewWithScroll() { + return _buildBoilerplate( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Theme( + data: ThemeData(), + child: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.threed_rotation), + onPressed: () { + setState(() { + isAlwaysShown = !isAlwaysShown; + }); + }, + ), + body: Scrollbar( + isAlwaysShown: isAlwaysShown, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox( + width: 4000.0, + height: 4000.0, + ), + ), + ), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + await tester.fling( + find.byType(SingleChildScrollView), + const Offset(0.0, -10.0), + 10, + ); + expect(find.byType(Scrollbar), paints..rect()); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + // Scrollbar is not showing after scroll finishes + expect(find.byType(Scrollbar), isNot(paints..rect())); + }); + + testWidgets( + 'With isAlwaysShown: false, fling a scroll. While it is still scrolling, set isAlwaysShown: true. The thumb should not fade even after the scrolling stops', + (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + bool isAlwaysShown = false; + Widget viewWithScroll() { + return _buildBoilerplate( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Theme( + data: ThemeData(), + child: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.threed_rotation), + onPressed: () { + setState(() { + isAlwaysShown = !isAlwaysShown; + }); + }, + ), + body: Scrollbar( + isAlwaysShown: isAlwaysShown, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox( + width: 4000.0, + height: 4000.0, + ), + ), + ), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + await tester.fling( + find.byType(SingleChildScrollView), + const Offset(0.0, -10.0), + 10, + ); + expect(find.byType(Scrollbar), paints..rect()); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + // Scrollbar is not showing after scroll finishes + expect(find.byType(Scrollbar), paints..rect()); + }); + + testWidgets( + 'Toggling isAlwaysShown while not scrolling fades the thumb in/out. This works even when you have never scrolled at all yet', + (WidgetTester tester) async { + final ScrollController controller = ScrollController(); + bool isAlwaysShown = true; + Widget viewWithScroll() { + return _buildBoilerplate( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Theme( + data: ThemeData(), + child: Scaffold( + floatingActionButton: FloatingActionButton( + child: const Icon(Icons.threed_rotation), + onPressed: () { + setState(() { + isAlwaysShown = !isAlwaysShown; + }); + }, + ), + body: Scrollbar( + isAlwaysShown: isAlwaysShown, + controller: controller, + child: SingleChildScrollView( + controller: controller, + child: const SizedBox( + width: 4000.0, + height: 4000.0, + ), + ), + ), + ), + ); + }, + ), + ); + } + + await tester.pumpWidget(viewWithScroll()); + await tester.pumpAndSettle(); + final Finder materialScrollbar = find.byType(Scrollbar); + expect(materialScrollbar, paints..rect()); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + expect(materialScrollbar, isNot(paints..rect())); + }); }