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