// 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 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'color_scheme.dart'; import 'material_state.dart'; import 'scrollbar_theme.dart'; import 'theme.dart'; const double _kScrollbarThickness = 8.0; const double _kScrollbarThicknessWithTrack = 12.0; const double _kScrollbarMargin = 2.0; const double _kScrollbarMinLength = 48.0; const Radius _kScrollbarRadius = Radius.circular(8.0); const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300); const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600); /// A Material Design scrollbar. /// /// To add a scrollbar to a [ScrollView], wrap the scroll view /// widget in a [Scrollbar] widget. /// /// {@macro flutter.widgets.Scrollbar} /// /// Dynamically changes to an iOS style scrollbar that looks like /// [CupertinoScrollbar] on the iOS platform. /// /// The color of the Scrollbar will change when dragged. A hover animation is /// also triggered when used on web and desktop platforms. A scrollbar track /// can also been drawn when triggered by a hover event, which is controlled by /// [showTrackOnHover]. The thickness of the track and scrollbar thumb will /// become larger when hovering, unless overridden by [hoverThickness]. /// // TODO(Piinks): Add code sample /// /// See also: /// /// * [RawScrollbar], a basic scrollbar that fades in and out, extended /// by this class to add more animations and behaviors. /// * [ScrollbarTheme], which configures the Scrollbar's appearance. /// * [CupertinoScrollbar], an iOS style scrollbar. /// * [ListView], which displays a linear, scrollable list of children. /// * [GridView], which displays a 2 dimensional, scrollable array of children. class Scrollbar extends StatefulWidget { /// Creates a material design scrollbar that by default will connect to the /// closest Scrollable descendant of [child]. /// /// The [child] should be a source of [ScrollNotification] notifications, /// typically a [Scrollable] widget. /// /// If the [controller] is null, the default behavior is to /// enable scrollbar dragging using the [PrimaryScrollController]. /// /// When null, [thickness] defaults to 8.0 pixels on desktop and web, and 4.0 /// pixels when on mobile platforms. A null [radius] will result in a default /// of an 8.0 pixel circular radius about the corners of the scrollbar thumb, /// except for when executing on [TargetPlatform.android], which will render the /// thumb without a radius. const Scrollbar({ Key? key, required this.child, this.controller, this.isAlwaysShown, this.showTrackOnHover, this.hoverThickness, this.thickness, this.radius, this.notificationPredicate, }) : super(key: key); /// {@macro flutter.widgets.Scrollbar.child} final Widget child; /// {@macro flutter.widgets.Scrollbar.controller} final ScrollController? controller; /// {@macro flutter.widgets.Scrollbar.isAlwaysShown} final bool? isAlwaysShown; /// Controls if the track will show on hover and remain, including during drag. /// /// If this property is null, then [ScrollbarThemeData.showTrackOnHover] of /// [ThemeData.scrollbarTheme] is used. If that is also null, the default value /// is false. final bool? showTrackOnHover; /// The thickness of the scrollbar when a hover state is active and /// [showTrackOnHover] is true. /// /// If this property is null, then [ScrollbarThemeData.thickness] of /// [ThemeData.scrollbarTheme] is used to resolve a thickness. If that is also /// null, the default value is 12.0 pixels. final double? hoverThickness; /// The thickness of the scrollbar in the cross axis of the scrollable. /// /// If null, the default value is platform dependent. On [TargetPlatform.android], /// the default thickness is 4.0 pixels. On [TargetPlatform.iOS], /// [CupertinoScrollbar.defaultThickness] is used. The remaining platforms have a /// default thickness of 8.0 pixels. final double? thickness; /// The color of the scrollbar thumb. /// /// If null, the default value is platform dependent. On [TargetPlatform.android], /// no radius is applied to the scrollbar thumb. On [TargetPlatform.iOS], /// [CupertinoScrollbar.defaultRadius] is used. The remaining platforms have a /// default [Radius.circular] of 8.0 pixels. final Radius? radius; /// {@macro flutter.widgets.Scrollbar.notificationPredicate} final ScrollNotificationPredicate? notificationPredicate; @override _ScrollbarState createState() => _ScrollbarState(); } class _ScrollbarState extends State<Scrollbar> { bool get _useCupertinoScrollbar => Theme.of(context).platform == TargetPlatform.iOS; @override Widget build(BuildContext context) { if (_useCupertinoScrollbar) { return CupertinoScrollbar( child: widget.child, isAlwaysShown: widget.isAlwaysShown ?? false, thickness: widget.thickness ?? CupertinoScrollbar.defaultThickness, thicknessWhileDragging: widget.thickness ?? CupertinoScrollbar.defaultThicknessWhileDragging, radius: widget.radius ?? CupertinoScrollbar.defaultRadius, radiusWhileDragging: widget.radius ?? CupertinoScrollbar.defaultRadiusWhileDragging, controller: widget.controller, notificationPredicate: widget.notificationPredicate, ); } return _MaterialScrollbar( child: widget.child, controller: widget.controller, isAlwaysShown: widget.isAlwaysShown, showTrackOnHover: widget.showTrackOnHover, hoverThickness: widget.hoverThickness, thickness: widget.thickness, radius: widget.radius, notificationPredicate: widget.notificationPredicate, ); } } class _MaterialScrollbar extends RawScrollbar { const _MaterialScrollbar({ Key? key, required Widget child, ScrollController? controller, bool? isAlwaysShown, this.showTrackOnHover, this.hoverThickness, double? thickness, Radius? radius, ScrollNotificationPredicate? notificationPredicate, }) : super( key: key, child: child, controller: controller, isAlwaysShown: isAlwaysShown, thickness: thickness, radius: radius, fadeDuration: _kScrollbarFadeDuration, timeToFade: _kScrollbarTimeToFade, pressDuration: Duration.zero, notificationPredicate: notificationPredicate ?? defaultScrollNotificationPredicate, ); final bool? showTrackOnHover; final double? hoverThickness; @override _MaterialScrollbarState createState() => _MaterialScrollbarState(); } class _MaterialScrollbarState extends RawScrollbarState<_MaterialScrollbar> { late AnimationController _hoverAnimationController; bool _dragIsActive = false; bool _hoverIsActive = false; late ColorScheme _colorScheme; late ScrollbarThemeData _scrollbarTheme; // On Android, scrollbars should match native appearance. late bool _useAndroidScrollbar; @override bool get showScrollbar => widget.isAlwaysShown ?? _scrollbarTheme.isAlwaysShown ?? false; bool get _showTrackOnHover => widget.showTrackOnHover ?? _scrollbarTheme.showTrackOnHover ?? false; Set<MaterialState> get _states => <MaterialState>{ if (_dragIsActive) MaterialState.dragged, if (_hoverIsActive) MaterialState.hovered, }; MaterialStateProperty<Color> get _thumbColor { final Color onSurface = _colorScheme.onSurface; final Brightness brightness = _colorScheme.brightness; late Color dragColor; late Color hoverColor; late Color idleColor; switch (brightness) { case Brightness.light: dragColor = onSurface.withOpacity(0.6); hoverColor = onSurface.withOpacity(0.5); idleColor = onSurface.withOpacity(0.1); break; case Brightness.dark: dragColor = onSurface.withOpacity(0.75); hoverColor = onSurface.withOpacity(0.65); idleColor = onSurface.withOpacity(0.3); break; } return MaterialStateProperty.resolveWith((Set<MaterialState> states) { if (states.contains(MaterialState.dragged)) return _scrollbarTheme.thumbColor?.resolve(states) ?? dragColor; // If the track is visible, the thumb color hover animation is ignored and // changes immediately. if (states.contains(MaterialState.hovered) && _showTrackOnHover) return _scrollbarTheme.thumbColor?.resolve(states) ?? hoverColor; return Color.lerp( _scrollbarTheme.thumbColor?.resolve(states) ?? idleColor, _scrollbarTheme.thumbColor?.resolve(states) ?? hoverColor, _hoverAnimationController.value, )!; }); } MaterialStateProperty<Color> get _trackColor { final Color onSurface = _colorScheme.onSurface; final Brightness brightness = _colorScheme.brightness; return MaterialStateProperty.resolveWith((Set<MaterialState> states) { if (states.contains(MaterialState.hovered) && _showTrackOnHover) { return _scrollbarTheme.trackColor?.resolve(states) ?? (brightness == Brightness.light ? onSurface.withOpacity(0.03) : onSurface.withOpacity(0.05)); } return const Color(0x00000000); }); } MaterialStateProperty<Color> get _trackBorderColor { final Color onSurface = _colorScheme.onSurface; final Brightness brightness = _colorScheme.brightness; return MaterialStateProperty.resolveWith((Set<MaterialState> states) { if (states.contains(MaterialState.hovered) && _showTrackOnHover) { return _scrollbarTheme.trackBorderColor?.resolve(states) ?? (brightness == Brightness.light ? onSurface.withOpacity(0.1) : onSurface.withOpacity(0.25)); } return const Color(0x00000000); }); } MaterialStateProperty<double> get _thickness { return MaterialStateProperty.resolveWith((Set<MaterialState> states) { if (states.contains(MaterialState.hovered) && _showTrackOnHover) return widget.hoverThickness ?? _scrollbarTheme.thickness?.resolve(states) ?? _kScrollbarThicknessWithTrack; // The default scrollbar thickness is smaller on mobile. return widget.thickness ?? _scrollbarTheme.thickness?.resolve(states) ?? (_kScrollbarThickness / (_useAndroidScrollbar ? 2 : 1)); }); } @override void initState() { super.initState(); _hoverAnimationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), ); _hoverAnimationController.addListener(() { updateScrollbarPainter(); }); } @override void didChangeDependencies() { final ThemeData theme = Theme.of(context); _colorScheme = theme.colorScheme; _scrollbarTheme = theme.scrollbarTheme; switch (theme.platform) { case TargetPlatform.android: _useAndroidScrollbar = true; break; case TargetPlatform.iOS: case TargetPlatform.linux: case TargetPlatform.fuchsia: case TargetPlatform.macOS: case TargetPlatform.windows: _useAndroidScrollbar = false; break; } super.didChangeDependencies(); } @override void updateScrollbarPainter() { scrollbarPainter ..color = _thumbColor.resolve(_states) ..trackColor = _trackColor.resolve(_states) ..trackBorderColor = _trackBorderColor.resolve(_states) ..textDirection = Directionality.of(context) ..thickness = _thickness.resolve(_states) ..radius = widget.radius ?? _scrollbarTheme.radius ?? (_useAndroidScrollbar ? null : _kScrollbarRadius) ..crossAxisMargin = _scrollbarTheme.crossAxisMargin ?? (_useAndroidScrollbar ? 0.0 : _kScrollbarMargin) ..mainAxisMargin = _scrollbarTheme.mainAxisMargin ?? 0.0 ..minLength = _scrollbarTheme.minThumbLength ?? _kScrollbarMinLength ..padding = MediaQuery.of(context).padding; } @override void handleThumbPressStart(Offset localPosition) { super.handleThumbPressStart(localPosition); setState(() { _dragIsActive = true; }); } @override void handleThumbPressEnd(Offset localPosition, Velocity velocity) { super.handleThumbPressEnd(localPosition, velocity); setState(() { _dragIsActive = false; }); } @override void handleHover(PointerHoverEvent event) { super.handleHover(event); // Check if the position of the pointer falls over the painted scrollbar if (isPointerOverScrollbar(event.position)) { // Pointer is hovering over the scrollbar setState(() { _hoverIsActive = true; }); _hoverAnimationController.forward(); } else if (_hoverIsActive) { // Pointer was, but is no longer over painted scrollbar. setState(() { _hoverIsActive = false; }); _hoverAnimationController.reverse(); } } @override void handleHoverExit(PointerExitEvent event) { super.handleHoverExit(event); setState(() { _hoverIsActive = false; }); _hoverAnimationController.reverse(); } @override void dispose() { _hoverAnimationController.dispose(); super.dispose(); } }