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'; ...@@ -8,15 +8,22 @@ import 'package:flutter/widgets.dart';
// All values eyeballed. // All values eyeballed.
const Color _kScrollbarColor = Color(0x99777777); 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 _kScrollbarMinLength = 36.0;
const double _kScrollbarMinOverscrollLength = 8.0; const double _kScrollbarMinOverscrollLength = 8.0;
const Radius _kScrollbarRadius = Radius.circular(1.25); const Radius _kScrollbarRadius = Radius.circular(1.25);
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 50); const Duration _kScrollbarTimeToFade = Duration(milliseconds: 50);
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 250); 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. /// An iOS style scrollbar.
/// ///
/// A scrollbar indicates which portion of a [Scrollable] widget is actually /// A scrollbar indicates which portion of a [Scrollable] widget is actually
...@@ -89,6 +96,7 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv ...@@ -89,6 +96,7 @@ class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProv
mainAxisMargin: _kScrollbarMainAxisMargin, mainAxisMargin: _kScrollbarMainAxisMargin,
crossAxisMargin: _kScrollbarCrossAxisMargin, crossAxisMargin: _kScrollbarCrossAxisMargin,
radius: _kScrollbarRadius, radius: _kScrollbarRadius,
padding: MediaQuery.of(context).padding,
minLength: _kScrollbarMinLength, minLength: _kScrollbarMinLength,
minOverscrollLength: _kScrollbarMinOverscrollLength, minOverscrollLength: _kScrollbarMinOverscrollLength,
); );
......
...@@ -104,6 +104,7 @@ class _ScrollbarState extends State<Scrollbar> with TickerProviderStateMixin { ...@@ -104,6 +104,7 @@ class _ScrollbarState extends State<Scrollbar> with TickerProviderStateMixin {
textDirection: _textDirection, textDirection: _textDirection,
thickness: _kScrollbarThickness, thickness: _kScrollbarThickness,
fadeoutOpacityAnimation: _fadeoutOpacityAnimation, fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
padding: MediaQuery.of(context).padding,
); );
} }
......
...@@ -123,8 +123,9 @@ abstract class ViewportOffset extends ChangeNotifier { ...@@ -123,8 +123,9 @@ abstract class ViewportOffset extends ChangeNotifier {
/// Called when the viewport's content extents are established. /// Called when the viewport's content extents are established.
/// ///
/// The arguments are the minimum and maximum scroll extents respectively. The /// 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 /// minimum will be equal to or less than the maximum. In the case of slivers,
/// or greater than zero. /// 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. /// The maximum scroll extent has the viewport dimension subtracted from it.
/// For instance, if there is 100.0 pixels of scrollable content, and the /// For instance, if there is 100.0 pixels of scrollable content, and the
......
...@@ -60,14 +60,16 @@ abstract class ScrollMetrics { ...@@ -60,14 +60,16 @@ abstract class ScrollMetrics {
/// ///
/// The actual [pixels] value might be [outOfRange]. /// 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; double get minScrollExtent;
/// The maximum in-range value for [pixels]. /// The maximum in-range value for [pixels].
/// ///
/// The actual [pixels] value might be [outOfRange]. /// 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; double get maxScrollExtent;
/// The current scroll position, in logical pixels along the [axisDirection]. /// The current scroll position, in logical pixels along the [axisDirection].
...@@ -90,25 +92,27 @@ abstract class ScrollMetrics { ...@@ -90,25 +92,27 @@ abstract class ScrollMetrics {
/// [maxScrollExtent]. /// [maxScrollExtent].
bool get atEdge => pixels == minScrollExtent || pixels == maxScrollExtent; bool get atEdge => pixels == minScrollExtent || pixels == maxScrollExtent;
/// The quantity of content conceptually "above" the currently visible content /// The quantity of content conceptually "above" the viewport in the scrollable.
/// of the viewport in the scrollable. This is the content above the content /// This is the content above the content described by [extentInside].
/// described by [extentInside].
double get extentBefore => math.max(pixels - minScrollExtent, 0.0); 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 value is typically the height of the viewport when [outOfRange] is false.
/// the height of the viewport. It could be less if there is less content /// It could be less if there is less content visible than the size of the
/// visible than the size of the viewport. /// viewport, such as when overscrolling.
///
/// The value is always non-negative, and less than or equal to [viewportDimension].
double get extentInside { double get extentInside {
return math.min(pixels, maxScrollExtent) - return viewportDimension
math.max(pixels, minScrollExtent) + // "above" overscroll value
math.min(viewportDimension, maxScrollExtent - minScrollExtent); - (minScrollExtent - pixels).clamp(0, viewportDimension)
// "below" overscroll value
- (pixels - maxScrollExtent).clamp(0, viewportDimension);
} }
/// The quantity of content conceptually "below" the currently visible content /// The quantity of content conceptually "below" the viewport in the scrollable.
/// of the viewport in the scrollable. This is the content below the content /// This is the content below the content described by [extentInside].
/// described by [extentInside].
double get extentAfter => math.max(maxScrollExtent - pixels, 0.0); double get extentAfter => math.max(maxScrollExtent - pixels, 0.0);
} }
......
...@@ -450,6 +450,9 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics { ...@@ -450,6 +450,9 @@ abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
if (!nearEqual(_minScrollExtent, minScrollExtent, Tolerance.defaultTolerance.distance) || if (!nearEqual(_minScrollExtent, minScrollExtent, Tolerance.defaultTolerance.distance) ||
!nearEqual(_maxScrollExtent, maxScrollExtent, Tolerance.defaultTolerance.distance) || !nearEqual(_maxScrollExtent, maxScrollExtent, Tolerance.defaultTolerance.distance) ||
_didChangeViewportDimensionOrReceiveCorrection) { _didChangeViewportDimensionOrReceiveCorrection) {
assert(minScrollExtent != null);
assert(maxScrollExtent != null);
assert(minScrollExtent <= maxScrollExtent);
_minScrollExtent = minScrollExtent; _minScrollExtent = minScrollExtent;
_maxScrollExtent = maxScrollExtent; _maxScrollExtent = maxScrollExtent;
_haveDimensions = true; _haveDimensions = true;
......
...@@ -7,32 +7,90 @@ import 'package:flutter_test/flutter_test.dart'; ...@@ -7,32 +7,90 @@ import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.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() { void main() {
testWidgets('Paints iOS spec', (WidgetTester tester) async { testWidgets('Paints iOS spec', (WidgetTester tester) async {
await tester.pumpWidget(const Directionality( await tester.pumpWidget(
textDirection: TextDirection.ltr, const Directionality(
child: CupertinoScrollbar( textDirection: TextDirection.ltr,
child: SingleChildScrollView( child: MediaQuery(
child: SizedBox(width: 4000.0, height: 4000.0), data: MediaQueryData(),
child: CupertinoScrollbar(
child: SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0),
),
),
), ),
), ),
)); );
expect(find.byType(CupertinoScrollbar), isNot(paints..rrect())); expect(find.byType(CupertinoScrollbar), isNot(paints..rrect()));
final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView))); 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. // 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();
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(CupertinoScrollbar), paints..rrect( expect(find.byType(CupertinoScrollbar), paints..rrect(
color: const Color(0x99777777), color: _kScrollbarColor,
rrect: RRect.fromRectAndRadius( rrect: RRect.fromRectAndRadius(
const Rect.fromLTWH( const Rect.fromLTWH(
800.0 - 2.5 - 2.5, // Screen width - margin - thickness. 800.0 - 3 - 2.5, // Screen width - margin - thickness.
4.0, // Initial position is the top margin. 3.0, // Initial position is the top margin.
2.5, // Thickness. 2.5, // Thickness.
// Fraction in viewport * scrollbar height - top, bottom margin. // 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), const Radius.circular(1.25),
), ),
......
...@@ -9,14 +9,17 @@ import '../rendering/mock_canvas.dart'; ...@@ -9,14 +9,17 @@ import '../rendering/mock_canvas.dart';
void main() { void main() {
testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async { testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async {
await tester.pumpWidget(const Directionality( await tester.pumpWidget(
textDirection: TextDirection.ltr, const Directionality(
child: CupertinoScrollbar( textDirection: TextDirection.ltr,
child: SingleChildScrollView( child: MediaQuery(
child: SizedBox(width: 4000.0, height: 4000.0), 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))); final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(SingleChildScrollView)));
await gesture.moveBy(const Offset(0.0, -10.0)); await gesture.moveBy(const Offset(0.0, -10.0));
await tester.pump(); await tester.pump();
...@@ -42,25 +45,4 @@ void main() { ...@@ -42,25 +45,4 @@ void main() {
color: const Color(0x15777777), 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'; ...@@ -7,15 +7,26 @@ import 'package:flutter/material.dart';
import '../rendering/mock_canvas.dart'; import '../rendering/mock_canvas.dart';
void main() { Widget _buildSingleChildScrollViewWithScrollbar({
testWidgets('Viewport basic test (LTR)', (WidgetTester tester) async { TextDirection textDirection = TextDirection.ltr,
await tester.pumpWidget(const Directionality( EdgeInsets padding = EdgeInsets.zero,
textDirection: TextDirection.ltr, Widget child}
) {
return Directionality(
textDirection: textDirection,
child: MediaQuery(
data: MediaQueryData(padding: padding),
child: Scrollbar( child: Scrollbar(
child: SingleChildScrollView( child: SingleChildScrollView(child: child),
child: SizedBox(width: 4000.0, height: 4000.0),
),
), ),
),
);
}
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())); expect(find.byType(Scrollbar), isNot(paints..rect()));
await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0); await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0);
...@@ -23,16 +34,47 @@ void main() { ...@@ -23,16 +34,47 @@ void main() {
}); });
testWidgets('Viewport basic test (RTL)', (WidgetTester tester) async { testWidgets('Viewport basic test (RTL)', (WidgetTester tester) async {
await tester.pumpWidget(const Directionality( await tester.pumpWidget(_buildSingleChildScrollViewWithScrollbar(
textDirection: TextDirection.rtl, textDirection: TextDirection.rtl,
child: Scrollbar( child: const SizedBox(width: 4000.0, height: 4000.0),
child: SingleChildScrollView(
child: SizedBox(width: 4000.0, height: 4000.0),
),
),
)); ));
expect(find.byType(Scrollbar), isNot(paints..rect())); expect(find.byType(Scrollbar), isNot(paints..rect()));
await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0); await tester.fling(find.byType(SingleChildScrollView), const Offset(0.0, -10.0), 10.0);
expect(find.byType(Scrollbar), paints..rect(rect: const Rect.fromLTRB(0.0, 1.5, 6.0, 91.5))); expect(find.byType(Scrollbar), paints..rect(rect: const Rect.fromLTRB(0.0, 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,34 +19,49 @@ class TestCanvas implements Canvas { ...@@ -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() { void main() {
testWidgets('Scrollbar doesn\'t show when tapping list', (WidgetTester tester) async { testWidgets('Scrollbar doesn\'t show when tapping list', (WidgetTester tester) async {
await tester.pumpWidget(Directionality( await tester.pumpWidget(
textDirection: TextDirection.ltr, _buildBoilerplate(
child: Center( child: Center(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFFFFF00)) border: Border.all(color: const Color(0xFFFFFF00))
), ),
height: 200.0, height: 200.0,
width: 300.0, width: 300.0,
child: Scrollbar( child: Scrollbar(
child: ListView( child: ListView(
children: <Widget>[ children: <Widget>[
Container(height: 40.0, child: const Text('0')), Container(height: 40.0, child: const Text('0')),
Container(height: 40.0, child: const Text('1')), Container(height: 40.0, child: const Text('1')),
Container(height: 40.0, child: const Text('2')), Container(height: 40.0, child: const Text('2')),
Container(height: 40.0, child: const Text('3')), Container(height: 40.0, child: const Text('3')),
Container(height: 40.0, child: const Text('4')), Container(height: 40.0, child: const Text('4')),
Container(height: 40.0, child: const Text('5')), Container(height: 40.0, child: const Text('5')),
Container(height: 40.0, child: const Text('6')), Container(height: 40.0, child: const Text('6')),
Container(height: 40.0, child: const Text('7')), Container(height: 40.0, child: const Text('7')),
], ],
),
), ),
), ),
), )
), )
)); );
SchedulerBinding.instance.debugAssertNoTransientCallbacks('Building a list with a scrollbar triggered an animation.'); SchedulerBinding.instance.debugAssertNoTransientCallbacks('Building a list with a scrollbar triggered an animation.');
await tester.tap(find.byType(ListView)); await tester.tap(find.byType(ListView));
...@@ -64,9 +79,8 @@ void main() { ...@@ -64,9 +79,8 @@ void main() {
}); });
testWidgets('ScrollbarPainter does not divide by zero', (WidgetTester tester) async { testWidgets('ScrollbarPainter does not divide by zero', (WidgetTester tester) async {
await tester.pumpWidget(Directionality( await tester.pumpWidget(
textDirection: TextDirection.ltr, _buildBoilerplate(child: Container(
child: Container(
height: 200.0, height: 200.0,
width: 300.0, width: 300.0,
child: Scrollbar( child: Scrollbar(
...@@ -76,8 +90,8 @@ void main() { ...@@ -76,8 +90,8 @@ void main() {
], ],
), ),
), ),
), ))
)); );
final CustomPaint custom = tester.widget(find.descendant( final CustomPaint custom = tester.widget(find.descendant(
of: find.byType(Scrollbar), of: find.byType(Scrollbar),
...@@ -107,8 +121,7 @@ void main() { ...@@ -107,8 +121,7 @@ void main() {
testWidgets('Adaptive scrollbar', (WidgetTester tester) async { testWidgets('Adaptive scrollbar', (WidgetTester tester) async {
Widget viewWithScroll(TargetPlatform platform) { Widget viewWithScroll(TargetPlatform platform) {
return Directionality( return _buildBoilerplate(
textDirection: TextDirection.ltr,
child: Theme( child: Theme(
data: ThemeData( data: ThemeData(
platform: platform platform: platform
......
This diff is collapsed.
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