1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'package:flutter/material.dart';
6///
7/// @docImport 'overscroll_indicator.dart';
8/// @docImport 'viewport.dart';
9library;
10
11import 'dart:async';
12import 'dart:math' as math;
13
14import 'package:flutter/foundation.dart';
15import 'package:flutter/rendering.dart';
16
17import 'actions.dart';
18import 'basic.dart';
19import 'framework.dart';
20import 'primary_scroll_controller.dart';
21import 'scroll_configuration.dart';
22import 'scroll_controller.dart';
23import 'scroll_metrics.dart';
24import 'scroll_physics.dart';
25import 'scrollable.dart';
26
27export 'package:flutter/physics.dart' show Tolerance;
28
29/// Describes the aspects of a Scrollable widget to inform inherited widgets
30/// like [ScrollBehavior] for decorating or enumerate the properties of combined
31/// Scrollables, such as [TwoDimensionalScrollable].
32///
33/// Decorations like [GlowingOverscrollIndicator]s and [Scrollbar]s require
34/// information about the Scrollable in order to be initialized.
35@immutable
36class ScrollableDetails {
37 /// Creates a set of details describing the [Scrollable].
38 const ScrollableDetails({
39 required this.direction,
40 this.controller,
41 this.physics,
42 @Deprecated(
43 'Migrate to decorationClipBehavior. '
44 'This property was deprecated so that its application is clearer. This clip '
45 'applies to decorators, and does not directly clip a scroll view. '
46 'This feature was deprecated after v3.9.0-1.0.pre.',
47 )
48 Clip? clipBehavior,
49 Clip? decorationClipBehavior,
50 }) : decorationClipBehavior = clipBehavior ?? decorationClipBehavior;
51
52 /// A constructor specific to a [Scrollable] with an [Axis.vertical].
53 const ScrollableDetails.vertical({
54 bool reverse = false,
55 this.controller,
56 this.physics,
57 this.decorationClipBehavior,
58 }) : direction = reverse ? AxisDirection.up : AxisDirection.down;
59
60 /// A constructor specific to a [Scrollable] with an [Axis.horizontal].
61 const ScrollableDetails.horizontal({
62 bool reverse = false,
63 this.controller,
64 this.physics,
65 this.decorationClipBehavior,
66 }) : direction = reverse ? AxisDirection.left : AxisDirection.right;
67
68 /// {@macro flutter.widgets.Scrollable.axisDirection}
69 final AxisDirection direction;
70
71 /// {@macro flutter.widgets.Scrollable.controller}
72 final ScrollController? controller;
73
74 /// {@macro flutter.widgets.Scrollable.physics}
75 final ScrollPhysics? physics;
76
77 /// {@macro flutter.material.Material.clipBehavior}
78 ///
79 /// This can be used by [MaterialScrollBehavior] to clip a
80 /// [StretchingOverscrollIndicator].
81 ///
82 /// This [Clip] does not affect the [Viewport.clipBehavior], but is rather
83 /// passed from the same value by [Scrollable] so that decorators like
84 /// [StretchingOverscrollIndicator] honor the same clip.
85 ///
86 /// Defaults to null.
87 final Clip? decorationClipBehavior;
88
89 /// Deprecated getter for [decorationClipBehavior].
90 @Deprecated(
91 'Migrate to decorationClipBehavior. '
92 'This property was deprecated so that its application is clearer. This clip '
93 'applies to decorators, and does not directly clip a scroll view. '
94 'This feature was deprecated after v3.9.0-1.0.pre.',
95 )
96 Clip? get clipBehavior => decorationClipBehavior;
97
98 /// Copy the current [ScrollableDetails] with the given values replacing the
99 /// current values.
100 ScrollableDetails copyWith({
101 AxisDirection? direction,
102 ScrollController? controller,
103 ScrollPhysics? physics,
104 Clip? decorationClipBehavior,
105 }) {
106 return ScrollableDetails(
107 direction: direction ?? this.direction,
108 controller: controller ?? this.controller,
109 physics: physics ?? this.physics,
110 decorationClipBehavior: decorationClipBehavior ?? this.decorationClipBehavior,
111 );
112 }
113
114 @override
115 String toString() {
116 final List<String> description = <String>[];
117 description.add('axisDirection: $direction');
118
119 void addIfNonNull(String prefix, Object? value) {
120 if (value != null) {
121 description.add(prefix + value.toString());
122 }
123 }
124
125 addIfNonNull('scroll controller: ', controller);
126 addIfNonNull('scroll physics: ', physics);
127 addIfNonNull('decorationClipBehavior: ', decorationClipBehavior);
128 return '${describeIdentity(this)}(${description.join(", ")})';
129 }
130
131 @override
132 int get hashCode => Object.hash(direction, controller, physics, decorationClipBehavior);
133
134 @override
135 bool operator ==(Object other) {
136 if (identical(this, other)) {
137 return true;
138 }
139 if (other.runtimeType != runtimeType) {
140 return false;
141 }
142 return other is ScrollableDetails &&
143 other.direction == direction &&
144 other.controller == controller &&
145 other.physics == physics &&
146 other.decorationClipBehavior == decorationClipBehavior;
147 }
148}
149
150/// An auto scroller that scrolls the [scrollable] if a drag gesture drags close
151/// to its edge.
152///
153/// The scroll velocity is controlled by the [velocityScalar]:
154///
155/// velocity = (distance of overscroll) * [velocityScalar].
156class EdgeDraggingAutoScroller {
157 /// Creates a auto scroller that scrolls the [scrollable].
158 EdgeDraggingAutoScroller(
159 this.scrollable, {
160 this.onScrollViewScrolled,
161 required this.velocityScalar,
162 });
163
164 /// The [Scrollable] this auto scroller is scrolling.
165 final ScrollableState scrollable;
166
167 /// Called when a scroll view is scrolled.
168 ///
169 /// The scroll view may be scrolled multiple times in a row until the drag
170 /// target no longer triggers the auto scroll. This callback will be called
171 /// in between each scroll.
172 final VoidCallback? onScrollViewScrolled;
173
174 /// {@template flutter.widgets.EdgeDraggingAutoScroller.velocityScalar}
175 /// The velocity scalar per pixel over scroll.
176 ///
177 /// It represents how the velocity scale with the over scroll distance. The
178 /// auto-scroll velocity = (distance of overscroll) * velocityScalar.
179 /// {@endtemplate}
180 final double velocityScalar;
181
182 late Rect _dragTargetRelatedToScrollOrigin;
183
184 /// Whether the auto scroll is in progress.
185 bool get scrolling => _scrolling;
186 bool _scrolling = false;
187
188 double _offsetExtent(Offset offset, Axis scrollDirection) {
189 return switch (scrollDirection) {
190 Axis.horizontal => offset.dx,
191 Axis.vertical => offset.dy,
192 };
193 }
194
195 double _sizeExtent(Size size, Axis scrollDirection) {
196 return switch (scrollDirection) {
197 Axis.horizontal => size.width,
198 Axis.vertical => size.height,
199 };
200 }
201
202 AxisDirection get _axisDirection => scrollable.axisDirection;
203 Axis get _scrollDirection => axisDirectionToAxis(_axisDirection);
204
205 /// Starts the auto scroll if the [dragTarget] is close to the edge.
206 ///
207 /// The scroll starts to scroll the [scrollable] if the target rect is close
208 /// to the edge of the [scrollable]; otherwise, it remains stationary.
209 ///
210 /// If the scrollable is already scrolling, calling this method updates the
211 /// previous dragTarget to the new value and continues scrolling if necessary.
212 void startAutoScrollIfNecessary(Rect dragTarget) {
213 final Offset deltaToOrigin = scrollable.deltaToScrollOrigin;
214 _dragTargetRelatedToScrollOrigin = dragTarget.translate(deltaToOrigin.dx, deltaToOrigin.dy);
215 if (_scrolling) {
216 // The change will be picked up in the next scroll.
217 return;
218 }
219 assert(!_scrolling);
220 _scroll();
221 }
222
223 /// Stop any ongoing auto scrolling.
224 void stopAutoScroll() {
225 _scrolling = false;
226 }
227
228 Future<void> _scroll() async {
229 final RenderBox scrollRenderBox = scrollable.context.findRenderObject()! as RenderBox;
230 final Rect globalRect = MatrixUtils.transformRect(
231 scrollRenderBox.getTransformTo(null),
232 Rect.fromLTWH(0, 0, scrollRenderBox.size.width, scrollRenderBox.size.height),
233 );
234 assert(
235 globalRect.size.width >= _dragTargetRelatedToScrollOrigin.size.width &&
236 globalRect.size.height >= _dragTargetRelatedToScrollOrigin.size.height,
237 'Drag target size is larger than scrollable size, which may cause bouncing',
238 );
239 _scrolling = true;
240 double? newOffset;
241 const double overDragMax = 20.0;
242
243 final Offset deltaToOrigin = scrollable.deltaToScrollOrigin;
244 final Offset viewportOrigin = globalRect.topLeft.translate(deltaToOrigin.dx, deltaToOrigin.dy);
245 final double viewportStart = _offsetExtent(viewportOrigin, _scrollDirection);
246 final double viewportEnd = viewportStart + _sizeExtent(globalRect.size, _scrollDirection);
247
248 final double proxyStart = _offsetExtent(
249 _dragTargetRelatedToScrollOrigin.topLeft,
250 _scrollDirection,
251 );
252 final double proxyEnd = _offsetExtent(
253 _dragTargetRelatedToScrollOrigin.bottomRight,
254 _scrollDirection,
255 );
256 switch (_axisDirection) {
257 case AxisDirection.up:
258 case AxisDirection.left:
259 if (proxyEnd > viewportEnd &&
260 scrollable.position.pixels > scrollable.position.minScrollExtent) {
261 final double overDrag = math.min(proxyEnd - viewportEnd, overDragMax);
262 newOffset = math.max(
263 scrollable.position.minScrollExtent,
264 scrollable.position.pixels - overDrag,
265 );
266 } else if (proxyStart < viewportStart &&
267 scrollable.position.pixels < scrollable.position.maxScrollExtent) {
268 final double overDrag = math.min(viewportStart - proxyStart, overDragMax);
269 newOffset = math.min(
270 scrollable.position.maxScrollExtent,
271 scrollable.position.pixels + overDrag,
272 );
273 }
274 case AxisDirection.right:
275 case AxisDirection.down:
276 if (proxyStart < viewportStart &&
277 scrollable.position.pixels > scrollable.position.minScrollExtent) {
278 final double overDrag = math.min(viewportStart - proxyStart, overDragMax);
279 newOffset = math.max(
280 scrollable.position.minScrollExtent,
281 scrollable.position.pixels - overDrag,
282 );
283 } else if (proxyEnd > viewportEnd &&
284 scrollable.position.pixels < scrollable.position.maxScrollExtent) {
285 final double overDrag = math.min(proxyEnd - viewportEnd, overDragMax);
286 newOffset = math.min(
287 scrollable.position.maxScrollExtent,
288 scrollable.position.pixels + overDrag,
289 );
290 }
291 }
292
293 if (newOffset == null || (newOffset - scrollable.position.pixels).abs() < 1.0) {
294 // Drag should not trigger scroll.
295 _scrolling = false;
296 return;
297 }
298 final Duration duration = Duration(milliseconds: (1000 / velocityScalar).round());
299 await scrollable.position.animateTo(newOffset, duration: duration, curve: Curves.linear);
300 onScrollViewScrolled?.call();
301 if (_scrolling) {
302 await _scroll();
303 }
304 }
305}
306
307/// A typedef for a function that can calculate the offset for a type of scroll
308/// increment given a [ScrollIncrementDetails].
309///
310/// This function is used as the type for [Scrollable.incrementCalculator],
311/// which is called from a [ScrollAction].
312typedef ScrollIncrementCalculator = double Function(ScrollIncrementDetails details);
313
314/// Describes the type of scroll increment that will be performed by a
315/// [ScrollAction] on a [Scrollable].
316///
317/// This is used to configure a [ScrollIncrementDetails] object to pass to a
318/// [ScrollIncrementCalculator] function on a [Scrollable].
319///
320/// {@template flutter.widgets.ScrollIncrementType.intent}
321/// This indicates the *intent* of the scroll, not necessarily the size. Not all
322/// scrollable areas will have the concept of a "line" or "page", but they can
323/// respond to the different standard key bindings that cause scrolling, which
324/// are bound to keys that people use to indicate a "line" scroll (e.g.
325/// control-arrowDown keys) or a "page" scroll (e.g. pageDown key). It is
326/// recommended that at least the relative magnitudes of the scrolls match
327/// expectations.
328/// {@endtemplate}
329enum ScrollIncrementType {
330 /// Indicates that the [ScrollIncrementCalculator] should return the scroll
331 /// distance it should move when the user requests to scroll by a "line".
332 ///
333 /// The distance a "line" scrolls refers to what should happen when the key
334 /// binding for "scroll down/up by a line" is triggered. It's up to the
335 /// [ScrollIncrementCalculator] function to decide what that means for a
336 /// particular scrollable.
337 line,
338
339 /// Indicates that the [ScrollIncrementCalculator] should return the scroll
340 /// distance it should move when the user requests to scroll by a "page".
341 ///
342 /// The distance a "page" scrolls refers to what should happen when the key
343 /// binding for "scroll down/up by a page" is triggered. It's up to the
344 /// [ScrollIncrementCalculator] function to decide what that means for a
345 /// particular scrollable.
346 page,
347}
348
349/// A details object that describes the type of scroll increment being requested
350/// of a [ScrollIncrementCalculator] function, as well as the current metrics
351/// for the scrollable.
352class ScrollIncrementDetails {
353 /// A const constructor for a [ScrollIncrementDetails].
354 const ScrollIncrementDetails({required this.type, required this.metrics});
355
356 /// The type of scroll this is (e.g. line, page, etc.).
357 ///
358 /// {@macro flutter.widgets.ScrollIncrementType.intent}
359 final ScrollIncrementType type;
360
361 /// The current metrics of the scrollable that is being scrolled.
362 final ScrollMetrics metrics;
363}
364
365/// An [Intent] that represents scrolling the nearest scrollable by an amount
366/// appropriate for the [type] specified.
367///
368/// The actual amount of the scroll is determined by the
369/// [Scrollable.incrementCalculator], or by its defaults if that is not
370/// specified.
371class ScrollIntent extends Intent {
372 /// Creates a const [ScrollIntent] that requests scrolling in the given
373 /// [direction], with the given [type].
374 const ScrollIntent({required this.direction, this.type = ScrollIncrementType.line});
375
376 /// The direction in which to scroll the scrollable containing the focused
377 /// widget.
378 final AxisDirection direction;
379
380 /// The type of scrolling that is intended.
381 final ScrollIncrementType type;
382}
383
384/// An [Action] that scrolls the relevant [Scrollable] by the amount configured
385/// in the [ScrollIntent] given to it.
386///
387/// If a Scrollable cannot be found above the given [BuildContext], the
388/// [PrimaryScrollController] will be considered for default handling of
389/// [ScrollAction]s.
390///
391/// If [Scrollable.incrementCalculator] is null for the scrollable, the default
392/// for a [ScrollIntent.type] set to [ScrollIncrementType.page] is 80% of the
393/// size of the scroll window, and for [ScrollIncrementType.line], 50 logical
394/// pixels.
395class ScrollAction extends ContextAction<ScrollIntent> {
396 @override
397 bool isEnabled(ScrollIntent intent, [BuildContext? context]) {
398 if (context == null) {
399 return false;
400 }
401 if (Scrollable.maybeOf(context) != null) {
402 return true;
403 }
404 final ScrollController? primaryScrollController = PrimaryScrollController.maybeOf(context);
405 return (primaryScrollController != null) && primaryScrollController.hasClients;
406 }
407
408 /// Returns the scroll increment for a single scroll request, for use when
409 /// scrolling using a hardware keyboard.
410 ///
411 /// Must not be called when the position is null, or when any of the position
412 /// metrics (pixels, viewportDimension, maxScrollExtent, minScrollExtent) are
413 /// null. The widget must have already been laid out so that the position
414 /// fields are valid.
415 static double _calculateScrollIncrement(
416 ScrollableState state, {
417 ScrollIncrementType type = ScrollIncrementType.line,
418 }) {
419 assert(state.position.hasPixels);
420 assert(
421 state.resolvedPhysics == null ||
422 state.resolvedPhysics!.shouldAcceptUserOffset(state.position),
423 );
424 if (state.widget.incrementCalculator != null) {
425 return state.widget.incrementCalculator!(
426 ScrollIncrementDetails(type: type, metrics: state.position),
427 );
428 }
429 return switch (type) {
430 ScrollIncrementType.line => 50.0,
431 ScrollIncrementType.page => 0.8 * state.position.viewportDimension,
432 };
433 }
434
435 /// Find out how much of an increment to move by, taking the different
436 /// directions into account.
437 static double getDirectionalIncrement(ScrollableState state, ScrollIntent intent) {
438 if (axisDirectionToAxis(intent.direction) == axisDirectionToAxis(state.axisDirection)) {
439 final double increment = _calculateScrollIncrement(state, type: intent.type);
440 return intent.direction == state.axisDirection ? increment : -increment;
441 }
442 return 0.0;
443 }
444
445 @override
446 void invoke(ScrollIntent intent, [BuildContext? context]) {
447 assert(context != null, 'Cannot scroll without a context.');
448 ScrollableState? state = Scrollable.maybeOf(context!);
449 if (state == null) {
450 final ScrollController primaryScrollController = PrimaryScrollController.of(context);
451 assert(() {
452 if (primaryScrollController.positions.length != 1) {
453 throw FlutterError.fromParts(<DiagnosticsNode>[
454 ErrorSummary(
455 'A ScrollAction was invoked with the PrimaryScrollController, but '
456 'more than one ScrollPosition is attached.',
457 ),
458 ErrorDescription(
459 'Only one ScrollPosition can be manipulated by a ScrollAction at '
460 'a time.',
461 ),
462 ErrorHint(
463 'The PrimaryScrollController can be inherited automatically by '
464 'descendant ScrollViews based on the TargetPlatform and scroll '
465 'direction. By default, the PrimaryScrollController is '
466 'automatically inherited on mobile platforms for vertical '
467 'ScrollViews. ScrollView.primary can also override this behavior.',
468 ),
469 ]);
470 }
471 return true;
472 }());
473
474 final BuildContext? notificationContext =
475 primaryScrollController.position.context.notificationContext;
476 if (notificationContext != null) {
477 state = Scrollable.maybeOf(notificationContext);
478 }
479 if (state == null) {
480 return;
481 }
482 }
483 assert(
484 state.position.hasPixels,
485 'Scrollable must be laid out before it can be scrolled via a ScrollAction',
486 );
487
488 // Don't do anything if the user isn't allowed to scroll.
489 if (state.resolvedPhysics != null &&
490 !state.resolvedPhysics!.shouldAcceptUserOffset(state.position)) {
491 return;
492 }
493 final double increment = getDirectionalIncrement(state, intent);
494 if (increment == 0.0) {
495 return;
496 }
497 state.position.moveTo(
498 state.position.pixels + increment,
499 duration: const Duration(milliseconds: 100),
500 curve: Curves.easeInOut,
501 );
502 }
503}
504