scrollable_helpers.dart 19.4 KB
Newer Older
1 2 3 4 5 6 7
// 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 'dart:math' as math;

8
import 'package:flutter/foundation.dart';
9 10 11 12 13 14 15 16 17
import 'package:flutter/rendering.dart';

import 'actions.dart';
import 'basic.dart';
import 'framework.dart';
import 'primary_scroll_controller.dart';
import 'scroll_configuration.dart';
import 'scroll_controller.dart';
import 'scroll_metrics.dart';
18
import 'scroll_physics.dart';
19 20 21 22 23
import 'scrollable.dart';

export 'package:flutter/physics.dart' show Tolerance;

/// Describes the aspects of a Scrollable widget to inform inherited widgets
24 25
/// like [ScrollBehavior] for decorating or enumerate the properties of combined
/// Scrollables, such as [TwoDimensionalScrollable].
26 27 28 29 30
///
/// Decorations like [GlowingOverscrollIndicator]s and [Scrollbar]s require
/// information about the Scrollable in order to be initialized.
@immutable
class ScrollableDetails {
31
  /// Creates a set of details describing the [Scrollable].
32 33
  const ScrollableDetails({
    required this.direction,
34 35 36 37 38 39 40 41 42 43 44
    this.controller,
    this.physics,
    @Deprecated(
      'Migrate to decorationClipBehavior. '
      'This property was deprecated so that its application is clearer. This clip '
      'applies to decorators, and does not directly clip a scroll view. '
      'This feature was deprecated after v3.9.0-1.0.pre.'
    )
    Clip? clipBehavior,
    Clip? decorationClipBehavior,
  }) : decorationClipBehavior = clipBehavior ?? decorationClipBehavior;
45

46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
  /// A constructor specific to a [Scrollable] with an [Axis.vertical].
  const ScrollableDetails.vertical({
    bool reverse = false,
    this.controller,
    this.physics,
    this.decorationClipBehavior,
  }) : direction = reverse ? AxisDirection.up : AxisDirection.down;

  /// A constructor specific to a [Scrollable] with an [Axis.horizontal].
  const ScrollableDetails.horizontal({
    bool reverse = false,
    this.controller,
    this.physics,
    this.decorationClipBehavior,
  }) : direction = reverse ? AxisDirection.left : AxisDirection.right;

  /// {@macro flutter.widgets.Scrollable.axisDirection}
63 64
  final AxisDirection direction;

65 66 67 68 69
  /// {@macro flutter.widgets.Scrollable.controller}
  final ScrollController? controller;

  /// {@macro flutter.widgets.Scrollable.physics}
  final ScrollPhysics? physics;
70 71 72

  /// {@macro flutter.material.Material.clipBehavior}
  ///
73 74 75 76 77 78
  /// This can be used by [MaterialScrollBehavior] to clip a
  /// [StretchingOverscrollIndicator].
  ///
  /// This [Clip] does not affect the [Viewport.clipBehavior], but is rather
  /// passed from the same value by [Scrollable] so that decorators like
  /// [StretchingOverscrollIndicator] honor the same clip.
79 80
  ///
  /// Defaults to null.
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 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
  final Clip? decorationClipBehavior;

  /// Deprecated getter for [decorationClipBehavior].
  @Deprecated(
    'Migrate to decorationClipBehavior. '
    'This property was deprecated so that its application is clearer. This clip '
    'applies to decorators, and does not directly clip a scroll view. '
    'This feature was deprecated after v3.9.0-1.0.pre.'
  )
  Clip? get clipBehavior => decorationClipBehavior;

  /// Copy the current [ScrollableDetails] with the given values replacing the
  /// current values.
  ScrollableDetails copyWith({
    AxisDirection? direction,
    ScrollController? controller,
    ScrollPhysics? physics,
    Clip? decorationClipBehavior,
  }) {
    return ScrollableDetails(
      direction: direction ?? this.direction,
      controller: controller ?? this.controller,
      physics: physics ?? this.physics,
      decorationClipBehavior: decorationClipBehavior ?? this.decorationClipBehavior,
    );
  }

  @override
  String toString() {
    final List<String> description = <String>[];
    description.add('axisDirection: $direction');

    void addIfNonNull(String prefix, Object? value) {
      if (value != null) {
        description.add(prefix + value.toString());
      }
    }
    addIfNonNull('scroll controller: ', controller);
    addIfNonNull('scroll physics: ', physics);
    addIfNonNull('decorationClipBehavior: ', decorationClipBehavior);
    return '${describeIdentity(this)}(${description.join(", ")})';
  }

  @override
  int get hashCode => Object.hash(
    direction,
    controller,
    physics,
    decorationClipBehavior,
  );

  @override
  bool operator ==(Object other) {
    if (identical(this, other)) {
      return true;
    }
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is ScrollableDetails
      && other.direction == direction
      && other.controller == controller
      && other.physics == physics
      && other.decorationClipBehavior == decorationClipBehavior;
  }
146 147 148 149 150 151 152 153 154 155 156 157 158
}

/// An auto scroller that scrolls the [scrollable] if a drag gesture drags close
/// to its edge.
///
/// The scroll velocity is controlled by the [velocityScalar]:
///
/// velocity = <distance of overscroll> * [velocityScalar].
class EdgeDraggingAutoScroller {
  /// Creates a auto scroller that scrolls the [scrollable].
  EdgeDraggingAutoScroller(
    this.scrollable, {
    this.onScrollViewScrolled,
159 160
    required this.velocityScalar,
  });
161 162 163 164 165 166 167 168 169 170 171

  /// The [Scrollable] this auto scroller is scrolling.
  final ScrollableState scrollable;

  /// Called when a scroll view is scrolled.
  ///
  /// The scroll view may be scrolled multiple times in a row until the drag
  /// target no longer triggers the auto scroll. This callback will be called
  /// in between each scroll.
  final VoidCallback? onScrollViewScrolled;

172
  /// {@template flutter.widgets.EdgeDraggingAutoScroller.velocityScalar}
173 174 175 176
  /// The velocity scalar per pixel over scroll.
  ///
  /// It represents how the velocity scale with the over scroll distance. The
  /// auto-scroll velocity = <distance of overscroll> * velocityScalar.
177
  /// {@endtemplate}
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375
  final double velocityScalar;

  late Rect _dragTargetRelatedToScrollOrigin;

  /// Whether the auto scroll is in progress.
  bool get scrolling => _scrolling;
  bool _scrolling = false;

  double _offsetExtent(Offset offset, Axis scrollDirection) {
    switch (scrollDirection) {
      case Axis.horizontal:
        return offset.dx;
      case Axis.vertical:
        return offset.dy;
    }
  }

  double _sizeExtent(Size size, Axis scrollDirection) {
    switch (scrollDirection) {
      case Axis.horizontal:
        return size.width;
      case Axis.vertical:
        return size.height;
    }
  }

  AxisDirection get _axisDirection => scrollable.axisDirection;
  Axis get _scrollDirection => axisDirectionToAxis(_axisDirection);

  /// Starts the auto scroll if the [dragTarget] is close to the edge.
  ///
  /// The scroll starts to scroll the [scrollable] if the target rect is close
  /// to the edge of the [scrollable]; otherwise, it remains stationary.
  ///
  /// If the scrollable is already scrolling, calling this method updates the
  /// previous dragTarget to the new value and continues scrolling if necessary.
  void startAutoScrollIfNecessary(Rect dragTarget) {
    final Offset deltaToOrigin = scrollable.deltaToScrollOrigin;
    _dragTargetRelatedToScrollOrigin = dragTarget.translate(deltaToOrigin.dx, deltaToOrigin.dy);
    if (_scrolling) {
      // The change will be picked up in the next scroll.
      return;
    }
    assert(!_scrolling);
    _scroll();
  }

  /// Stop any ongoing auto scrolling.
  void stopAutoScroll() {
    _scrolling = false;
  }

  Future<void> _scroll() async {
    final RenderBox scrollRenderBox = scrollable.context.findRenderObject()! as RenderBox;
    final Rect globalRect = MatrixUtils.transformRect(
      scrollRenderBox.getTransformTo(null),
      Rect.fromLTWH(0, 0, scrollRenderBox.size.width, scrollRenderBox.size.height),
    );
    assert(
      globalRect.size.width >= _dragTargetRelatedToScrollOrigin.size.width &&
        globalRect.size.height >= _dragTargetRelatedToScrollOrigin.size.height,
      'Drag target size is larger than scrollable size, which may cause bouncing',
    );
    _scrolling = true;
    double? newOffset;
    const double overDragMax = 20.0;

    final Offset deltaToOrigin = scrollable.deltaToScrollOrigin;
    final Offset viewportOrigin = globalRect.topLeft.translate(deltaToOrigin.dx, deltaToOrigin.dy);
    final double viewportStart = _offsetExtent(viewportOrigin, _scrollDirection);
    final double viewportEnd = viewportStart + _sizeExtent(globalRect.size, _scrollDirection);

    final double proxyStart = _offsetExtent(_dragTargetRelatedToScrollOrigin.topLeft, _scrollDirection);
    final double proxyEnd = _offsetExtent(_dragTargetRelatedToScrollOrigin.bottomRight, _scrollDirection);
    switch (_axisDirection) {
      case AxisDirection.up:
      case AxisDirection.left:
        if (proxyEnd > viewportEnd && scrollable.position.pixels > scrollable.position.minScrollExtent) {
          final double overDrag = math.min(proxyEnd - viewportEnd, overDragMax);
          newOffset = math.max(scrollable.position.minScrollExtent, scrollable.position.pixels - overDrag);
        } else if (proxyStart < viewportStart && scrollable.position.pixels < scrollable.position.maxScrollExtent) {
          final double overDrag = math.min(viewportStart - proxyStart, overDragMax);
          newOffset = math.min(scrollable.position.maxScrollExtent, scrollable.position.pixels + overDrag);
        }
      case AxisDirection.right:
      case AxisDirection.down:
        if (proxyStart < viewportStart && scrollable.position.pixels > scrollable.position.minScrollExtent) {
          final double overDrag = math.min(viewportStart - proxyStart, overDragMax);
          newOffset = math.max(scrollable.position.minScrollExtent, scrollable.position.pixels -  overDrag);
        } else if (proxyEnd > viewportEnd && scrollable.position.pixels < scrollable.position.maxScrollExtent) {
          final double overDrag = math.min(proxyEnd - viewportEnd, overDragMax);
          newOffset = math.min(scrollable.position.maxScrollExtent, scrollable.position.pixels + overDrag);
        }
    }

    if (newOffset == null || (newOffset - scrollable.position.pixels).abs() < 1.0) {
      // Drag should not trigger scroll.
      _scrolling = false;
      return;
    }
    final Duration duration = Duration(milliseconds: (1000 / velocityScalar).round());
    await scrollable.position.animateTo(
      newOffset,
      duration: duration,
      curve: Curves.linear,
    );
    if (onScrollViewScrolled != null) {
      onScrollViewScrolled!();
    }
    if (_scrolling) {
      await _scroll();
    }
  }
}

/// A typedef for a function that can calculate the offset for a type of scroll
/// increment given a [ScrollIncrementDetails].
///
/// This function is used as the type for [Scrollable.incrementCalculator],
/// which is called from a [ScrollAction].
typedef ScrollIncrementCalculator = double Function(ScrollIncrementDetails details);

/// Describes the type of scroll increment that will be performed by a
/// [ScrollAction] on a [Scrollable].
///
/// This is used to configure a [ScrollIncrementDetails] object to pass to a
/// [ScrollIncrementCalculator] function on a [Scrollable].
///
/// {@template flutter.widgets.ScrollIncrementType.intent}
/// This indicates the *intent* of the scroll, not necessarily the size. Not all
/// scrollable areas will have the concept of a "line" or "page", but they can
/// respond to the different standard key bindings that cause scrolling, which
/// are bound to keys that people use to indicate a "line" scroll (e.g.
/// control-arrowDown keys) or a "page" scroll (e.g. pageDown key). It is
/// recommended that at least the relative magnitudes of the scrolls match
/// expectations.
/// {@endtemplate}
enum ScrollIncrementType {
  /// Indicates that the [ScrollIncrementCalculator] should return the scroll
  /// distance it should move when the user requests to scroll by a "line".
  ///
  /// The distance a "line" scrolls refers to what should happen when the key
  /// binding for "scroll down/up by a line" is triggered. It's up to the
  /// [ScrollIncrementCalculator] function to decide what that means for a
  /// particular scrollable.
  line,

  /// Indicates that the [ScrollIncrementCalculator] should return the scroll
  /// distance it should move when the user requests to scroll by a "page".
  ///
  /// The distance a "page" scrolls refers to what should happen when the key
  /// binding for "scroll down/up by a page" is triggered. It's up to the
  /// [ScrollIncrementCalculator] function to decide what that means for a
  /// particular scrollable.
  page,
}

/// A details object that describes the type of scroll increment being requested
/// of a [ScrollIncrementCalculator] function, as well as the current metrics
/// for the scrollable.
class ScrollIncrementDetails {
  /// A const constructor for a [ScrollIncrementDetails].
  const ScrollIncrementDetails({
    required this.type,
    required this.metrics,
  });

  /// The type of scroll this is (e.g. line, page, etc.).
  ///
  /// {@macro flutter.widgets.ScrollIncrementType.intent}
  final ScrollIncrementType type;

  /// The current metrics of the scrollable that is being scrolled.
  final ScrollMetrics metrics;
}

/// An [Intent] that represents scrolling the nearest scrollable by an amount
/// appropriate for the [type] specified.
///
/// The actual amount of the scroll is determined by the
/// [Scrollable.incrementCalculator], or by its defaults if that is not
/// specified.
class ScrollIntent extends Intent {
  /// Creates a const [ScrollIntent] that requests scrolling in the given
  /// [direction], with the given [type].
  const ScrollIntent({
    required this.direction,
    this.type = ScrollIncrementType.line,
  });

  /// The direction in which to scroll the scrollable containing the focused
  /// widget.
  final AxisDirection direction;

  /// The type of scrolling that is intended.
  final ScrollIncrementType type;
}

376 377
/// An [Action] that scrolls the relevant [Scrollable] by the amount configured
/// in the [ScrollIntent] given to it.
378
///
379
/// If a Scrollable cannot be found above the given [BuildContext], the
380 381 382 383 384 385 386
/// [PrimaryScrollController] will be considered for default handling of
/// [ScrollAction]s.
///
/// If [Scrollable.incrementCalculator] is null for the scrollable, the default
/// for a [ScrollIntent.type] set to [ScrollIncrementType.page] is 80% of the
/// size of the scroll window, and for [ScrollIncrementType.line], 50 logical
/// pixels.
387
class ScrollAction extends ContextAction<ScrollIntent> {
388
  @override
389 390 391 392 393 394
  bool isEnabled(ScrollIntent intent, [BuildContext? context]) {
    if (context == null) {
      return false;
    }
    if (Scrollable.maybeOf(context) != null) {
      return true;
395
    }
396 397
    final ScrollController? primaryScrollController = PrimaryScrollController.maybeOf(context);
    return (primaryScrollController != null) && (primaryScrollController.hasClients);
398 399 400 401 402 403 404
  }

  /// Returns the scroll increment for a single scroll request, for use when
  /// scrolling using a hardware keyboard.
  ///
  /// Must not be called when the position is null, or when any of the position
  /// metrics (pixels, viewportDimension, maxScrollExtent, minScrollExtent) are
405 406
  /// null. The widget must have already been laid out so that the position
  /// fields are valid.
407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474
  static double _calculateScrollIncrement(ScrollableState state, { ScrollIncrementType type = ScrollIncrementType.line }) {
    assert(state.position.hasPixels);
    assert(state.resolvedPhysics == null || state.resolvedPhysics!.shouldAcceptUserOffset(state.position));
    if (state.widget.incrementCalculator != null) {
      return state.widget.incrementCalculator!(
        ScrollIncrementDetails(
          type: type,
          metrics: state.position,
        ),
      );
    }
    switch (type) {
      case ScrollIncrementType.line:
        return 50.0;
      case ScrollIncrementType.page:
        return 0.8 * state.position.viewportDimension;
    }
  }

  /// Find out how much of an increment to move by, taking the different
  /// directions into account.
  static double getDirectionalIncrement(ScrollableState state, ScrollIntent intent) {
    final double increment = _calculateScrollIncrement(state, type: intent.type);
    switch (intent.direction) {
      case AxisDirection.down:
        switch (state.axisDirection) {
          case AxisDirection.up:
            return -increment;
          case AxisDirection.down:
            return increment;
          case AxisDirection.right:
          case AxisDirection.left:
            return 0.0;
        }
      case AxisDirection.up:
        switch (state.axisDirection) {
          case AxisDirection.up:
            return increment;
          case AxisDirection.down:
            return -increment;
          case AxisDirection.right:
          case AxisDirection.left:
            return 0.0;
        }
      case AxisDirection.left:
        switch (state.axisDirection) {
          case AxisDirection.right:
            return -increment;
          case AxisDirection.left:
            return increment;
          case AxisDirection.up:
          case AxisDirection.down:
            return 0.0;
        }
      case AxisDirection.right:
        switch (state.axisDirection) {
          case AxisDirection.right:
            return increment;
          case AxisDirection.left:
            return -increment;
          case AxisDirection.up:
          case AxisDirection.down:
            return 0.0;
        }
    }
  }

  @override
475 476 477
  void invoke(ScrollIntent intent, [BuildContext? context]) {
    assert(context != null, 'Cannot scroll without a context.');
    ScrollableState? state = Scrollable.maybeOf(context!);
478
    if (state == null) {
479
      final ScrollController primaryScrollController = PrimaryScrollController.of(context);
480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526
      assert (() {
        if (primaryScrollController.positions.length != 1) {
          throw FlutterError.fromParts(<DiagnosticsNode>[
            ErrorSummary(
              'A ScrollAction was invoked with the PrimaryScrollController, but '
              'more than one ScrollPosition is attached.',
            ),
            ErrorDescription(
              'Only one ScrollPosition can be manipulated by a ScrollAction at '
              'a time.',
            ),
            ErrorHint(
              'The PrimaryScrollController can be inherited automatically by '
              'descendant ScrollViews based on the TargetPlatform and scroll '
              'direction. By default, the PrimaryScrollController is '
              'automatically inherited on mobile platforms for vertical '
              'ScrollViews. ScrollView.primary can also override this behavior.',
            ),
          ]);
        }
        return true;
      }());

      if (primaryScrollController.position.context.notificationContext == null
          && Scrollable.maybeOf(primaryScrollController.position.context.notificationContext!) == null) {
        return;
      }
      state = Scrollable.maybeOf(primaryScrollController.position.context.notificationContext!);
    }
    assert(state != null, '$ScrollAction was invoked on a context that has no scrollable parent');
    assert(state!.position.hasPixels, 'Scrollable must be laid out before it can be scrolled via a ScrollAction');

    // Don't do anything if the user isn't allowed to scroll.
    if (state!.resolvedPhysics != null && !state.resolvedPhysics!.shouldAcceptUserOffset(state.position)) {
      return;
    }
    final double increment = getDirectionalIncrement(state, intent);
    if (increment == 0.0) {
      return;
    }
    state.position.moveTo(
      state.position.pixels + increment,
      duration: const Duration(milliseconds: 100),
      curve: Curves.easeInOut,
    );
  }
}