Unverified Commit 22ea031e authored by LongCatIsLooong's avatar LongCatIsLooong Committed by GitHub

Fix ScrollbarPainter thumbExtent calculation and add padding (#31763)

- Fixed extentInside calculation in ScrollMetrics
- Added asserts to extentInside getter, as well as ScrollPosition.applyContentDimensions to enforce minScrollExtent <= maxScrollExtent
- Added padding to ScrollbarPainter, updated implementation. Took care of some edge cases.
- Changed some scroll bar constants on Cupertino side.
parent c926aae4
......@@ -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<CupertinoScrollbar> with TickerProv
mainAxisMargin: _kScrollbarMainAxisMargin,
crossAxisMargin: _kScrollbarCrossAxisMargin,
radius: _kScrollbarRadius,
padding: MediaQuery.of(context).padding,
minLength: _kScrollbarMinLength,
minOverscrollLength: _kScrollbarMinOverscrollLength,
);
......
......@@ -104,6 +104,7 @@ class _ScrollbarState extends State<Scrollbar> with TickerProviderStateMixin {
textDirection: _textDirection,
thickness: _kScrollbarThickness,
fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
padding: MediaQuery.of(context).padding,
);
}
......
......@@ -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
......
......@@ -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);
}
......
......@@ -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;
......
......@@ -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<double> 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;
}
return null;
}
void _paintThumbCrossAxis(Canvas canvas, Size size, double thumbOffset, double thumbExtent, AxisDirection direction) {
double x, y;
Size thumbSize;
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;
if (radius == null)
canvas.drawRect(thumbRect, _paint);
else
canvas.drawRRect(RRect.fromRectAndRadius(thumbRect, radius), _paint);
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;
}
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;
final Rect thumbRect = Offset(x, y) & 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);
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,
// contentExtent >= viewportDimension, so (contentExtent - mainAxisPadding) > 0
final double fractionVisible = ((extentInside - mainAxisPadding) / (contentExtent - mainAxisPadding))
.clamp(0.0, 1.0);
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),
);
}
}
final double fractionPast = before / (before + after);
final double thumbOffset = (before + after > 0.0)
? fractionPast * (viewport - thumbExtent - 2 * mainAxisMargin) + mainAxisMargin
: mainAxisMargin;
: safeMinLength * ((fractionVisible - 0.8).clamp(0.0, 0.2) / 0.2);
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
......
......@@ -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(
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 <Widget> [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),
),
......
......@@ -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(
await tester.pumpWidget(
const Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData(),
child: CupertinoScrollbar(
child: SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0),
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)),
));
});
}
......@@ -7,15 +7,26 @@ import 'package:flutter/material.dart';
import '../rendering/mock_canvas.dart';
void main() {
testWidgets('Viewport basic test (LTR)', (WidgetTester tester) async {
await tester.pumpWidget(const Directionality(
textDirection: TextDirection.ltr,
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: SizedBox(width: 4000.0, height: 4000.0),
child: SingleChildScrollView(child: child),
),
),
);
}
void main() {
testWidgets('Viewport basic test (LTR)', (WidgetTester tester) async {
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 <Widget>[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),
),
));
});
}
......@@ -19,10 +19,24 @@ 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,
await tester.pumpWidget(
_buildBoilerplate(
child: Center(
child: Container(
decoration: BoxDecoration(
......@@ -45,8 +59,9 @@ void main() {
),
),
),
),
));
)
)
);
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
......
// 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<ScrollMetrics> metricsList =
<ScrollMetrics> [startingMetrics.copyWith(pixels: 0.01)]
..addAll(List<ScrollMetrics>.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<double> margins = <double> [-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 <double> [_kMinThumbExtent, double.infinity]) {
// Disregard `minLength` and `minOverscrollLength` to keep
// scroll direction correct, if needed
painter = _buildPainter(
minLength: minLength,
minOverscrollLength: minLength,
scrollMetrics: startingMetrics,
);
final Iterable<ScrollMetrics> metricsList = Iterable<ScrollMetrics>.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;
}
}
}
);
}
......@@ -73,7 +73,10 @@ class TestViewportScrollPosition extends ScrollPositionWithSingleContext {
void main() {
testWidgets('Evil test of sliver features - 1', (WidgetTester tester) async {
final GlobalKey centerKey = GlobalKey();
await tester.pumpWidget(Directionality(
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(),
child: Directionality(
textDirection: TextDirection.ltr,
child: ScrollConfiguration(
behavior: TestBehavior(),
......@@ -160,7 +163,9 @@ void main() {
),
),
),
));
),
),
);
final ScrollPosition position = tester.state<ScrollableState>(find.byType(Scrollable)).position;
position.animateTo(10000.0, curve: Curves.linear, duration: const Duration(minutes: 1));
......
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