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
This commit is contained in:
@@ -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';
|
||||
|
||||
140
packages/flutter/lib/src/cupertino/scrollbar.dart
Normal file
140
packages/flutter/lib/src/cupertino/scrollbar.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
// 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,25 @@
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'theme.dart';
|
||||
|
||||
const double _kScrollbarThickness = 6.0;
|
||||
const Duration _kScrollbarFadeDuration = const Duration(milliseconds: 300);
|
||||
const Duration _kScrollbarTimeToFade = const Duration(milliseconds: 600);
|
||||
|
||||
/// A material design scrollbar.
|
||||
///
|
||||
/// A scrollbar indicates which portion of a [Scrollable] widget is actually
|
||||
/// visible.
|
||||
///
|
||||
/// Dynamically changes to a iOS style scrollbar that looks like
|
||||
/// [CupertinoScrollbar] on iOS platform.
|
||||
///
|
||||
/// To add a scrollbar to a [ScrollView], simply wrap the scroll view widget in
|
||||
/// a [Scrollbar] widget.
|
||||
///
|
||||
@@ -45,182 +51,112 @@ class Scrollbar extends StatefulWidget {
|
||||
_ScrollbarState createState() => new _ScrollbarState();
|
||||
}
|
||||
|
||||
|
||||
class _ScrollbarState extends State<Scrollbar> with TickerProviderStateMixin {
|
||||
_ScrollbarPainter _painter;
|
||||
ScrollbarPainter _materialPainter;
|
||||
TargetPlatform _currentPlatform;
|
||||
TextDirection _textDirection;
|
||||
Color _themeColor;
|
||||
|
||||
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();
|
||||
_painter ??= new _ScrollbarPainter(this);
|
||||
_painter
|
||||
..color = Theme.of(context).highlightColor
|
||||
..textDirection = Directionality.of(context);
|
||||
|
||||
final ThemeData theme = Theme.of(context);
|
||||
_currentPlatform = theme.platform;
|
||||
|
||||
switch (_currentPlatform) {
|
||||
case TargetPlatform.iOS:
|
||||
// On iOS, stop all local animations. CupertinoScrollbar has its own
|
||||
// animations.
|
||||
_fadeoutTimer?.cancel();
|
||||
_fadeoutTimer = null;
|
||||
_fadeoutAnimationController.reset();
|
||||
break;
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
_themeColor = theme.highlightColor.withOpacity(1.0);
|
||||
_textDirection = Directionality.of(context);
|
||||
_materialPainter = _buildMaterialScrollbarPainter();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ScrollbarPainter _buildMaterialScrollbarPainter() {
|
||||
return new ScrollbarPainter(
|
||||
color: _themeColor,
|
||||
textDirection: _textDirection,
|
||||
thickness: _kScrollbarThickness,
|
||||
fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
|
||||
);
|
||||
}
|
||||
|
||||
bool _handleScrollNotification(ScrollNotification notification) {
|
||||
if (notification is ScrollUpdateNotification ||
|
||||
notification is OverscrollNotification)
|
||||
_painter.update(notification.metrics, notification.metrics.axisDirection);
|
||||
// iOS sub-delegates to the CupertinoScrollbar instead and doesn't handle
|
||||
// scroll notifications here.
|
||||
if (_currentPlatform != TargetPlatform.iOS
|
||||
&& (notification is ScrollUpdateNotification
|
||||
|| notification is OverscrollNotification)) {
|
||||
if (_fadeoutAnimationController.status != AnimationStatus.forward) {
|
||||
_fadeoutAnimationController.forward();
|
||||
}
|
||||
|
||||
_materialPainter.update(notification.metrics, notification.metrics.axisDirection);
|
||||
_fadeoutTimer?.cancel();
|
||||
_fadeoutTimer = new Timer(_kScrollbarTimeToFade, () {
|
||||
_fadeoutAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_painter.dispose();
|
||||
_fadeoutAnimationController.dispose();
|
||||
_fadeoutTimer?.cancel();
|
||||
_materialPainter?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return new NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleScrollNotification,
|
||||
// TODO(ianh): Maybe we should try to collapse out these repaint
|
||||
// boundaries when the scroll bars are invisible.
|
||||
child: new RepaintBoundary(
|
||||
child: new CustomPaint(
|
||||
foregroundPainter: _painter,
|
||||
switch (_currentPlatform) {
|
||||
case TargetPlatform.iOS:
|
||||
return new CupertinoScrollbar(
|
||||
child: widget.child,
|
||||
);
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.fuchsia:
|
||||
return new NotificationListener<ScrollNotification>(
|
||||
onNotification: _handleScrollNotification,
|
||||
child: new RepaintBoundary(
|
||||
child: widget.child,
|
||||
child: new CustomPaint(
|
||||
foregroundPainter: _materialPainter,
|
||||
child: new RepaintBoundary(
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
throw new FlutterError('Unknown platform for scrollbar insertion');
|
||||
}
|
||||
}
|
||||
|
||||
class _ScrollbarPainter extends ChangeNotifier implements CustomPainter {
|
||||
_ScrollbarPainter(TickerProvider vsync)
|
||||
: assert(vsync != null) {
|
||||
_fadeController = new AnimationController(duration: _kThumbFadeDuration, vsync: vsync);
|
||||
_opacity = new CurvedAnimation(parent: _fadeController, curve: Curves.fastOutSlowIn)
|
||||
..addListener(notifyListeners);
|
||||
}
|
||||
|
||||
// animation of the main axis direction
|
||||
AnimationController _fadeController;
|
||||
Animation<double> _opacity;
|
||||
|
||||
// fade-out timer
|
||||
Timer _fadeOut;
|
||||
|
||||
Color get color => _color;
|
||||
Color _color;
|
||||
set color(Color value) {
|
||||
assert(value != null);
|
||||
if (_color == value)
|
||||
return;
|
||||
_color = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
TextDirection get textDirection => _textDirection;
|
||||
TextDirection _textDirection;
|
||||
set textDirection(TextDirection value) {
|
||||
assert(value != null);
|
||||
if (_textDirection == value)
|
||||
return;
|
||||
_textDirection = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fadeOut?.cancel();
|
||||
_fadeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
ScrollMetrics _lastMetrics;
|
||||
AxisDirection _lastAxisDirection;
|
||||
|
||||
static const double _kMinThumbExtent = 18.0;
|
||||
static const double _kThumbGirth = 6.0;
|
||||
static const Duration _kThumbFadeDuration = const Duration(milliseconds: 300);
|
||||
static const Duration _kFadeOutTimeout = const Duration(milliseconds: 600);
|
||||
|
||||
void update(ScrollMetrics metrics, AxisDirection axisDirection) {
|
||||
_lastMetrics = metrics;
|
||||
_lastAxisDirection = axisDirection;
|
||||
if (_fadeController.status == AnimationStatus.completed) {
|
||||
notifyListeners();
|
||||
} else if (_fadeController.status != AnimationStatus.forward) {
|
||||
_fadeController.forward();
|
||||
}
|
||||
_fadeOut?.cancel();
|
||||
_fadeOut = new Timer(_kFadeOutTimeout, startFadeOut);
|
||||
}
|
||||
|
||||
void startFadeOut() {
|
||||
_fadeOut = null;
|
||||
_fadeController.reverse();
|
||||
}
|
||||
|
||||
Paint get _paint => new Paint()..color = color.withOpacity(_opacity.value);
|
||||
|
||||
double _getThumbX(Size size) {
|
||||
assert(textDirection != null);
|
||||
switch (textDirection) {
|
||||
case TextDirection.rtl:
|
||||
return 0.0;
|
||||
case TextDirection.ltr:
|
||||
return size.width - _kThumbGirth;
|
||||
}
|
||||
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(_kThumbGirth, thumbExtent);
|
||||
canvas.drawRect(thumbOrigin & thumbSize, _paint);
|
||||
}
|
||||
|
||||
void _paintHorizontalThumb(Canvas canvas, Size size, double thumbOffset, double thumbExtent) {
|
||||
final Offset thumbOrigin = new Offset(thumbOffset, size.height - _kThumbGirth);
|
||||
final Size thumbSize = new Size(thumbExtent, _kThumbGirth);
|
||||
canvas.drawRect(thumbOrigin & thumbSize, _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 = math.min(viewport, _kMinThumbExtent);
|
||||
if (before + inside + after > 0.0)
|
||||
thumbExtent = math.max(thumbExtent, viewport * inside / (before + inside + after));
|
||||
|
||||
final double thumbOffset = (before + after > 0.0) ?
|
||||
before * (viewport - thumbExtent) / (before + after) : 0.0;
|
||||
|
||||
painter(canvas, size, thumbOffset, thumbExtent);
|
||||
}
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (_lastAxisDirection == null || _lastMetrics == null || _opacity.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;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool hitTest(Offset position) => null;
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_ScrollbarPainter oldDelegate) => false;
|
||||
|
||||
@override
|
||||
bool shouldRebuildSemantics(CustomPainter oldDelegate) => false;
|
||||
|
||||
@override
|
||||
SemanticsBuilderCallback get semanticsBuilder => null;
|
||||
}
|
||||
|
||||
222
packages/flutter/lib/src/widgets/scrollbar.dart
Normal file
222
packages/flutter/lib/src/widgets/scrollbar.dart
Normal file
@@ -0,0 +1,222 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
41
packages/flutter/test/cupertino/scrollbar_paint_test.dart
Normal file
41
packages/flutter/test/cupertino/scrollbar_paint_test.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
// 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),
|
||||
),
|
||||
));
|
||||
});
|
||||
}
|
||||
45
packages/flutter/test/cupertino/scrollbar_test.dart
Normal file
45
packages/flutter/test/cupertino/scrollbar_test.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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());
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user