blob: 74f27b95411a943d4feec43091c4ae6aaf0cf0b2 [file] [log] [blame]
// 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/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'basic.dart';
import 'framework.dart';
import 'gesture_detector.dart';
import 'media_query.dart';
import 'notification_listener.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_physics.dart';
import 'scroll_position.dart';
import 'scrollable_helpers.dart';
import 'selectable_region.dart';
import 'selection_container.dart';
import 'ticker_provider.dart';
import 'view.dart';
import 'viewport.dart';
export 'package:flutter/physics.dart' show Tolerance;
// Examples can assume:
// late BuildContext context;
/// Signature used by [Scrollable] to build the viewport through which the
/// scrollable content is displayed.
typedef ViewportBuilder = Widget Function(BuildContext context, ViewportOffset position);
/// Signature used by [TwoDimensionalScrollable] to build the viewport through
/// which the scrollable content is displayed.
typedef TwoDimensionalViewportBuilder = Widget Function(BuildContext context, ViewportOffset verticalPosition, ViewportOffset horizontalPosition);
// The return type of _performEnsureVisible.
//
// The list of futures represents each pending ScrollPosition call to
// ensureVisible. The returned ScrollableState's context is used to find the
// next potential ancestor Scrollable.
typedef _EnsureVisibleResults = (List<Future<void>>, ScrollableState);
/// A widget that manages scrolling in one dimension and informs the [Viewport]
/// through which the content is viewed.
///
/// [Scrollable] implements the interaction model for a scrollable widget,
/// including gesture recognition, but does not have an opinion about how the
/// viewport, which actually displays the children, is constructed.
///
/// It's rare to construct a [Scrollable] directly. Instead, consider [ListView]
/// or [GridView], which combine scrolling, viewporting, and a layout model. To
/// combine layout models (or to use a custom layout mode), consider using
/// [CustomScrollView].
///
/// The static [Scrollable.of] and [Scrollable.ensureVisible] functions are
/// often used to interact with the [Scrollable] widget inside a [ListView] or
/// a [GridView].
///
/// To further customize scrolling behavior with a [Scrollable]:
///
/// 1. You can provide a [viewportBuilder] to customize the child model. For
/// example, [SingleChildScrollView] uses a viewport that displays a single
/// box child whereas [CustomScrollView] uses a [Viewport] or a
/// [ShrinkWrappingViewport], both of which display a list of slivers.
///
/// 2. You can provide a custom [ScrollController] that creates a custom
/// [ScrollPosition] subclass. For example, [PageView] uses a
/// [PageController], which creates a page-oriented scroll position subclass
/// that keeps the same page visible when the [Scrollable] resizes.
///
/// ## Persisting the scroll position during a session
///
/// Scrollables attempt to persist their scroll position using [PageStorage].
/// This can be disabled by setting [ScrollController.keepScrollOffset] to false
/// on the [controller]. If it is enabled, using a [PageStorageKey] for the
/// [key] of this widget (or one of its ancestors, e.g. a [ScrollView]) is
/// recommended to help disambiguate different [Scrollable]s from each other.
///
/// See also:
///
/// * [ListView], which is a commonly used [ScrollView] that displays a
/// scrolling, linear list of child widgets.
/// * [PageView], which is a scrolling list of child widgets that are each the
/// size of the viewport.
/// * [GridView], which is a [ScrollView] that displays a scrolling, 2D array
/// of child widgets.
/// * [CustomScrollView], which is a [ScrollView] that creates custom scroll
/// effects using slivers.
/// * [SingleChildScrollView], which is a scrollable widget that has a single
/// child.
/// * [ScrollNotification] and [NotificationListener], which can be used to watch
/// the scroll position without using a [ScrollController].
class Scrollable extends StatefulWidget {
/// Creates a widget that scrolls.
const Scrollable({
super.key,
this.axisDirection = AxisDirection.down,
this.controller,
this.physics,
required this.viewportBuilder,
this.incrementCalculator,
this.excludeFromSemantics = false,
this.semanticChildCount,
this.dragStartBehavior = DragStartBehavior.start,
this.restorationId,
this.scrollBehavior,
this.clipBehavior = Clip.hardEdge,
}) : assert(semanticChildCount == null || semanticChildCount >= 0);
/// {@template flutter.widgets.Scrollable.axisDirection}
/// The direction in which this widget scrolls.
///
/// For example, if the [Scrollable.axisDirection] is [AxisDirection.down],
/// increasing the scroll position will cause content below the bottom of the
/// viewport to become visible through the viewport. Similarly, if the
/// axisDirection is [AxisDirection.right], increasing the scroll position
/// will cause content beyond the right edge of the viewport to become visible
/// through the viewport.
///
/// Defaults to [AxisDirection.down].
/// {@endtemplate}
final AxisDirection axisDirection;
/// {@template flutter.widgets.Scrollable.controller}
/// An object that can be used to control the position to which this widget is
/// scrolled.
///
/// A [ScrollController] serves several purposes. It can be used to control
/// the initial scroll position (see [ScrollController.initialScrollOffset]).
/// It can be used to control whether the scroll view should automatically
/// save and restore its scroll position in the [PageStorage] (see
/// [ScrollController.keepScrollOffset]). It can be used to read the current
/// scroll position (see [ScrollController.offset]), or change it (see
/// [ScrollController.animateTo]).
///
/// If null, a [ScrollController] will be created internally by [Scrollable]
/// in order to create and manage the [ScrollPosition].
///
/// See also:
///
/// * [Scrollable.ensureVisible], which animates the scroll position to
/// reveal a given [BuildContext].
/// {@endtemplate}
final ScrollController? controller;
/// {@template flutter.widgets.Scrollable.physics}
/// How the widgets should respond to user input.
///
/// For example, determines how the widget continues to animate after the
/// user stops dragging the scroll view.
///
/// Defaults to matching platform conventions via the physics provided from
/// the ambient [ScrollConfiguration].
///
/// If an explicit [ScrollBehavior] is provided to
/// [Scrollable.scrollBehavior], the [ScrollPhysics] provided by that behavior
/// will take precedence after [Scrollable.physics].
///
/// The physics can be changed dynamically, but new physics will only take
/// effect if the _class_ of the provided object changes. Merely constructing
/// a new instance with a different configuration is insufficient to cause the
/// physics to be reapplied. (This is because the final object used is
/// generated dynamically, which can be relatively expensive, and it would be
/// inefficient to speculatively create this object each frame to see if the
/// physics should be updated.)
///
/// See also:
///
/// * [AlwaysScrollableScrollPhysics], which can be used to indicate that the
/// scrollable should react to scroll requests (and possible overscroll)
/// even if the scrollable's contents fit without scrolling being necessary.
/// {@endtemplate}
final ScrollPhysics? physics;
/// Builds the viewport through which the scrollable content is displayed.
///
/// A typical viewport uses the given [ViewportOffset] to determine which part
/// of its content is actually visible through the viewport.
///
/// See also:
///
/// * [Viewport], which is a viewport that displays a list of slivers.
/// * [ShrinkWrappingViewport], which is a viewport that displays a list of
/// slivers and sizes itself based on the size of the slivers.
final ViewportBuilder viewportBuilder;
/// {@template flutter.widgets.Scrollable.incrementCalculator}
/// An optional function that will be called to calculate the distance to
/// scroll when the scrollable is asked to scroll via the keyboard using a
/// [ScrollAction].
///
/// If not supplied, the [Scrollable] will scroll a default amount when a
/// keyboard navigation key is pressed (e.g. pageUp/pageDown, control-upArrow,
/// etc.), or otherwise invoked by a [ScrollAction].
///
/// If [incrementCalculator] is null, the default for
/// [ScrollIncrementType.page] is 80% of the size of the scroll window, and
/// for [ScrollIncrementType.line], 50 logical pixels.
/// {@endtemplate}
final ScrollIncrementCalculator? incrementCalculator;
/// {@template flutter.widgets.scrollable.excludeFromSemantics}
/// Whether the scroll actions introduced by this [Scrollable] are exposed
/// in the semantics tree.
///
/// Text fields with an overflow are usually scrollable to make sure that the
/// user can get to the beginning/end of the entered text. However, these
/// scrolling actions are generally not exposed to the semantics layer.
/// {@endtemplate}
///
/// See also:
///
/// * [GestureDetector.excludeFromSemantics], which is used to accomplish the
/// exclusion.
final bool excludeFromSemantics;
/// The number of children that will contribute semantic information.
///
/// The value will be null if the number of children is unknown or unbounded.
///
/// Some subtypes of [ScrollView] can infer this value automatically. For
/// example [ListView] will use the number of widgets in the child list,
/// while the [ListView.separated] constructor will use half that amount.
///
/// For [CustomScrollView] and other types which do not receive a builder
/// or list of widgets, the child count must be explicitly provided.
///
/// See also:
///
/// * [CustomScrollView], for an explanation of scroll semantics.
/// * [SemanticsConfiguration.scrollChildCount], the corresponding semantics property.
final int? semanticChildCount;
// TODO(jslavitz): Set the DragStartBehavior default to be start across all widgets.
/// {@template flutter.widgets.scrollable.dragStartBehavior}
/// Determines the way that drag start behavior is handled.
///
/// If set to [DragStartBehavior.start], scrolling drag behavior will
/// begin at the position where the drag gesture won the arena. If set to
/// [DragStartBehavior.down] it will begin at the position where a down
/// event is first detected.
///
/// In general, setting this to [DragStartBehavior.start] will make drag
/// animation smoother and setting it to [DragStartBehavior.down] will make
/// drag behavior feel slightly more reactive.
///
/// By default, the drag start behavior is [DragStartBehavior.start].
///
/// See also:
///
/// * [DragGestureRecognizer.dragStartBehavior], which gives an example for
/// the different behaviors.
///
/// {@endtemplate}
final DragStartBehavior dragStartBehavior;
/// {@template flutter.widgets.scrollable.restorationId}
/// Restoration ID to save and restore the scroll offset of the scrollable.
///
/// If a restoration id is provided, the scrollable will persist its current
/// scroll offset and restore it during state restoration.
///
/// The scroll offset is persisted in a [RestorationBucket] claimed from
/// the surrounding [RestorationScope] using the provided restoration ID.
///
/// See also:
///
/// * [RestorationManager], which explains how state restoration works in
/// Flutter.
/// {@endtemplate}
final String? restorationId;
/// {@macro flutter.widgets.shadow.scrollBehavior}
///
/// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit
/// [ScrollPhysics] is provided in [physics], it will take precedence,
/// followed by [scrollBehavior], and then the inherited ancestor
/// [ScrollBehavior].
final ScrollBehavior? scrollBehavior;
/// {@macro flutter.material.Material.clipBehavior}
///
/// Defaults to [Clip.hardEdge].
///
/// This is passed to decorators in [ScrollableDetails], and does not directly affect
/// clipping of the [Scrollable]. This reflects the same [Clip] that is provided
/// to [ScrollView.clipBehavior] and is supplied to the [Viewport].
final Clip clipBehavior;
/// The axis along which the scroll view scrolls.
///
/// Determined by the [axisDirection].
Axis get axis => axisDirectionToAxis(axisDirection);
@override
ScrollableState createState() => ScrollableState();
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty<AxisDirection>('axisDirection', axisDirection));
properties.add(DiagnosticsProperty<ScrollPhysics>('physics', physics));
properties.add(StringProperty('restorationId', restorationId));
}
/// The state from the closest instance of this class that encloses the given
/// context, or null if none is found.
///
/// Typical usage is as follows:
///
/// ```dart
/// ScrollableState? scrollable = Scrollable.maybeOf(context);
/// ```
///
/// Calling this method will create a dependency on the [ScrollableState]
/// that is returned, if there is one. This is typically the closest
/// [Scrollable], but may be a more distant ancestor if [axis] is used to
/// target a specific [Scrollable].
///
/// Using the optional [Axis] is useful when Scrollables are nested and the
/// target [Scrollable] is not the closest instance. When [axis] is provided,
/// the nearest enclosing [ScrollableState] in that [Axis] is returned, or
/// null if there is none.
///
/// This finds the nearest _ancestor_ [Scrollable] of the `context`. This
/// means that if the `context` is that of a [Scrollable], it will _not_ find
/// _that_ [Scrollable].
///
/// See also:
///
/// * [Scrollable.of], which is similar to this method, but asserts
/// if no [Scrollable] ancestor is found.
static ScrollableState? maybeOf(BuildContext context, { Axis? axis }) {
// This is the context that will need to establish the dependency.
final BuildContext originalContext = context;
InheritedElement? element = context.getElementForInheritedWidgetOfExactType<_ScrollableScope>();
while (element != null) {
final ScrollableState scrollable = (element.widget as _ScrollableScope).scrollable;
if (axis == null || axisDirectionToAxis(scrollable.axisDirection) == axis) {
// Establish the dependency on the correct context.
originalContext.dependOnInheritedElement(element);
return scrollable;
}
context = scrollable.context;
element = context.getElementForInheritedWidgetOfExactType<_ScrollableScope>();
}
return null;
}
/// The state from the closest instance of this class that encloses the given
/// context.
///
/// Typical usage is as follows:
///
/// ```dart
/// ScrollableState scrollable = Scrollable.of(context);
/// ```
///
/// Calling this method will create a dependency on the [ScrollableState]
/// that is returned, if there is one. This is typically the closest
/// [Scrollable], but may be a more distant ancestor if [axis] is used to
/// target a specific [Scrollable].
///
/// Using the optional [Axis] is useful when Scrollables are nested and the
/// target [Scrollable] is not the closest instance. When [axis] is provided,
/// the nearest enclosing [ScrollableState] in that [Axis] is returned.
///
/// This finds the nearest _ancestor_ [Scrollable] of the `context`. This
/// means that if the `context` is that of a [Scrollable], it will _not_ find
/// _that_ [Scrollable].
///
/// If no [Scrollable] ancestor is found, then this method will assert in
/// debug mode, and throw an exception in release mode.
///
/// See also:
///
/// * [Scrollable.maybeOf], which is similar to this method, but returns null
/// if no [Scrollable] ancestor is found.
static ScrollableState of(BuildContext context, { Axis? axis }) {
final ScrollableState? scrollableState = maybeOf(context, axis: axis);
assert(() {
if (scrollableState == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'Scrollable.of() was called with a context that does not contain a '
'Scrollable widget.',
),
ErrorDescription(
'No Scrollable widget ancestor could be found '
'${axis == null ? '' : 'for the provided Axis: $axis '}'
'starting from the context that was passed to Scrollable.of(). This '
'can happen because you are using a widget that looks for a Scrollable '
'ancestor, but no such ancestor exists.\n'
'The context used was:\n'
' $context',
),
if (axis != null) ErrorHint(
'When specifying an axis, this method will only look for a Scrollable '
'that matches the given Axis.',
),
]);
}
return true;
}());
return scrollableState!;
}
/// Provides a heuristic to determine if expensive frame-bound tasks should be
/// deferred for the [context] at a specific point in time.
///
/// Calling this method does _not_ create a dependency on any other widget.
/// This also means that the value returned is only good for the point in time
/// when it is called, and callers will not get updated if the value changes.
///
/// The heuristic used is determined by the [physics] of this [Scrollable]
/// via [ScrollPhysics.recommendDeferredLoading]. That method is called with
/// the current [ScrollPosition.activity]'s [ScrollActivity.velocity].
///
/// The optional [Axis] allows targeting of a specific [Scrollable] of that
/// axis, useful when Scrollables are nested. When [axis] is provided,
/// [ScrollPosition.recommendDeferredLoading] is called for the nearest
/// [Scrollable] in that [Axis].
///
/// If there is no [Scrollable] in the widget tree above the [context], this
/// method returns false.
static bool recommendDeferredLoadingForContext(BuildContext context, { Axis? axis }) {
_ScrollableScope? widget = context.getInheritedWidgetOfExactType<_ScrollableScope>();
while (widget != null) {
if (axis == null || axisDirectionToAxis(widget.scrollable.axisDirection) == axis) {
return widget.position.recommendDeferredLoading(context);
}
context = widget.scrollable.context;
widget = context.getInheritedWidgetOfExactType<_ScrollableScope>();
}
return false;
}
/// Scrolls the scrollables that enclose the given context so as to make the
/// given context visible.
///
/// If the [Scrollable] of the provided [BuildContext] is a
/// [TwoDimensionalScrollable], both vertical and horizontal axes will ensure
/// the target is made visible.
static Future<void> ensureVisible(
BuildContext context, {
double alignment = 0.0,
Duration duration = Duration.zero,
Curve curve = Curves.ease,
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
}) {
final List<Future<void>> futures = <Future<void>>[];
// The targetRenderObject is used to record the first target renderObject.
// If there are multiple scrollable widgets nested, the targetRenderObject
// is made to be as visible as possible to improve the user experience. If
// the targetRenderObject is already visible, then let the outer
// renderObject be as visible as possible.
//
// Also see https://github.com/flutter/flutter/issues/65100
RenderObject? targetRenderObject;
ScrollableState? scrollable = Scrollable.maybeOf(context);
while (scrollable != null) {
final List<Future<void>> newFutures;
(newFutures, scrollable) = scrollable._performEnsureVisible(
context.findRenderObject()!,
alignment: alignment,
duration: duration,
curve: curve,
alignmentPolicy: alignmentPolicy,
targetRenderObject: targetRenderObject,
);
futures.addAll(newFutures);
targetRenderObject ??= context.findRenderObject();
context = scrollable.context;
scrollable = Scrollable.maybeOf(context);
}
if (futures.isEmpty || duration == Duration.zero) {
return Future<void>.value();
}
if (futures.length == 1) {
return futures.single;
}
return Future.wait<void>(futures).then<void>((List<void> _) => null);
}
}
// Enable Scrollable.of() to work as if ScrollableState was an inherited widget.
// ScrollableState.build() always rebuilds its _ScrollableScope.
class _ScrollableScope extends InheritedWidget {
const _ScrollableScope({
required this.scrollable,
required this.position,
required super.child,
});
final ScrollableState scrollable;
final ScrollPosition position;
@override
bool updateShouldNotify(_ScrollableScope old) {
return position != old.position;
}
}
/// State object for a [Scrollable] widget.
///
/// To manipulate a [Scrollable] widget's scroll position, use the object
/// obtained from the [position] property.
///
/// To be informed of when a [Scrollable] widget is scrolling, use a
/// [NotificationListener] to listen for [ScrollNotification] notifications.
///
/// This class is not intended to be subclassed. To specialize the behavior of a
/// [Scrollable], provide it with a [ScrollPhysics].
class ScrollableState extends State<Scrollable> 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],
/// provide it with custom [ScrollController] that creates the appropriate
/// [ScrollPosition] in its [ScrollController.createScrollPosition] method.
ScrollPosition get position => _position!;
ScrollPosition? _position;
/// 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 {
return switch (axisDirection) {
AxisDirection.up => Offset(0, -position.pixels),
AxisDirection.down => Offset(0, position.pixels),
AxisDirection.left => Offset(-position.pixels, 0),
AxisDirection.right => Offset(position.pixels, 0),
};
}
ScrollController get _effectiveScrollController => widget.controller ?? _fallbackScrollController!;
@override
AxisDirection get axisDirection => widget.axisDirection;
@override
TickerProvider get vsync => this;
@override
double get devicePixelRatio => _devicePixelRatio;
late double _devicePixelRatio;
@override
BuildContext? get notificationContext => _gestureDetectorKey.currentContext;
@override
BuildContext get storageContext => context;
@override
String? get restorationId => widget.restorationId;
final _RestorableScrollOffset _persistedScrollOffset = _RestorableScrollOffset();
late ScrollBehavior _configuration;
ScrollController? _fallbackScrollController;
DeviceGestureSettings? _mediaQueryGestureSettings;
// Only call this from places that will definitely trigger a rebuild.
void _updatePosition() {
_configuration = widget.scrollBehavior ?? ScrollConfiguration.of(context);
_physics = _configuration.getScrollPhysics(context);
if (widget.physics != null) {
_physics = widget.physics!.applyTo(_physics);
} else if (widget.scrollBehavior != null) {
_physics = widget.scrollBehavior!.getScrollPhysics(context).applyTo(_physics);
}
final ScrollPosition? oldPosition = _position;
if (oldPosition != null) {
_effectiveScrollController.detach(oldPosition);
// It's important that we not dispose the old position until after the
// viewport has had a chance to unregister its listeners from the old
// position. So, schedule a microtask to do it.
scheduleMicrotask(oldPosition.dispose);
}
_position = _effectiveScrollController.createScrollPosition(_physics!, this, oldPosition);
assert(_position != null);
_effectiveScrollController.attach(position);
}
@override
void restoreState(RestorationBucket? oldBucket, bool initialRestore) {
registerForRestoration(_persistedScrollOffset, 'offset');
assert(_position != null);
if (_persistedScrollOffset.value != null) {
position.restoreOffset(_persistedScrollOffset.value!, initialRestore: initialRestore);
}
}
@override
void saveOffset(double offset) {
assert(debugIsSerializableForRestoration(offset));
_persistedScrollOffset.value = offset;
// [saveOffset] is called after a scrolling ends and it is usually not
// followed by a frame. Therefore, manually flush restoration data.
ServicesBinding.instance.restorationManager.flushData();
}
@override
void initState() {
if (widget.controller == null) {
_fallbackScrollController = ScrollController();
}
super.initState();
}
@override
void didChangeDependencies() {
_mediaQueryGestureSettings = MediaQuery.maybeGestureSettingsOf(context);
_devicePixelRatio = MediaQuery.maybeDevicePixelRatioOf(context) ?? View.of(context).devicePixelRatio;
_updatePosition();
super.didChangeDependencies();
}
bool _shouldUpdatePosition(Scrollable oldWidget) {
if ((widget.scrollBehavior == null) != (oldWidget.scrollBehavior == null)) {
return true;
}
if (widget.scrollBehavior != null && oldWidget.scrollBehavior != null && widget.scrollBehavior!.shouldNotify(oldWidget.scrollBehavior!)) {
return true;
}
ScrollPhysics? newPhysics = widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context);
ScrollPhysics? oldPhysics = oldWidget.physics ?? oldWidget.scrollBehavior?.getScrollPhysics(context);
do {
if (newPhysics?.runtimeType != oldPhysics?.runtimeType) {
return true;
}
newPhysics = newPhysics?.parent;
oldPhysics = oldPhysics?.parent;
} while (newPhysics != null || oldPhysics != null);
return widget.controller?.runtimeType != oldWidget.controller?.runtimeType;
}
@override
void didUpdateWidget(Scrollable oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller != oldWidget.controller) {
if (oldWidget.controller == null) {
// The old controller was null, meaning the fallback cannot be null.
// Dispose of the fallback.
assert(_fallbackScrollController != null);
assert(widget.controller != null);
_fallbackScrollController!.detach(position);
_fallbackScrollController!.dispose();
_fallbackScrollController = null;
} else {
// The old controller was not null, detach.
oldWidget.controller?.detach(position);
if (widget.controller == null) {
// If the new controller is null, we need to set up the fallback
// ScrollController.
_fallbackScrollController = ScrollController();
}
}
// Attach the updated effective scroll controller.
_effectiveScrollController.attach(position);
}
if (_shouldUpdatePosition(oldWidget)) {
_updatePosition();
}
}
@override
void dispose() {
if (widget.controller != null) {
widget.controller!.detach(position);
} else {
_fallbackScrollController?.detach(position);
_fallbackScrollController?.dispose();
}
position.dispose();
_persistedScrollOffset.dispose();
super.dispose();
}
// SEMANTICS
final GlobalKey _scrollSemanticsKey = GlobalKey();
@override
@protected
void setSemanticsActions(Set<SemanticsAction> actions) {
if (_gestureDetectorKey.currentState != null) {
_gestureDetectorKey.currentState!.replaceSemanticsActions(actions);
}
}
// GESTURE RECOGNITION AND POINTER IGNORING
final GlobalKey<RawGestureDetectorState> _gestureDetectorKey = GlobalKey<RawGestureDetectorState>();
final GlobalKey _ignorePointerKey = GlobalKey();
// This field is set during layout, and then reused until the next time it is set.
Map<Type, GestureRecognizerFactory> _gestureRecognizers = const <Type, GestureRecognizerFactory>{};
bool _shouldIgnorePointer = false;
bool? _lastCanDrag;
Axis? _lastAxisDirection;
@override
@protected
void setCanDrag(bool value) {
if (value == _lastCanDrag && (!value || widget.axis == _lastAxisDirection)) {
return;
}
if (!value) {
_gestureRecognizers = const <Type, GestureRecognizerFactory>{};
// Cancel the active hold/drag (if any) because the gesture recognizers
// will soon be disposed by our RawGestureDetector, and we won't be
// receiving pointer up events to cancel the hold/drag.
_handleDragCancel();
} else {
switch (widget.axis) {
case Axis.vertical:
_gestureRecognizers = <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(supportedDevices: _configuration.dragDevices),
(VerticalDragGestureRecognizer instance) {
instance
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel
..minFlingDistance = _physics?.minFlingDistance
..minFlingVelocity = _physics?.minFlingVelocity
..maxFlingVelocity = _physics?.maxFlingVelocity
..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
..dragStartBehavior = widget.dragStartBehavior
..multitouchDragStrategy = _configuration.getMultitouchDragStrategy(context)
..gestureSettings = _mediaQueryGestureSettings
..supportedDevices = _configuration.dragDevices;
},
),
};
case Axis.horizontal:
_gestureRecognizers = <Type, GestureRecognizerFactory>{
HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(supportedDevices: _configuration.dragDevices),
(HorizontalDragGestureRecognizer instance) {
instance
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel
..minFlingDistance = _physics?.minFlingDistance
..minFlingVelocity = _physics?.minFlingVelocity
..maxFlingVelocity = _physics?.maxFlingVelocity
..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
..dragStartBehavior = widget.dragStartBehavior
..multitouchDragStrategy = _configuration.getMultitouchDragStrategy(context)
..gestureSettings = _mediaQueryGestureSettings
..supportedDevices = _configuration.dragDevices;
},
),
};
}
}
_lastCanDrag = value;
_lastAxisDirection = widget.axis;
if (_gestureDetectorKey.currentState != null) {
_gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers);
}
}
@override
@protected
void setIgnorePointer(bool value) {
if (_shouldIgnorePointer == value) {
return;
}
_shouldIgnorePointer = value;
if (_ignorePointerKey.currentContext != null) {
final RenderIgnorePointer renderBox = _ignorePointerKey.currentContext!.findRenderObject()! as RenderIgnorePointer;
renderBox.ignoring = _shouldIgnorePointer;
}
}
// TOUCH HANDLERS
Drag? _drag;
ScrollHoldController? _hold;
void _handleDragDown(DragDownDetails details) {
assert(_drag == null);
assert(_hold == null);
_hold = position.hold(_disposeHold);
}
void _handleDragStart(DragStartDetails details) {
// It's possible for _hold to become null between _handleDragDown and
// _handleDragStart, for example if some user code calls jumpTo or otherwise
// triggers a new activity to begin.
assert(_drag == null);
_drag = position.drag(details, _disposeDrag);
assert(_drag != null);
assert(_hold == null);
}
void _handleDragUpdate(DragUpdateDetails details) {
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_drag?.update(details);
}
void _handleDragEnd(DragEndDetails details) {
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_drag?.end(details);
assert(_drag == null);
}
void _handleDragCancel() {
if (_gestureDetectorKey.currentContext == null) {
// The cancel was caused by the GestureDetector getting disposed, which
// means we will get disposed momentarily as well and shouldn't do
// any work.
return;
}
// _hold might be null if the drag started.
// _drag might be null if the drag activity ended and called _disposeDrag.
assert(_hold == null || _drag == null);
_hold?.cancel();
_drag?.cancel();
assert(_hold == null);
assert(_drag == null);
}
void _disposeHold() {
_hold = null;
}
void _disposeDrag() {
_drag = null;
}
// SCROLL WHEEL
// Returns the offset that should result from applying [event] to the current
// position, taking min/max scroll extent into account.
double _targetScrollOffsetForPointerScroll(double delta) {
return math.min(
math.max(position.pixels + delta, position.minScrollExtent),
position.maxScrollExtent,
);
}
// Returns the delta that should result from applying [event] with axis,
// direction, and any modifiers specified by the ScrollBehavior taken into
// account.
double _pointerSignalEventDelta(PointerScrollEvent event) {
final Set<LogicalKeyboardKey> pressed = HardwareKeyboard.instance.logicalKeysPressed;
final bool flipAxes = pressed.any(_configuration.pointerAxisModifiers.contains) &&
// Axes are only flipped for physical mouse wheel input.
// On some platforms, like web, trackpad input is handled through pointer
// signals, but should not be included in this axis modifying behavior.
// This is because on a trackpad, all directional axes are available to
// the user, while mouse scroll wheels typically are restricted to one
// axis.
event.kind == PointerDeviceKind.mouse;
final Axis axis = flipAxes ? flipAxis(widget.axis) : widget.axis;
final double delta = switch (axis) {
Axis.horizontal => event.scrollDelta.dx,
Axis.vertical => event.scrollDelta.dy,
};
return axisDirectionIsReversed(widget.axisDirection) ? -delta : delta;
}
void _receivedPointerSignal(PointerSignalEvent event) {
if (event is PointerScrollEvent && _position != null) {
if (_physics != null && !_physics!.shouldAcceptUserOffset(position)) {
return;
}
final double delta = _pointerSignalEventDelta(event);
final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
// Only express interest in the event if it would actually result in a scroll.
if (delta != 0.0 && targetScrollOffset != position.pixels) {
GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll);
}
} else if (event is PointerScrollInertiaCancelEvent) {
position.pointerScroll(0);
// Don't use the pointer signal resolver, all hit-tested scrollables should stop.
}
}
void _handlePointerScroll(PointerEvent event) {
assert(event is PointerScrollEvent);
final double delta = _pointerSignalEventDelta(event as PointerScrollEvent);
final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta);
if (delta != 0.0 && targetScrollOffset != position.pixels) {
position.pointerScroll(delta);
}
}
bool _handleScrollMetricsNotification(ScrollMetricsNotification notification) {
if (notification.depth == 0) {
final RenderObject? scrollSemanticsRenderObject = _scrollSemanticsKey.currentContext?.findRenderObject();
if (scrollSemanticsRenderObject != null) {
scrollSemanticsRenderObject.markNeedsSemanticsUpdate();
}
}
return false;
}
Widget _buildChrome(BuildContext context, Widget child) {
final ScrollableDetails details = ScrollableDetails(
direction: widget.axisDirection,
controller: _effectiveScrollController,
decorationClipBehavior: widget.clipBehavior,
);
return _configuration.buildScrollbar(
context,
_configuration.buildOverscrollIndicator(context, child, details),
details,
);
}
// DESCRIPTION
@override
Widget build(BuildContext context) {
assert(_position != null);
// _ScrollableScope must be placed above the BuildContext returned by notificationContext
// so that we can get this ScrollableState by doing the following:
//
// ScrollNotification notification;
// Scrollable.of(notification.context)
//
// Since notificationContext is pointing to _gestureDetectorKey.context, _ScrollableScope
// must be placed above the widget using it: RawGestureDetector
Widget result = _ScrollableScope(
scrollable: this,
position: position,
child: Listener(
onPointerSignal: _receivedPointerSignal,
child: RawGestureDetector(
key: _gestureDetectorKey,
gestures: _gestureRecognizers,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: widget.excludeFromSemantics,
child: Semantics(
explicitChildNodes: !widget.excludeFromSemantics,
child: IgnorePointer(
key: _ignorePointerKey,
ignoring: _shouldIgnorePointer,
child: widget.viewportBuilder(context, position),
),
),
),
),
);
if (!widget.excludeFromSemantics) {
result = NotificationListener<ScrollMetricsNotification>(
onNotification: _handleScrollMetricsNotification,
child: _ScrollSemantics(
key: _scrollSemanticsKey,
position: position,
allowImplicitScrolling: _physics!.allowImplicitScrolling,
semanticChildCount: widget.semanticChildCount,
child: result,
)
);
}
result = _buildChrome(context, result);
// Selection is only enabled when there is a parent registrar.
final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context);
if (registrar != null) {
result = _ScrollableSelectionHandler(
state: this,
position: position,
registrar: registrar,
child: result,
);
}
return result;
}
// Returns the Future from calling ensureVisible for the ScrollPosition, as
// as well as this ScrollableState instance so its context can be used to
// check for other ancestor Scrollables in executing ensureVisible.
_EnsureVisibleResults _performEnsureVisible(
RenderObject object, {
double alignment = 0.0,
Duration duration = Duration.zero,
Curve curve = Curves.ease,
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
RenderObject? targetRenderObject,
}) {
final Future<void> ensureVisibleFuture = position.ensureVisible(
object,
alignment: alignment,
duration: duration,
curve: curve,
alignmentPolicy: alignmentPolicy,
targetRenderObject: targetRenderObject,
);
return (<Future<void>>[ ensureVisibleFuture ], this);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<ScrollPosition>('position', _position));
properties.add(DiagnosticsProperty<ScrollPhysics>('effective physics', _physics));
}
}
/// A widget to handle selection for a scrollable.
///
/// This widget registers itself to the [registrar] and uses
/// [SelectionContainer] to collect selectables from its subtree.
class _ScrollableSelectionHandler extends StatefulWidget {
const _ScrollableSelectionHandler({
required this.state,
required this.position,
required this.registrar,
required this.child,
});
final ScrollableState state;
final ScrollPosition position;
final Widget child;
final SelectionRegistrar registrar;
@override
_ScrollableSelectionHandlerState createState() => _ScrollableSelectionHandlerState();
}
class _ScrollableSelectionHandlerState extends State<_ScrollableSelectionHandler> {
late _ScrollableSelectionContainerDelegate _selectionDelegate;
@override
void initState() {
super.initState();
_selectionDelegate = _ScrollableSelectionContainerDelegate(
state: widget.state,
position: widget.position,
);
}
@override
void didUpdateWidget(_ScrollableSelectionHandler oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.position != widget.position) {
_selectionDelegate.position = widget.position;
}
}
@override
void dispose() {
_selectionDelegate.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SelectionContainer(
registrar: widget.registrar,
delegate: _selectionDelegate,
child: widget.child,
);
}
}
/// This updater handles the case where the selectables change frequently, and
/// it optimizes toward scrolling updates.
///
/// It keeps track of the drag start offset relative to scroll origin for every
/// selectable. The records are used to determine whether the selection is up to
/// date with the scroll position when it sends the drag update event to a
/// selectable.
class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionContainerDelegate {
_ScrollableSelectionContainerDelegate({
required this.state,
required ScrollPosition position
}) : _position = position,
_autoScroller = EdgeDraggingAutoScroller(state, velocityScalar: _kDefaultSelectToScrollVelocityScalar) {
_position.addListener(_scheduleLayoutChange);
}
// Pointer drag is a single point, it should not have a size.
static const double _kDefaultDragTargetSize = 0;
// An eye-balled value for a smooth scrolling speed.
static const double _kDefaultSelectToScrollVelocityScalar = 30;
final ScrollableState state;
final EdgeDraggingAutoScroller _autoScroller;
bool _scheduledLayoutChange = false;
Offset? _currentDragStartRelatedToOrigin;
Offset? _currentDragEndRelatedToOrigin;
// The scrollable only auto scrolls if the selection starts in the scrollable.
bool _selectionStartsInScrollable = false;
ScrollPosition get position => _position;
ScrollPosition _position;
set position(ScrollPosition other) {
if (other == _position) {
return;
}
_position.removeListener(_scheduleLayoutChange);
_position = other;
_position.addListener(_scheduleLayoutChange);
}
// The layout will only be updated a frame later than position changes.
// Schedule PostFrameCallback to capture the accurate layout.
void _scheduleLayoutChange() {
if (_scheduledLayoutChange) {
return;
}
_scheduledLayoutChange = true;
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
if (!_scheduledLayoutChange) {
return;
}
_scheduledLayoutChange = false;
layoutDidChange();
}, debugLabel: 'ScrollableSelectionContainer.layoutDidChange');
}
/// Stores the scroll offset when a scrollable receives the last
/// [SelectionEdgeUpdateEvent].
///
/// The stored scroll offset may be null if a scrollable never receives a
/// [SelectionEdgeUpdateEvent].
///
/// When a new [SelectionEdgeUpdateEvent] is dispatched to a selectable, this
/// updater checks the current scroll offset against the one stored in these
/// records. If the scroll offset is different, it synthesizes an opposite
/// [SelectionEdgeUpdateEvent] and dispatches the event before dispatching the
/// new event.
///
/// For example, if a selectable receives an end [SelectionEdgeUpdateEvent]
/// and its scroll offset in the records is different from the current value,
/// it synthesizes a start [SelectionEdgeUpdateEvent] and dispatches it before
/// dispatching the original end [SelectionEdgeUpdateEvent].
final Map<Selectable, double> _selectableStartEdgeUpdateRecords = <Selectable, double>{};
final Map<Selectable, double> _selectableEndEdgeUpdateRecords = <Selectable, double>{};
@override
void didChangeSelectables() {
final Set<Selectable> selectableSet = selectables.toSet();
_selectableStartEdgeUpdateRecords.removeWhere((Selectable key, double value) => !selectableSet.contains(key));
_selectableEndEdgeUpdateRecords.removeWhere((Selectable key, double value) => !selectableSet.contains(key));
super.didChangeSelectables();
}
@override
SelectionResult handleClearSelection(ClearSelectionEvent event) {
_selectableStartEdgeUpdateRecords.clear();
_selectableEndEdgeUpdateRecords.clear();
_currentDragStartRelatedToOrigin = null;
_currentDragEndRelatedToOrigin = null;
_selectionStartsInScrollable = false;
return super.handleClearSelection(event);
}
@override
SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) {
if (_currentDragEndRelatedToOrigin == null && _currentDragStartRelatedToOrigin == null) {
assert(!_selectionStartsInScrollable);
_selectionStartsInScrollable = _globalPositionInScrollable(event.globalPosition);
}
final Offset deltaToOrigin = _getDeltaToScrollOrigin(state);
if (event.type == SelectionEventType.endEdgeUpdate) {
_currentDragEndRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition);
final Offset endOffset = _currentDragEndRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy);
event = SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset, granularity: event.granularity);
} else {
_currentDragStartRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition);
final Offset startOffset = _currentDragStartRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy);
event = SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset, granularity: event.granularity);
}
final SelectionResult result = super.handleSelectionEdgeUpdate(event);
// Result may be pending if one of the selectable child is also a scrollable.
// In that case, the parent scrollable needs to wait for the child to finish
// scrolling.
if (result == SelectionResult.pending) {
_autoScroller.stopAutoScroll();
return result;
}
if (_selectionStartsInScrollable) {
_autoScroller.startAutoScrollIfNecessary(_dragTargetFromEvent(event));
if (_autoScroller.scrolling) {
return SelectionResult.pending;
}
}
return result;
}
Offset _inferPositionRelatedToOrigin(Offset globalPosition) {
final RenderBox box = state.context.findRenderObject()! as RenderBox;
final Offset localPosition = box.globalToLocal(globalPosition);
if (!_selectionStartsInScrollable) {
// If the selection starts outside of the scrollable, selecting across the
// scrollable boundary will act as selecting the entire content in the
// scrollable. This logic move the offset to the 0.0 or infinity to cover
// the entire content if the input position is outside of the scrollable.
if (localPosition.dy < 0 || localPosition.dx < 0) {
return box.localToGlobal(Offset.zero);
}
if (localPosition.dy > box.size.height || localPosition.dx > box.size.width) {
return Offset.infinite;
}
}
final Offset deltaToOrigin = _getDeltaToScrollOrigin(state);
return box.localToGlobal(localPosition.translate(deltaToOrigin.dx, deltaToOrigin.dy));
}
/// Infers the [_currentDragStartRelatedToOrigin] and
/// [_currentDragEndRelatedToOrigin] from the geometry.
///
/// This method is called after a select word and select all event where the
/// selection is triggered by none drag events. The
/// [_currentDragStartRelatedToOrigin] and [_currentDragEndRelatedToOrigin]
/// are essential to handle future [SelectionEdgeUpdateEvent]s.
void _updateDragLocationsFromGeometries({bool forceUpdateStart = true, bool forceUpdateEnd = true}) {
final Offset deltaToOrigin = _getDeltaToScrollOrigin(state);
final RenderBox box = state.context.findRenderObject()! as RenderBox;
final Matrix4 transform = box.getTransformTo(null);
if (currentSelectionStartIndex != -1 && (_currentDragStartRelatedToOrigin == null || forceUpdateStart)) {
final SelectionGeometry geometry = selectables[currentSelectionStartIndex].value;
assert(geometry.hasSelection);
final SelectionPoint start = geometry.startSelectionPoint!;
final Matrix4 childTransform = selectables[currentSelectionStartIndex].getTransformTo(box);
final Offset localDragStart = MatrixUtils.transformPoint(
childTransform,
start.localPosition + Offset(0, - start.lineHeight / 2),
);
_currentDragStartRelatedToOrigin = MatrixUtils.transformPoint(transform, localDragStart + deltaToOrigin);
}
if (currentSelectionEndIndex != -1 && (_currentDragEndRelatedToOrigin == null || forceUpdateEnd)) {
final SelectionGeometry geometry = selectables[currentSelectionEndIndex].value;
assert(geometry.hasSelection);
final SelectionPoint end = geometry.endSelectionPoint!;
final Matrix4 childTransform = selectables[currentSelectionEndIndex].getTransformTo(box);
final Offset localDragEnd = MatrixUtils.transformPoint(
childTransform,
end.localPosition + Offset(0, - end.lineHeight / 2),
);
_currentDragEndRelatedToOrigin = MatrixUtils.transformPoint(transform, localDragEnd + deltaToOrigin);
}
}
@override
SelectionResult handleSelectAll(SelectAllSelectionEvent event) {
assert(!_selectionStartsInScrollable);
final SelectionResult result = super.handleSelectAll(event);
assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1));
if (currentSelectionStartIndex != -1) {
_updateDragLocationsFromGeometries();
}
return result;
}
@override
SelectionResult handleSelectWord(SelectWordSelectionEvent event) {
_selectionStartsInScrollable = _globalPositionInScrollable(event.globalPosition);
final SelectionResult result = super.handleSelectWord(event);
_updateDragLocationsFromGeometries();
return result;
}
@override
SelectionResult handleGranularlyExtendSelection(GranularlyExtendSelectionEvent event) {
final SelectionResult result = super.handleGranularlyExtendSelection(event);
// The selection geometry may not have the accurate offset for the edges
// that are outside of the viewport whose transform may not be valid. Only
// the edge this event is updating is sure to be accurate.
_updateDragLocationsFromGeometries(
forceUpdateStart: !event.isEnd,
forceUpdateEnd: event.isEnd,
);
if (_selectionStartsInScrollable) {
_jumpToEdge(event.isEnd);
}
return result;
}
@override
SelectionResult handleDirectionallyExtendSelection(DirectionallyExtendSelectionEvent event) {
final SelectionResult result = super.handleDirectionallyExtendSelection(event);
// The selection geometry may not have the accurate offset for the edges
// that are outside of the viewport whose transform may not be valid. Only
// the edge this event is updating is sure to be accurate.
_updateDragLocationsFromGeometries(
forceUpdateStart: !event.isEnd,
forceUpdateEnd: event.isEnd,
);
if (_selectionStartsInScrollable) {
_jumpToEdge(event.isEnd);
}
return result;
}
void _jumpToEdge(bool isExtent) {
final Selectable selectable;
final double? lineHeight;
final SelectionPoint? edge;
if (isExtent) {
selectable = selectables[currentSelectionEndIndex];
edge = selectable.value.endSelectionPoint;
lineHeight = selectable.value.endSelectionPoint!.lineHeight;
} else {
selectable = selectables[currentSelectionStartIndex];
edge = selectable.value.startSelectionPoint;
lineHeight = selectable.value.startSelectionPoint?.lineHeight;
}
if (lineHeight == null || edge == null) {
return;
}
final RenderBox scrollableBox = state.context.findRenderObject()! as RenderBox;
final Matrix4 transform = selectable.getTransformTo(scrollableBox);
final Offset edgeOffsetInScrollableCoordinates = MatrixUtils.transformPoint(transform, edge.localPosition);
final Rect scrollableRect = Rect.fromLTRB(0, 0, scrollableBox.size.width, scrollableBox.size.height);
switch (state.axisDirection) {
case AxisDirection.up:
final double edgeBottom = edgeOffsetInScrollableCoordinates.dy;
final double edgeTop = edgeOffsetInScrollableCoordinates.dy - lineHeight;
if (edgeBottom >= scrollableRect.bottom && edgeTop <= scrollableRect.top) {
return;
}
if (edgeBottom > scrollableRect.bottom) {
position.jumpTo(position.pixels + scrollableRect.bottom - edgeBottom);
return;
}
if (edgeTop < scrollableRect.top) {
position.jumpTo(position.pixels + scrollableRect.top - edgeTop);
}
return;
case AxisDirection.right:
final double edge = edgeOffsetInScrollableCoordinates.dx;
if (edge >= scrollableRect.right && edge <= scrollableRect.left) {
return;
}
if (edge > scrollableRect.right) {
position.jumpTo(position.pixels + edge - scrollableRect.right);
return;
}
if (edge < scrollableRect.left) {
position.jumpTo(position.pixels + edge - scrollableRect.left);
}
return;
case AxisDirection.down:
final double edgeBottom = edgeOffsetInScrollableCoordinates.dy;
final double edgeTop = edgeOffsetInScrollableCoordinates.dy - lineHeight;
if (edgeBottom >= scrollableRect.bottom && edgeTop <= scrollableRect.top) {
return;
}
if (edgeBottom > scrollableRect.bottom) {
position.jumpTo(position.pixels + edgeBottom - scrollableRect.bottom);
return;
}
if (edgeTop < scrollableRect.top) {
position.jumpTo(position.pixels + edgeTop - scrollableRect.top);
}
return;
case AxisDirection.left:
final double edge = edgeOffsetInScrollableCoordinates.dx;
if (edge >= scrollableRect.right && edge <= scrollableRect.left) {
return;
}
if (edge > scrollableRect.right) {
position.jumpTo(position.pixels + scrollableRect.right - edge);
return;
}
if (edge < scrollableRect.left) {
position.jumpTo(position.pixels + scrollableRect.left - edge);
}
return;
}
}
bool _globalPositionInScrollable(Offset globalPosition) {
final RenderBox box = state.context.findRenderObject()! as RenderBox;
final Offset localPosition = box.globalToLocal(globalPosition);
final Rect rect = Rect.fromLTWH(0, 0, box.size.width, box.size.height);
return rect.contains(localPosition);
}
Rect _dragTargetFromEvent(SelectionEdgeUpdateEvent event) {
return Rect.fromCenter(center: event.globalPosition, width: _kDefaultDragTargetSize, height: _kDefaultDragTargetSize);
}
@override
SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) {
switch (event.type) {
case SelectionEventType.startEdgeUpdate:
_selectableStartEdgeUpdateRecords[selectable] = state.position.pixels;
ensureChildUpdated(selectable);
case SelectionEventType.endEdgeUpdate:
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
ensureChildUpdated(selectable);
case SelectionEventType.granularlyExtendSelection:
case SelectionEventType.directionallyExtendSelection:
ensureChildUpdated(selectable);
_selectableStartEdgeUpdateRecords[selectable] = state.position.pixels;
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
case SelectionEventType.clear:
_selectableEndEdgeUpdateRecords.remove(selectable);
_selectableStartEdgeUpdateRecords.remove(selectable);
case SelectionEventType.selectAll:
case SelectionEventType.selectWord:
case SelectionEventType.selectParagraph:
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
_selectableStartEdgeUpdateRecords[selectable] = state.position.pixels;
}
return super.dispatchSelectionEventToChild(selectable, event);
}
@override
void ensureChildUpdated(Selectable selectable) {
final double newRecord = state.position.pixels;
final double? previousStartRecord = _selectableStartEdgeUpdateRecords[selectable];
if (_currentDragStartRelatedToOrigin != null &&
(previousStartRecord == null || (newRecord - previousStartRecord).abs() > precisionErrorTolerance)) {
// Make sure the selectable has up to date events.
final Offset deltaToOrigin = _getDeltaToScrollOrigin(state);
final Offset startOffset = _currentDragStartRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy);
selectable.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset));
// Make sure we track that we have synthesized a start event for this selectable,
// so we don't synthesize events unnecessarily.
_selectableStartEdgeUpdateRecords[selectable] = state.position.pixels;
}
final double? previousEndRecord = _selectableEndEdgeUpdateRecords[selectable];
if (_currentDragEndRelatedToOrigin != null &&
(previousEndRecord == null || (newRecord - previousEndRecord).abs() > precisionErrorTolerance)) {
// Make sure the selectable has up to date events.
final Offset deltaToOrigin = _getDeltaToScrollOrigin(state);
final Offset endOffset = _currentDragEndRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy);
selectable.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset));
// Make sure we track that we have synthesized an end event for this selectable,
// so we don't synthesize events unnecessarily.
_selectableEndEdgeUpdateRecords[selectable] = state.position.pixels;
}
}
@override
void dispose() {
_selectableStartEdgeUpdateRecords.clear();
_selectableEndEdgeUpdateRecords.clear();
_scheduledLayoutChange = false;
_autoScroller.stopAutoScroll();
super.dispose();
}
}
Offset _getDeltaToScrollOrigin(ScrollableState scrollableState) {
return switch (scrollableState.axisDirection) {
AxisDirection.up => Offset(0, -scrollableState.position.pixels),
AxisDirection.down => Offset(0, scrollableState.position.pixels),
AxisDirection.left => Offset(-scrollableState.position.pixels, 0),
AxisDirection.right => Offset(scrollableState.position.pixels, 0),
};
}
/// With [_ScrollSemantics] certain child [SemanticsNode]s can be
/// excluded from the scrollable area for semantics purposes.
///
/// Nodes, that are to be excluded, have to be tagged with
/// [RenderViewport.excludeFromScrolling] and the [RenderAbstractViewport] in
/// use has to add the [RenderViewport.useTwoPaneSemantics] tag to its
/// [SemanticsConfiguration] by overriding
/// [RenderObject.describeSemanticsConfiguration].
///
/// If the tag [RenderViewport.useTwoPaneSemantics] is present on the viewport,
/// two semantics nodes will be used to represent the [Scrollable]: The outer
/// node will contain all children, that are excluded from scrolling. The inner
/// node, which is annotated with the scrolling actions, will house the
/// scrollable children.
class _ScrollSemantics extends SingleChildRenderObjectWidget {
const _ScrollSemantics({
super.key,
required this.position,
required this.allowImplicitScrolling,
required this.semanticChildCount,
super.child,
}) : assert(semanticChildCount == null || semanticChildCount >= 0);
final ScrollPosition position;
final bool allowImplicitScrolling;
final int? semanticChildCount;
@override
_RenderScrollSemantics createRenderObject(BuildContext context) {
return _RenderScrollSemantics(
position: position,
allowImplicitScrolling: allowImplicitScrolling,
semanticChildCount: semanticChildCount,
);
}
@override
void updateRenderObject(BuildContext context, _RenderScrollSemantics renderObject) {
renderObject
..allowImplicitScrolling = allowImplicitScrolling
..position = position
..semanticChildCount = semanticChildCount;
}
}
class _RenderScrollSemantics extends RenderProxyBox {
_RenderScrollSemantics({
required ScrollPosition position,
required bool allowImplicitScrolling,
required int? semanticChildCount,
RenderBox? child,
}) : _position = position,
_allowImplicitScrolling = allowImplicitScrolling,
_semanticChildCount = semanticChildCount,
super(child) {
position.addListener(markNeedsSemanticsUpdate);
}
/// Whether this render object is excluded from the semantic tree.
ScrollPosition get position => _position;
ScrollPosition _position;
set position(ScrollPosition value) {
if (value == _position) {
return;
}
_position.removeListener(markNeedsSemanticsUpdate);
_position = value;
_position.addListener(markNeedsSemanticsUpdate);
markNeedsSemanticsUpdate();
}
/// Whether this node can be scrolled implicitly.
bool get allowImplicitScrolling => _allowImplicitScrolling;
bool _allowImplicitScrolling;
set allowImplicitScrolling(bool value) {
if (value == _allowImplicitScrolling) {
return;
}
_allowImplicitScrolling = value;
markNeedsSemanticsUpdate();
}
int? get semanticChildCount => _semanticChildCount;
int? _semanticChildCount;
set semanticChildCount(int? value) {
if (value == semanticChildCount) {
return;
}
_semanticChildCount = value;
markNeedsSemanticsUpdate();
}
@override
void describeSemanticsConfiguration(SemanticsConfiguration config) {
super.describeSemanticsConfiguration(config);
config.isSemanticBoundary = true;
if (position.haveDimensions) {
config
..hasImplicitScrolling = allowImplicitScrolling
..scrollPosition = _position.pixels
..scrollExtentMax = _position.maxScrollExtent
..scrollExtentMin = _position.minScrollExtent
..scrollChildCount = semanticChildCount;
}
}
SemanticsNode? _innerNode;
@override
void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) {
if (children.isEmpty || !children.first.isTagged(RenderViewport.useTwoPaneSemantics)) {
_innerNode = null;
super.assembleSemanticsNode(node, config, children);
return;
}
(_innerNode ??= SemanticsNode(showOnScreen: showOnScreen)).rect = node.rect;
int? firstVisibleIndex;
final List<SemanticsNode> excluded = <SemanticsNode>[_innerNode!];
final List<SemanticsNode> included = <SemanticsNode>[];
for (final SemanticsNode child in children) {
assert(child.isTagged(RenderViewport.useTwoPaneSemantics));
if (child.isTagged(RenderViewport.excludeFromScrolling)) {
excluded.add(child);
} else {
if (!child.hasFlag(SemanticsFlag.isHidden)) {
firstVisibleIndex ??= child.indexInParent;
}
included.add(child);
}
}
config.scrollIndex = firstVisibleIndex;
node.updateWith(config: null, childrenInInversePaintOrder: excluded);
_innerNode!.updateWith(config: config, childrenInInversePaintOrder: included);
}
@override
void clearSemantics() {
super.clearSemantics();
_innerNode = null;
}
}
// Not using a RestorableDouble because we want to allow null values and override
// [enabled].
class _RestorableScrollOffset extends RestorableValue<double?> {
@override
double? createDefaultValue() => null;
@override
void didUpdateValue(double? oldValue) {
notifyListeners();
}
@override
double fromPrimitives(Object? data) {
return data! as double;
}
@override
Object? toPrimitives() {
return value;
}
@override
bool get enabled => value != null;
}
// 2D SCROLLING
/// Specifies how to configure the [DragGestureRecognizer]s of a
/// [TwoDimensionalScrollable].
// TODO(Piinks): Add sample code, https://github.com/flutter/flutter/issues/126298
enum DiagonalDragBehavior {
/// This behavior will not allow for any diagonal scrolling.
///
/// Drag gestures in one direction or the other will lock the input axis until
/// the gesture is released.
none,
/// This behavior will only allow diagonal scrolling on a weighted
/// scale per gesture event.
///
/// This means that after initially evaluating the drag gesture, the weighted
/// evaluation (based on [kTouchSlop]) stands until the gesture is released.
weightedEvent,
/// This behavior will only allow diagonal scrolling on a weighted
/// scale that is evaluated throughout a gesture event.
///
/// This means that during each update to the drag gesture, the scrolling
/// axis will be allowed to scroll diagonally if it exceeds the
/// [kTouchSlop].
weightedContinuous,
/// This behavior allows free movement in any and all directions when
/// dragging.
free,
}
/// A widget that manages scrolling in both the vertical and horizontal
/// dimensions and informs the [TwoDimensionalViewport] through which the
/// content is viewed.
///
/// [TwoDimensionalScrollable] implements the interaction model for a scrollable
/// widget in both the vertical and horizontal axes, including gesture
/// recognition, but does not have an opinion about how the
/// [TwoDimensionalViewport], which actually displays the children, is
/// constructed.
///
/// It's rare to construct a [TwoDimensionalScrollable] directly. Instead,
/// consider subclassing [TwoDimensionalScrollView], which combines scrolling,
/// viewporting, and a layout model in both dimensions.
///
/// See also:
///
/// * [TwoDimensionalScrollView], an abstract base class for displaying a
/// scrolling array of children in both directions.
/// * [TwoDimensionalViewport], which can be used to customize the child layout
/// model.
class TwoDimensionalScrollable extends StatefulWidget {
/// Creates a widget that scrolls in two dimensions.
///
/// The [horizontalDetails], [verticalDetails], and [viewportBuilder] must not
/// be null.
const TwoDimensionalScrollable({
super.key,
required this.horizontalDetails,
required this.verticalDetails,
required this.viewportBuilder,
this.incrementCalculator,
this.restorationId,
this.excludeFromSemantics = false,
this.diagonalDragBehavior = DiagonalDragBehavior.none,
this.dragStartBehavior = DragStartBehavior.start,
});
/// How scrolling gestures should lock to one axis, or allow free movement
/// in both axes.
final DiagonalDragBehavior diagonalDragBehavior;
/// The configuration of the horizontal [Scrollable].
///
/// These [ScrollableDetails] can be used to set the [AxisDirection],
/// [ScrollController], [ScrollPhysics] and more for the horizontal axis.
final ScrollableDetails horizontalDetails;
/// The configuration of the vertical [Scrollable].
///
/// These [ScrollableDetails] can be used to set the [AxisDirection],
/// [ScrollController], [ScrollPhysics] and more for the vertical axis.
final ScrollableDetails verticalDetails;
/// Builds the viewport through which the scrollable content is displayed.
///
/// A [TwoDimensionalViewport] uses two given [ViewportOffset]s to determine
/// which part of its content is actually visible through the viewport.
///
/// See also:
///
/// * [TwoDimensionalViewport], which is a viewport that displays a span of
/// widgets in both dimensions.
final TwoDimensionalViewportBuilder viewportBuilder;
/// {@macro flutter.widgets.Scrollable.incrementCalculator}
///
/// This value applies in both axes.
final ScrollIncrementCalculator? incrementCalculator;
/// {@macro flutter.widgets.scrollable.restorationId}
///
/// Internally, the [TwoDimensionalScrollable] will introduce a
/// [RestorationScope] that will be assigned this value. The two [Scrollable]s
/// within will then be given unique IDs within this scope.
final String? restorationId;
/// {@macro flutter.widgets.scrollable.excludeFromSemantics}
///
/// This value applies to both axes.
final bool excludeFromSemantics;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
///
/// This value applies in both axes.
final DragStartBehavior dragStartBehavior;
@override
State<TwoDimensionalScrollable> createState() => TwoDimensionalScrollableState();
/// The state from the closest instance of this class that encloses the given
/// context, or null if none is found.
///
/// Typical usage is as follows:
///
/// ```dart
/// TwoDimensionalScrollableState? scrollable = TwoDimensionalScrollable.maybeOf(context);
/// ```
///
/// Calling this method will create a dependency on the closest
/// [TwoDimensionalScrollable] in the [context]. The internal [Scrollable]s
/// can be accessed through [TwoDimensionalScrollableState.verticalScrollable]
/// and [TwoDimensionalScrollableState.horizontalScrollable].
///
/// Alternatively, [Scrollable.maybeOf] can be used by providing the desired
/// [Axis] to the `axis` parameter.
///
/// See also:
///
/// * [TwoDimensionalScrollable.of], which is similar to this method, but
/// asserts if no [Scrollable] ancestor is found.
static TwoDimensionalScrollableState? maybeOf(BuildContext context) {
final _TwoDimensionalScrollableScope? widget = context.dependOnInheritedWidgetOfExactType<_TwoDimensionalScrollableScope>();
return widget?.twoDimensionalScrollable;
}
/// The state from the closest instance of this class that encloses the given
/// context.
///
/// Typical usage is as follows:
///
/// ```dart
/// TwoDimensionalScrollableState scrollable = TwoDimensionalScrollable.of(context);
/// ```
///
/// Calling this method will create a dependency on the closest
/// [TwoDimensionalScrollable] in the [context]. The internal [Scrollable]s
/// can be accessed through [TwoDimensionalScrollableState.verticalScrollable]
/// and [TwoDimensionalScrollableState.horizontalScrollable].
///
/// If no [TwoDimensionalScrollable] ancestor is found, then this method will
/// assert in debug mode, and throw an exception in release mode.
///
/// Alternatively, [Scrollable.of] can be used by providing the desired [Axis]
/// to the `axis` parameter.
///
/// See also:
///
/// * [TwoDimensionalScrollable.maybeOf], which is similar to this method,
/// but returns null if no [TwoDimensionalScrollable] ancestor is found.
static TwoDimensionalScrollableState of(BuildContext context) {
final TwoDimensionalScrollableState? scrollableState = maybeOf(context);
assert(() {
if (scrollableState == null) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'TwoDimensionalScrollable.of() was called with a context that does '
'not contain a TwoDimensionalScrollable widget.\n'
),
ErrorDescription(
'No TwoDimensionalScrollable widget ancestor could be found starting '
'from the context that was passed to TwoDimensionalScrollable.of(). '
'This can happen because you are using a widget that looks for a '
'TwoDimensionalScrollable ancestor, but no such ancestor exists.\n'
'The context used was:\n'
' $context',
),
]);
}
return true;
}());
return scrollableState!;
}
}
/// State object for a [TwoDimensionalScrollable] widget.
///
/// To manipulate one of the internal [Scrollable] widget's scroll position, use
/// the object obtained from the [verticalScrollable] or [horizontalScrollable]
/// property.
///
/// To be informed of when a [TwoDimensionalScrollable] widget is scrolling,
/// use a [NotificationListener] to listen for [ScrollNotification]s.
/// Both axes will have the same viewport depth since there is only one
/// viewport, and so should be differentiated by the [Axis] of the
/// [ScrollMetrics] provided by the notification.
class TwoDimensionalScrollableState extends State<TwoDimensionalScrollable> {
ScrollController? _verticalFallbackController;
ScrollController? _horizontalFallbackController;
final GlobalKey<ScrollableState> _verticalOuterScrollableKey = GlobalKey<ScrollableState>();
final GlobalKey<ScrollableState> _horizontalInnerScrollableKey = GlobalKey<ScrollableState>();
/// The [ScrollableState] of the vertical axis.
///
/// Accessible by calling [TwoDimensionalScrollable.of].
///
/// Alternatively, [Scrollable.of] can be used by providing [Axis.vertical]
/// to the `axis` parameter.
ScrollableState get verticalScrollable {
assert(_verticalOuterScrollableKey.currentState != null);
return _verticalOuterScrollableKey.currentState!;
}
/// The [ScrollableState] of the horizontal axis.
///
/// Accessible by calling [TwoDimensionalScrollable.of].
///
/// Alternatively, [Scrollable.of] can be used by providing [Axis.horizontal]
/// to the `axis` parameter.
ScrollableState get horizontalScrollable {
assert(_horizontalInnerScrollableKey.currentState != null);
return _horizontalInnerScrollableKey.currentState!;
}
@override
void initState() {
if (widget.verticalDetails.controller == null) {
_verticalFallbackController = ScrollController();
}
if (widget.horizontalDetails.controller == null) {
_horizontalFallbackController = ScrollController();
}
super.initState();
}
@override
void didUpdateWidget(TwoDimensionalScrollable oldWidget) {
super.didUpdateWidget(oldWidget);
// Handle changes in the provided/fallback scroll controllers
// Vertical
if (oldWidget.verticalDetails.controller != widget.verticalDetails.controller) {
if (oldWidget.verticalDetails.controller == null) {
// The old controller was null, meaning the fallback cannot be null.
// Dispose of the fallback.
assert(_verticalFallbackController != null);
assert(widget.verticalDetails.controller != null);
_verticalFallbackController!.dispose();
_verticalFallbackController = null;
} else if (widget.verticalDetails.controller == null) {
// If the new controller is null, we need to set up the fallback
// ScrollController.
assert(_verticalFallbackController == null);
_verticalFallbackController = ScrollController();
}
}
// Horizontal
if (oldWidget.horizontalDetails.controller != widget.horizontalDetails.controller) {
if (oldWidget.horizontalDetails.controller == null) {
// The old controller was null, meaning the fallback cannot be null.
// Dispose of the fallback.
assert(_horizontalFallbackController != null);
assert(widget.horizontalDetails.controller != null);
_horizontalFallbackController!.dispose();
_horizontalFallbackController = null;
} else if (widget.horizontalDetails.controller == null) {
// If the new controller is null, we need to set up the fallback
// ScrollController.
assert(_horizontalFallbackController == null);
_horizontalFallbackController = ScrollController();
}
}
}
@override
Widget build(BuildContext context) {
assert(
axisDirectionToAxis(widget.verticalDetails.direction) == Axis.vertical,
'TwoDimensionalScrollable.verticalDetails are not Axis.vertical.'
);
assert(
axisDirectionToAxis(widget.horizontalDetails.direction) == Axis.horizontal,
'TwoDimensionalScrollable.horizontalDetails are not Axis.horizontal.'
);
final Widget result = RestorationScope(
restorationId: widget.restorationId,
child: _VerticalOuterDimension(
key: _verticalOuterScrollableKey,
// For gesture forwarding
horizontalKey: _horizontalInnerScrollableKey,
axisDirection: widget.verticalDetails.direction,
controller: widget.verticalDetails.controller
?? _verticalFallbackController!,
physics: widget.verticalDetails.physics,
clipBehavior: widget.verticalDetails.clipBehavior
?? widget.verticalDetails.decorationClipBehavior
?? Clip.hardEdge,
incrementCalculator: widget.incrementCalculator,
excludeFromSemantics: widget.excludeFromSemantics,
restorationId: 'OuterVerticalTwoDimensionalScrollable',
dragStartBehavior: widget.dragStartBehavior,
diagonalDragBehavior: widget.diagonalDragBehavior,
viewportBuilder: (BuildContext context, ViewportOffset verticalOffset) {
return _HorizontalInnerDimension(
key: _horizontalInnerScrollableKey,
verticalOuterKey: _verticalOuterScrollableKey,
axisDirection: widget.horizontalDetails.direction,
controller: widget.horizontalDetails.controller
?? _horizontalFallbackController!,
physics: widget.horizontalDetails.physics,
clipBehavior: widget.horizontalDetails.clipBehavior
?? widget.horizontalDetails.decorationClipBehavior
?? Clip.hardEdge,
incrementCalculator: widget.incrementCalculator,
excludeFromSemantics: widget.excludeFromSemantics,
restorationId: 'InnerHorizontalTwoDimensionalScrollable',
dragStartBehavior: widget.dragStartBehavior,
diagonalDragBehavior: widget.diagonalDragBehavior,
viewportBuilder: (BuildContext context, ViewportOffset horizontalOffset) {
return widget.viewportBuilder(context, verticalOffset, horizontalOffset);
},
);
}
)
);
// TODO(Piinks): Build scrollbars for 2 dimensions instead of 1,
// https://github.com/flutter/flutter/issues/122348
return _TwoDimensionalScrollableScope(
twoDimensionalScrollable: this,
child: result,
);
}
@override
void dispose() {
_verticalFallbackController?.dispose();
_horizontalFallbackController?.dispose();
super.dispose();
}
}
// Enable TwoDimensionalScrollable.of() to work as if
// TwoDimensionalScrollableState was an inherited widget.
// TwoDimensionalScrollableState.build() always rebuilds its
// _TwoDimensionalScrollableScope.
class _TwoDimensionalScrollableScope extends InheritedWidget {
const _TwoDimensionalScrollableScope({
required this.twoDimensionalScrollable,
required super.child,
});
final TwoDimensionalScrollableState twoDimensionalScrollable;
@override
bool updateShouldNotify(_TwoDimensionalScrollableScope old) => false;
}
// Vertical outer scrollable of 2D scrolling
class _VerticalOuterDimension extends Scrollable {
const _VerticalOuterDimension({
super.key,
required this.horizontalKey,
required super.viewportBuilder,
required super.axisDirection,
super.controller,
super.physics,
super.clipBehavior,
super.incrementCalculator,
super.excludeFromSemantics,
super.dragStartBehavior,
super.restorationId,
this.diagonalDragBehavior = DiagonalDragBehavior.none,
}) : assert(axisDirection == AxisDirection.up || axisDirection == AxisDirection.down);
final DiagonalDragBehavior diagonalDragBehavior;
final GlobalKey<ScrollableState> horizontalKey;
@override
_VerticalOuterDimensionState createState() => _VerticalOuterDimensionState();
}
class _VerticalOuterDimensionState extends ScrollableState {
DiagonalDragBehavior get diagonalDragBehavior => (widget as _VerticalOuterDimension).diagonalDragBehavior;
ScrollableState get horizontalScrollable => (widget as _VerticalOuterDimension).horizontalKey.currentState!;
Axis? lockedAxis;
Offset? lastDragOffset;
// Implemented in the _HorizontalInnerDimension instead.
@override
_EnsureVisibleResults _performEnsureVisible(
RenderObject object, {
double alignment = 0.0,
Duration duration = Duration.zero,
Curve curve = Curves.ease,
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
RenderObject? targetRenderObject,
}) {
assert(
false,
'The _performEnsureVisible method was called for the vertical scrollable '
'of a TwoDimensionalScrollable. This should not happen as the horizontal '
'scrollable handles both axes.'
);
return (<Future<void>>[], this);
}
void _evaluateLockedAxis(Offset offset) {
assert(lastDragOffset != null);
final Offset offsetDelta = lastDragOffset! - offset;
final double axisDifferential = offsetDelta.dx.abs() - offsetDelta.dy.abs();
if (axisDifferential.abs() >= kTouchSlop) {
// We have single axis winner.
lockedAxis = axisDifferential > 0.0 ? Axis.horizontal : Axis.vertical;
} else {
lockedAxis = null;
}
}
@override
void _handleDragDown(DragDownDetails details) {
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
break;
case DiagonalDragBehavior.weightedEvent:
case DiagonalDragBehavior.weightedContinuous:
case DiagonalDragBehavior.free:
// Initiate hold. If one or the other wins the gesture, cancel the
// opposite axis.
horizontalScrollable._handleDragDown(details);
}
super._handleDragDown(details);
}
@override
void _handleDragStart(DragStartDetails details) {
lastDragOffset = details.globalPosition;
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
break;
case DiagonalDragBehavior.free:
// Prepare to scroll both.
// vertical - will call super below after switch.
horizontalScrollable._handleDragStart(details);
case DiagonalDragBehavior.weightedEvent:
case DiagonalDragBehavior.weightedContinuous:
// See if one axis wins the drag.
_evaluateLockedAxis(details.globalPosition);
switch (lockedAxis) {
case null:
// Prepare to scroll both, null means no winner yet.
// vertical - will call super below after switch.
horizontalScrollable._handleDragStart(details);
case Axis.horizontal:
// Prepare to scroll horizontally.
horizontalScrollable._handleDragStart(details);
return;
case Axis.vertical:
// Prepare to scroll vertically - will call super below after switch.
}
}
super._handleDragStart(details);
}
@override
void _handleDragUpdate(DragUpdateDetails details) {
final DragUpdateDetails verticalDragDetails = DragUpdateDetails(
sourceTimeStamp: details.sourceTimeStamp,
delta: Offset(0.0, details.delta.dy),
primaryDelta: details.delta.dy,
globalPosition: details.globalPosition,
localPosition: details.localPosition,
);
final DragUpdateDetails horizontalDragDetails = DragUpdateDetails(
sourceTimeStamp: details.sourceTimeStamp,
delta: Offset(details.delta.dx, 0.0),
primaryDelta: details.delta.dx,
globalPosition: details.globalPosition,
localPosition: details.localPosition,
);
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
// Default gesture handling from super class.
super._handleDragUpdate(verticalDragDetails);
return;
case DiagonalDragBehavior.free:
// Scroll both axes
horizontalScrollable._handleDragUpdate(horizontalDragDetails);
super._handleDragUpdate(verticalDragDetails);
return;
case DiagonalDragBehavior.weightedContinuous:
// Re-evaluate locked axis for every update.
_evaluateLockedAxis(details.globalPosition);
lastDragOffset = details.globalPosition;
case DiagonalDragBehavior.weightedEvent:
// Lock axis only once per gesture.
if (lockedAxis == null && lastDragOffset != null) {
// A winner has not been declared yet.
// See if one axis has won the drag.
_evaluateLockedAxis(details.globalPosition);
}
}
switch (lockedAxis) {
case null:
// Scroll both - vertical after switch
horizontalScrollable._handleDragUpdate(horizontalDragDetails);
case Axis.horizontal:
// Scroll horizontally
horizontalScrollable._handleDragUpdate(horizontalDragDetails);
return;
case Axis.vertical:
// Scroll vertically - after switch
}
super._handleDragUpdate(verticalDragDetails);
}
@override
void _handleDragEnd(DragEndDetails details) {
lastDragOffset = null;
lockedAxis = null;
final double dx = details.velocity.pixelsPerSecond.dx;
final double dy = details.velocity.pixelsPerSecond.dy;
final DragEndDetails verticalDragDetails = DragEndDetails(
velocity: Velocity(pixelsPerSecond: Offset(0.0, dy)),
primaryVelocity: dy,
);
final DragEndDetails horizontalDragDetails = DragEndDetails(
velocity: Velocity(pixelsPerSecond: Offset(dx, 0.0)),
primaryVelocity: dx,
);
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
break;
case DiagonalDragBehavior.weightedEvent:
case DiagonalDragBehavior.weightedContinuous:
case DiagonalDragBehavior.free:
horizontalScrollable._handleDragEnd(horizontalDragDetails);
}
super._handleDragEnd(verticalDragDetails);
}
@override
void _handleDragCancel() {
lastDragOffset = null;
lockedAxis = null;
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
break;
case DiagonalDragBehavior.weightedEvent:
case DiagonalDragBehavior.weightedContinuous:
case DiagonalDragBehavior.free:
horizontalScrollable._handleDragCancel();
}
super._handleDragCancel();
}
@override
void setCanDrag(bool value) {
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
// If we aren't scrolling diagonally, the default drag gesture recognizer
// is used.
super.setCanDrag(value);
return;
case DiagonalDragBehavior.weightedEvent:
case DiagonalDragBehavior.weightedContinuous:
case DiagonalDragBehavior.free:
if (value) {
// Replaces the typical vertical/horizontal drag gesture recognizers
// with a pan gesture recognizer to allow bidirectional scrolling.
// Based on the diagonalDragBehavior, valid vertical deltas are
// applied to this scrollable, while horizontal deltas are routed to
// the horizontal scrollable.
_gestureRecognizers = <Type, GestureRecognizerFactory>{
PanGestureRecognizer: GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>(
() => PanGestureRecognizer(supportedDevices: _configuration.dragDevices),
(PanGestureRecognizer instance) {
instance
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
..onEnd = _handleDragEnd
..onCancel = _handleDragCancel
..minFlingDistance = _physics?.minFlingDistance
..minFlingVelocity = _physics?.minFlingVelocity
..maxFlingVelocity = _physics?.maxFlingVelocity
..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context)
..dragStartBehavior = widget.dragStartBehavior
..gestureSettings = _mediaQueryGestureSettings;
},
),
};
// Cancel the active hold/drag (if any) because the gesture recognizers
// will soon be disposed by our RawGestureDetector, and we won't be
// receiving pointer up events to cancel the hold/drag.
_handleDragCancel();
_lastCanDrag = value;
_lastAxisDirection = widget.axis;
if (_gestureDetectorKey.currentState != null) {
_gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers);
}
}
return;
}
}
@override
Widget _buildChrome(BuildContext context, Widget child) {
final ScrollableDetails details = ScrollableDetails(
direction: widget.axisDirection,
controller: _effectiveScrollController,
clipBehavior: widget.clipBehavior,
);
// Skip building a scrollbar here, the dual scrollbar is added in
// TwoDimensionalScrollableState.
return _configuration.buildOverscrollIndicator(context, child, details);
}
}
// Horizontal inner scrollable of 2D scrolling
class _HorizontalInnerDimension extends Scrollable {
const _HorizontalInnerDimension({
super.key,
required this.verticalOuterKey,
required super.viewportBuilder,
required super.axisDirection,
super.controller,
super.physics,
super.clipBehavior,
super.incrementCalculator,
super.excludeFromSemantics,
super.dragStartBehavior,
super.restorationId,
this.diagonalDragBehavior = DiagonalDragBehavior.none,
}) : assert(axisDirection == AxisDirection.left || axisDirection == AxisDirection.right);
final GlobalKey<ScrollableState> verticalOuterKey;
final DiagonalDragBehavior diagonalDragBehavior;
@override
_HorizontalInnerDimensionState createState() => _HorizontalInnerDimensionState();
}
class _HorizontalInnerDimensionState extends ScrollableState {
late ScrollableState verticalScrollable;
GlobalKey<ScrollableState> get verticalOuterKey => (widget as _HorizontalInnerDimension).verticalOuterKey;
DiagonalDragBehavior get diagonalDragBehavior => (widget as _HorizontalInnerDimension).diagonalDragBehavior;
@override
void didChangeDependencies() {
verticalScrollable = Scrollable.of(context);
assert(axisDirectionToAxis(verticalScrollable.axisDirection) == Axis.vertical);
super.didChangeDependencies();
}
// Returns the Future from calling ensureVisible for the ScrollPosition, as
// as well as the vertical ScrollableState instance so its context can be
// used to check for other ancestor Scrollables in executing ensureVisible.
@override
_EnsureVisibleResults _performEnsureVisible(
RenderObject object, {
double alignment = 0.0,
Duration duration = Duration.zero,
Curve curve = Curves.ease,
ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit,
RenderObject? targetRenderObject,
}) {
final List<Future<void>> newFutures = <Future<void>>[
position.ensureVisible(
object,
alignment: alignment,
duration: duration,
curve: curve,
alignmentPolicy: alignmentPolicy,
),
verticalScrollable.position.ensureVisible(
object,
alignment: alignment,
duration: duration,
curve: curve,
alignmentPolicy: alignmentPolicy,
),
];
return (newFutures, verticalScrollable);
}
@override
void setCanDrag(bool value) {
switch (diagonalDragBehavior) {
case DiagonalDragBehavior.none:
// If we aren't scrolling diagonally, the default drag gesture
// recognizer is used.
super.setCanDrag(value);
return;
case DiagonalDragBehavior.weightedEvent:
case DiagonalDragBehavior.weightedContinuous:
case DiagonalDragBehavior.free:
if (value) {
// If a type of diagonal scrolling is enabled, a panning gesture
// recognizer will be created for the _VerticalOuterDimension. So in
// this case, the _HorizontalInnerDimension does not require a gesture
// recognizer, meanwhile we should ensure the outer dimension has
// updated in case it did not have enough content to enable dragging.
_gestureRecognizers = const <Type, GestureRecognizerFactory>{};
verticalOuterKey.currentState!.setCanDrag(value);
// Cancel the active hold/drag (if any) because the gesture recognizers
// will soon be disposed by our RawGestureDetector, and we won't be
// receiving pointer up events to cancel the hold/drag.
_handleDragCancel();
_lastCanDrag = value;
_lastAxisDirection = widget.axis;
if (_gestureDetectorKey.currentState != null) {
_gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers);
}
}
return;
}
}
@override
Widget _buildChrome(BuildContext context, Widget child) {
final ScrollableDetails details = ScrollableDetails(
direction: widget.axisDirection,
controller: _effectiveScrollController,
clipBehavior: widget.clipBehavior,
);
// Skip building a scrollbar here, the dual scrollbar is added in
// TwoDimensionalScrollableState.
return _configuration.buildOverscrollIndicator(context, child, details);
}
}