scrollbar.dart 15.6 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
/// widget in a [Scrollbar] widget.
///
26 27
/// {@youtube 560 315 https://www.youtube.com/watch?v=DbkIQSvwnZc}
///
28
/// {@macro flutter.widgets.Scrollbar}
29
///
30 31
/// Dynamically changes to a [CupertinoScrollbar], an iOS style scrollbar, by
/// default on the iOS platform.
32
///
33 34 35 36
/// The color of the Scrollbar thumb will change when [MaterialState.dragged],
/// or [MaterialState.hovered] on desktop and web platforms. These stateful
/// color choices can be changed using [ScrollbarThemeData.thumbColor].
///
37
/// {@tool dartpad}
38 39 40
/// 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.
41 42
///
/// ** See code in examples/api/lib/material/scrollbar/scrollbar.0.dart **
43 44
/// {@end-tool}
///
45
/// {@tool dartpad}
46 47 48
/// When [thumbVisibility] 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.
49
///
50 51 52 53
/// When a [ScrollView.scrollDirection] is [Axis.horizontal], it is recommended
/// that the [Scrollbar] is always visible, since scrolling in the horizontal
/// axis is less discoverable.
///
54
/// ** See code in examples/api/lib/material/scrollbar/scrollbar.1.dart **
55
/// {@end-tool}
56
///
57 58 59 60 61 62 63
/// A scrollbar track can be added using [trackVisibility]. This can also be
/// drawn when triggered by a hover event, or based on any [MaterialState] by
/// using [ScrollbarThemeData.trackVisibility].
///
/// The [thickness] of the track and scrollbar thumb can be changed dynamically
/// in response to [MaterialState]s using [ScrollbarThemeData.thickness].
///
64 65
/// See also:
///
66 67
///  * [RawScrollbar], a basic scrollbar that fades in and out, extended
///    by this class to add more animations and behaviors.
68
///  * [ScrollbarTheme], which configures the Scrollbar's appearance.
69 70 71
///  * [CupertinoScrollbar], an iOS style scrollbar.
///  * [ListView], which displays a linear, scrollable list of children.
///  * [GridView], which displays a 2 dimensional, scrollable array of children.
72
class Scrollbar extends StatelessWidget {
73
  /// Creates a Material Design scrollbar that by default will connect to the
74
  /// closest Scrollable descendant of [child].
75 76 77
  ///
  /// The [child] should be a source of [ScrollNotification] notifications,
  /// typically a [Scrollable] widget.
78 79 80 81
  ///
  /// If the [controller] is null, the default behavior is to
  /// enable scrollbar dragging using the [PrimaryScrollController].
  ///
82 83 84 85 86
  /// 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.
87
  const Scrollbar({
88
    super.key,
89 90
    required this.child,
    this.controller,
91
    this.thumbVisibility,
92
    this.trackVisibility,
93 94
    this.thickness,
    this.radius,
95
    this.notificationPredicate,
96
    this.interactive,
97
    this.scrollbarOrientation,
98 99
    @Deprecated(
      'Use ScrollbarThemeData.trackVisibility to resolve based on the current state instead. '
100
      'This feature was deprecated after v3.4.0-19.0.pre.',
101 102
    )
    this.showTrackOnHover,
103
  });
104 105 106 107 108 109 110

  /// {@macro flutter.widgets.Scrollbar.child}
  final Widget child;

  /// {@macro flutter.widgets.Scrollbar.controller}
  final ScrollController? controller;

111 112 113 114 115 116 117 118 119 120 121
  /// {@macro flutter.widgets.Scrollbar.thumbVisibility}
  ///
  /// If this property is null, then [ScrollbarThemeData.thumbVisibility] of
  /// [ThemeData.scrollbarTheme] is used. If that is also null, the default value
  /// is false.
  ///
  /// If the thumb visibility is related to the scrollbar's material state,
  /// use the global [ScrollbarThemeData.thumbVisibility] or override the
  /// sub-tree's theme data.
  final bool? thumbVisibility;

122
  /// {@macro flutter.widgets.Scrollbar.trackVisibility}
123 124 125 126 127 128 129 130 131
  ///
  /// If this property is null, then [ScrollbarThemeData.trackVisibility] of
  /// [ThemeData.scrollbarTheme] is used. If that is also null, the default value
  /// is false.
  ///
  /// If the track visibility is related to the scrollbar's material state,
  /// use the global [ScrollbarThemeData.trackVisibility] or override the
  /// sub-tree's theme data.
  ///
132
  /// Replaces deprecated [showTrackOnHover].
133 134
  final bool? trackVisibility;

135 136 137 138 139
  /// 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.
140
  ///
141 142 143 144
  /// This is deprecated, [trackVisibility] or [ScrollbarThemeData.trackVisibility]
  /// should be used instead.
  @Deprecated(
    'Use ScrollbarThemeData.trackVisibility to resolve based on the current state instead. '
145
    'This feature was deprecated after v3.4.0-19.0.pre.',
146
  )
147 148 149 150 151 152 153 154 155 156
  final bool? showTrackOnHover;

  /// 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;

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

165 166 167
  /// {@macro flutter.widgets.Scrollbar.interactive}
  final bool? interactive;

168 169 170
  /// {@macro flutter.widgets.Scrollbar.notificationPredicate}
  final ScrollNotificationPredicate? notificationPredicate;

171 172 173
  /// {@macro flutter.widgets.Scrollbar.scrollbarOrientation}
  final ScrollbarOrientation? scrollbarOrientation;

174 175
  @override
  Widget build(BuildContext context) {
176
    if (Theme.of(context).platform == TargetPlatform.iOS) {
177
      return CupertinoScrollbar(
178
        thumbVisibility: thumbVisibility ?? false,
179 180 181 182 183 184 185 186
        thickness: thickness ?? CupertinoScrollbar.defaultThickness,
        thicknessWhileDragging: thickness ?? CupertinoScrollbar.defaultThicknessWhileDragging,
        radius: radius ?? CupertinoScrollbar.defaultRadius,
        radiusWhileDragging: radius ?? CupertinoScrollbar.defaultRadiusWhileDragging,
        controller: controller,
        notificationPredicate: notificationPredicate,
        scrollbarOrientation: scrollbarOrientation,
        child: child,
187 188 189
      );
    }
    return _MaterialScrollbar(
190
      controller: controller,
191
      thumbVisibility: thumbVisibility,
192
      trackVisibility: trackVisibility,
193 194 195 196 197 198 199
      showTrackOnHover: showTrackOnHover,
      thickness: thickness,
      radius: radius,
      notificationPredicate: notificationPredicate,
      interactive: interactive,
      scrollbarOrientation: scrollbarOrientation,
      child: child,
200 201 202 203 204 205
    );
  }
}

class _MaterialScrollbar extends RawScrollbar {
  const _MaterialScrollbar({
206 207 208 209
    required super.child,
    super.controller,
    super.thumbVisibility,
    super.trackVisibility,
210
    this.showTrackOnHover,
211 212
    super.thickness,
    super.radius,
213
    ScrollNotificationPredicate? notificationPredicate,
214 215
    super.interactive,
    super.scrollbarOrientation,
216 217 218 219
  }) : super(
         fadeDuration: _kScrollbarFadeDuration,
         timeToFade: _kScrollbarTimeToFade,
         pressDuration: Duration.zero,
220
         notificationPredicate: notificationPredicate ?? defaultScrollNotificationPredicate,
221 222
       );

223
  final bool? showTrackOnHover;
224

225
  @override
226
  _MaterialScrollbarState createState() => _MaterialScrollbarState();
227 228
}

229
class _MaterialScrollbarState extends RawScrollbarState<_MaterialScrollbar> {
230 231 232 233
  late AnimationController _hoverAnimationController;
  bool _dragIsActive = false;
  bool _hoverIsActive = false;
  late ColorScheme _colorScheme;
234
  late ScrollbarThemeData _scrollbarTheme;
235 236
  // On Android, scrollbars should match native appearance.
  late bool _useAndroidScrollbar;
237

238
  @override
239
  bool get showScrollbar => widget.thumbVisibility ?? _scrollbarTheme.thumbVisibility?.resolve(_states) ?? false;
240

241 242 243
  @override
  bool get enableGestures => widget.interactive ?? _scrollbarTheme.interactive ?? !_useAndroidScrollbar;

244 245
  bool get _showTrackOnHover => widget.showTrackOnHover ?? _scrollbarTheme.showTrackOnHover ?? false;

246 247 248 249 250 251 252
  MaterialStateProperty<bool> get _trackVisibility => MaterialStateProperty.resolveWith((Set<MaterialState> states) {
    if (states.contains(MaterialState.hovered) && _showTrackOnHover) {
      return true;
    }
    return widget.trackVisibility ?? _scrollbarTheme.trackVisibility?.resolve(states) ?? 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 272 273
      case Brightness.dark:
        dragColor = onSurface.withOpacity(0.75);
        hoverColor = onSurface.withOpacity(0.65);
274 275 276
        idleColor = _useAndroidScrollbar
          ? Theme.of(context).highlightColor.withOpacity(1.0)
          : onSurface.withOpacity(0.3);
277
    }
278 279

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

      // If the track is visible, the thumb color hover animation is ignored and
      // changes immediately.
286
      if (_trackVisibility.resolve(states)) {
287
        return _scrollbarTheme.thumbColor?.resolve(states) ?? hoverColor;
288
      }
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
      if (showScrollbar && _trackVisibility.resolve(states)) {
303 304 305 306
        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
      if (showScrollbar && _trackVisibility.resolve(states)) {
317 318 319 320
        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
      if (states.contains(MaterialState.hovered) && _trackVisibility.resolve(states)) {
329
        return _scrollbarTheme.thickness?.resolve(states)
330
          ?? _kScrollbarThicknessWithTrack;
331
      }
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
    _colorScheme = theme.colorScheme;
355
    _scrollbarTheme = ScrollbarTheme.of(context);
356 357 358 359 360 361 362 363 364 365 366 367 368
    switch (theme.platform) {
      case TargetPlatform.android:
        _useAndroidScrollbar = true;
      case TargetPlatform.iOS:
      case TargetPlatform.linux:
      case TargetPlatform.fuchsia:
      case TargetPlatform.macOS:
      case TargetPlatform.windows:
        _useAndroidScrollbar = false;
    }
    super.didChangeDependencies();
  }

369 370 371 372 373 374 375 376
  @override
  void updateScrollbarPainter() {
    scrollbarPainter
      ..color = _thumbColor.resolve(_states)
      ..trackColor = _trackColor.resolve(_states)
      ..trackBorderColor = _trackBorderColor.resolve(_states)
      ..textDirection = Directionality.of(context)
      ..thickness = _thickness.resolve(_states)
377 378 379 380
      ..radius = widget.radius ?? _scrollbarTheme.radius ?? (_useAndroidScrollbar ? null : _kScrollbarRadius)
      ..crossAxisMargin = _scrollbarTheme.crossAxisMargin ?? (_useAndroidScrollbar ? 0.0 : _kScrollbarMargin)
      ..mainAxisMargin = _scrollbarTheme.mainAxisMargin ?? 0.0
      ..minLength = _scrollbarTheme.minThumbLength ?? _kScrollbarMinLength
381
      ..padding = MediaQuery.paddingOf(context)
382 383
      ..scrollbarOrientation = widget.scrollbarOrientation
      ..ignorePointer = !enableGestures;
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
  }
}