blob: b026dee783ab1122250f88087af54a762e077bd6 [file] [log] [blame]
// Copyright 2014 The Flutter 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/cupertino.dart';
import 'package:flutter/widgets.dart';
import 'theme.dart';
const double _kScrollbarThickness = 6.0;
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300);
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
/// A material design scrollbar.
///
/// A scrollbar indicates which portion of a [Scrollable] widget is actually
/// visible.
///
/// Dynamically changes to an iOS style scrollbar that looks like
/// [CupertinoScrollbar] on the iOS platform.
///
/// To add a scrollbar to a [ScrollView], simply wrap the scroll view widget in
/// a [Scrollbar] widget.
///
/// See also:
///
/// * [ListView], which display a linear, scrollable list of children.
/// * [GridView], which display a 2 dimensional, scrollable array of children.
class Scrollbar extends StatefulWidget {
/// Creates a material design scrollbar that wraps the given [child].
///
/// The [child] should be a source of [ScrollNotification] notifications,
/// typically a [Scrollable] widget.
const Scrollbar({
Key? key,
required this.child,
this.controller,
this.isAlwaysShown = false,
this.thickness,
this.radius,
}) : assert(!isAlwaysShown || controller != null, 'When isAlwaysShown is true, must pass a controller that is attached to a scroll view'),
super(key: key);
/// The widget below this widget in the tree.
///
/// The scrollbar will be stacked on top of this child. This child (and its
/// subtree) should include a source of [ScrollNotification] notifications.
///
/// Typically a [ListView] or [CustomScrollView].
final Widget child;
/// {@macro flutter.cupertino.cupertinoScrollbar.controller}
final ScrollController? controller;
/// {@macro flutter.cupertino.cupertinoScrollbar.isAlwaysShown}
final bool isAlwaysShown;
/// The thickness of the scrollbar.
///
/// If this is non-null, it will be used as the thickness of the scrollbar on
/// all platforms, whether the scrollbar is being dragged by the user or not.
/// By default (if this is left null), each platform will get a thickness
/// that matches the look and feel of the platform, and the thickness may
/// grow while the scrollbar is being dragged if the platform look and feel
/// calls for such behavior.
final double? thickness;
/// The radius of the corners of the scrollbar.
///
/// If this is non-null, it will be used as the fixed radius of the scrollbar
/// on all platforms, whether the scrollbar is being dragged by the user or
/// not. By default (if this is left null), each platform will get a radius
/// that matches the look and feel of the platform, and the radius may
/// change while the scrollbar is being dragged if the platform look and feel
/// calls for such behavior.
final Radius? radius;
@override
_ScrollbarState createState() => _ScrollbarState();
}
class _ScrollbarState extends State<Scrollbar> with SingleTickerProviderStateMixin {
ScrollbarPainter? _materialPainter;
late TextDirection _textDirection;
late Color _themeColor;
late bool _useCupertinoScrollbar;
late AnimationController _fadeoutAnimationController;
late Animation<double> _fadeoutOpacityAnimation;
Timer? _fadeoutTimer;
@override
void initState() {
super.initState();
_fadeoutAnimationController = AnimationController(
vsync: this,
duration: _kScrollbarFadeDuration,
);
_fadeoutOpacityAnimation = CurvedAnimation(
parent: _fadeoutAnimationController,
curve: Curves.fastOutSlowIn,
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final ThemeData theme = Theme.of(context)!;
switch (theme.platform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
// On iOS, stop all local animations. CupertinoScrollbar has its own
// animations.
_fadeoutTimer?.cancel();
_fadeoutTimer = null;
_fadeoutAnimationController.reset();
_useCupertinoScrollbar = true;
break;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
_themeColor = theme.highlightColor.withOpacity(1.0);
_textDirection = Directionality.of(context)!;
_materialPainter = _buildMaterialScrollbarPainter();
_useCupertinoScrollbar = false;
_triggerScrollbar();
break;
}
}
@override
void didUpdateWidget(Scrollbar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isAlwaysShown != oldWidget.isAlwaysShown) {
if (widget.isAlwaysShown == false) {
_fadeoutAnimationController.reverse();
} else {
_triggerScrollbar();
_fadeoutAnimationController.animateTo(1.0);
}
}
if (!_useCupertinoScrollbar) {
_materialPainter!
..thickness = widget.thickness ?? _kScrollbarThickness
..radius = widget.radius;
}
}
// Wait one frame and cause an empty scroll event. This allows the thumb to
// show immediately when isAlwaysShown is true. A scroll event is required in
// order to paint the thumb.
void _triggerScrollbar() {
WidgetsBinding.instance!.addPostFrameCallback((Duration duration) {
if (widget.isAlwaysShown) {
_fadeoutTimer?.cancel();
widget.controller!.position.didUpdateScrollPositionBy(0);
}
});
}
ScrollbarPainter _buildMaterialScrollbarPainter() {
return ScrollbarPainter(
color: _themeColor,
textDirection: _textDirection,
thickness: widget.thickness ?? _kScrollbarThickness,
radius: widget.radius,
fadeoutOpacityAnimation: _fadeoutOpacityAnimation,
padding: MediaQuery.of(context)!.padding,
);
}
bool _handleScrollNotification(ScrollNotification notification) {
final ScrollMetrics metrics = notification.metrics;
if (metrics.maxScrollExtent <= metrics.minScrollExtent) {
return false;
}
// iOS sub-delegates to the CupertinoScrollbar instead and doesn't handle
// scroll notifications here.
if (!_useCupertinoScrollbar &&
(notification is ScrollUpdateNotification ||
notification is OverscrollNotification)) {
if (_fadeoutAnimationController.status != AnimationStatus.forward) {
_fadeoutAnimationController.forward();
}
_materialPainter!.update(
notification.metrics,
notification.metrics.axisDirection,
);
if (!widget.isAlwaysShown) {
_fadeoutTimer?.cancel();
_fadeoutTimer = Timer(_kScrollbarTimeToFade, () {
_fadeoutAnimationController.reverse();
_fadeoutTimer = null;
});
}
}
return false;
}
@override
void dispose() {
_fadeoutAnimationController.dispose();
_fadeoutTimer?.cancel();
_materialPainter?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_useCupertinoScrollbar) {
return CupertinoScrollbar(
child: widget.child,
isAlwaysShown: widget.isAlwaysShown,
thickness: widget.thickness ?? CupertinoScrollbar.defaultThickness,
thicknessWhileDragging: widget.thickness ?? CupertinoScrollbar.defaultThicknessWhileDragging,
radius: widget.radius ?? CupertinoScrollbar.defaultRadius,
radiusWhileDragging: widget.radius ?? CupertinoScrollbar.defaultRadiusWhileDragging,
controller: widget.controller,
);
}
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: RepaintBoundary(
child: CustomPaint(
foregroundPainter: _materialPainter,
child: RepaintBoundary(
child: widget.child,
),
),
),
);
}
}