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 addIfNonNull('scroll controller: ', controller);
125 addIfNonNull('scroll physics: ', physics);
126 addIfNonNull('decorationClipBehavior: ', decorationClipBehavior);
127 return '${describeIdentity(this)}(${description.join(", ")})';
128 }
129
130 @override
131 int get hashCode => Object.hash(
132 direction,
133 controller,
134 physics,
135 decorationClipBehavior,
136 );
137
138 @override
139 bool operator ==(Object other) {
140 if (identical(this, other)) {
141 return true;
142 }
143 if (other.runtimeType != runtimeType) {
144 return false;
145 }
146 return other is ScrollableDetails
147 && other.direction == direction
148 && other.controller == controller
149 && other.physics == physics
150 && other.decorationClipBehavior == decorationClipBehavior;
151 }
152}
153
154/// An auto scroller that scrolls the [scrollable] if a drag gesture drags close
155/// to its edge.
156///
157/// The scroll velocity is controlled by the [velocityScalar]:
158///
159/// velocity = (distance of overscroll) * [velocityScalar].
160class EdgeDraggingAutoScroller {
161 /// Creates a auto scroller that scrolls the [scrollable].
162 EdgeDraggingAutoScroller(
163 this.scrollable, {
164 this.onScrollViewScrolled,
165 required this.velocityScalar,
166 });
167
168 /// The [Scrollable] this auto scroller is scrolling.
169 final ScrollableState scrollable;
170
171 /// Called when a scroll view is scrolled.
172 ///
173 /// The scroll view may be scrolled multiple times in a row until the drag
174 /// target no longer triggers the auto scroll. This callback will be called
175 /// in between each scroll.
176 final VoidCallback? onScrollViewScrolled;
177
178 /// {@template flutter.widgets.EdgeDraggingAutoScroller.velocityScalar}
179 /// The velocity scalar per pixel over scroll.
180 ///
181 /// It represents how the velocity scale with the over scroll distance. The
182 /// auto-scroll velocity = (distance of overscroll) * velocityScalar.
183 /// {@endtemplate}
184 final double velocityScalar;
185
186 late Rect _dragTargetRelatedToScrollOrigin;
187
188 /// Whether the auto scroll is in progress.
189 bool get scrolling => _scrolling;
190 bool _scrolling = false;
191
192 double _offsetExtent(Offset offset, Axis scrollDirection) {
193 return switch (scrollDirection) {
194 Axis.horizontal => offset.dx,
195 Axis.vertical => offset.dy,
196 };
197 }
198
199 double _sizeExtent(Size size, Axis scrollDirection) {
200 return switch (scrollDirection) {
201 Axis.horizontal => size.width,
202 Axis.vertical => size.height,
203 };
204 }
205
206 AxisDirection get _axisDirection => scrollable.axisDirection;
207 Axis get _scrollDirection => axisDirectionToAxis(_axisDirection);
208
209 /// Starts the auto scroll if the [dragTarget] is close to the edge.
210 ///
211 /// The scroll starts to scroll the [scrollable] if the target rect is close
212 /// to the edge of the [scrollable]; otherwise, it remains stationary.
213 ///
214 /// If the scrollable is already scrolling, calling this method updates the
215 /// previous dragTarget to the new value and continues scrolling if necessary.
216 void startAutoScrollIfNecessary(Rect dragTarget) {
217 final Offset deltaToOrigin = scrollable.deltaToScrollOrigin;
218 _dragTargetRelatedToScrollOrigin = dragTarget.translate(deltaToOrigin.dx, deltaToOrigin.dy);
219 if (_scrolling) {
220 // The change will be picked up in the next scroll.
221 return;
222 }
223 assert(!_scrolling);
224 _scroll();
225 }
226
227 /// Stop any ongoing auto scrolling.
228 void stopAutoScroll() {
229 _scrolling = false;
230 }
231
232 Future<void> _scroll() async {
233 final RenderBox scrollRenderBox = scrollable.context.findRenderObject()! as RenderBox;
234 final Rect globalRect = MatrixUtils.transformRect(
235 scrollRenderBox.getTransformTo(null),
236 Rect.fromLTWH(0, 0, scrollRenderBox.size.width, scrollRenderBox.size.height),
237 );
238 assert(
239 globalRect.size.width >= _dragTargetRelatedToScrollOrigin.size.width &&
240 globalRect.size.height >= _dragTargetRelatedToScrollOrigin.size.height,
241 'Drag target size is larger than scrollable size, which may cause bouncing',
242 );
243 _scrolling = true;
244 double? newOffset;
245 const double overDragMax = 20.0;
246
247 final Offset deltaToOrigin = scrollable.deltaToScrollOrigin;
248 final Offset viewportOrigin = globalRect.topLeft.translate(deltaToOrigin.dx, deltaToOrigin.dy);
249 final double viewportStart = _offsetExtent(viewportOrigin, _scrollDirection);
250 final double viewportEnd = viewportStart + _sizeExtent(globalRect.size, _scrollDirection);
251
252 final double proxyStart = _offsetExtent(_dragTargetRelatedToScrollOrigin.topLeft, _scrollDirection);
253 final double proxyEnd = _offsetExtent(_dragTargetRelatedToScrollOrigin.bottomRight, _scrollDirection);
254 switch (_axisDirection) {
255 case AxisDirection.up:
256 case AxisDirection.left:
257 if (proxyEnd > viewportEnd && scrollable.position.pixels > scrollable.position.minScrollExtent) {
258 final double overDrag = math.min(proxyEnd - viewportEnd, overDragMax);
259 newOffset = math.max(scrollable.position.minScrollExtent, scrollable.position.pixels - overDrag);
260 } else if (proxyStart < viewportStart && scrollable.position.pixels < scrollable.position.maxScrollExtent) {
261 final double overDrag = math.min(viewportStart - proxyStart, overDragMax);
262 newOffset = math.min(scrollable.position.maxScrollExtent, scrollable.position.pixels + overDrag);
263 }
264 case AxisDirection.right:
265 case AxisDirection.down:
266 if (proxyStart < viewportStart && scrollable.position.pixels > scrollable.position.minScrollExtent) {
267 final double overDrag = math.min(viewportStart - proxyStart, overDragMax);
268 newOffset = math.max(scrollable.position.minScrollExtent, scrollable.position.pixels - overDrag);
269 } else if (proxyEnd > viewportEnd && scrollable.position.pixels < scrollable.position.maxScrollExtent) {
270 final double overDrag = math.min(proxyEnd - viewportEnd, overDragMax);
271 newOffset = math.min(scrollable.position.maxScrollExtent, scrollable.position.pixels + overDrag);
272 }
273 }
274
275 if (newOffset == null || (newOffset - scrollable.position.pixels).abs() < 1.0) {
276 // Drag should not trigger scroll.
277 _scrolling = false;
278 return;
279 }
280 final Duration duration = Duration(milliseconds: (1000 / velocityScalar).round());
281 await scrollable.position.animateTo(
282 newOffset,
283 duration: duration,
284 curve: Curves.linear,
285 );
286 onScrollViewScrolled?.call();
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 return switch (type) {
419 ScrollIncrementType.line => 50.0,
420 ScrollIncrementType.page => 0.8 * state.position.viewportDimension,
421 };
422 }
423
424 /// Find out how much of an increment to move by, taking the different
425 /// directions into account.
426 static double getDirectionalIncrement(ScrollableState state, ScrollIntent intent) {
427 if (axisDirectionToAxis(intent.direction) == axisDirectionToAxis(state.axisDirection)) {
428 final double increment = _calculateScrollIncrement(state, type: intent.type);
429 return intent.direction == state.axisDirection ? increment : -increment;
430 }
431 return 0.0;
432 }
433
434 @override
435 void invoke(ScrollIntent intent, [BuildContext? context]) {
436 assert(context != null, 'Cannot scroll without a context.');
437 ScrollableState? state = Scrollable.maybeOf(context!);
438 if (state == null) {
439 final ScrollController primaryScrollController = PrimaryScrollController.of(context);
440 assert (() {
441 if (primaryScrollController.positions.length != 1) {
442 throw FlutterError.fromParts(<DiagnosticsNode>[
443 ErrorSummary(
444 'A ScrollAction was invoked with the PrimaryScrollController, but '
445 'more than one ScrollPosition is attached.',
446 ),
447 ErrorDescription(
448 'Only one ScrollPosition can be manipulated by a ScrollAction at '
449 'a time.',
450 ),
451 ErrorHint(
452 'The PrimaryScrollController can be inherited automatically by '
453 'descendant ScrollViews based on the TargetPlatform and scroll '
454 'direction. By default, the PrimaryScrollController is '
455 'automatically inherited on mobile platforms for vertical '
456 'ScrollViews. ScrollView.primary can also override this behavior.',
457 ),
458 ]);
459 }
460 return true;
461 }());
462
463 if (primaryScrollController.position.context.notificationContext == null
464 && Scrollable.maybeOf(primaryScrollController.position.context.notificationContext!) == null) {
465 return;
466 }
467 state = Scrollable.maybeOf(primaryScrollController.position.context.notificationContext!);
468 }
469 assert(state != null, '$ScrollAction was invoked on a context that has no scrollable parent');
470 assert(state!.position.hasPixels, 'Scrollable must be laid out before it can be scrolled via a ScrollAction');
471
472 // Don't do anything if the user isn't allowed to scroll.
473 if (state!.resolvedPhysics != null && !state.resolvedPhysics!.shouldAcceptUserOffset(state.position)) {
474 return;
475 }
476 final double increment = getDirectionalIncrement(state, intent);
477 if (increment == 0.0) {
478 return;
479 }
480 state.position.moveTo(
481 state.position.pixels + increment,
482 duration: const Duration(milliseconds: 100),
483 curve: Curves.easeInOut,
484 );
485 }
486}
487

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com