scrollbar.dart 15.5 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5
import 'package:flutter/cupertino.dart';
6
import 'package:flutter/gestures.dart';
7

8 9
import 'color_scheme.dart';
import 'material_state.dart';
10
import 'scrollbar_theme.dart';
11 12
import 'theme.dart';

13 14 15 16 17
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);
18 19
const Duration _kScrollbarFadeDuration = Duration(milliseconds: 300);
const Duration _kScrollbarTimeToFade = Duration(milliseconds: 600);
20

21
/// A Material Design scrollbar.
22
///
23
/// To add a scrollbar to a [ScrollView], wrap the scroll view
24 25 26
/// widget in a [Scrollbar] widget.
///
/// {@macro flutter.widgets.Scrollbar}
27
///
28 29 30
/// Dynamically changes to an iOS style scrollbar that looks like
/// [CupertinoScrollbar] on the iOS platform.
///
31 32 33 34 35
/// 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].
36
///
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84
/// {@tool dartpad --template=stateless_widget_scaffold}
/// This sample shows a [Scrollbar] that executes a fade animation as scrolling occurs.
/// The Scrollbar will fade into view as the user scrolls, and fade out when scrolling stops.
/// ```dart
/// Widget build(BuildContext context) {
///   return Scrollbar(
///     child: GridView.builder(
///       itemCount: 120,
///       gridDelegate:
///         const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
///       itemBuilder: (BuildContext context, int index) {
///         return Center(
///           child: Text('item $index'),
///         );
///       },
///     ),
///   );
/// }
/// ```
/// {@end-tool}
///
/// {@tool dartpad --template=stateful_widget_scaffold}
/// When isAlwaysShown is true, the scrollbar thumb will remain visible without the
/// fade animation. This requires that a ScrollController is provided to controller,
/// or that the PrimaryScrollController is available.
/// ```dart
/// final ScrollController _controllerOne = ScrollController();
///
/// @override
/// Widget build(BuildContext context) {
///   return Scrollbar(
///     controller: _controllerOne,
///     isAlwaysShown: true,
///     child: GridView.builder(
///       controller: _controllerOne,
///       itemCount: 120,
///       gridDelegate:
///         const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3),
///       itemBuilder: (BuildContext context, int index) {
///         return Center(
///           child: Text('item $index'),
///         );
///       },
///     ),
///   );
/// }
/// ```
/// {@end-tool}
85 86 87
///
/// See also:
///
88 89
///  * [RawScrollbar], a basic scrollbar that fades in and out, extended
///    by this class to add more animations and behaviors.
90
///  * [ScrollbarTheme], which configures the Scrollbar's appearance.
91 92 93
///  * [CupertinoScrollbar], an iOS style scrollbar.
///  * [ListView], which displays a linear, scrollable list of children.
///  * [GridView], which displays a 2 dimensional, scrollable array of children.
94
class Scrollbar extends StatefulWidget {
95 96
  /// Creates a material design scrollbar that by default will connect to the
  /// closest Scrollable descendant of [child].
97 98 99
  ///
  /// The [child] should be a source of [ScrollNotification] notifications,
  /// typically a [Scrollable] widget.
100 101 102 103
  ///
  /// If the [controller] is null, the default behavior is to
  /// enable scrollbar dragging using the [PrimaryScrollController].
  ///
104 105 106 107 108
  /// 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.
109
  const Scrollbar({
110 111 112 113 114 115 116 117
    Key? key,
    required this.child,
    this.controller,
    this.isAlwaysShown,
    this.showTrackOnHover,
    this.hoverThickness,
    this.thickness,
    this.radius,
118
    this.notificationPredicate,
119
    this.interactive,
120
    this.scrollbarOrientation,
121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154
  }) : 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;

155
  /// The [Radius] of the scrollbar thumb's rounded rectangle corners.
156 157 158 159 160 161 162
  ///
  /// 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;

163 164 165
  /// {@macro flutter.widgets.Scrollbar.interactive}
  final bool? interactive;

166 167 168
  /// {@macro flutter.widgets.Scrollbar.notificationPredicate}
  final ScrollNotificationPredicate? notificationPredicate;

169 170 171
  /// {@macro flutter.widgets.Scrollbar.scrollbarOrientation}
  final ScrollbarOrientation? scrollbarOrientation;

172
  @override
173
  State<Scrollbar> createState() => _ScrollbarState();
174 175 176 177 178 179 180 181 182 183 184 185 186 187 188
}

class _ScrollbarState extends State<Scrollbar> {
  bool get _useCupertinoScrollbar => Theme.of(context).platform == TargetPlatform.iOS;

  @override
  Widget build(BuildContext context) {
    if (_useCupertinoScrollbar) {
      return CupertinoScrollbar(
        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,
189
        notificationPredicate: widget.notificationPredicate,
190
        scrollbarOrientation: widget.scrollbarOrientation,
191
        child: widget.child,
192 193 194 195 196 197 198 199 200
      );
    }
    return _MaterialScrollbar(
      controller: widget.controller,
      isAlwaysShown: widget.isAlwaysShown,
      showTrackOnHover: widget.showTrackOnHover,
      hoverThickness: widget.hoverThickness,
      thickness: widget.thickness,
      radius: widget.radius,
201
      notificationPredicate: widget.notificationPredicate,
202
      interactive: widget.interactive,
203
      scrollbarOrientation: widget.scrollbarOrientation,
204
      child: widget.child,
205 206 207 208 209 210
    );
  }
}

class _MaterialScrollbar extends RawScrollbar {
  const _MaterialScrollbar({
211
    Key? key,
212 213
    required Widget child,
    ScrollController? controller,
214 215
    bool? isAlwaysShown,
    this.showTrackOnHover,
216 217 218
    this.hoverThickness,
    double? thickness,
    Radius? radius,
219
    ScrollNotificationPredicate? notificationPredicate,
220
    bool? interactive,
221
    ScrollbarOrientation? scrollbarOrientation,
222 223 224 225 226
  }) : super(
         key: key,
         child: child,
         controller: controller,
         isAlwaysShown: isAlwaysShown,
227
         thickness: thickness,
228 229 230 231
         radius: radius,
         fadeDuration: _kScrollbarFadeDuration,
         timeToFade: _kScrollbarTimeToFade,
         pressDuration: Duration.zero,
232
         notificationPredicate: notificationPredicate ?? defaultScrollNotificationPredicate,
233
         interactive: interactive,
234
         scrollbarOrientation: scrollbarOrientation,
235 236
       );

237
  final bool? showTrackOnHover;
238
  final double? hoverThickness;
239

240
  @override
241
  _MaterialScrollbarState createState() => _MaterialScrollbarState();
242 243
}

244
class _MaterialScrollbarState extends RawScrollbarState<_MaterialScrollbar> {
245 246 247 248
  late AnimationController _hoverAnimationController;
  bool _dragIsActive = false;
  bool _hoverIsActive = false;
  late ColorScheme _colorScheme;
249
  late ScrollbarThemeData _scrollbarTheme;
250 251
  // On Android, scrollbars should match native appearance.
  late bool _useAndroidScrollbar;
252

253 254 255
  @override
  bool get showScrollbar => widget.isAlwaysShown ?? _scrollbarTheme.isAlwaysShown ?? false;

256 257 258
  @override
  bool get enableGestures => widget.interactive ?? _scrollbarTheme.interactive ?? !_useAndroidScrollbar;

259 260
  bool get _showTrackOnHover => widget.showTrackOnHover ?? _scrollbarTheme.showTrackOnHover ?? false;

261 262 263 264 265 266 267 268 269 270 271 272 273 274 275
  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);
276 277 278
        idleColor = _useAndroidScrollbar
          ? Theme.of(context).highlightColor.withOpacity(1.0)
          : onSurface.withOpacity(0.1);
279
        break;
280 281 282
      case Brightness.dark:
        dragColor = onSurface.withOpacity(0.75);
        hoverColor = onSurface.withOpacity(0.65);
283 284 285
        idleColor = _useAndroidScrollbar
          ? Theme.of(context).highlightColor.withOpacity(1.0)
          : onSurface.withOpacity(0.3);
286
        break;
287
    }
288 289 290

    return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
      if (states.contains(MaterialState.dragged))
291
        return _scrollbarTheme.thumbColor?.resolve(states) ?? dragColor;
292 293 294

      // If the track is visible, the thumb color hover animation is ignored and
      // changes immediately.
295 296
      if (states.contains(MaterialState.hovered) && _showTrackOnHover)
        return _scrollbarTheme.thumbColor?.resolve(states) ?? hoverColor;
297 298

      return Color.lerp(
299 300
        _scrollbarTheme.thumbColor?.resolve(states) ?? idleColor,
        _scrollbarTheme.thumbColor?.resolve(states) ?? hoverColor,
301 302 303
        _hoverAnimationController.value,
      )!;
    });
304 305
  }

306 307 308 309
  MaterialStateProperty<Color> get _trackColor {
    final Color onSurface = _colorScheme.onSurface;
    final Brightness brightness = _colorScheme.brightness;
    return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
310 311 312 313 314
      if (states.contains(MaterialState.hovered) && _showTrackOnHover) {
        return _scrollbarTheme.trackColor?.resolve(states)
          ?? (brightness == Brightness.light
            ? onSurface.withOpacity(0.03)
            : onSurface.withOpacity(0.05));
315
      }
316 317
      return const Color(0x00000000);
    });
318 319
  }

320 321 322 323
  MaterialStateProperty<Color> get _trackBorderColor {
    final Color onSurface = _colorScheme.onSurface;
    final Brightness brightness = _colorScheme.brightness;
    return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
324 325 326 327 328
      if (states.contains(MaterialState.hovered) && _showTrackOnHover) {
        return _scrollbarTheme.trackBorderColor?.resolve(states)
          ?? (brightness == Brightness.light
            ? onSurface.withOpacity(0.1)
            : onSurface.withOpacity(0.25));
329
      }
330
      return const Color(0x00000000);
331 332 333
    });
  }

334 335
  MaterialStateProperty<double> get _thickness {
    return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
336 337 338 339
      if (states.contains(MaterialState.hovered) && _showTrackOnHover)
        return widget.hoverThickness
          ?? _scrollbarTheme.thickness?.resolve(states)
          ?? _kScrollbarThicknessWithTrack;
340
      // The default scrollbar thickness is smaller on mobile.
341 342 343
      return widget.thickness
        ?? _scrollbarTheme.thickness?.resolve(states)
        ?? (_kScrollbarThickness / (_useAndroidScrollbar ? 2 : 1));
344 345 346 347 348 349 350 351 352
    });
  }

  @override
  void initState() {
    super.initState();
    _hoverAnimationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 200),
353
    );
354 355 356
    _hoverAnimationController.addListener(() {
      updateScrollbarPainter();
    });
357 358
  }

359 360 361
  @override
  void didChangeDependencies() {
    final ThemeData theme = Theme.of(context);
362 363
    _colorScheme = theme.colorScheme;
    _scrollbarTheme = theme.scrollbarTheme;
364 365 366 367 368 369 370 371 372 373 374 375 376 377 378
    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();
  }

379 380 381 382 383 384 385 386
  @override
  void updateScrollbarPainter() {
    scrollbarPainter
      ..color = _thumbColor.resolve(_states)
      ..trackColor = _trackColor.resolve(_states)
      ..trackBorderColor = _trackBorderColor.resolve(_states)
      ..textDirection = Directionality.of(context)
      ..thickness = _thickness.resolve(_states)
387 388 389 390
      ..radius = widget.radius ?? _scrollbarTheme.radius ?? (_useAndroidScrollbar ? null : _kScrollbarRadius)
      ..crossAxisMargin = _scrollbarTheme.crossAxisMargin ?? (_useAndroidScrollbar ? 0.0 : _kScrollbarMargin)
      ..mainAxisMargin = _scrollbarTheme.mainAxisMargin ?? 0.0
      ..minLength = _scrollbarTheme.minThumbLength ?? _kScrollbarMinLength
391 392
      ..padding = MediaQuery.of(context).padding
      ..scrollbarOrientation = widget.scrollbarOrientation;
393
  }
394

395 396 397 398 399
  @override
  void handleThumbPressStart(Offset localPosition) {
    super.handleThumbPressStart(localPosition);
    setState(() { _dragIsActive = true; });
  }
400

401 402 403 404 405 406 407 408 409 410
  @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
411
    if (isPointerOverScrollbar(event.position, event.kind, forHover: true)) {
412 413 414 415 416 417 418
      // 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();
419
    }
420 421
  }

422
  @override
423 424 425 426
  void handleHoverExit(PointerExitEvent event) {
    super.handleHoverExit(event);
    setState(() { _hoverIsActive = false; });
    _hoverAnimationController.reverse();
427 428
  }

429
  @override
430 431 432
  void dispose() {
    _hoverAnimationController.dispose();
    super.dispose();
433 434
  }
}