diff --git a/packages/flutter/lib/src/cupertino/scrollbar.dart b/packages/flutter/lib/src/cupertino/scrollbar.dart index 09721754beb..9a54fb2a06c 100644 --- a/packages/flutter/lib/src/cupertino/scrollbar.dart +++ b/packages/flutter/lib/src/cupertino/scrollbar.dart @@ -8,15 +8,22 @@ import 'package:flutter/widgets.dart'; // All values eyeballed. const Color _kScrollbarColor = Color(0x99777777); -const double _kScrollbarThickness = 2.5; -const double _kScrollbarMainAxisMargin = 4.0; -const double _kScrollbarCrossAxisMargin = 2.5; const double _kScrollbarMinLength = 36.0; const double _kScrollbarMinOverscrollLength = 8.0; const Radius _kScrollbarRadius = Radius.circular(1.25); const Duration _kScrollbarTimeToFade = Duration(milliseconds: 50); const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250); +// These values are measured using screenshots from an iPhone XR 12.1 simulator. +const double _kScrollbarThickness = 2.5; +// 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 +// to the top. +// TODO(LongCatIsLooong): fix https://github.com/flutter/flutter/issues/32175 +const double _kScrollbarMainAxisMargin = 3.0; +const double _kScrollbarCrossAxisMargin = 3.0; + + /// An iOS style scrollbar. /// /// A scrollbar indicates which portion of a [Scrollable] widget is actually @@ -89,6 +96,7 @@ class _CupertinoScrollbarState extends State with TickerProv mainAxisMargin: _kScrollbarMainAxisMargin, crossAxisMargin: _kScrollbarCrossAxisMargin, radius: _kScrollbarRadius, + padding: MediaQuery.of(context).padding, minLength: _kScrollbarMinLength, minOverscrollLength: _kScrollbarMinOverscrollLength, ); diff --git a/packages/flutter/lib/src/material/scrollbar.dart b/packages/flutter/lib/src/material/scrollbar.dart index d91048b6e6d..60dcfcb0d5b 100644 --- a/packages/flutter/lib/src/material/scrollbar.dart +++ b/packages/flutter/lib/src/material/scrollbar.dart @@ -104,6 +104,7 @@ class _ScrollbarState extends State with TickerProviderStateMixin { textDirection: _textDirection, thickness: _kScrollbarThickness, fadeoutOpacityAnimation: _fadeoutOpacityAnimation, + padding: MediaQuery.of(context).padding, ); } diff --git a/packages/flutter/lib/src/rendering/viewport_offset.dart b/packages/flutter/lib/src/rendering/viewport_offset.dart index ea3f7f27d08..47c92555338 100644 --- a/packages/flutter/lib/src/rendering/viewport_offset.dart +++ b/packages/flutter/lib/src/rendering/viewport_offset.dart @@ -123,8 +123,9 @@ abstract class ViewportOffset extends ChangeNotifier { /// Called when the viewport's content extents are established. /// /// The arguments are the minimum and maximum scroll extents respectively. The - /// minimum will be equal to or less than zero, the maximum will be equal to - /// or greater than zero. + /// minimum will be equal to or less than the maximum. In the case of slivers, + /// the minimum will be equal to or less than zero, the maximum will be equal + /// to or greater than zero. /// /// The maximum scroll extent has the viewport dimension subtracted from it. /// For instance, if there is 100.0 pixels of scrollable content, and the diff --git a/packages/flutter/lib/src/widgets/scroll_metrics.dart b/packages/flutter/lib/src/widgets/scroll_metrics.dart index 97675e3a49e..b5908a1238d 100644 --- a/packages/flutter/lib/src/widgets/scroll_metrics.dart +++ b/packages/flutter/lib/src/widgets/scroll_metrics.dart @@ -60,14 +60,16 @@ abstract class ScrollMetrics { /// /// The actual [pixels] value might be [outOfRange]. /// - /// This value can be negative infinity, if the scroll is unbounded. + /// This value should typically be non-null and less than or equal to + /// [maxScrollExtent]. It can be negative infinity, if the scroll is unbounded. double get minScrollExtent; /// The maximum in-range value for [pixels]. /// /// The actual [pixels] value might be [outOfRange]. /// - /// This value can be infinity, if the scroll is unbounded. + /// This value should typically be non-null and greater than or equal to + /// [minScrollExtent]. It can be infinity, if the scroll is unbounded. double get maxScrollExtent; /// The current scroll position, in logical pixels along the [axisDirection]. @@ -90,25 +92,27 @@ abstract class ScrollMetrics { /// [maxScrollExtent]. bool get atEdge => pixels == minScrollExtent || pixels == maxScrollExtent; - /// The quantity of content conceptually "above" the currently visible content - /// of the viewport in the scrollable. This is the content above the content - /// described by [extentInside]. + /// The quantity of content conceptually "above" the viewport in the scrollable. + /// This is the content above the content described by [extentInside]. double get extentBefore => math.max(pixels - minScrollExtent, 0.0); - /// The quantity of visible content. + /// The quantity of content conceptually "inside" the viewport in the scrollable. /// - /// If [extentBefore] and [extentAfter] are non-zero, then this is typically - /// the height of the viewport. It could be less if there is less content - /// visible than the size of the viewport. + /// The value is typically the height of the viewport when [outOfRange] is false. + /// It could be less if there is less content visible than the size of the + /// viewport, such as when overscrolling. + /// + /// The value is always non-negative, and less than or equal to [viewportDimension]. double get extentInside { - return math.min(pixels, maxScrollExtent) - - math.max(pixels, minScrollExtent) + - math.min(viewportDimension, maxScrollExtent - minScrollExtent); + return viewportDimension + // "above" overscroll value + - (minScrollExtent - pixels).clamp(0, viewportDimension) + // "below" overscroll value + - (pixels - maxScrollExtent).clamp(0, viewportDimension); } - /// The quantity of content conceptually "below" the currently visible content - /// of the viewport in the scrollable. This is the content below the content - /// described by [extentInside]. + /// The quantity of content conceptually "below" the viewport in the scrollable. + /// This is the content below the content described by [extentInside]. double get extentAfter => math.max(maxScrollExtent - pixels, 0.0); } diff --git a/packages/flutter/lib/src/widgets/scroll_position.dart b/packages/flutter/lib/src/widgets/scroll_position.dart index 3018a17f4a6..4d6a5fa8a1f 100644 --- a/packages/flutter/lib/src/widgets/scroll_position.dart +++ b/packages/flutter/lib/src/widgets/scroll_position.dart @@ -450,6 +450,9 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { if (!nearEqual(_minScrollExtent, minScrollExtent, Tolerance.defaultTolerance.distance) || !nearEqual(_maxScrollExtent, maxScrollExtent, Tolerance.defaultTolerance.distance) || _didChangeViewportDimensionOrReceiveCorrection) { + assert(minScrollExtent != null); + assert(maxScrollExtent != null); + assert(minScrollExtent <= maxScrollExtent); _minScrollExtent = minScrollExtent; _maxScrollExtent = maxScrollExtent; _haveDimensions = true; diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index be30b222cc8..cd507fd18b0 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -14,6 +14,10 @@ const double _kMinThumbExtent = 18.0; /// A [CustomPainter] for painting scrollbars. /// +/// The size of the scrollbar along its scroll direction is typically +/// proportional to the percentage of content completely visible on screen, +/// as long as its size isn't less than [minLength] and it isn't overscrolling. +/// /// Unlike [CustomPainter]s that subclasses [CustomPainter] and only repaint /// when [shouldRepaint] returns true (which requires this [CustomPainter] to /// be rebuilt), this painter has the added optimization of repainting and not @@ -43,18 +47,25 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { @required this.textDirection, @required this.thickness, @required this.fadeoutOpacityAnimation, + this.padding = EdgeInsets.zero, this.mainAxisMargin = 0.0, this.crossAxisMargin = 0.0, this.radius, this.minLength = _kMinThumbExtent, - this.minOverscrollLength = _kMinThumbExtent, + double minOverscrollLength, }) : assert(color != null), assert(textDirection != null), assert(thickness != null), assert(fadeoutOpacityAnimation != null), assert(mainAxisMargin != null), assert(crossAxisMargin != null), - assert(minLength != null) { + assert(minLength != null), + assert(minLength >= 0), + assert(minOverscrollLength == null || minOverscrollLength <= minLength), + assert(minOverscrollLength == null || minOverscrollLength >= 0), + assert(padding != null), + assert(padding.isNonNegative), + minOverscrollLength = minOverscrollLength ?? minLength { fadeoutOpacityAnimation.addListener(notifyListeners); } @@ -65,7 +76,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { /// screen the scrollbar appears in (the trailing side). Mustn't be null. final TextDirection textDirection; - /// Thickness of the scrollbar in its cross-axis in pixels. Mustn't be null. + /// Thickness of the scrollbar in its cross-axis in logical pixels. Mustn't be null. final double thickness; /// An opacity [Animation] that dictates the opacity of the thumb. @@ -73,12 +84,15 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { /// Mustn't be null. final Animation fadeoutOpacityAnimation; - /// Distance from the scrollbar's start and end to the edge of the viewport in - /// pixels. Mustn't be null. + /// Distance from the scrollbar's start and end to the edge of the viewport + /// in logical pixels. It affects the amount of available paint area. + /// + /// Mustn't be null and defaults to 0. final double mainAxisMargin; - /// Distance from the scrollbar's side to the nearest edge in pixels. Must not - /// be null. + /// Distance from the scrollbar's side to the nearest edge in logical pixels. + /// + /// Must not be null and defaults to 0. final double crossAxisMargin; /// [Radius] of corners if the scrollbar should have rounded corners. @@ -86,13 +100,40 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { /// Scrollbar will be rectangular if [radius] is null. final Radius radius; - /// The smallest size the scrollbar can shrink to when the total scrollable - /// extent is large and the current visible viewport is small, and the - /// viewport is not overscrolled. Mustn't be null. + /// The amount of space by which to inset the scrollbar's start and end, as + /// well as its side to the nearest edge, in logical pixels. + /// + /// This is typically set to the current [MediaQueryData.padding] to avoid + /// partial obstructions such as display notches. If you only want additional + /// margins around the scrollbar, see [mainAxisMargin]. + /// + /// Defaults to [EdgeInsets.zero]. Must not be null and offsets from all four + /// directions must be greater than or equal to zero. + final EdgeInsets padding; + + /// The preferred smallest size the scrollbar can shrink to when the total + /// scrollable extent is large, the current visible viewport is small, and the + /// viewport is not overscrolled. + /// + /// The size of the scrollbar may shrink to a smaller size than [minLength] + /// to fit in the available paint area. E.g., when [minLength] is + /// `double.infinity`, it will not be respected if [viewportDimension] and + /// [mainAxisMargin] are finite. + /// + /// Mustn't be null and the value has to be within the range of 0 to + /// [minOverscrollLength], inclusive. Defaults to 18.0. final double minLength; - /// The smallest size the scrollbar can shrink to when viewport is - /// overscrolled. Mustn't be null. + /// The preferred smallest size the scrollbar can shrink to when viewport is + /// overscrolled. + /// + /// When overscrolling, the size of the scrollbar may shrink to a smaller size + /// than [minOverscrollLength] to fit in the available paint area. E.g., when + /// [minOverscrollLength] is `double.infinity`, it will not be respected if + /// the [viewportDimension] and [mainAxisMargin] are finite. + /// + /// The value is less than or equal to [minLength] and greater than or equal to 0. + /// If unspecified or set to null, it will defaults to the value of [minLength]. final double minOverscrollLength; ScrollMetrics _lastMetrics; @@ -116,64 +157,67 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { color.withOpacity(color.opacity * fadeoutOpacityAnimation.value); } - double _getThumbX(Size size) { - assert(textDirection != null); - switch (textDirection) { - case TextDirection.rtl: - return crossAxisMargin; - case TextDirection.ltr: - return size.width - thickness - crossAxisMargin; + void _paintThumbCrossAxis(Canvas canvas, Size size, double thumbOffset, double thumbExtent, AxisDirection direction) { + double x, y; + Size thumbSize; + + switch (direction) { + case AxisDirection.down: + thumbSize = Size(thickness, thumbExtent); + x = textDirection == TextDirection.rtl + ? crossAxisMargin + padding.left + : size.width - thickness - crossAxisMargin - padding.right; + y = thumbOffset; + break; + case AxisDirection.up: + thumbSize = Size(thickness, thumbExtent); + x = textDirection == TextDirection.rtl + ? crossAxisMargin + padding.left + : size.width - thickness - crossAxisMargin - padding.right; + y = thumbOffset; + break; + case AxisDirection.left: + thumbSize = Size(thumbExtent, thickness); + x = thumbOffset; + y = size.height - thickness - crossAxisMargin - padding.bottom; + break; + case AxisDirection.right: + thumbSize = Size(thumbExtent, thickness); + x = thumbOffset; + y = size.height - thickness - crossAxisMargin - padding.bottom; + break; } - return null; - } - void _paintVerticalThumb(Canvas canvas, Size size, double thumbOffset, double thumbExtent) { - final Offset thumbOrigin = Offset(_getThumbX(size), thumbOffset); - final Size thumbSize = Size(thickness, thumbExtent); - final Rect thumbRect = thumbOrigin & thumbSize; + final Rect thumbRect = Offset(x, y) & thumbSize; if (radius == null) canvas.drawRect(thumbRect, _paint); else canvas.drawRRect(RRect.fromRectAndRadius(thumbRect, radius), _paint); } - void _paintHorizontalThumb(Canvas canvas, Size size, double thumbOffset, double thumbExtent) { - final Offset thumbOrigin = Offset(thumbOffset, size.height - thickness); - final Size thumbSize = Size(thumbExtent, thickness); - final Rect thumbRect = thumbOrigin & thumbSize; - if (radius == null) - canvas.drawRect(thumbRect, _paint); - else - canvas.drawRRect(RRect.fromRectAndRadius(thumbRect, radius), _paint); - } - - void _paintThumb( - double before, - double inside, - double after, - double viewport, - Canvas canvas, - Size size, - void painter(Canvas canvas, Size size, double thumbOffset, double thumbExtent), + double _thumbExtent( + double mainAxisPadding, + double extentInside, + double contentExtent, + double beforeExtent, + double afterExtent, + double trackExtent ) { - // Establish the minimum size possible. - double thumbExtent = math.min(viewport, minOverscrollLength); + // Thumb extent reflects fraction of content visible, as long as this + // isn't less than the absolute minimum size. + // contentExtent >= viewportDimension, so (contentExtent - mainAxisPadding) > 0 + final double fractionVisible = ((extentInside - mainAxisPadding) / (contentExtent - mainAxisPadding)) + .clamp(0.0, 1.0); - if (before + inside + after > 0.0) { - // Thumb extent reflects fraction of content visible, as long as this - // isn't less than the absolute minimum size. - final double fractionVisible = inside / (before + inside + after); - thumbExtent = math.max( - thumbExtent, - viewport * fractionVisible - 2 * mainAxisMargin, - ); + final double thumbExtent = math.max( + math.min(trackExtent, minOverscrollLength), + trackExtent * fractionVisible + ); + + final double safeMinLength = math.min(minLength, trackExtent); + final double newMinLength = (beforeExtent > 0 && afterExtent > 0) // Thumb extent is no smaller than minLength if scrolling normally. - if (before != 0.0 && after != 0.0) { - thumbExtent = math.max( - minLength, - thumbExtent, - ); - } + ? safeMinLength // User is overscrolling. Thumb extent can be less than minLength // but no smaller than minOverscrollLength. We can't use the // fractionVisible to produce intermediate values between minLength and @@ -185,20 +229,11 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { // [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce // values for the thumb that range between minLength and the smallest // possible value, minOverscrollLength. - else { - thumbExtent = math.max( - thumbExtent, - minLength * (((inside / viewport) - 0.8) / 0.2), - ); - } - } + : safeMinLength * ((fractionVisible - 0.8).clamp(0.0, 0.2) / 0.2); - final double fractionPast = before / (before + after); - final double thumbOffset = (before + after > 0.0) - ? fractionPast * (viewport - thumbExtent - 2 * mainAxisMargin) + mainAxisMargin - : mainAxisMargin; - - painter(canvas, size, thumbOffset, thumbExtent); + // The `thumbExtent` should be no greater than `trackSize`, otherwise + // the scrollbar may scroll towards the wrong direction. + return thumbExtent.clamp(newMinLength, trackExtent); } @override @@ -213,20 +248,41 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { || _lastMetrics == null || fadeoutOpacityAnimation.value == 0.0) return; - switch (_lastAxisDirection) { - case AxisDirection.down: - _paintThumb(_lastMetrics.extentBefore, _lastMetrics.extentInside, _lastMetrics.extentAfter, size.height, canvas, size, _paintVerticalThumb); - break; - case AxisDirection.up: - _paintThumb(_lastMetrics.extentAfter, _lastMetrics.extentInside, _lastMetrics.extentBefore, size.height, canvas, size, _paintVerticalThumb); - break; - case AxisDirection.right: - _paintThumb(_lastMetrics.extentBefore, _lastMetrics.extentInside, _lastMetrics.extentAfter, size.width, canvas, size, _paintHorizontalThumb); - break; - case AxisDirection.left: - _paintThumb(_lastMetrics.extentAfter, _lastMetrics.extentInside, _lastMetrics.extentBefore, size.width, canvas, size, _paintHorizontalThumb); - break; + + final bool isVertical = _lastAxisDirection == AxisDirection.down || _lastAxisDirection == AxisDirection.up; + final bool isReversed = _lastAxisDirection == AxisDirection.up || _lastAxisDirection == AxisDirection.left; + + final double mainAxisPadding = isVertical ? padding.vertical : padding.horizontal; + // The size of the scrollable area. + final double trackExtent = _lastMetrics.viewportDimension - 2 * mainAxisMargin - mainAxisPadding; + + // Skip painting if there's not enough space. + if (_lastMetrics.viewportDimension <= mainAxisPadding || trackExtent <= 0) { + return; } + + final double totalContentExtent = + _lastMetrics.maxScrollExtent + - _lastMetrics.minScrollExtent + + _lastMetrics.viewportDimension; + + final double beforeExtent = isReversed ? _lastMetrics.extentAfter : _lastMetrics.extentBefore; + final double afterExtent = isReversed ? _lastMetrics.extentBefore : _lastMetrics.extentAfter; + + final double thumbExtent = _thumbExtent(mainAxisPadding, _lastMetrics.extentInside, totalContentExtent, + beforeExtent, afterExtent, trackExtent); + + final double beforePadding = isVertical ? padding.top : padding.left; + final double scrollableExtent = _lastMetrics.maxScrollExtent - _lastMetrics.minScrollExtent; + + final double fractionPast = (scrollableExtent > 0) + ? ((_lastMetrics.pixels - _lastMetrics.minScrollExtent) / scrollableExtent).clamp(0.0, 1.0) + : 0; + + final double thumbOffset = (isReversed ? 1 - fractionPast : fractionPast) * (trackExtent - thumbExtent) + + mainAxisMargin + beforePadding; + + return _paintThumbCrossAxis(canvas, size, thumbOffset, thumbExtent, _lastAxisDirection); } // Scrollbars are (currently) not interactive. @@ -243,7 +299,8 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { || mainAxisMargin != old.mainAxisMargin || crossAxisMargin != old.crossAxisMargin || radius != old.radius - || minLength != old.minLength; + || minLength != old.minLength + || padding != old.padding; } @override diff --git a/packages/flutter/test/cupertino/scrollbar_paint_test.dart b/packages/flutter/test/cupertino/scrollbar_paint_test.dart index 2512512fbee..b82728f3956 100644 --- a/packages/flutter/test/cupertino/scrollbar_paint_test.dart +++ b/packages/flutter/test/cupertino/scrollbar_paint_test.dart @@ -7,32 +7,90 @@ import 'package:flutter_test/flutter_test.dart'; import '../rendering/mock_canvas.dart'; +const Color _kScrollbarColor = Color(0x99777777); + +// The `y` offset has to be larger than `ScrollDragController._bigThresholdBreakDistance` +// to prevent [motionStartDistanceThreshold] from affecting the actual drag distance. +const Offset _kGestureOffset = Offset(0, -25); + void main() { testWidgets('Paints iOS spec', (WidgetTester tester) async { - await tester.pumpWidget(const Directionality( - textDirection: TextDirection.ltr, - child: CupertinoScrollbar( - child: SingleChildScrollView( - child: SizedBox(width: 4000.0, height: 4000.0), + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(), + child: CupertinoScrollbar( + child: SingleChildScrollView( + child: SizedBox(width: 4000.0, height: 4000.0), + ), + ), ), ), - )); + ); + expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView))); - await gesture.moveBy(const Offset(0.0, -10.0)); + await gesture.moveBy(_kGestureOffset); // Move back to original position. - await gesture.moveBy(const Offset(0.0, 10.0)); + await gesture.moveBy(Offset.zero.translate(-_kGestureOffset.dx, -_kGestureOffset.dy)); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); expect(find.byType(CupertinoScrollbar), paints..rrect( - color: const Color(0x99777777), + color: _kScrollbarColor, rrect: RRect.fromRectAndRadius( const Rect.fromLTWH( - 800.0 - 2.5 - 2.5, // Screen width - margin - thickness. - 4.0, // Initial position is the top margin. + 800.0 - 3 - 2.5, // Screen width - margin - thickness. + 3.0, // Initial position is the top margin. 2.5, // Thickness. // Fraction in viewport * scrollbar height - top, bottom margin. - 600.0 / 4000.0 * 600.0 - 4.0 - 4.0, + 600.0 / 4000.0 * (600.0 - 2 * 3), + ), + const Radius.circular(1.25), + ), + )); + }); + + testWidgets('Paints iOS spec with nav bar', (WidgetTester tester) async { + await tester.pumpWidget( + CupertinoApp( + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.fromLTRB(0, 20, 0, 34), + ), + child: CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar( + middle: Text('Title'), + backgroundColor: Color(0x11111111), + ), + child: CupertinoScrollbar( + child: ListView( + children: const [SizedBox(width: 4000, height: 4000)] + ), + ), + ), + ), + ), + ); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView))); + await gesture.moveBy(_kGestureOffset); + // Move back to original position. + await gesture.moveBy(Offset.zero.translate(-_kGestureOffset.dx, -_kGestureOffset.dy)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(CupertinoScrollbar), paints..rrect( + color: _kScrollbarColor, + rrect: RRect.fromRectAndRadius( + const Rect.fromLTWH( + 800.0 - 3 - 2.5, // Screen width - margin - thickness. + 44 + 20 + 3.0, // nav bar height + top margin + 2.5, // Thickness. + // Fraction visible * (viewport size - padding - margin) + // where Fraction visible = (viewport size - padding) / content size + (600.0 - 34 - 44 - 20) / 4000.0 * (600.0 - 2 * 3 - 34 - 44 - 20), ), const Radius.circular(1.25), ), diff --git a/packages/flutter/test/cupertino/scrollbar_test.dart b/packages/flutter/test/cupertino/scrollbar_test.dart index 7df6642116c..d5b14600795 100644 --- a/packages/flutter/test/cupertino/scrollbar_test.dart +++ b/packages/flutter/test/cupertino/scrollbar_test.dart @@ -9,14 +9,17 @@ import '../rendering/mock_canvas.dart'; void main() { testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async { - await tester.pumpWidget(const Directionality( - textDirection: TextDirection.ltr, - child: CupertinoScrollbar( - child: SingleChildScrollView( - child: SizedBox(width: 4000.0, height: 4000.0), + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData(), + child: CupertinoScrollbar( + 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, -10.0)); await tester.pump(); @@ -42,25 +45,4 @@ void main() { color: const Color(0x15777777), )); }); - - testWidgets('Scrollbar is not smaller than minLength with large scroll views', (WidgetTester tester) async { - await tester.pumpWidget(const Directionality( - textDirection: TextDirection.ltr, - child: CupertinoScrollbar( - child: SingleChildScrollView( - child: SizedBox(width: 800.0, height: 20000.0), - ), - ), - )); - final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView))); - await gesture.moveBy(const Offset(0.0, -10.0)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 200)); - - // Height is 36.0. - const Rect scrollbarRect = Rect.fromLTWH(795.0, 4.28659793814433, 2.5, 36.0); - expect(find.byType(CupertinoScrollbar), paints..rrect( - rrect: RRect.fromRectAndRadius(scrollbarRect, const Radius.circular(1.25)), - )); - }); } diff --git a/packages/flutter/test/material/scrollbar_paint_test.dart b/packages/flutter/test/material/scrollbar_paint_test.dart index 2db24cc4ee6..e1e6d81045a 100644 --- a/packages/flutter/test/material/scrollbar_paint_test.dart +++ b/packages/flutter/test/material/scrollbar_paint_test.dart @@ -7,15 +7,26 @@ import 'package:flutter/material.dart'; import '../rendering/mock_canvas.dart'; +Widget _buildSingleChildScrollViewWithScrollbar({ + TextDirection textDirection = TextDirection.ltr, + EdgeInsets padding = EdgeInsets.zero, + Widget child} +) { + return Directionality( + textDirection: textDirection, + child: MediaQuery( + data: MediaQueryData(padding: padding), + child: Scrollbar( + child: SingleChildScrollView(child: child), + ), + ), + ); +} + void main() { testWidgets('Viewport basic test (LTR)', (WidgetTester tester) async { - await tester.pumpWidget(const Directionality( - textDirection: TextDirection.ltr, - child: Scrollbar( - child: SingleChildScrollView( - child: SizedBox(width: 4000.0, height: 4000.0), - ), - ), + await tester.pumpWidget(_buildSingleChildScrollViewWithScrollbar( + child: const SizedBox(width: 4000.0, height: 4000.0), )); expect(find.byType(Scrollbar), isNot(paints..rect())); await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0); @@ -23,16 +34,47 @@ void main() { }); testWidgets('Viewport basic test (RTL)', (WidgetTester tester) async { - await tester.pumpWidget(const Directionality( + await tester.pumpWidget(_buildSingleChildScrollViewWithScrollbar( textDirection: TextDirection.rtl, - child: Scrollbar( - child: SingleChildScrollView( - child: SizedBox(width: 4000.0, height: 4000.0), - ), - ), + child: const SizedBox(width: 4000.0, height: 4000.0), )); expect(find.byType(Scrollbar), isNot(paints..rect())); await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0); expect(find.byType(Scrollbar), paints..rect(rect: const Rect.fromLTRB(0.0, 1.5, 6.0, 91.5))); }); + + testWidgets('workds with MaterialApp and Scaffold', (WidgetTester tester) async { + await tester.pumpWidget(MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + padding: EdgeInsets.fromLTRB(0, 20, 0, 34) + ), + child: Scaffold( + appBar: AppBar(title: const Text('Title')), + body: Scrollbar( + child: ListView( + children: const [SizedBox(width: 4000, height: 4000)] + ), + ), + ), + ), + )); + + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(ListView))); + // On Android it should not overscroll. + await gesture.moveBy(const Offset(0, 100)); + // Trigger fade in animation. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(Scrollbar), paints..rect( + rect: const Rect.fromLTWH( + 800.0 - 6, // screen width - thickness + 0, // the paint area starts from the bottom of the app bar + 6, // thickness + // 56 being the height of the app bar + (600.0 - 56 - 34 - 20) / 4000 * (600 - 56 - 34 - 20), + ), + )); + }); } diff --git a/packages/flutter/test/material/scrollbar_test.dart b/packages/flutter/test/material/scrollbar_test.dart index bb108e36c73..1370ab39d17 100644 --- a/packages/flutter/test/material/scrollbar_test.dart +++ b/packages/flutter/test/material/scrollbar_test.dart @@ -19,34 +19,49 @@ class TestCanvas implements Canvas { } } +Widget _buildBoilerplate({ + TextDirection textDirection = TextDirection.ltr, + EdgeInsets padding = EdgeInsets.zero, + Widget child +}) { + return Directionality( + textDirection: textDirection, + child: MediaQuery( + data: MediaQueryData(padding: padding), + child: child, + ), + ); +} + void main() { testWidgets('Scrollbar doesn\'t show when tapping list', (WidgetTester tester) async { - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: Center( - child: Container( - decoration: BoxDecoration( - border: Border.all(color: const Color(0xFFFFFF00)) - ), - height: 200.0, - width: 300.0, - child: Scrollbar( - child: ListView( - children: [ - Container(height: 40.0, child: const Text('0')), - Container(height: 40.0, child: const Text('1')), - Container(height: 40.0, child: const Text('2')), - Container(height: 40.0, child: const Text('3')), - Container(height: 40.0, child: const Text('4')), - Container(height: 40.0, child: const Text('5')), - Container(height: 40.0, child: const Text('6')), - Container(height: 40.0, child: const Text('7')), - ], + await tester.pumpWidget( + _buildBoilerplate( + child: Center( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFFFFF00)) + ), + height: 200.0, + width: 300.0, + child: Scrollbar( + child: ListView( + children: [ + Container(height: 40.0, child: const Text('0')), + Container(height: 40.0, child: const Text('1')), + Container(height: 40.0, child: const Text('2')), + Container(height: 40.0, child: const Text('3')), + Container(height: 40.0, child: const Text('4')), + Container(height: 40.0, child: const Text('5')), + Container(height: 40.0, child: const Text('6')), + Container(height: 40.0, child: const Text('7')), + ], + ), ), ), - ), - ), - )); + ) + ) + ); SchedulerBinding.instance.debugAssertNoTransientCallbacks('Building a list with a scrollbar triggered an animation.'); await tester.tap(find.byType(ListView)); @@ -64,9 +79,8 @@ void main() { }); testWidgets('ScrollbarPainter does not divide by zero', (WidgetTester tester) async { - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: Container( + await tester.pumpWidget( + _buildBoilerplate(child: Container( height: 200.0, width: 300.0, child: Scrollbar( @@ -76,8 +90,8 @@ void main() { ], ), ), - ), - )); + )) + ); final CustomPaint custom = tester.widget(find.descendant( of: find.byType(Scrollbar), @@ -107,8 +121,7 @@ void main() { testWidgets('Adaptive scrollbar', (WidgetTester tester) async { Widget viewWithScroll(TargetPlatform platform) { - return Directionality( - textDirection: TextDirection.ltr, + return _buildBoilerplate( child: Theme( data: ThemeData( platform: platform diff --git a/packages/flutter/test/widgets/scrollbar_test.dart b/packages/flutter/test/widgets/scrollbar_test.dart new file mode 100644 index 00000000000..f05994a3193 --- /dev/null +++ b/packages/flutter/test/widgets/scrollbar_test.dart @@ -0,0 +1,424 @@ +// Copyright 2016 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/src/physics/utils.dart' show nearEqual; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +const Color _kScrollbarColor = Color(0xFF123456); +const double _kThickness = 2.5; +const double _kMinThumbExtent = 18.0; + +CustomPainter _buildPainter({ + TextDirection textDirection = TextDirection.ltr, + EdgeInsets padding = EdgeInsets.zero, + Color color = _kScrollbarColor, + double thickness = _kThickness, + double mainAxisMargin = 0.0, + double crossAxisMargin = 0.0, + Radius radius, + double minLength = _kMinThumbExtent, + double minOverscrollLength, + ScrollMetrics scrollMetrics, +}) { + return ScrollbarPainter( + color: color, + textDirection: textDirection, + thickness: thickness, + padding: padding, + mainAxisMargin: mainAxisMargin, + crossAxisMargin: crossAxisMargin, + radius: radius, + minLength: minLength, + minOverscrollLength: minOverscrollLength ?? minLength, + fadeoutOpacityAnimation: kAlwaysCompleteAnimation, + )..update(scrollMetrics, scrollMetrics.axisDirection); +} + +class _DrawRectOnceCanvas extends Mock implements Canvas { } + +void main() { + final _DrawRectOnceCanvas testCanvas = _DrawRectOnceCanvas(); + ScrollbarPainter painter; + Rect captureRect() => verify(testCanvas.drawRect(captureAny, any)).captured.single; + + tearDown(() => painter = null); + + final ScrollMetrics defaultMetrics = FixedScrollMetrics( + minScrollExtent: 0, + maxScrollExtent: 0, + pixels: 0, + viewportDimension: 100, + axisDirection: AxisDirection.down + ); + + test( + 'Scrollbar is not smaller than minLength with large scroll views, ' + 'if minLength is small ', + () { + const double minLen = 3.5; + const Size size = Size(600, 10); + final ScrollMetrics metrics = defaultMetrics.copyWith( + maxScrollExtent: 100000, + viewportDimension: size.height, + ); + + // When overscroll. + painter = _buildPainter( + minLength: minLen, + minOverscrollLength: minLen, + scrollMetrics: metrics, + ); + + painter.paint(testCanvas, size); + + final Rect rect0 = captureRect(); + expect(rect0.top, 0); + expect(rect0.left, size.width - _kThickness); + expect(rect0.width, _kThickness); + expect(rect0.height >= minLen, true); + + // When scroll normally. + const double newPixels = 1.0; + + painter.update(metrics.copyWith(pixels: newPixels), metrics.axisDirection); + + painter.paint(testCanvas, size); + + final Rect rect1 = captureRect(); + expect(rect1.left, size.width - _kThickness); + expect(rect1.width, _kThickness); + expect(rect1.height >= minLen, true); + } + ); + + test( + 'When scrolling normally (no overscrolling), the size of the scrollbar stays the same, ' + 'and it scrolls evenly', + () { + const double viewportDimension = 23; + const double maxExtent = 100; + final ScrollMetrics startingMetrics = defaultMetrics.copyWith( + maxScrollExtent: maxExtent, + viewportDimension: viewportDimension, + ); + const Size size = Size(600, viewportDimension); + const double minLen = 0; + + painter = _buildPainter( + minLength: minLen, + minOverscrollLength: minLen, + scrollMetrics: defaultMetrics, + ); + + final List metricsList = + [startingMetrics.copyWith(pixels: 0.01)] + ..addAll(List.generate( + (maxExtent/viewportDimension).round(), + (int index) => startingMetrics.copyWith(pixels: (index + 1) * viewportDimension), + ).where((ScrollMetrics metrics) => !metrics.outOfRange)) + ..add(startingMetrics.copyWith(pixels: maxExtent - 0.01)); + + double lastCoefficient; + for(ScrollMetrics metrics in metricsList) { + painter.update(metrics, metrics.axisDirection); + painter.paint(testCanvas, size); + + final Rect rect = captureRect(); + final double newCoefficient = metrics.pixels/rect.top; + lastCoefficient ??= newCoefficient; + + expect(rect.top >= 0, true); + expect(rect.bottom <= maxExtent, true); + expect(rect.left, size.width - _kThickness); + expect(rect.width, _kThickness); + expect(nearEqual(rect.height, viewportDimension * viewportDimension / (viewportDimension + maxExtent), 0.001), true); + expect(nearEqual(lastCoefficient, newCoefficient, 0.001), true); + } + } + ); + + test( + 'mainAxisMargin is respected', + () { + const double viewportDimension = 23; + const double maxExtent = 100; + final ScrollMetrics startingMetrics = defaultMetrics.copyWith( + maxScrollExtent: maxExtent, + viewportDimension: viewportDimension, + ); + const Size size = Size(600, viewportDimension); + const double minLen = 0; + + const List margins = [-10, 1, viewportDimension/2 - 0.01]; + for(double margin in margins) { + painter = _buildPainter( + mainAxisMargin: margin, + minLength: minLen, + scrollMetrics: defaultMetrics, + ); + + // Overscroll to double.negativeInfinity (top). + painter.update( + startingMetrics.copyWith(pixels: double.negativeInfinity), + startingMetrics.axisDirection, + ); + + painter.paint(testCanvas, size); + expect(captureRect().top, margin); + + // Overscroll to double.infinity (down). + painter.update( + startingMetrics.copyWith(pixels: double.infinity), + startingMetrics.axisDirection, + ); + + painter.paint(testCanvas, size); + expect(size.height - captureRect().bottom, margin); + } + } + ); + + test( + 'crossAxisMargin & text direction are respected', + () { + const double viewportDimension = 23; + const double maxExtent = 100; + final ScrollMetrics startingMetrics = defaultMetrics.copyWith( + maxScrollExtent: maxExtent, + viewportDimension: viewportDimension, + ); + const Size size = Size(600, viewportDimension); + const double margin = 4; + + for(TextDirection textDirection in TextDirection.values) { + painter = _buildPainter( + crossAxisMargin: margin, + scrollMetrics: startingMetrics, + textDirection: textDirection, + ); + + for(AxisDirection direction in AxisDirection.values) { + painter.update( + startingMetrics.copyWith(axisDirection: direction), + direction, + ); + + painter.paint(testCanvas, size); + final Rect rect = captureRect(); + + switch (direction) { + case AxisDirection.up: + case AxisDirection.down: + expect( + margin, + textDirection == TextDirection.ltr + ? size.width - rect.right + : rect.left + ); + break; + case AxisDirection.left: + case AxisDirection.right: + expect(margin, size.height - rect.bottom); + break; + } + } + } + } + ); + + group('Padding works for all scroll directions', () { + const EdgeInsets padding = EdgeInsets.fromLTRB(1, 2, 3, 4); + const Size size = Size(60, 80); + final ScrollMetrics metrics = defaultMetrics.copyWith( + minScrollExtent: -100, + maxScrollExtent: 240, + axisDirection: AxisDirection.down, + ); + + final ScrollbarPainter p = _buildPainter( + padding: padding, + scrollMetrics: metrics, + ); + + testWidgets('down', (WidgetTester tester) async { + p.update( + metrics.copyWith( + viewportDimension: size.height, + pixels: double.negativeInfinity, + ), + AxisDirection.down, + ); + + // Top overscroll. + p.paint(testCanvas, size); + final Rect rect0 = captureRect(); + expect(rect0.top, padding.top); + expect(size.width - rect0.right, padding.right); + + // Bottom overscroll. + p.update( + metrics.copyWith( + viewportDimension: size.height, + pixels: double.infinity, + ), + AxisDirection.down, + ); + + p.paint(testCanvas, size); + final Rect rect1 = captureRect(); + expect(size.height - rect1.bottom, padding.bottom); + expect(size.width - rect1.right, padding.right); + }); + + testWidgets('up', (WidgetTester tester) async { + p.update( + metrics.copyWith( + viewportDimension: size.height, + pixels: double.infinity, + axisDirection: AxisDirection.up, + ), + AxisDirection.up, + ); + + // Top overscroll. + p.paint(testCanvas, size); + final Rect rect0 = captureRect(); + expect(rect0.top, padding.top); + expect(size.width - rect0.right, padding.right); + + // Bottom overscroll. + p.update( + metrics.copyWith( + viewportDimension: size.height, + pixels: double.negativeInfinity, + axisDirection: AxisDirection.up, + ), + AxisDirection.up, + ); + + p.paint(testCanvas, size); + final Rect rect1 = captureRect(); + expect(size.height - rect1.bottom, padding.bottom); + expect(size.width - rect1.right, padding.right); + }); + + testWidgets('left', (WidgetTester tester) async { + p.update( + metrics.copyWith( + viewportDimension: size.width, + pixels: double.negativeInfinity, + axisDirection: AxisDirection.left, + ), + AxisDirection.left, + ); + + // Right overscroll. + p.paint(testCanvas, size); + final Rect rect0 = captureRect(); + expect(size.height - rect0.bottom, padding.bottom); + expect(size.width - rect0.right, padding.right); + + // Left overscroll. + p.update( + metrics.copyWith( + viewportDimension: size.width, + pixels: double.infinity, + axisDirection: AxisDirection.left, + ), + AxisDirection.left, + ); + + p.paint(testCanvas, size); + final Rect rect1 = captureRect(); + expect(size.height - rect1.bottom, padding.bottom); + expect(rect1.left, padding.left); + }); + + testWidgets('right', (WidgetTester tester) async { + p.update( + metrics.copyWith( + viewportDimension: size.width, + pixels: double.infinity, + axisDirection: AxisDirection.right, + ), + AxisDirection.right, + ); + + // Right overscroll. + p.paint(testCanvas, size); + final Rect rect0 = captureRect(); + expect(size.height - rect0.bottom, padding.bottom); + expect(size.width - rect0.right, padding.right); + + // Left overscroll. + p.update( + metrics.copyWith( + viewportDimension: size.width, + pixels: double.negativeInfinity, + axisDirection: AxisDirection.right, + ), + AxisDirection.right, + ); + + p.paint(testCanvas, size); + final Rect rect1 = captureRect(); + expect(size.height - rect1.bottom, padding.bottom); + expect(rect1.left, padding.left); + }); + }); + + test('should scroll towards the right direction', + () { + const Size size = Size(60, 80); + const double maxScrollExtent = 240; + const double minScrollExtent = -100; + final ScrollMetrics startingMetrics = defaultMetrics.copyWith( + minScrollExtent: minScrollExtent, + maxScrollExtent: maxScrollExtent, + axisDirection: AxisDirection.down, + viewportDimension: size.height, + ); + + for(double minLength in [_kMinThumbExtent, double.infinity]) { + // Disregard `minLength` and `minOverscrollLength` to keep + // scroll direction correct, if needed + painter = _buildPainter( + minLength: minLength, + minOverscrollLength: minLength, + scrollMetrics: startingMetrics, + ); + + final Iterable metricsList = Iterable.generate( + 9999, + (int index) => startingMetrics.copyWith(pixels: minScrollExtent + index * size.height / 3) + ) + .takeWhile((ScrollMetrics metrics) => !metrics.outOfRange); + + Rect previousRect; + + for(ScrollMetrics metrics in metricsList) { + painter.update(metrics, metrics.axisDirection); + painter.paint(testCanvas, size); + final Rect rect = captureRect(); + + if (previousRect != null) { + if (rect.height == size.height) { + // Size of the scrollbar is too large for the view port + expect(previousRect.top <= rect.top, true); + expect(previousRect.bottom <= rect.bottom, true); + } else { + // The scrollbar can fit in the view port. + expect(previousRect.top < rect.top, true); + expect(previousRect.bottom < rect.bottom, true); + } + } + + previousRect = rect; + } + } + } + ); +} diff --git a/packages/flutter/test/widgets/slivers_evil_test.dart b/packages/flutter/test/widgets/slivers_evil_test.dart index f03e416f4ce..ab087f6f0b3 100644 --- a/packages/flutter/test/widgets/slivers_evil_test.dart +++ b/packages/flutter/test/widgets/slivers_evil_test.dart @@ -73,94 +73,99 @@ class TestViewportScrollPosition extends ScrollPositionWithSingleContext { void main() { testWidgets('Evil test of sliver features - 1', (WidgetTester tester) async { final GlobalKey centerKey = GlobalKey(); - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: ScrollConfiguration( - behavior: TestBehavior(), - child: Scrollbar( - child: Scrollable( - axisDirection: AxisDirection.down, - physics: const TestScrollPhysics(), - viewportBuilder: (BuildContext context, ViewportOffset offset) { - return Viewport( + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(), + child: Directionality( + textDirection: TextDirection.ltr, + child: ScrollConfiguration( + behavior: TestBehavior(), + child: Scrollbar( + child: Scrollable( axisDirection: AxisDirection.down, - anchor: 0.25, - offset: offset, - center: centerKey, - slivers: [ - SliverToBoxAdapter(child: Container(height: 5.0)), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(150.0), pinned: true), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverPadding( - padding: const EdgeInsets.all(50.0), - sliver: SliverToBoxAdapter(child: Container(height: 520.0)), - ), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(150.0), floating: true), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverToBoxAdapter(key: centerKey, child: Container(height: 520.0)), // ------------------------ CENTER ------------------------ - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(150.0), pinned: true), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverPadding( - padding: const EdgeInsets.all(50.0), - sliver: SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0), pinned: true), - ), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0), pinned: true), - SliverToBoxAdapter(child: Container(height: 5.0)), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0), pinned: true), - SliverToBoxAdapter(child: Container(height: 5.0)), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0), pinned: true), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0), pinned: true), - SliverToBoxAdapter(child: Container(height: 5.0)), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0), pinned: true), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0)), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(150.0), floating: true), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(150.0), floating: true), - SliverToBoxAdapter(child: Container(height: 5.0)), - SliverList( - delegate: SliverChildListDelegate([ - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - Container(height: 50.0), - ]), - ), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0)), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0)), - SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0)), - SliverPadding( - padding: const EdgeInsets.symmetric(horizontal: 50.0), - sliver: SliverToBoxAdapter(child: Container(height: 520.0)), - ), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverToBoxAdapter(child: Container(height: 520.0)), - SliverToBoxAdapter(child: Container(height: 5.0)), - ], - ); - }, + physics: const TestScrollPhysics(), + viewportBuilder: (BuildContext context, ViewportOffset offset) { + return Viewport( + axisDirection: AxisDirection.down, + anchor: 0.25, + offset: offset, + center: centerKey, + slivers: [ + SliverToBoxAdapter(child: Container(height: 5.0)), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(150.0), pinned: true), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverPadding( + padding: const EdgeInsets.all(50.0), + sliver: SliverToBoxAdapter(child: Container(height: 520.0)), + ), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(150.0), floating: true), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverToBoxAdapter(key: centerKey, child: Container(height: 520.0)), // ------------------------ CENTER ------------------------ + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(150.0), pinned: true), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverPadding( + padding: const EdgeInsets.all(50.0), + sliver: SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0), pinned: true), + ), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0), pinned: true), + SliverToBoxAdapter(child: Container(height: 5.0)), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0), pinned: true), + SliverToBoxAdapter(child: Container(height: 5.0)), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0), pinned: true), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0), pinned: true), + SliverToBoxAdapter(child: Container(height: 5.0)), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0), pinned: true), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0)), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(150.0), floating: true), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(150.0), floating: true), + SliverToBoxAdapter(child: Container(height: 5.0)), + SliverList( + delegate: SliverChildListDelegate([ + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + Container(height: 50.0), + ]), + ), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0)), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0)), + SliverPersistentHeader(delegate: TestSliverPersistentHeaderDelegate(250.0)), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 50.0), + sliver: SliverToBoxAdapter(child: Container(height: 520.0)), + ), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverToBoxAdapter(child: Container(height: 520.0)), + SliverToBoxAdapter(child: Container(height: 5.0)), + ], + ); + }, + ), + ), ), ), ), - )); + ); final ScrollPosition position = tester.state(find.byType(Scrollable)).position; position.animateTo(10000.0, curve: Curves.linear, duration: const Duration(minutes: 1));