scrollbar.dart 15.1 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 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
  }) : 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;

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

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

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

168
  @override
169
  State<Scrollbar> createState() => _ScrollbarState();
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184
}

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,
185
        notificationPredicate: widget.notificationPredicate,
186
        child: widget.child,
187 188 189 190 191 192 193 194 195
      );
    }
    return _MaterialScrollbar(
      controller: widget.controller,
      isAlwaysShown: widget.isAlwaysShown,
      showTrackOnHover: widget.showTrackOnHover,
      hoverThickness: widget.hoverThickness,
      thickness: widget.thickness,
      radius: widget.radius,
196
      notificationPredicate: widget.notificationPredicate,
197
      interactive: widget.interactive,
198
      child: widget.child,
199 200 201 202 203 204
    );
  }
}

class _MaterialScrollbar extends RawScrollbar {
  const _MaterialScrollbar({
205
    Key? key,
206 207
    required Widget child,
    ScrollController? controller,
208 209
    bool? isAlwaysShown,
    this.showTrackOnHover,
210 211 212
    this.hoverThickness,
    double? thickness,
    Radius? radius,
213
    ScrollNotificationPredicate? notificationPredicate,
214
    bool? interactive,
215 216 217 218 219
  }) : super(
         key: key,
         child: child,
         controller: controller,
         isAlwaysShown: isAlwaysShown,
220
         thickness: thickness,
221 222 223 224
         radius: radius,
         fadeDuration: _kScrollbarFadeDuration,
         timeToFade: _kScrollbarTimeToFade,
         pressDuration: Duration.zero,
225
         notificationPredicate: notificationPredicate ?? defaultScrollNotificationPredicate,
226
         interactive: interactive,
227 228
       );

229
  final bool? showTrackOnHover;
230
  final double? hoverThickness;
231

232
  @override
233
  _MaterialScrollbarState createState() => _MaterialScrollbarState();
234 235
}

236
class _MaterialScrollbarState extends RawScrollbarState<_MaterialScrollbar> {
237 238 239 240
  late AnimationController _hoverAnimationController;
  bool _dragIsActive = false;
  bool _hoverIsActive = false;
  late ColorScheme _colorScheme;
241
  late ScrollbarThemeData _scrollbarTheme;
242 243
  // On Android, scrollbars should match native appearance.
  late bool _useAndroidScrollbar;
244

245 246 247
  @override
  bool get showScrollbar => widget.isAlwaysShown ?? _scrollbarTheme.isAlwaysShown ?? false;

248 249 250
  @override
  bool get enableGestures => widget.interactive ?? _scrollbarTheme.interactive ?? !_useAndroidScrollbar;

251 252
  bool get _showTrackOnHover => widget.showTrackOnHover ?? _scrollbarTheme.showTrackOnHover ?? false;

253 254 255 256 257 258 259 260 261 262 263 264 265 266 267
  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);
268 269 270
        idleColor = _useAndroidScrollbar
          ? Theme.of(context).highlightColor.withOpacity(1.0)
          : onSurface.withOpacity(0.1);
271
        break;
272 273 274
      case Brightness.dark:
        dragColor = onSurface.withOpacity(0.75);
        hoverColor = onSurface.withOpacity(0.65);
275 276 277
        idleColor = _useAndroidScrollbar
          ? Theme.of(context).highlightColor.withOpacity(1.0)
          : onSurface.withOpacity(0.3);
278
        break;
279
    }
280 281 282

    return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
      if (states.contains(MaterialState.dragged))
283
        return _scrollbarTheme.thumbColor?.resolve(states) ?? dragColor;
284 285 286

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

      return Color.lerp(
291 292
        _scrollbarTheme.thumbColor?.resolve(states) ?? idleColor,
        _scrollbarTheme.thumbColor?.resolve(states) ?? hoverColor,
293 294 295
        _hoverAnimationController.value,
      )!;
    });
296 297
  }

298 299 300 301
  MaterialStateProperty<Color> get _trackColor {
    final Color onSurface = _colorScheme.onSurface;
    final Brightness brightness = _colorScheme.brightness;
    return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
302 303 304 305 306
      if (states.contains(MaterialState.hovered) && _showTrackOnHover) {
        return _scrollbarTheme.trackColor?.resolve(states)
          ?? (brightness == Brightness.light
            ? onSurface.withOpacity(0.03)
            : onSurface.withOpacity(0.05));
307
      }
308 309
      return const Color(0x00000000);
    });
310 311
  }

312 313 314 315
  MaterialStateProperty<Color> get _trackBorderColor {
    final Color onSurface = _colorScheme.onSurface;
    final Brightness brightness = _colorScheme.brightness;
    return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
316 317 318 319 320
      if (states.contains(MaterialState.hovered) && _showTrackOnHover) {
        return _scrollbarTheme.trackBorderColor?.resolve(states)
          ?? (brightness == Brightness.light
            ? onSurface.withOpacity(0.1)
            : onSurface.withOpacity(0.25));
321
      }
322
      return const Color(0x00000000);
323 324 325
    });
  }

326 327
  MaterialStateProperty<double> get _thickness {
    return MaterialStateProperty.resolveWith((Set<MaterialState> states) {
328 329 330 331
      if (states.contains(MaterialState.hovered) && _showTrackOnHover)
        return widget.hoverThickness
          ?? _scrollbarTheme.thickness?.resolve(states)
          ?? _kScrollbarThicknessWithTrack;
332
      // The default scrollbar thickness is smaller on mobile.
333 334 335
      return widget.thickness
        ?? _scrollbarTheme.thickness?.resolve(states)
        ?? (_kScrollbarThickness / (_useAndroidScrollbar ? 2 : 1));
336 337 338 339 340 341 342 343 344
    });
  }

  @override
  void initState() {
    super.initState();
    _hoverAnimationController = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 200),
345
    );
346 347 348
    _hoverAnimationController.addListener(() {
      updateScrollbarPainter();
    });
349 350
  }

351 352 353
  @override
  void didChangeDependencies() {
    final ThemeData theme = Theme.of(context);
354 355
    _colorScheme = theme.colorScheme;
    _scrollbarTheme = theme.scrollbarTheme;
356 357 358 359 360 361 362 363 364 365 366 367 368 369 370
    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();
  }

371 372 373 374 375 376 377 378
  @override
  void updateScrollbarPainter() {
    scrollbarPainter
      ..color = _thumbColor.resolve(_states)
      ..trackColor = _trackColor.resolve(_states)
      ..trackBorderColor = _trackBorderColor.resolve(_states)
      ..textDirection = Directionality.of(context)
      ..thickness = _thickness.resolve(_states)
379 380 381 382
      ..radius = widget.radius ?? _scrollbarTheme.radius ?? (_useAndroidScrollbar ? null : _kScrollbarRadius)
      ..crossAxisMargin = _scrollbarTheme.crossAxisMargin ?? (_useAndroidScrollbar ? 0.0 : _kScrollbarMargin)
      ..mainAxisMargin = _scrollbarTheme.mainAxisMargin ?? 0.0
      ..minLength = _scrollbarTheme.minThumbLength ?? _kScrollbarMinLength
383
      ..padding = MediaQuery.of(context).padding;
384
  }
385

386 387 388 389 390
  @override
  void handleThumbPressStart(Offset localPosition) {
    super.handleThumbPressStart(localPosition);
    setState(() { _dragIsActive = true; });
  }
391

392 393 394 395 396 397 398 399 400 401
  @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
402
    if (isPointerOverScrollbar(event.position, event.kind, forHover: true)) {
403 404 405 406 407 408 409
      // 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();
410
    }
411 412
  }

413
  @override
414 415 416 417
  void handleHoverExit(PointerExitEvent event) {
    super.handleHoverExit(event);
    setState(() { _hoverIsActive = false; });
    _hoverAnimationController.reverse();
418 419
  }

420
  @override
421 422 423
  void dispose() {
    _hoverAnimationController.dispose();
    super.dispose();
424 425
  }
}