Unverified Commit 4fcdb50d authored by xster's avatar xster Committed by GitHub

Add Cupertino scrollbar (#13290)

* Create CupertinoScrollbar

* handle main axis margin

* Adaptive material scrollbar and tests

* Small tweaks

* reapply changes on head

* Docs

* start

* Refactored ScrollbarPainter to be more immutable

* fix tests

* fix bug: one animationcontroller pointed to multiple painters

* some docs tweak

* remove unused import

* review

* review

* add dispose
parent 4e13cd07
......@@ -16,6 +16,7 @@ export 'src/cupertino/icons.dart';
export 'src/cupertino/nav_bar.dart';
export 'src/cupertino/page_scaffold.dart';
export 'src/cupertino/route.dart';
export 'src/cupertino/scrollbar.dart';
export 'src/cupertino/slider.dart';
export 'src/cupertino/switch.dart';
export 'src/cupertino/tab_scaffold.dart';
......
// Copyright 2017 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 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
// All values eyeballed.
const Color _kScrollbarColor = const Color(0x99777777);
const double _kScrollbarThickness = 2.5;
const double _kScrollbarMainAxisMargin = 4.0;
const double _kScrollbarCrossAxisMargin = 2.5;
const double _kScrollbarMinLength = 4.0;
const Radius _kScrollbarRadius = const Radius.circular(1.25);
const Duration _kScrollbarTimeToFade = const Duration(milliseconds: 50);
const Duration _kScrollbarFadeDuration = const Duration(milliseconds: 250);
/// A iOS style scrollbar.
///
/// A scrollbar indicates which portion of a [Scrollable] widget is actually
/// visible.
///
/// To add a scrollbar to a [ScrollView], simply wrap the scroll view widget in
/// a [CupertinoScrollbar] widget.
///
/// See also:
///
/// * [ListView], which display a linear, scrollable list of children.
/// * [GridView], which display a 2 dimensional, scrollable array of children.
/// * [Scrollbar], a Material Design scrollbar that dynamically adapts to the
/// platform showing either an Android style or iOS style scrollbar.
class CupertinoScrollbar extends StatefulWidget {
/// Creates an iOS style scrollbar that wraps the given [child].
///
/// The [child] should be a source of [ScrollNotification] notifications,
/// typically a [Scrollable] widget.
const CupertinoScrollbar({
Key key,
@required this.child,
}) : super(key: key);
/// The subtree to place inside the [CupertinoScrollbar].
///
/// This should include a source of [ScrollNotification] notifications,
/// typically a [Scrollable] widget.
final Widget child;
@override
_CupertinoScrollbarState createState() => new _CupertinoScrollbarState();
}
class _CupertinoScrollbarState extends State<CupertinoScrollbar> with TickerProviderStateMixin {
ScrollbarPainter _painter;
TextDirection _textDirection;
AnimationController _fadeoutAnimationController;
Animation<double> _fadeoutOpacityAnimation;
Timer _fadeoutTimer;
@override
void initState() {
super.initState();
_fadeoutAnimationController = new AnimationController(
vsync: this,
duration: _kScrollbarFadeDuration,
);
_fadeoutOpacityAnimation = new CurvedAnimation(
parent: _fadeoutAnimationController,
curve: Curves.fastOutSlowIn
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_textDirection = Directionality.of(context);
_painter = _buildCupertinoScrollbarPainter();
}
/// Returns a [ScrollbarPainter] visually styled like the iOS scrollbar.
ScrollbarPainter _buildCupertinoScrollbarPainter() {
return new ScrollbarPainter(
color: _kScrollbarColor,
textDirection: _textDirection,
thickness: _kScrollbarThickness,
fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
mainAxisMargin: _kScrollbarMainAxisMargin,
crossAxisMargin: _kScrollbarCrossAxisMargin,
radius: _kScrollbarRadius,
minLength: _kScrollbarMinLength,
);
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification is ScrollUpdateNotification ||
notification is OverscrollNotification) {
// Any movements always makes the scrollbar start showing up.
if (_fadeoutAnimationController.status != AnimationStatus.forward) {
_fadeoutAnimationController.forward();
}
_fadeoutTimer?.cancel();
_painter.update(notification.metrics, notification.metrics.axisDirection);
} else if (notification is ScrollEndNotification) {
// On iOS, the scrollbar can only go away once the user lifted the finger.
_fadeoutTimer?.cancel();
_fadeoutTimer = new Timer(_kScrollbarTimeToFade, () {
_fadeoutAnimationController.reverse();
_fadeoutTimer = null;
});
}
return false;
}
@override
void dispose() {
_fadeoutAnimationController.dispose();
_fadeoutTimer?.cancel();
_painter.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return new NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: new RepaintBoundary(
child: new CustomPaint(
foregroundPainter: _painter,
child: new RepaintBoundary(
child: widget.child,
),
),
),
);
}
}
// 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 'dart:math' as math;
import 'package:flutter/animation.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'scroll_metrics.dart';
const double _kMinThumbExtent = 18.0;
/// A [CustomPainter] for painting scrollbars.
///
/// 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
/// rebuilding when:
///
/// * the scroll position changes; and
/// * when the scrollbar fades away.
///
/// Calling [update] with the new [ScrollMetrics] will repaint the new scrollbar
/// position.
///
/// Updating the value on the provided [fadeoutOpacityAnimation] will repaint
/// with the new opacity.
///
/// You must call [dispose] on this [ScrollbarPainter] when it's no longer used.
///
/// See also:
///
/// * [Scrollbar] for a widget showing a scrollbar around a [Scrollable] in the
/// Material Design style.
/// * [CupertinoScrollbar] for a widget showing a scrollbar around a
/// [Scrollable] in the iOS style.
class ScrollbarPainter extends ChangeNotifier implements CustomPainter {
/// Creates a scrollbar with customizations given by construction arguments.
ScrollbarPainter({
@required this.color,
@required this.textDirection,
@required this.thickness,
@required this.fadeoutOpacityAnimation,
this.mainAxisMargin: 0.0,
this.crossAxisMargin: 0.0,
this.radius,
this.minLength: _kMinThumbExtent,
}) : assert(color != null),
assert(textDirection != null),
assert(thickness != null),
assert(fadeoutOpacityAnimation != null),
assert(mainAxisMargin != null),
assert(crossAxisMargin != null),
assert(minLength != null) {
fadeoutOpacityAnimation.addListener(notifyListeners);
}
/// [Color] of the thumb. Mustn't be null.
final Color color;
/// [TextDirection] of the [BuildContext] which dictates the side of the
/// 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.
final double thickness;
/// An opacity [Animation] that dictates the opacity of the thumb.
/// Changes in value of this [Listenable] will automatically trigger repaints.
/// 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.
final double mainAxisMargin;
/// Distance from the scrollbar's side to the nearest edge in pixels. Musn't
/// be null.
final double crossAxisMargin;
/// [Radius] of corners if the scrollbar should have rounded corners.
///
/// 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. Mustn't be
/// null.
final double minLength;
ScrollMetrics _lastMetrics;
AxisDirection _lastAxisDirection;
/// Update with new [ScrollMetrics]. The scrollbar will show and redraw itself
/// based on these new metrics.
///
/// The scrollbar will remain on screen.
void update(
ScrollMetrics metrics,
AxisDirection axisDirection,
) {
_lastMetrics = metrics;
_lastAxisDirection = axisDirection;
notifyListeners();
}
Paint get _paint {
return new Paint()..color =
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 _paintVerticalThumb(Canvas canvas, Size size, double thumbOffset, double thumbExtent) {
final Offset thumbOrigin = new Offset(_getThumbX(size), thumbOffset);
final Size thumbSize = new Size(thickness, thumbExtent);
final Rect thumbRect = thumbOrigin & thumbSize;
if (radius == null)
canvas.drawRect(thumbRect, _paint);
else
canvas.drawRRect(new RRect.fromRectAndRadius(thumbRect, radius), _paint);
}
void _paintHorizontalThumb(Canvas canvas, Size size, double thumbOffset, double thumbExtent) {
final Offset thumbOrigin = new Offset(thumbOffset, size.height - thickness);
final Size thumbSize = new Size(thumbExtent, thickness);
final Rect thumbRect = thumbOrigin & thumbSize;
if (radius == null)
canvas.drawRect(thumbRect, _paint);
else
canvas.drawRRect(new 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),
) {
// Establish the minimum size possible.
double thumbExtent = math.min(viewport, minLength);
if (before + inside + after > 0.0) {
final double fractionVisible = inside / (before + inside + after);
thumbExtent = math.max(
thumbExtent,
viewport * fractionVisible - 2 * mainAxisMargin,
);
}
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);
}
@override
void dispose() {
fadeoutOpacityAnimation.removeListener(notifyListeners);
super.dispose();
}
@override
void paint(Canvas canvas, Size size) {
if (_lastAxisDirection == null
|| _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;
}
}
// Scrollbars are (currently) not interactive.
@override
bool hitTest(Offset position) => null;
@override
bool shouldRepaint(ScrollbarPainter old) {
// Should repaint if any properties changed.
return color != old.color
|| textDirection != old.textDirection
|| thickness != old.thickness
|| fadeoutOpacityAnimation != old.fadeoutOpacityAnimation
|| mainAxisMargin != old.mainAxisMargin
|| crossAxisMargin != old.crossAxisMargin
|| radius != old.radius
|| minLength != old.minLength;
}
@override
bool shouldRebuildSemantics(CustomPainter oldDelegate) => false;
@override
SemanticsBuilderCallback get semanticsBuilder => null;
}
\ No newline at end of file
......@@ -77,6 +77,7 @@ export 'src/widgets/scroll_position_with_single_context.dart';
export 'src/widgets/scroll_simulation.dart';
export 'src/widgets/scroll_view.dart';
export 'src/widgets/scrollable.dart';
export 'src/widgets/scrollbar.dart';
export 'src/widgets/semantics_debugger.dart';
export 'src/widgets/single_child_scroll_view.dart';
export 'src/widgets/size_changed_layout_notifier.dart';
......
// Copyright 2017 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/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
void main() {
testWidgets('Paints iOS spec', (WidgetTester tester) async {
await tester.pumpWidget(new Directionality(
textDirection: TextDirection.ltr,
child: new CupertinoScrollbar(
child: new SingleChildScrollView(
child: const 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));
// Move back to original position.
await gesture.moveBy(const Offset(0.0, 10.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(CupertinoScrollbar), paints..rrect(
color: const Color(0x99777777),
rrect: new RRect.fromRectAndRadius(
new Rect.fromLTWH(
800.0 - 2.5 - 2.5, // Screen width - margin - thickness.
4.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,
),
const Radius.circular(1.25),
),
));
});
}
// Copyright 2017 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/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
void main() {
testWidgets('Scrollbar never goes away until finger lift', (WidgetTester tester) async {
await tester.pumpWidget(new Directionality(
textDirection: TextDirection.ltr,
child: new CupertinoScrollbar(
child: new SingleChildScrollView(
child: const 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();
// Scrollbar fully showing
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(CupertinoScrollbar), paints..rrect(
color: const Color(0x99777777),
));
await tester.pump(const Duration(seconds: 3));
await tester.pump(const Duration(seconds: 3));
// Still there.
expect(find.byType(CupertinoScrollbar), paints..rrect(
color: const Color(0x99777777),
));
await gesture.up();
await tester.pump(const Duration(milliseconds: 200));
await tester.pump(const Duration(milliseconds: 200));
// Opacity going down now.
expect(find.byType(CupertinoScrollbar), paints..rrect(
color: const Color(0x15777777),
));
});
}
......@@ -6,6 +6,8 @@ import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_test/flutter_test.dart';
import '../rendering/mock_canvas.dart';
class TestCanvas implements Canvas {
TestCanvas([this.invocations]);
......@@ -77,8 +79,16 @@ void main() {
),
));
final CustomPaint custom = tester.widget(find.descendant(of: find.byType(Scrollbar), matching: find.byType(CustomPaint)).first);
final CustomPaint custom = tester.widget(find.descendant(
of: find.byType(Scrollbar),
matching: find.byType(CustomPaint)).first
);
final dynamic scrollPainter = custom.foregroundPainter;
// Dragging makes the scrollbar first appear.
await tester.drag(find.text('0'), const Offset(0.0, -10.0));
await tester.pump(const Duration(milliseconds: 200));
await tester.pump(const Duration(milliseconds: 200));
final ScrollMetrics metrics = new FixedScrollMetrics(
minScrollExtent: 0.0,
maxScrollExtent: 0.0,
......@@ -87,8 +97,6 @@ void main() {
axisDirection: AxisDirection.down
);
scrollPainter.update(metrics, AxisDirection.down);
await tester.pump(const Duration(milliseconds: 200));
await tester.pump(const Duration(milliseconds: 200));
final List<Invocation> invocations = <Invocation>[];
final TestCanvas canvas = new TestCanvas(invocations);
......@@ -96,4 +104,39 @@ void main() {
final Rect thumbRect = invocations.single.positionalArguments[0];
expect(thumbRect.isFinite, isTrue);
});
testWidgets('Adaptive scrollbar', (WidgetTester tester) async {
Widget viewWithScroll(TargetPlatform platform) {
return new Directionality(
textDirection: TextDirection.ltr,
child: new Theme(
data: new ThemeData(
platform: platform
),
child: new Scrollbar(
child: new SingleChildScrollView(
child: const SizedBox(width: 4000.0, height: 4000.0),
),
),
),
);
}
await tester.pumpWidget(viewWithScroll(TargetPlatform.android));
await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0));
await tester.pump();
// Scrollbar fully showing
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(Scrollbar), paints..rect());
await tester.pumpWidget(viewWithScroll(TargetPlatform.iOS));
final TestGesture gesture = await tester.startGesture(
tester.getCenter(find.byType(SingleChildScrollView))
);
await gesture.moveBy(const Offset(0.0, -10.0));
await tester.drag(find.byType(SingleChildScrollView), const Offset(0.0, -10.0));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.byType(Scrollbar), paints..rrect());
});
}
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