diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index af01875ddb..8e3fff969e 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -23,7 +23,7 @@ import 'pages.dart'; import 'performance_overlay.dart'; import 'restoration.dart'; import 'router.dart'; -import 'scrollable.dart'; +import 'scrollable_helpers.dart'; import 'semantics_debugger.dart'; import 'shared_app_data.dart'; import 'shortcuts.dart'; diff --git a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart index 41945b951f..b681fa44df 100644 --- a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart +++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart @@ -9,7 +9,7 @@ import 'package:flutter/services.dart'; import 'actions.dart'; import 'focus_traversal.dart'; import 'framework.dart'; -import 'scrollable.dart'; +import 'scrollable_helpers.dart'; import 'shortcuts.dart'; import 'text_editing_intents.dart'; diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index fdeda834ee..49fee7617a 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -35,6 +35,7 @@ import 'scroll_controller.dart'; import 'scroll_physics.dart'; import 'scroll_position.dart'; import 'scrollable.dart'; +import 'scrollable_helpers.dart'; import 'shortcuts.dart'; import 'spell_check.dart'; import 'tap_region.dart'; diff --git a/packages/flutter/lib/src/widgets/reorderable_list.dart b/packages/flutter/lib/src/widgets/reorderable_list.dart index 8535bab22b..e9cd5ec3da 100644 --- a/packages/flutter/lib/src/widgets/reorderable_list.dart +++ b/packages/flutter/lib/src/widgets/reorderable_list.dart @@ -16,6 +16,7 @@ import 'scroll_controller.dart'; import 'scroll_physics.dart'; import 'scroll_view.dart'; import 'scrollable.dart'; +import 'scrollable_helpers.dart'; import 'sliver.dart'; import 'sliver_prototype_extent_list.dart'; import 'ticker_provider.dart'; diff --git a/packages/flutter/lib/src/widgets/scroll_configuration.dart b/packages/flutter/lib/src/widgets/scroll_configuration.dart index 8455e6ef7f..f25d425628 100644 --- a/packages/flutter/lib/src/widgets/scroll_configuration.dart +++ b/packages/flutter/lib/src/widgets/scroll_configuration.dart @@ -11,6 +11,7 @@ import 'framework.dart'; import 'overscroll_indicator.dart'; import 'scroll_physics.dart'; import 'scrollable.dart'; +import 'scrollable_helpers.dart'; import 'scrollbar.dart'; const Color _kDefaultGlowColor = Color(0xFFFFFFFF); diff --git a/packages/flutter/lib/src/widgets/scrollable.dart b/packages/flutter/lib/src/widgets/scrollable.dart index 71afaab249..22525d0f06 100644 --- a/packages/flutter/lib/src/widgets/scrollable.dart +++ b/packages/flutter/lib/src/widgets/scrollable.dart @@ -11,23 +11,20 @@ import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; -import 'actions.dart'; import 'basic.dart'; -import 'focus_manager.dart'; import 'framework.dart'; import 'gesture_detector.dart'; import 'media_query.dart'; import 'notification_listener.dart'; -import 'primary_scroll_controller.dart'; import 'restoration.dart'; import 'restoration_properties.dart'; import 'scroll_activity.dart'; import 'scroll_configuration.dart'; import 'scroll_context.dart'; import 'scroll_controller.dart'; -import 'scroll_metrics.dart'; import 'scroll_physics.dart'; import 'scroll_position.dart'; +import 'scrollable_helpers.dart'; import 'selectable_region.dart'; import 'selection_container.dart'; import 'ticker_provider.dart'; @@ -440,6 +437,9 @@ class _ScrollableScope extends InheritedWidget { /// [Scrollable], provide it with a [ScrollPhysics]. class ScrollableState extends State with TickerProviderStateMixin, RestorationMixin implements ScrollContext { + + // GETTERS + /// The manager for this [Scrollable] widget's viewport position. /// /// To control what kind of [ScrollPosition] is created for a [Scrollable], @@ -448,18 +448,50 @@ class ScrollableState extends State with TickerProviderStateMixin, R ScrollPosition get position => _position!; ScrollPosition? _position; - final _RestorableScrollOffset _persistedScrollOffset = _RestorableScrollOffset(); + /// The resolved [ScrollPhysics] of the [ScrollableState]. + ScrollPhysics? get resolvedPhysics => _physics; + ScrollPhysics? _physics; + + /// An [Offset] that represents the absolute distance from the origin, or 0, + /// of the [ScrollPosition] expressed in the associated [Axis]. + /// + /// Used by [EdgeDraggingAutoScroller] to progress the position forward when a + /// drag gesture reaches the edge of the [Viewport]. + Offset get deltaToScrollOrigin { + switch (axisDirection) { + case AxisDirection.down: + return Offset(0, position.pixels); + case AxisDirection.up: + return Offset(0, -position.pixels); + case AxisDirection.left: + return Offset(-position.pixels, 0); + case AxisDirection.right: + return Offset(position.pixels, 0); + } + } + + ScrollController get _effectiveScrollController => widget.controller ?? _fallbackScrollController!; @override AxisDirection get axisDirection => widget.axisDirection; + @override + TickerProvider get vsync => this; + + @override + BuildContext? get notificationContext => _gestureDetectorKey.currentContext; + + @override + BuildContext get storageContext => context; + + @override + String? get restorationId => widget.restorationId; + final _RestorableScrollOffset _persistedScrollOffset = _RestorableScrollOffset(); + late ScrollBehavior _configuration; - ScrollPhysics? _physics; ScrollController? _fallbackScrollController; DeviceGestureSettings? _mediaQueryGestureSettings; - ScrollController get _effectiveScrollController => widget.controller ?? _fallbackScrollController!; - // Only call this from places that will definitely trigger a rebuild. void _updatePosition() { _configuration = widget.scrollBehavior ?? ScrollConfiguration.of(context); @@ -575,7 +607,6 @@ class ScrollableState extends State with TickerProviderStateMixin, R super.dispose(); } - // SEMANTICS final GlobalKey _scrollSemanticsKey = GlobalKey(); @@ -667,9 +698,6 @@ class ScrollableState extends State with TickerProviderStateMixin, R } } - @override - TickerProvider get vsync => this; - @override @protected void setIgnorePointer(bool value) { @@ -683,12 +711,6 @@ class ScrollableState extends State with TickerProviderStateMixin, R } } - @override - BuildContext? get notificationContext => _gestureDetectorKey.currentContext; - - @override - BuildContext get storageContext => context; - // TOUCH HANDLERS Drag? _drag; @@ -909,9 +931,6 @@ class ScrollableState extends State with TickerProviderStateMixin, R properties.add(DiagnosticsProperty('position', position)); properties.add(DiagnosticsProperty('effective physics', _physics)); } - - @override - String? get restorationId => widget.restorationId; } /// A widget to handle selection for a scrollable. @@ -971,151 +990,6 @@ class _ScrollableSelectionHandlerState extends State<_ScrollableSelectionHandler } } -/// 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 = * [velocityScalar]. -class EdgeDraggingAutoScroller { - /// Creates a auto scroller that scrolls the [scrollable]. - EdgeDraggingAutoScroller(this.scrollable, {this.onScrollViewScrolled, this.velocityScalar = _kDefaultAutoScrollVelocityScalar}); - - // An eyeballed value for a smooth scrolling experience. - static const double _kDefaultAutoScrollVelocityScalar = 7; - - /// 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; - - /// The velocity scalar per pixel over scroll. - /// - /// It represents how the velocity scale with the over scroll distance. The - /// auto-scroll velocity = * velocityScalar. - 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 = _getDeltaToScrollOrigin(scrollable); - _dragTargetRelatedToScrollOrigin = dragTarget.translate(deltaToOrigin.dx, deltaToOrigin.dy); - if (_scrolling) { - // The change will be picked up in the next scroll. - return; - } - if (!_scrolling) { - _scroll(); - } - } - - /// Stop any ongoing auto scrolling. - void stopAutoScroll() { - _scrolling = false; - } - - Future _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 = _getDeltaToScrollOrigin(scrollable); - 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); - } - break; - 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); - } - break; - } - - 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(); - } - } -} - /// This updater handles the case where the selectables change frequently, and /// it optimizes toward scrolling updates. /// @@ -1513,41 +1387,6 @@ Offset _getDeltaToScrollOrigin(ScrollableState scrollableState) { } } -/// Describes the aspects of a Scrollable widget to inform inherited widgets -/// like [ScrollBehavior] for decorating. -/// -/// Decorations like [GlowingOverscrollIndicator]s and [Scrollbar]s require -/// information about the Scrollable in order to be initialized. -@immutable -class ScrollableDetails { - /// Creates a set of details describing the [Scrollable]. The [direction] - /// cannot be null. - const ScrollableDetails({ - required this.direction, - required this.controller, - this.clipBehavior, - }); - - /// The direction in which this widget scrolls. - /// - /// Cannot be null. - final AxisDirection direction; - - /// A [ScrollController] that can be used to control the position of the - /// [Scrollable] widget. - /// - /// This can be used by [ScrollBehavior] to apply a [Scrollbar] to the associated - /// [Scrollable]. - final ScrollController controller; - - /// {@macro flutter.material.Material.clipBehavior} - /// - /// This can be used by [MaterialScrollBehavior] to clip [StretchingOverscrollIndicator]. - /// - /// Defaults to null. - final Clip? clipBehavior; -} - /// With [_ScrollSemantics] certain child [SemanticsNode]s can be /// excluded from the scrollable area for semantics purposes. /// @@ -1695,246 +1534,6 @@ class _RenderScrollSemantics extends RenderProxyBox { } } -/// 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]. - /// - /// All of the arguments must not be null, and are required. - 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; -} - -/// An [Action] that scrolls the [Scrollable] that encloses the current -/// [primaryFocus] by the amount configured in the [ScrollIntent] given to it. -/// -/// If a Scrollable cannot be found above the current [primaryFocus], the -/// [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. -class ScrollAction extends Action { - @override - bool isEnabled(ScrollIntent intent) { - final FocusNode? focus = primaryFocus; - final bool contextIsValid = focus != null && focus.context != null; - if (contextIsValid) { - // Check for primary scrollable within the current context - if (Scrollable.maybeOf(focus.context!) != null) { - return true; - } - // Check for fallback scrollable with context from PrimaryScrollController - final ScrollController? primaryScrollController = PrimaryScrollController.maybeOf(focus.context!); - return primaryScrollController != null && primaryScrollController.hasClients; - } - return false; - } - - /// 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 - /// null. The type and state arguments must not be null, and the widget must - /// have already been laid out so that the position fields are valid. - static double _calculateScrollIncrement(ScrollableState state, { ScrollIncrementType type = ScrollIncrementType.line }) { - assert(state.position.hasPixels); - assert(state._physics == null || state._physics!.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 - void invoke(ScrollIntent intent) { - ScrollableState? state = Scrollable.maybeOf(primaryFocus!.context!); - if (state == null) { - final ScrollController primaryScrollController = PrimaryScrollController.of(primaryFocus!.context!); - assert (() { - if (primaryScrollController.positions.length != 1) { - throw FlutterError.fromParts([ - 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!._physics != null && !state._physics!.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, - ); - } -} - // Not using a RestorableDouble because we want to allow null values and override // [enabled]. class _RestorableScrollOffset extends RestorableValue { diff --git a/packages/flutter/lib/src/widgets/scrollable_helpers.dart b/packages/flutter/lib/src/widgets/scrollable_helpers.dart new file mode 100644 index 0000000000..0433889bcf --- /dev/null +++ b/packages/flutter/lib/src/widgets/scrollable_helpers.dart @@ -0,0 +1,443 @@ +// 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; + +import 'package:flutter/rendering.dart'; + +import 'actions.dart'; +import 'basic.dart'; +import 'focus_manager.dart'; +import 'framework.dart'; +import 'primary_scroll_controller.dart'; +import 'scroll_configuration.dart'; +import 'scroll_controller.dart'; +import 'scroll_metrics.dart'; +import 'scrollable.dart'; + +export 'package:flutter/physics.dart' show Tolerance; + +/// Describes the aspects of a Scrollable widget to inform inherited widgets +/// like [ScrollBehavior] for decorating. +/// +/// Decorations like [GlowingOverscrollIndicator]s and [Scrollbar]s require +/// information about the Scrollable in order to be initialized. +@immutable +class ScrollableDetails { + /// Creates a set of details describing the [Scrollable]. The [direction] + /// cannot be null. + const ScrollableDetails({ + required this.direction, + required this.controller, + this.clipBehavior, + }); + + /// The direction in which this widget scrolls. + /// + /// Cannot be null. + final AxisDirection direction; + + /// A [ScrollController] that can be used to control the position of the + /// [Scrollable] widget. + /// + /// This can be used by [ScrollBehavior] to apply a [Scrollbar] to the associated + /// [Scrollable]. + final ScrollController controller; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// This can be used by [MaterialScrollBehavior] to clip [StretchingOverscrollIndicator]. + /// + /// Defaults to null. + final Clip? clipBehavior; +} + +/// 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 = * [velocityScalar]. +class EdgeDraggingAutoScroller { + /// Creates a auto scroller that scrolls the [scrollable]. + EdgeDraggingAutoScroller( + this.scrollable, { + this.onScrollViewScrolled, + this.velocityScalar = _kDefaultAutoScrollVelocityScalar, + }); + + // An eyeballed value for a smooth scrolling experience. + static const double _kDefaultAutoScrollVelocityScalar = 7; + + /// 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; + + /// The velocity scalar per pixel over scroll. + /// + /// It represents how the velocity scale with the over scroll distance. The + /// auto-scroll velocity = * velocityScalar. + 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 _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); + } + break; + 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); + } + break; + } + + 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]. + /// + /// All of the arguments must not be null, and are required. + 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; +} + +/// An [Action] that scrolls the [Scrollable] that encloses the current +/// [primaryFocus] by the amount configured in the [ScrollIntent] given to it. +/// +/// If a Scrollable cannot be found above the current [primaryFocus], the +/// [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. +class ScrollAction extends Action { + @override + bool isEnabled(ScrollIntent intent) { + final FocusNode? focus = primaryFocus; + final bool contextIsValid = focus != null && focus.context != null; + if (contextIsValid) { + // Check for primary scrollable within the current context + if (Scrollable.maybeOf(focus.context!) != null) { + return true; + } + // Check for fallback scrollable with context from PrimaryScrollController + final ScrollController? primaryScrollController = PrimaryScrollController.maybeOf(focus.context!); + return primaryScrollController != null && primaryScrollController.hasClients; + } + return false; + } + + /// 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 + /// null. The type and state arguments must not be null, and the widget must + /// have already been laid out so that the position fields are valid. + 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 + void invoke(ScrollIntent intent) { + ScrollableState? state = Scrollable.maybeOf(primaryFocus!.context!); + if (state == null) { + final ScrollController primaryScrollController = PrimaryScrollController.of(primaryFocus!.context!); + assert (() { + if (primaryScrollController.positions.length != 1) { + throw FlutterError.fromParts([ + 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, + ); + } +} diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index f6ca4f3441..014318d99d 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -22,6 +22,7 @@ import 'scroll_metrics.dart'; import 'scroll_notification.dart'; import 'scroll_position.dart'; import 'scrollable.dart'; +import 'scrollable_helpers.dart'; import 'ticker_provider.dart'; const double _kMinThumbExtent = 18.0; diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 0464daa9c7..425d474aad 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -115,6 +115,7 @@ export 'src/widgets/scroll_position_with_single_context.dart'; export 'src/widgets/scroll_simulation.dart'; export 'src/widgets/scroll_view.dart'; export 'src/widgets/scrollable.dart'; +export 'src/widgets/scrollable_helpers.dart'; export 'src/widgets/scrollbar.dart'; export 'src/widgets/selectable_region.dart'; export 'src/widgets/selection_container.dart'; diff --git a/packages/flutter/test/widgets/scrollable_test.dart b/packages/flutter/test/widgets/scrollable_test.dart index 8415b7fa37..73a71414d5 100644 --- a/packages/flutter/test/widgets/scrollable_test.dart +++ b/packages/flutter/test/widgets/scrollable_test.dart @@ -1690,6 +1690,57 @@ void main() { expect(tester.takeException(), isNull); semantics.dispose(); }); + + testWidgets('deltaToScrollOrigin getter', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: 2000.0)), + ], + ), + ) + ); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.unknown); + expect(getScrollOffset(tester), 0.0); + await gesture.moveBy(const Offset(0.0, -200)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(getScrollOffset(tester), 200); + final ScrollableState scrollable = tester.state(find.byType(Scrollable)); + expect(scrollable.deltaToScrollOrigin, const Offset(0.0, 200)); + }); + + testWidgets('resolvedPhysics getter', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.light().copyWith( + platform: TargetPlatform.android, + ), + home: const CustomScrollView( + physics: AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: SizedBox(height: 2000.0)), + ], + ), + ) + ); + final TestGesture gesture = await tester.startGesture(tester.getCenter(find.byType(Scrollable), warnIfMissed: true), kind: ui.PointerDeviceKind.unknown); + expect(getScrollOffset(tester), 0.0); + await gesture.moveBy(const Offset(0.0, -200)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(getScrollOffset(tester), 200); + final ScrollableState scrollable = tester.state(find.byType(Scrollable)); + String types(ScrollPhysics? value) => value!.parent == null ? '${value.runtimeType}' : '${value.runtimeType} ${types(value.parent)}'; + + expect( + types(scrollable.resolvedPhysics), + 'AlwaysScrollableScrollPhysics ClampingScrollPhysics RangeMaintainingScrollPhysics', + ); + }); } // ignore: must_be_immutable