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 | import 'dart:async'; |
6 | import 'dart:math' as math; |
7 | |
8 | import 'package:flutter/foundation.dart'; |
9 | import 'package:flutter/gestures.dart'; |
10 | import 'package:flutter/rendering.dart'; |
11 | import 'package:flutter/scheduler.dart'; |
12 | import 'package:flutter/services.dart'; |
13 | |
14 | import 'basic.dart'; |
15 | import 'framework.dart'; |
16 | import 'gesture_detector.dart'; |
17 | import 'media_query.dart'; |
18 | import 'notification_listener.dart'; |
19 | import 'restoration.dart'; |
20 | import 'restoration_properties.dart'; |
21 | import 'scroll_activity.dart'; |
22 | import 'scroll_configuration.dart'; |
23 | import 'scroll_context.dart'; |
24 | import 'scroll_controller.dart'; |
25 | import 'scroll_physics.dart'; |
26 | import 'scroll_position.dart'; |
27 | import 'scrollable_helpers.dart'; |
28 | import 'selectable_region.dart'; |
29 | import 'selection_container.dart'; |
30 | import 'ticker_provider.dart'; |
31 | import 'view.dart'; |
32 | import 'viewport.dart'; |
33 | |
34 | export 'package:flutter/physics.dart' show Tolerance; |
35 | |
36 | // Examples can assume: |
37 | // late BuildContext context; |
38 | |
39 | /// Signature used by [Scrollable] to build the viewport through which the |
40 | /// scrollable content is displayed. |
41 | typedef ViewportBuilder = Widget Function(BuildContext context, ViewportOffset position); |
42 | |
43 | /// Signature used by [TwoDimensionalScrollable] to build the viewport through |
44 | /// which the scrollable content is displayed. |
45 | typedef TwoDimensionalViewportBuilder = Widget Function(BuildContext context, ViewportOffset verticalPosition, ViewportOffset horizontalPosition); |
46 | |
47 | // The return type of _performEnsureVisible. |
48 | // |
49 | // The list of futures represents each pending ScrollPosition call to |
50 | // ensureVisible. The returned ScrollableState's context is used to find the |
51 | // next potential ancestor Scrollable. |
52 | typedef _EnsureVisibleResults = (List<Future<void>>, ScrollableState); |
53 | |
54 | /// A widget that manages scrolling in one dimension and informs the [Viewport] |
55 | /// through which the content is viewed. |
56 | /// |
57 | /// [Scrollable] implements the interaction model for a scrollable widget, |
58 | /// including gesture recognition, but does not have an opinion about how the |
59 | /// viewport, which actually displays the children, is constructed. |
60 | /// |
61 | /// It's rare to construct a [Scrollable] directly. Instead, consider [ListView] |
62 | /// or [GridView], which combine scrolling, viewporting, and a layout model. To |
63 | /// combine layout models (or to use a custom layout mode), consider using |
64 | /// [CustomScrollView]. |
65 | /// |
66 | /// The static [Scrollable.of] and [Scrollable.ensureVisible] functions are |
67 | /// often used to interact with the [Scrollable] widget inside a [ListView] or |
68 | /// a [GridView]. |
69 | /// |
70 | /// To further customize scrolling behavior with a [Scrollable]: |
71 | /// |
72 | /// 1. You can provide a [viewportBuilder] to customize the child model. For |
73 | /// example, [SingleChildScrollView] uses a viewport that displays a single |
74 | /// box child whereas [CustomScrollView] uses a [Viewport] or a |
75 | /// [ShrinkWrappingViewport], both of which display a list of slivers. |
76 | /// |
77 | /// 2. You can provide a custom [ScrollController] that creates a custom |
78 | /// [ScrollPosition] subclass. For example, [PageView] uses a |
79 | /// [PageController], which creates a page-oriented scroll position subclass |
80 | /// that keeps the same page visible when the [Scrollable] resizes. |
81 | /// |
82 | /// ## Persisting the scroll position during a session |
83 | /// |
84 | /// Scrollables attempt to persist their scroll position using [PageStorage]. |
85 | /// This can be disabled by setting [ScrollController.keepScrollOffset] to false |
86 | /// on the [controller]. If it is enabled, using a [PageStorageKey] for the |
87 | /// [key] of this widget (or one of its ancestors, e.g. a [ScrollView]) is |
88 | /// recommended to help disambiguate different [Scrollable]s from each other. |
89 | /// |
90 | /// See also: |
91 | /// |
92 | /// * [ListView], which is a commonly used [ScrollView] that displays a |
93 | /// scrolling, linear list of child widgets. |
94 | /// * [PageView], which is a scrolling list of child widgets that are each the |
95 | /// size of the viewport. |
96 | /// * [GridView], which is a [ScrollView] that displays a scrolling, 2D array |
97 | /// of child widgets. |
98 | /// * [CustomScrollView], which is a [ScrollView] that creates custom scroll |
99 | /// effects using slivers. |
100 | /// * [SingleChildScrollView], which is a scrollable widget that has a single |
101 | /// child. |
102 | /// * [ScrollNotification] and [NotificationListener], which can be used to watch |
103 | /// the scroll position without using a [ScrollController]. |
104 | class Scrollable extends StatefulWidget { |
105 | /// Creates a widget that scrolls. |
106 | const Scrollable({ |
107 | super.key, |
108 | this.axisDirection = AxisDirection.down, |
109 | this.controller, |
110 | this.physics, |
111 | required this.viewportBuilder, |
112 | this.incrementCalculator, |
113 | this.excludeFromSemantics = false, |
114 | this.semanticChildCount, |
115 | this.dragStartBehavior = DragStartBehavior.start, |
116 | this.restorationId, |
117 | this.scrollBehavior, |
118 | this.clipBehavior = Clip.hardEdge, |
119 | }) : assert(semanticChildCount == null || semanticChildCount >= 0); |
120 | |
121 | /// {@template flutter.widgets.Scrollable.axisDirection} |
122 | /// The direction in which this widget scrolls. |
123 | /// |
124 | /// For example, if the [Scrollable.axisDirection] is [AxisDirection.down], |
125 | /// increasing the scroll position will cause content below the bottom of the |
126 | /// viewport to become visible through the viewport. Similarly, if the |
127 | /// axisDirection is [AxisDirection.right], increasing the scroll position |
128 | /// will cause content beyond the right edge of the viewport to become visible |
129 | /// through the viewport. |
130 | /// |
131 | /// Defaults to [AxisDirection.down]. |
132 | /// {@endtemplate} |
133 | final AxisDirection axisDirection; |
134 | |
135 | /// {@template flutter.widgets.Scrollable.controller} |
136 | /// An object that can be used to control the position to which this widget is |
137 | /// scrolled. |
138 | /// |
139 | /// A [ScrollController] serves several purposes. It can be used to control |
140 | /// the initial scroll position (see [ScrollController.initialScrollOffset]). |
141 | /// It can be used to control whether the scroll view should automatically |
142 | /// save and restore its scroll position in the [PageStorage] (see |
143 | /// [ScrollController.keepScrollOffset]). It can be used to read the current |
144 | /// scroll position (see [ScrollController.offset]), or change it (see |
145 | /// [ScrollController.animateTo]). |
146 | /// |
147 | /// If null, a [ScrollController] will be created internally by [Scrollable] |
148 | /// in order to create and manage the [ScrollPosition]. |
149 | /// |
150 | /// See also: |
151 | /// |
152 | /// * [Scrollable.ensureVisible], which animates the scroll position to |
153 | /// reveal a given [BuildContext]. |
154 | /// {@endtemplate} |
155 | final ScrollController? controller; |
156 | |
157 | /// {@template flutter.widgets.Scrollable.physics} |
158 | /// How the widgets should respond to user input. |
159 | /// |
160 | /// For example, determines how the widget continues to animate after the |
161 | /// user stops dragging the scroll view. |
162 | /// |
163 | /// Defaults to matching platform conventions via the physics provided from |
164 | /// the ambient [ScrollConfiguration]. |
165 | /// |
166 | /// If an explicit [ScrollBehavior] is provided to |
167 | /// [Scrollable.scrollBehavior], the [ScrollPhysics] provided by that behavior |
168 | /// will take precedence after [Scrollable.physics]. |
169 | /// |
170 | /// The physics can be changed dynamically, but new physics will only take |
171 | /// effect if the _class_ of the provided object changes. Merely constructing |
172 | /// a new instance with a different configuration is insufficient to cause the |
173 | /// physics to be reapplied. (This is because the final object used is |
174 | /// generated dynamically, which can be relatively expensive, and it would be |
175 | /// inefficient to speculatively create this object each frame to see if the |
176 | /// physics should be updated.) |
177 | /// |
178 | /// See also: |
179 | /// |
180 | /// * [AlwaysScrollableScrollPhysics], which can be used to indicate that the |
181 | /// scrollable should react to scroll requests (and possible overscroll) |
182 | /// even if the scrollable's contents fit without scrolling being necessary. |
183 | /// {@endtemplate} |
184 | final ScrollPhysics? physics; |
185 | |
186 | /// Builds the viewport through which the scrollable content is displayed. |
187 | /// |
188 | /// A typical viewport uses the given [ViewportOffset] to determine which part |
189 | /// of its content is actually visible through the viewport. |
190 | /// |
191 | /// See also: |
192 | /// |
193 | /// * [Viewport], which is a viewport that displays a list of slivers. |
194 | /// * [ShrinkWrappingViewport], which is a viewport that displays a list of |
195 | /// slivers and sizes itself based on the size of the slivers. |
196 | final ViewportBuilder viewportBuilder; |
197 | |
198 | /// {@template flutter.widgets.Scrollable.incrementCalculator} |
199 | /// An optional function that will be called to calculate the distance to |
200 | /// scroll when the scrollable is asked to scroll via the keyboard using a |
201 | /// [ScrollAction]. |
202 | /// |
203 | /// If not supplied, the [Scrollable] will scroll a default amount when a |
204 | /// keyboard navigation key is pressed (e.g. pageUp/pageDown, control-upArrow, |
205 | /// etc.), or otherwise invoked by a [ScrollAction]. |
206 | /// |
207 | /// If [incrementCalculator] is null, the default for |
208 | /// [ScrollIncrementType.page] is 80% of the size of the scroll window, and |
209 | /// for [ScrollIncrementType.line], 50 logical pixels. |
210 | /// {@endtemplate} |
211 | final ScrollIncrementCalculator? incrementCalculator; |
212 | |
213 | /// {@template flutter.widgets.scrollable.excludeFromSemantics} |
214 | /// Whether the scroll actions introduced by this [Scrollable] are exposed |
215 | /// in the semantics tree. |
216 | /// |
217 | /// Text fields with an overflow are usually scrollable to make sure that the |
218 | /// user can get to the beginning/end of the entered text. However, these |
219 | /// scrolling actions are generally not exposed to the semantics layer. |
220 | /// {@endtemplate} |
221 | /// |
222 | /// See also: |
223 | /// |
224 | /// * [GestureDetector.excludeFromSemantics], which is used to accomplish the |
225 | /// exclusion. |
226 | final bool excludeFromSemantics; |
227 | |
228 | /// The number of children that will contribute semantic information. |
229 | /// |
230 | /// The value will be null if the number of children is unknown or unbounded. |
231 | /// |
232 | /// Some subtypes of [ScrollView] can infer this value automatically. For |
233 | /// example [ListView] will use the number of widgets in the child list, |
234 | /// while the [ListView.separated] constructor will use half that amount. |
235 | /// |
236 | /// For [CustomScrollView] and other types which do not receive a builder |
237 | /// or list of widgets, the child count must be explicitly provided. |
238 | /// |
239 | /// See also: |
240 | /// |
241 | /// * [CustomScrollView], for an explanation of scroll semantics. |
242 | /// * [SemanticsConfiguration.scrollChildCount], the corresponding semantics property. |
243 | final int? semanticChildCount; |
244 | |
245 | // TODO(jslavitz): Set the DragStartBehavior default to be start across all widgets. |
246 | /// {@template flutter.widgets.scrollable.dragStartBehavior} |
247 | /// Determines the way that drag start behavior is handled. |
248 | /// |
249 | /// If set to [DragStartBehavior.start], scrolling drag behavior will |
250 | /// begin at the position where the drag gesture won the arena. If set to |
251 | /// [DragStartBehavior.down] it will begin at the position where a down |
252 | /// event is first detected. |
253 | /// |
254 | /// In general, setting this to [DragStartBehavior.start] will make drag |
255 | /// animation smoother and setting it to [DragStartBehavior.down] will make |
256 | /// drag behavior feel slightly more reactive. |
257 | /// |
258 | /// By default, the drag start behavior is [DragStartBehavior.start]. |
259 | /// |
260 | /// See also: |
261 | /// |
262 | /// * [DragGestureRecognizer.dragStartBehavior], which gives an example for |
263 | /// the different behaviors. |
264 | /// |
265 | /// {@endtemplate} |
266 | final DragStartBehavior dragStartBehavior; |
267 | |
268 | /// {@template flutter.widgets.scrollable.restorationId} |
269 | /// Restoration ID to save and restore the scroll offset of the scrollable. |
270 | /// |
271 | /// If a restoration id is provided, the scrollable will persist its current |
272 | /// scroll offset and restore it during state restoration. |
273 | /// |
274 | /// The scroll offset is persisted in a [RestorationBucket] claimed from |
275 | /// the surrounding [RestorationScope] using the provided restoration ID. |
276 | /// |
277 | /// See also: |
278 | /// |
279 | /// * [RestorationManager], which explains how state restoration works in |
280 | /// Flutter. |
281 | /// {@endtemplate} |
282 | final String? restorationId; |
283 | |
284 | /// {@macro flutter.widgets.shadow.scrollBehavior} |
285 | /// |
286 | /// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit |
287 | /// [ScrollPhysics] is provided in [physics], it will take precedence, |
288 | /// followed by [scrollBehavior], and then the inherited ancestor |
289 | /// [ScrollBehavior]. |
290 | final ScrollBehavior? scrollBehavior; |
291 | |
292 | /// {@macro flutter.material.Material.clipBehavior} |
293 | /// |
294 | /// Defaults to [Clip.hardEdge]. |
295 | /// |
296 | /// This is passed to decorators in [ScrollableDetails], and does not directly affect |
297 | /// clipping of the [Scrollable]. This reflects the same [Clip] that is provided |
298 | /// to [ScrollView.clipBehavior] and is supplied to the [Viewport]. |
299 | final Clip clipBehavior; |
300 | |
301 | /// The axis along which the scroll view scrolls. |
302 | /// |
303 | /// Determined by the [axisDirection]. |
304 | Axis get axis => axisDirectionToAxis(axisDirection); |
305 | |
306 | @override |
307 | ScrollableState createState() => ScrollableState(); |
308 | |
309 | @override |
310 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
311 | super.debugFillProperties(properties); |
312 | properties.add(EnumProperty<AxisDirection>('axisDirection' , axisDirection)); |
313 | properties.add(DiagnosticsProperty<ScrollPhysics>('physics' , physics)); |
314 | properties.add(StringProperty('restorationId' , restorationId)); |
315 | } |
316 | |
317 | /// The state from the closest instance of this class that encloses the given |
318 | /// context, or null if none is found. |
319 | /// |
320 | /// Typical usage is as follows: |
321 | /// |
322 | /// ```dart |
323 | /// ScrollableState? scrollable = Scrollable.maybeOf(context); |
324 | /// ``` |
325 | /// |
326 | /// Calling this method will create a dependency on the [ScrollableState] |
327 | /// that is returned, if there is one. This is typically the closest |
328 | /// [Scrollable], but may be a more distant ancestor if [axis] is used to |
329 | /// target a specific [Scrollable]. |
330 | /// |
331 | /// Using the optional [Axis] is useful when Scrollables are nested and the |
332 | /// target [Scrollable] is not the closest instance. When [axis] is provided, |
333 | /// the nearest enclosing [ScrollableState] in that [Axis] is returned, or |
334 | /// null if there is none. |
335 | /// |
336 | /// This finds the nearest _ancestor_ [Scrollable] of the `context`. This |
337 | /// means that if the `context` is that of a [Scrollable], it will _not_ find |
338 | /// _that_ [Scrollable]. |
339 | /// |
340 | /// See also: |
341 | /// |
342 | /// * [Scrollable.of], which is similar to this method, but asserts |
343 | /// if no [Scrollable] ancestor is found. |
344 | static ScrollableState? maybeOf(BuildContext context, { Axis? axis }) { |
345 | // This is the context that will need to establish the dependency. |
346 | final BuildContext originalContext = context; |
347 | InheritedElement? element = context.getElementForInheritedWidgetOfExactType<_ScrollableScope>(); |
348 | while (element != null) { |
349 | final ScrollableState scrollable = (element.widget as _ScrollableScope).scrollable; |
350 | if (axis == null || axisDirectionToAxis(scrollable.axisDirection) == axis) { |
351 | // Establish the dependency on the correct context. |
352 | originalContext.dependOnInheritedElement(element); |
353 | return scrollable; |
354 | } |
355 | context = scrollable.context; |
356 | element = context.getElementForInheritedWidgetOfExactType<_ScrollableScope>(); |
357 | } |
358 | return null; |
359 | } |
360 | |
361 | /// The state from the closest instance of this class that encloses the given |
362 | /// context. |
363 | /// |
364 | /// Typical usage is as follows: |
365 | /// |
366 | /// ```dart |
367 | /// ScrollableState scrollable = Scrollable.of(context); |
368 | /// ``` |
369 | /// |
370 | /// Calling this method will create a dependency on the [ScrollableState] |
371 | /// that is returned, if there is one. This is typically the closest |
372 | /// [Scrollable], but may be a more distant ancestor if [axis] is used to |
373 | /// target a specific [Scrollable]. |
374 | /// |
375 | /// Using the optional [Axis] is useful when Scrollables are nested and the |
376 | /// target [Scrollable] is not the closest instance. When [axis] is provided, |
377 | /// the nearest enclosing [ScrollableState] in that [Axis] is returned. |
378 | /// |
379 | /// This finds the nearest _ancestor_ [Scrollable] of the `context`. This |
380 | /// means that if the `context` is that of a [Scrollable], it will _not_ find |
381 | /// _that_ [Scrollable]. |
382 | /// |
383 | /// If no [Scrollable] ancestor is found, then this method will assert in |
384 | /// debug mode, and throw an exception in release mode. |
385 | /// |
386 | /// See also: |
387 | /// |
388 | /// * [Scrollable.maybeOf], which is similar to this method, but returns null |
389 | /// if no [Scrollable] ancestor is found. |
390 | static ScrollableState of(BuildContext context, { Axis? axis }) { |
391 | final ScrollableState? scrollableState = maybeOf(context, axis: axis); |
392 | assert(() { |
393 | if (scrollableState == null) { |
394 | throw FlutterError.fromParts(<DiagnosticsNode>[ |
395 | ErrorSummary( |
396 | 'Scrollable.of() was called with a context that does not contain a ' |
397 | 'Scrollable widget.' , |
398 | ), |
399 | ErrorDescription( |
400 | 'No Scrollable widget ancestor could be found ' |
401 | ' ${axis == null ? '' : 'for the provided Axis: $axis ' }' |
402 | 'starting from the context that was passed to Scrollable.of(). This ' |
403 | 'can happen because you are using a widget that looks for a Scrollable ' |
404 | 'ancestor, but no such ancestor exists.\n' |
405 | 'The context used was:\n' |
406 | ' $context' , |
407 | ), |
408 | if (axis != null) ErrorHint( |
409 | 'When specifying an axis, this method will only look for a Scrollable ' |
410 | 'that matches the given Axis.' , |
411 | ), |
412 | ]); |
413 | } |
414 | return true; |
415 | }()); |
416 | return scrollableState!; |
417 | } |
418 | |
419 | /// Provides a heuristic to determine if expensive frame-bound tasks should be |
420 | /// deferred for the [context] at a specific point in time. |
421 | /// |
422 | /// Calling this method does _not_ create a dependency on any other widget. |
423 | /// This also means that the value returned is only good for the point in time |
424 | /// when it is called, and callers will not get updated if the value changes. |
425 | /// |
426 | /// The heuristic used is determined by the [physics] of this [Scrollable] |
427 | /// via [ScrollPhysics.recommendDeferredLoading]. That method is called with |
428 | /// the current [ScrollPosition.activity]'s [ScrollActivity.velocity]. |
429 | /// |
430 | /// The optional [Axis] allows targeting of a specific [Scrollable] of that |
431 | /// axis, useful when Scrollables are nested. When [axis] is provided, |
432 | /// [ScrollPosition.recommendDeferredLoading] is called for the nearest |
433 | /// [Scrollable] in that [Axis]. |
434 | /// |
435 | /// If there is no [Scrollable] in the widget tree above the [context], this |
436 | /// method returns false. |
437 | static bool recommendDeferredLoadingForContext(BuildContext context, { Axis? axis }) { |
438 | _ScrollableScope? widget = context.getInheritedWidgetOfExactType<_ScrollableScope>(); |
439 | while (widget != null) { |
440 | if (axis == null || axisDirectionToAxis(widget.scrollable.axisDirection) == axis) { |
441 | return widget.position.recommendDeferredLoading(context); |
442 | } |
443 | context = widget.scrollable.context; |
444 | widget = context.getInheritedWidgetOfExactType<_ScrollableScope>(); |
445 | } |
446 | return false; |
447 | } |
448 | |
449 | /// Scrolls the scrollables that enclose the given context so as to make the |
450 | /// given context visible. |
451 | /// |
452 | /// If the [Scrollable] of the provided [BuildContext] is a |
453 | /// [TwoDimensionalScrollable], both vertical and horizontal axes will ensure |
454 | /// the target is made visible. |
455 | static Future<void> ensureVisible( |
456 | BuildContext context, { |
457 | double alignment = 0.0, |
458 | Duration duration = Duration.zero, |
459 | Curve curve = Curves.ease, |
460 | ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, |
461 | }) { |
462 | final List<Future<void>> futures = <Future<void>>[]; |
463 | |
464 | // The targetRenderObject is used to record the first target renderObject. |
465 | // If there are multiple scrollable widgets nested, the targetRenderObject |
466 | // is made to be as visible as possible to improve the user experience. If |
467 | // the targetRenderObject is already visible, then let the outer |
468 | // renderObject be as visible as possible. |
469 | // |
470 | // Also see https://github.com/flutter/flutter/issues/65100 |
471 | RenderObject? targetRenderObject; |
472 | ScrollableState? scrollable = Scrollable.maybeOf(context); |
473 | while (scrollable != null) { |
474 | final List<Future<void>> newFutures; |
475 | (newFutures, scrollable) = scrollable._performEnsureVisible( |
476 | context.findRenderObject()!, |
477 | alignment: alignment, |
478 | duration: duration, |
479 | curve: curve, |
480 | alignmentPolicy: alignmentPolicy, |
481 | targetRenderObject: targetRenderObject, |
482 | ); |
483 | futures.addAll(newFutures); |
484 | |
485 | targetRenderObject ??= context.findRenderObject(); |
486 | context = scrollable.context; |
487 | scrollable = Scrollable.maybeOf(context); |
488 | } |
489 | |
490 | if (futures.isEmpty || duration == Duration.zero) { |
491 | return Future<void>.value(); |
492 | } |
493 | if (futures.length == 1) { |
494 | return futures.single; |
495 | } |
496 | return Future.wait<void>(futures).then<void>((List<void> _) => null); |
497 | } |
498 | } |
499 | |
500 | // Enable Scrollable.of() to work as if ScrollableState was an inherited widget. |
501 | // ScrollableState.build() always rebuilds its _ScrollableScope. |
502 | class _ScrollableScope extends InheritedWidget { |
503 | const _ScrollableScope({ |
504 | required this.scrollable, |
505 | required this.position, |
506 | required super.child, |
507 | }); |
508 | |
509 | final ScrollableState scrollable; |
510 | final ScrollPosition position; |
511 | |
512 | @override |
513 | bool updateShouldNotify(_ScrollableScope old) { |
514 | return position != old.position; |
515 | } |
516 | } |
517 | |
518 | /// State object for a [Scrollable] widget. |
519 | /// |
520 | /// To manipulate a [Scrollable] widget's scroll position, use the object |
521 | /// obtained from the [position] property. |
522 | /// |
523 | /// To be informed of when a [Scrollable] widget is scrolling, use a |
524 | /// [NotificationListener] to listen for [ScrollNotification] notifications. |
525 | /// |
526 | /// This class is not intended to be subclassed. To specialize the behavior of a |
527 | /// [Scrollable], provide it with a [ScrollPhysics]. |
528 | class ScrollableState extends State<Scrollable> with TickerProviderStateMixin, RestorationMixin |
529 | implements ScrollContext { |
530 | |
531 | // GETTERS |
532 | |
533 | /// The manager for this [Scrollable] widget's viewport position. |
534 | /// |
535 | /// To control what kind of [ScrollPosition] is created for a [Scrollable], |
536 | /// provide it with custom [ScrollController] that creates the appropriate |
537 | /// [ScrollPosition] in its [ScrollController.createScrollPosition] method. |
538 | ScrollPosition get position => _position!; |
539 | ScrollPosition? _position; |
540 | |
541 | /// The resolved [ScrollPhysics] of the [ScrollableState]. |
542 | ScrollPhysics? get resolvedPhysics => _physics; |
543 | ScrollPhysics? _physics; |
544 | |
545 | /// An [Offset] that represents the absolute distance from the origin, or 0, |
546 | /// of the [ScrollPosition] expressed in the associated [Axis]. |
547 | /// |
548 | /// Used by [EdgeDraggingAutoScroller] to progress the position forward when a |
549 | /// drag gesture reaches the edge of the [Viewport]. |
550 | Offset get deltaToScrollOrigin { |
551 | switch (axisDirection) { |
552 | case AxisDirection.down: |
553 | return Offset(0, position.pixels); |
554 | case AxisDirection.up: |
555 | return Offset(0, -position.pixels); |
556 | case AxisDirection.left: |
557 | return Offset(-position.pixels, 0); |
558 | case AxisDirection.right: |
559 | return Offset(position.pixels, 0); |
560 | } |
561 | } |
562 | |
563 | ScrollController get _effectiveScrollController => widget.controller ?? _fallbackScrollController!; |
564 | |
565 | @override |
566 | AxisDirection get axisDirection => widget.axisDirection; |
567 | |
568 | @override |
569 | TickerProvider get vsync => this; |
570 | |
571 | @override |
572 | double get devicePixelRatio => _devicePixelRatio; |
573 | late double _devicePixelRatio; |
574 | |
575 | @override |
576 | BuildContext? get notificationContext => _gestureDetectorKey.currentContext; |
577 | |
578 | @override |
579 | BuildContext get storageContext => context; |
580 | |
581 | @override |
582 | String? get restorationId => widget.restorationId; |
583 | final _RestorableScrollOffset _persistedScrollOffset = _RestorableScrollOffset(); |
584 | |
585 | late ScrollBehavior _configuration; |
586 | ScrollController? _fallbackScrollController; |
587 | DeviceGestureSettings? _mediaQueryGestureSettings; |
588 | |
589 | // Only call this from places that will definitely trigger a rebuild. |
590 | void _updatePosition() { |
591 | _configuration = widget.scrollBehavior ?? ScrollConfiguration.of(context); |
592 | _physics = _configuration.getScrollPhysics(context); |
593 | if (widget.physics != null) { |
594 | _physics = widget.physics!.applyTo(_physics); |
595 | } else if (widget.scrollBehavior != null) { |
596 | _physics = widget.scrollBehavior!.getScrollPhysics(context).applyTo(_physics); |
597 | } |
598 | final ScrollPosition? oldPosition = _position; |
599 | if (oldPosition != null) { |
600 | _effectiveScrollController.detach(oldPosition); |
601 | // It's important that we not dispose the old position until after the |
602 | // viewport has had a chance to unregister its listeners from the old |
603 | // position. So, schedule a microtask to do it. |
604 | scheduleMicrotask(oldPosition.dispose); |
605 | } |
606 | |
607 | _position = _effectiveScrollController.createScrollPosition(_physics!, this, oldPosition); |
608 | assert(_position != null); |
609 | _effectiveScrollController.attach(position); |
610 | } |
611 | |
612 | @override |
613 | void restoreState(RestorationBucket? oldBucket, bool initialRestore) { |
614 | registerForRestoration(_persistedScrollOffset, 'offset' ); |
615 | assert(_position != null); |
616 | if (_persistedScrollOffset.value != null) { |
617 | position.restoreOffset(_persistedScrollOffset.value!, initialRestore: initialRestore); |
618 | } |
619 | } |
620 | |
621 | @override |
622 | void saveOffset(double offset) { |
623 | assert(debugIsSerializableForRestoration(offset)); |
624 | _persistedScrollOffset.value = offset; |
625 | // [saveOffset] is called after a scrolling ends and it is usually not |
626 | // followed by a frame. Therefore, manually flush restoration data. |
627 | ServicesBinding.instance.restorationManager.flushData(); |
628 | } |
629 | |
630 | @override |
631 | void initState() { |
632 | if (widget.controller == null) { |
633 | _fallbackScrollController = ScrollController(); |
634 | } |
635 | super.initState(); |
636 | } |
637 | |
638 | @override |
639 | void didChangeDependencies() { |
640 | _mediaQueryGestureSettings = MediaQuery.maybeGestureSettingsOf(context); |
641 | _devicePixelRatio = MediaQuery.maybeDevicePixelRatioOf(context) ?? View.of(context).devicePixelRatio; |
642 | _updatePosition(); |
643 | super.didChangeDependencies(); |
644 | } |
645 | |
646 | bool _shouldUpdatePosition(Scrollable oldWidget) { |
647 | if ((widget.scrollBehavior == null) != (oldWidget.scrollBehavior == null)) { |
648 | return true; |
649 | } |
650 | if (widget.scrollBehavior != null && oldWidget.scrollBehavior != null && widget.scrollBehavior!.shouldNotify(oldWidget.scrollBehavior!)) { |
651 | return true; |
652 | } |
653 | ScrollPhysics? newPhysics = widget.physics ?? widget.scrollBehavior?.getScrollPhysics(context); |
654 | ScrollPhysics? oldPhysics = oldWidget.physics ?? oldWidget.scrollBehavior?.getScrollPhysics(context); |
655 | do { |
656 | if (newPhysics?.runtimeType != oldPhysics?.runtimeType) { |
657 | return true; |
658 | } |
659 | newPhysics = newPhysics?.parent; |
660 | oldPhysics = oldPhysics?.parent; |
661 | } while (newPhysics != null || oldPhysics != null); |
662 | |
663 | return widget.controller?.runtimeType != oldWidget.controller?.runtimeType; |
664 | } |
665 | |
666 | @override |
667 | void didUpdateWidget(Scrollable oldWidget) { |
668 | super.didUpdateWidget(oldWidget); |
669 | |
670 | if (widget.controller != oldWidget.controller) { |
671 | if (oldWidget.controller == null) { |
672 | // The old controller was null, meaning the fallback cannot be null. |
673 | // Dispose of the fallback. |
674 | assert(_fallbackScrollController != null); |
675 | assert(widget.controller != null); |
676 | _fallbackScrollController!.detach(position); |
677 | _fallbackScrollController!.dispose(); |
678 | _fallbackScrollController = null; |
679 | } else { |
680 | // The old controller was not null, detach. |
681 | oldWidget.controller?.detach(position); |
682 | if (widget.controller == null) { |
683 | // If the new controller is null, we need to set up the fallback |
684 | // ScrollController. |
685 | _fallbackScrollController = ScrollController(); |
686 | } |
687 | } |
688 | // Attach the updated effective scroll controller. |
689 | _effectiveScrollController.attach(position); |
690 | } |
691 | |
692 | if (_shouldUpdatePosition(oldWidget)) { |
693 | _updatePosition(); |
694 | } |
695 | } |
696 | |
697 | @override |
698 | void dispose() { |
699 | if (widget.controller != null) { |
700 | widget.controller!.detach(position); |
701 | } else { |
702 | _fallbackScrollController?.detach(position); |
703 | _fallbackScrollController?.dispose(); |
704 | } |
705 | |
706 | position.dispose(); |
707 | _persistedScrollOffset.dispose(); |
708 | super.dispose(); |
709 | } |
710 | |
711 | // SEMANTICS |
712 | |
713 | final GlobalKey _scrollSemanticsKey = GlobalKey(); |
714 | |
715 | @override |
716 | @protected |
717 | void setSemanticsActions(Set<SemanticsAction> actions) { |
718 | if (_gestureDetectorKey.currentState != null) { |
719 | _gestureDetectorKey.currentState!.replaceSemanticsActions(actions); |
720 | } |
721 | } |
722 | |
723 | // GESTURE RECOGNITION AND POINTER IGNORING |
724 | |
725 | final GlobalKey<RawGestureDetectorState> _gestureDetectorKey = GlobalKey<RawGestureDetectorState>(); |
726 | final GlobalKey _ignorePointerKey = GlobalKey(); |
727 | |
728 | // This field is set during layout, and then reused until the next time it is set. |
729 | Map<Type, GestureRecognizerFactory> _gestureRecognizers = const <Type, GestureRecognizerFactory>{}; |
730 | bool _shouldIgnorePointer = false; |
731 | |
732 | bool? _lastCanDrag; |
733 | Axis? _lastAxisDirection; |
734 | |
735 | @override |
736 | @protected |
737 | void setCanDrag(bool value) { |
738 | if (value == _lastCanDrag && (!value || widget.axis == _lastAxisDirection)) { |
739 | return; |
740 | } |
741 | if (!value) { |
742 | _gestureRecognizers = const <Type, GestureRecognizerFactory>{}; |
743 | // Cancel the active hold/drag (if any) because the gesture recognizers |
744 | // will soon be disposed by our RawGestureDetector, and we won't be |
745 | // receiving pointer up events to cancel the hold/drag. |
746 | _handleDragCancel(); |
747 | } else { |
748 | switch (widget.axis) { |
749 | case Axis.vertical: |
750 | _gestureRecognizers = <Type, GestureRecognizerFactory>{ |
751 | VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>( |
752 | () => VerticalDragGestureRecognizer(supportedDevices: _configuration.dragDevices), |
753 | (VerticalDragGestureRecognizer instance) { |
754 | instance |
755 | ..onDown = _handleDragDown |
756 | ..onStart = _handleDragStart |
757 | ..onUpdate = _handleDragUpdate |
758 | ..onEnd = _handleDragEnd |
759 | ..onCancel = _handleDragCancel |
760 | ..minFlingDistance = _physics?.minFlingDistance |
761 | ..minFlingVelocity = _physics?.minFlingVelocity |
762 | ..maxFlingVelocity = _physics?.maxFlingVelocity |
763 | ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context) |
764 | ..dragStartBehavior = widget.dragStartBehavior |
765 | ..multitouchDragStrategy = _configuration.multitouchDragStrategy |
766 | ..gestureSettings = _mediaQueryGestureSettings |
767 | ..supportedDevices = _configuration.dragDevices; |
768 | }, |
769 | ), |
770 | }; |
771 | case Axis.horizontal: |
772 | _gestureRecognizers = <Type, GestureRecognizerFactory>{ |
773 | HorizontalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers<HorizontalDragGestureRecognizer>( |
774 | () => HorizontalDragGestureRecognizer(supportedDevices: _configuration.dragDevices), |
775 | (HorizontalDragGestureRecognizer instance) { |
776 | instance |
777 | ..onDown = _handleDragDown |
778 | ..onStart = _handleDragStart |
779 | ..onUpdate = _handleDragUpdate |
780 | ..onEnd = _handleDragEnd |
781 | ..onCancel = _handleDragCancel |
782 | ..minFlingDistance = _physics?.minFlingDistance |
783 | ..minFlingVelocity = _physics?.minFlingVelocity |
784 | ..maxFlingVelocity = _physics?.maxFlingVelocity |
785 | ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context) |
786 | ..dragStartBehavior = widget.dragStartBehavior |
787 | ..multitouchDragStrategy = _configuration.multitouchDragStrategy |
788 | ..gestureSettings = _mediaQueryGestureSettings |
789 | ..supportedDevices = _configuration.dragDevices; |
790 | }, |
791 | ), |
792 | }; |
793 | } |
794 | } |
795 | _lastCanDrag = value; |
796 | _lastAxisDirection = widget.axis; |
797 | if (_gestureDetectorKey.currentState != null) { |
798 | _gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers); |
799 | } |
800 | } |
801 | |
802 | @override |
803 | @protected |
804 | void setIgnorePointer(bool value) { |
805 | if (_shouldIgnorePointer == value) { |
806 | return; |
807 | } |
808 | _shouldIgnorePointer = value; |
809 | if (_ignorePointerKey.currentContext != null) { |
810 | final RenderIgnorePointer renderBox = _ignorePointerKey.currentContext!.findRenderObject()! as RenderIgnorePointer; |
811 | renderBox.ignoring = _shouldIgnorePointer; |
812 | } |
813 | } |
814 | |
815 | // TOUCH HANDLERS |
816 | |
817 | Drag? _drag; |
818 | ScrollHoldController? _hold; |
819 | |
820 | void _handleDragDown(DragDownDetails details) { |
821 | assert(_drag == null); |
822 | assert(_hold == null); |
823 | _hold = position.hold(_disposeHold); |
824 | } |
825 | |
826 | void _handleDragStart(DragStartDetails details) { |
827 | // It's possible for _hold to become null between _handleDragDown and |
828 | // _handleDragStart, for example if some user code calls jumpTo or otherwise |
829 | // triggers a new activity to begin. |
830 | assert(_drag == null); |
831 | _drag = position.drag(details, _disposeDrag); |
832 | assert(_drag != null); |
833 | assert(_hold == null); |
834 | } |
835 | |
836 | void _handleDragUpdate(DragUpdateDetails details) { |
837 | // _drag might be null if the drag activity ended and called _disposeDrag. |
838 | assert(_hold == null || _drag == null); |
839 | _drag?.update(details); |
840 | } |
841 | |
842 | void _handleDragEnd(DragEndDetails details) { |
843 | // _drag might be null if the drag activity ended and called _disposeDrag. |
844 | assert(_hold == null || _drag == null); |
845 | _drag?.end(details); |
846 | assert(_drag == null); |
847 | } |
848 | |
849 | void _handleDragCancel() { |
850 | if (_gestureDetectorKey.currentContext == null) { |
851 | // The cancel was caused by the GestureDetector getting disposed, which |
852 | // means we will get disposed momentarily as well and shouldn't do |
853 | // any work. |
854 | return; |
855 | } |
856 | // _hold might be null if the drag started. |
857 | // _drag might be null if the drag activity ended and called _disposeDrag. |
858 | assert(_hold == null || _drag == null); |
859 | _hold?.cancel(); |
860 | _drag?.cancel(); |
861 | assert(_hold == null); |
862 | assert(_drag == null); |
863 | } |
864 | |
865 | void _disposeHold() { |
866 | _hold = null; |
867 | } |
868 | |
869 | void _disposeDrag() { |
870 | _drag = null; |
871 | } |
872 | |
873 | // SCROLL WHEEL |
874 | |
875 | // Returns the offset that should result from applying [event] to the current |
876 | // position, taking min/max scroll extent into account. |
877 | double _targetScrollOffsetForPointerScroll(double delta) { |
878 | return math.min( |
879 | math.max(position.pixels + delta, position.minScrollExtent), |
880 | position.maxScrollExtent, |
881 | ); |
882 | } |
883 | |
884 | // Returns the delta that should result from applying [event] with axis, |
885 | // direction, and any modifiers specified by the ScrollBehavior taken into |
886 | // account. |
887 | double _pointerSignalEventDelta(PointerScrollEvent event) { |
888 | late double delta; |
889 | final Set<LogicalKeyboardKey> pressed = HardwareKeyboard.instance.logicalKeysPressed; |
890 | final bool flipAxes = pressed.any(_configuration.pointerAxisModifiers.contains) && |
891 | // Axes are only flipped for physical mouse wheel input. |
892 | // On some platforms, like web, trackpad input is handled through pointer |
893 | // signals, but should not be included in this axis modifying behavior. |
894 | // This is because on a trackpad, all directional axes are available to |
895 | // the user, while mouse scroll wheels typically are restricted to one |
896 | // axis. |
897 | event.kind == PointerDeviceKind.mouse; |
898 | |
899 | switch (widget.axis) { |
900 | case Axis.horizontal: |
901 | delta = flipAxes |
902 | ? event.scrollDelta.dy |
903 | : event.scrollDelta.dx; |
904 | case Axis.vertical: |
905 | delta = flipAxes |
906 | ? event.scrollDelta.dx |
907 | : event.scrollDelta.dy; |
908 | } |
909 | |
910 | if (axisDirectionIsReversed(widget.axisDirection)) { |
911 | delta *= -1; |
912 | } |
913 | return delta; |
914 | } |
915 | |
916 | void _receivedPointerSignal(PointerSignalEvent event) { |
917 | if (event is PointerScrollEvent && _position != null) { |
918 | if (_physics != null && !_physics!.shouldAcceptUserOffset(position)) { |
919 | return; |
920 | } |
921 | final double delta = _pointerSignalEventDelta(event); |
922 | final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta); |
923 | // Only express interest in the event if it would actually result in a scroll. |
924 | if (delta != 0.0 && targetScrollOffset != position.pixels) { |
925 | GestureBinding.instance.pointerSignalResolver.register(event, _handlePointerScroll); |
926 | } |
927 | } else if (event is PointerScrollInertiaCancelEvent) { |
928 | position.pointerScroll(0); |
929 | // Don't use the pointer signal resolver, all hit-tested scrollables should stop. |
930 | } |
931 | } |
932 | |
933 | void _handlePointerScroll(PointerEvent event) { |
934 | assert(event is PointerScrollEvent); |
935 | final double delta = _pointerSignalEventDelta(event as PointerScrollEvent); |
936 | final double targetScrollOffset = _targetScrollOffsetForPointerScroll(delta); |
937 | if (delta != 0.0 && targetScrollOffset != position.pixels) { |
938 | position.pointerScroll(delta); |
939 | } |
940 | } |
941 | |
942 | bool _handleScrollMetricsNotification(ScrollMetricsNotification notification) { |
943 | if (notification.depth == 0) { |
944 | final RenderObject? scrollSemanticsRenderObject = _scrollSemanticsKey.currentContext?.findRenderObject(); |
945 | if (scrollSemanticsRenderObject != null) { |
946 | scrollSemanticsRenderObject.markNeedsSemanticsUpdate(); |
947 | } |
948 | } |
949 | return false; |
950 | } |
951 | |
952 | Widget _buildChrome(BuildContext context, Widget child) { |
953 | final ScrollableDetails details = ScrollableDetails( |
954 | direction: widget.axisDirection, |
955 | controller: _effectiveScrollController, |
956 | decorationClipBehavior: widget.clipBehavior, |
957 | ); |
958 | |
959 | return _configuration.buildScrollbar( |
960 | context, |
961 | _configuration.buildOverscrollIndicator(context, child, details), |
962 | details, |
963 | ); |
964 | } |
965 | |
966 | // DESCRIPTION |
967 | |
968 | @override |
969 | Widget build(BuildContext context) { |
970 | assert(_position != null); |
971 | // _ScrollableScope must be placed above the BuildContext returned by notificationContext |
972 | // so that we can get this ScrollableState by doing the following: |
973 | // |
974 | // ScrollNotification notification; |
975 | // Scrollable.of(notification.context) |
976 | // |
977 | // Since notificationContext is pointing to _gestureDetectorKey.context, _ScrollableScope |
978 | // must be placed above the widget using it: RawGestureDetector |
979 | Widget result = _ScrollableScope( |
980 | scrollable: this, |
981 | position: position, |
982 | child: Listener( |
983 | onPointerSignal: _receivedPointerSignal, |
984 | child: RawGestureDetector( |
985 | key: _gestureDetectorKey, |
986 | gestures: _gestureRecognizers, |
987 | behavior: HitTestBehavior.opaque, |
988 | excludeFromSemantics: widget.excludeFromSemantics, |
989 | child: Semantics( |
990 | explicitChildNodes: !widget.excludeFromSemantics, |
991 | child: IgnorePointer( |
992 | key: _ignorePointerKey, |
993 | ignoring: _shouldIgnorePointer, |
994 | child: widget.viewportBuilder(context, position), |
995 | ), |
996 | ), |
997 | ), |
998 | ), |
999 | ); |
1000 | |
1001 | if (!widget.excludeFromSemantics) { |
1002 | result = NotificationListener<ScrollMetricsNotification>( |
1003 | onNotification: _handleScrollMetricsNotification, |
1004 | child: _ScrollSemantics( |
1005 | key: _scrollSemanticsKey, |
1006 | position: position, |
1007 | allowImplicitScrolling: _physics!.allowImplicitScrolling, |
1008 | semanticChildCount: widget.semanticChildCount, |
1009 | child: result, |
1010 | ) |
1011 | ); |
1012 | } |
1013 | |
1014 | result = _buildChrome(context, result); |
1015 | |
1016 | // Selection is only enabled when there is a parent registrar. |
1017 | final SelectionRegistrar? registrar = SelectionContainer.maybeOf(context); |
1018 | if (registrar != null) { |
1019 | result = _ScrollableSelectionHandler( |
1020 | state: this, |
1021 | position: position, |
1022 | registrar: registrar, |
1023 | child: result, |
1024 | ); |
1025 | } |
1026 | |
1027 | return result; |
1028 | } |
1029 | |
1030 | // Returns the Future from calling ensureVisible for the ScrollPosition, as |
1031 | // as well as this ScrollableState instance so its context can be used to |
1032 | // check for other ancestor Scrollables in executing ensureVisible. |
1033 | _EnsureVisibleResults _performEnsureVisible( |
1034 | RenderObject object, { |
1035 | double alignment = 0.0, |
1036 | Duration duration = Duration.zero, |
1037 | Curve curve = Curves.ease, |
1038 | ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, |
1039 | RenderObject? targetRenderObject, |
1040 | }) { |
1041 | final Future<void> ensureVisibleFuture = position.ensureVisible( |
1042 | object, |
1043 | alignment: alignment, |
1044 | duration: duration, |
1045 | curve: curve, |
1046 | alignmentPolicy: alignmentPolicy, |
1047 | targetRenderObject: targetRenderObject, |
1048 | ); |
1049 | return (<Future<void>>[ ensureVisibleFuture ], this); |
1050 | } |
1051 | |
1052 | @override |
1053 | void debugFillProperties(DiagnosticPropertiesBuilder properties) { |
1054 | super.debugFillProperties(properties); |
1055 | properties.add(DiagnosticsProperty<ScrollPosition>('position' , _position)); |
1056 | properties.add(DiagnosticsProperty<ScrollPhysics>('effective physics' , _physics)); |
1057 | } |
1058 | } |
1059 | |
1060 | /// A widget to handle selection for a scrollable. |
1061 | /// |
1062 | /// This widget registers itself to the [registrar] and uses |
1063 | /// [SelectionContainer] to collect selectables from its subtree. |
1064 | class _ScrollableSelectionHandler extends StatefulWidget { |
1065 | const _ScrollableSelectionHandler({ |
1066 | required this.state, |
1067 | required this.position, |
1068 | required this.registrar, |
1069 | required this.child, |
1070 | }); |
1071 | |
1072 | final ScrollableState state; |
1073 | final ScrollPosition position; |
1074 | final Widget child; |
1075 | final SelectionRegistrar registrar; |
1076 | |
1077 | @override |
1078 | _ScrollableSelectionHandlerState createState() => _ScrollableSelectionHandlerState(); |
1079 | } |
1080 | |
1081 | class _ScrollableSelectionHandlerState extends State<_ScrollableSelectionHandler> { |
1082 | late _ScrollableSelectionContainerDelegate _selectionDelegate; |
1083 | |
1084 | @override |
1085 | void initState() { |
1086 | super.initState(); |
1087 | _selectionDelegate = _ScrollableSelectionContainerDelegate( |
1088 | state: widget.state, |
1089 | position: widget.position, |
1090 | ); |
1091 | } |
1092 | |
1093 | @override |
1094 | void didUpdateWidget(_ScrollableSelectionHandler oldWidget) { |
1095 | super.didUpdateWidget(oldWidget); |
1096 | if (oldWidget.position != widget.position) { |
1097 | _selectionDelegate.position = widget.position; |
1098 | } |
1099 | } |
1100 | |
1101 | @override |
1102 | void dispose() { |
1103 | _selectionDelegate.dispose(); |
1104 | super.dispose(); |
1105 | } |
1106 | |
1107 | @override |
1108 | Widget build(BuildContext context) { |
1109 | return SelectionContainer( |
1110 | registrar: widget.registrar, |
1111 | delegate: _selectionDelegate, |
1112 | child: widget.child, |
1113 | ); |
1114 | } |
1115 | } |
1116 | |
1117 | /// This updater handles the case where the selectables change frequently, and |
1118 | /// it optimizes toward scrolling updates. |
1119 | /// |
1120 | /// It keeps track of the drag start offset relative to scroll origin for every |
1121 | /// selectable. The records are used to determine whether the selection is up to |
1122 | /// date with the scroll position when it sends the drag update event to a |
1123 | /// selectable. |
1124 | class _ScrollableSelectionContainerDelegate extends MultiSelectableSelectionContainerDelegate { |
1125 | _ScrollableSelectionContainerDelegate({ |
1126 | required this.state, |
1127 | required ScrollPosition position |
1128 | }) : _position = position, |
1129 | _autoScroller = EdgeDraggingAutoScroller(state, velocityScalar: _kDefaultSelectToScrollVelocityScalar) { |
1130 | _position.addListener(_scheduleLayoutChange); |
1131 | } |
1132 | |
1133 | // Pointer drag is a single point, it should not have a size. |
1134 | static const double _kDefaultDragTargetSize = 0; |
1135 | |
1136 | // An eye-balled value for a smooth scrolling speed. |
1137 | static const double _kDefaultSelectToScrollVelocityScalar = 30; |
1138 | |
1139 | final ScrollableState state; |
1140 | final EdgeDraggingAutoScroller _autoScroller; |
1141 | bool _scheduledLayoutChange = false; |
1142 | Offset? _currentDragStartRelatedToOrigin; |
1143 | Offset? _currentDragEndRelatedToOrigin; |
1144 | |
1145 | // The scrollable only auto scrolls if the selection starts in the scrollable. |
1146 | bool _selectionStartsInScrollable = false; |
1147 | |
1148 | ScrollPosition get position => _position; |
1149 | ScrollPosition _position; |
1150 | set position(ScrollPosition other) { |
1151 | if (other == _position) { |
1152 | return; |
1153 | } |
1154 | _position.removeListener(_scheduleLayoutChange); |
1155 | _position = other; |
1156 | _position.addListener(_scheduleLayoutChange); |
1157 | } |
1158 | |
1159 | // The layout will only be updated a frame later than position changes. |
1160 | // Schedule PostFrameCallback to capture the accurate layout. |
1161 | void _scheduleLayoutChange() { |
1162 | if (_scheduledLayoutChange) { |
1163 | return; |
1164 | } |
1165 | _scheduledLayoutChange = true; |
1166 | SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { |
1167 | if (!_scheduledLayoutChange) { |
1168 | return; |
1169 | } |
1170 | _scheduledLayoutChange = false; |
1171 | layoutDidChange(); |
1172 | }, debugLabel: 'ScrollableSelectionContainer.layoutDidChange' ); |
1173 | } |
1174 | |
1175 | /// Stores the scroll offset when a scrollable receives the last |
1176 | /// [SelectionEdgeUpdateEvent]. |
1177 | /// |
1178 | /// The stored scroll offset may be null if a scrollable never receives a |
1179 | /// [SelectionEdgeUpdateEvent]. |
1180 | /// |
1181 | /// When a new [SelectionEdgeUpdateEvent] is dispatched to a selectable, this |
1182 | /// updater checks the current scroll offset against the one stored in these |
1183 | /// records. If the scroll offset is different, it synthesizes an opposite |
1184 | /// [SelectionEdgeUpdateEvent] and dispatches the event before dispatching the |
1185 | /// new event. |
1186 | /// |
1187 | /// For example, if a selectable receives an end [SelectionEdgeUpdateEvent] |
1188 | /// and its scroll offset in the records is different from the current value, |
1189 | /// it synthesizes a start [SelectionEdgeUpdateEvent] and dispatches it before |
1190 | /// dispatching the original end [SelectionEdgeUpdateEvent]. |
1191 | final Map<Selectable, double> _selectableStartEdgeUpdateRecords = <Selectable, double>{}; |
1192 | final Map<Selectable, double> _selectableEndEdgeUpdateRecords = <Selectable, double>{}; |
1193 | |
1194 | @override |
1195 | void didChangeSelectables() { |
1196 | final Set<Selectable> selectableSet = selectables.toSet(); |
1197 | _selectableStartEdgeUpdateRecords.removeWhere((Selectable key, double value) => !selectableSet.contains(key)); |
1198 | _selectableEndEdgeUpdateRecords.removeWhere((Selectable key, double value) => !selectableSet.contains(key)); |
1199 | super.didChangeSelectables(); |
1200 | } |
1201 | |
1202 | @override |
1203 | SelectionResult handleClearSelection(ClearSelectionEvent event) { |
1204 | _selectableStartEdgeUpdateRecords.clear(); |
1205 | _selectableEndEdgeUpdateRecords.clear(); |
1206 | _currentDragStartRelatedToOrigin = null; |
1207 | _currentDragEndRelatedToOrigin = null; |
1208 | _selectionStartsInScrollable = false; |
1209 | return super.handleClearSelection(event); |
1210 | } |
1211 | |
1212 | @override |
1213 | SelectionResult handleSelectionEdgeUpdate(SelectionEdgeUpdateEvent event) { |
1214 | if (_currentDragEndRelatedToOrigin == null && _currentDragStartRelatedToOrigin == null) { |
1215 | assert(!_selectionStartsInScrollable); |
1216 | _selectionStartsInScrollable = _globalPositionInScrollable(event.globalPosition); |
1217 | } |
1218 | final Offset deltaToOrigin = _getDeltaToScrollOrigin(state); |
1219 | if (event.type == SelectionEventType.endEdgeUpdate) { |
1220 | _currentDragEndRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition); |
1221 | final Offset endOffset = _currentDragEndRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy); |
1222 | event = SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset, granularity: event.granularity); |
1223 | } else { |
1224 | _currentDragStartRelatedToOrigin = _inferPositionRelatedToOrigin(event.globalPosition); |
1225 | final Offset startOffset = _currentDragStartRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy); |
1226 | event = SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset, granularity: event.granularity); |
1227 | } |
1228 | final SelectionResult result = super.handleSelectionEdgeUpdate(event); |
1229 | |
1230 | // Result may be pending if one of the selectable child is also a scrollable. |
1231 | // In that case, the parent scrollable needs to wait for the child to finish |
1232 | // scrolling. |
1233 | if (result == SelectionResult.pending) { |
1234 | _autoScroller.stopAutoScroll(); |
1235 | return result; |
1236 | } |
1237 | if (_selectionStartsInScrollable) { |
1238 | _autoScroller.startAutoScrollIfNecessary(_dragTargetFromEvent(event)); |
1239 | if (_autoScroller.scrolling) { |
1240 | return SelectionResult.pending; |
1241 | } |
1242 | } |
1243 | return result; |
1244 | } |
1245 | |
1246 | Offset _inferPositionRelatedToOrigin(Offset globalPosition) { |
1247 | final RenderBox box = state.context.findRenderObject()! as RenderBox; |
1248 | final Offset localPosition = box.globalToLocal(globalPosition); |
1249 | if (!_selectionStartsInScrollable) { |
1250 | // If the selection starts outside of the scrollable, selecting across the |
1251 | // scrollable boundary will act as selecting the entire content in the |
1252 | // scrollable. This logic move the offset to the 0.0 or infinity to cover |
1253 | // the entire content if the input position is outside of the scrollable. |
1254 | if (localPosition.dy < 0 || localPosition.dx < 0) { |
1255 | return box.localToGlobal(Offset.zero); |
1256 | } |
1257 | if (localPosition.dy > box.size.height || localPosition.dx > box.size.width) { |
1258 | return Offset.infinite; |
1259 | } |
1260 | } |
1261 | final Offset deltaToOrigin = _getDeltaToScrollOrigin(state); |
1262 | return box.localToGlobal(localPosition.translate(deltaToOrigin.dx, deltaToOrigin.dy)); |
1263 | } |
1264 | |
1265 | /// Infers the [_currentDragStartRelatedToOrigin] and |
1266 | /// [_currentDragEndRelatedToOrigin] from the geometry. |
1267 | /// |
1268 | /// This method is called after a select word and select all event where the |
1269 | /// selection is triggered by none drag events. The |
1270 | /// [_currentDragStartRelatedToOrigin] and [_currentDragEndRelatedToOrigin] |
1271 | /// are essential to handle future [SelectionEdgeUpdateEvent]s. |
1272 | void _updateDragLocationsFromGeometries({bool forceUpdateStart = true, bool forceUpdateEnd = true}) { |
1273 | final Offset deltaToOrigin = _getDeltaToScrollOrigin(state); |
1274 | final RenderBox box = state.context.findRenderObject()! as RenderBox; |
1275 | final Matrix4 transform = box.getTransformTo(null); |
1276 | if (currentSelectionStartIndex != -1 && (_currentDragStartRelatedToOrigin == null || forceUpdateStart)) { |
1277 | final SelectionGeometry geometry = selectables[currentSelectionStartIndex].value; |
1278 | assert(geometry.hasSelection); |
1279 | final SelectionPoint start = geometry.startSelectionPoint!; |
1280 | final Matrix4 childTransform = selectables[currentSelectionStartIndex].getTransformTo(box); |
1281 | final Offset localDragStart = MatrixUtils.transformPoint( |
1282 | childTransform, |
1283 | start.localPosition + Offset(0, - start.lineHeight / 2), |
1284 | ); |
1285 | _currentDragStartRelatedToOrigin = MatrixUtils.transformPoint(transform, localDragStart + deltaToOrigin); |
1286 | } |
1287 | if (currentSelectionEndIndex != -1 && (_currentDragEndRelatedToOrigin == null || forceUpdateEnd)) { |
1288 | final SelectionGeometry geometry = selectables[currentSelectionEndIndex].value; |
1289 | assert(geometry.hasSelection); |
1290 | final SelectionPoint end = geometry.endSelectionPoint!; |
1291 | final Matrix4 childTransform = selectables[currentSelectionEndIndex].getTransformTo(box); |
1292 | final Offset localDragEnd = MatrixUtils.transformPoint( |
1293 | childTransform, |
1294 | end.localPosition + Offset(0, - end.lineHeight / 2), |
1295 | ); |
1296 | _currentDragEndRelatedToOrigin = MatrixUtils.transformPoint(transform, localDragEnd + deltaToOrigin); |
1297 | } |
1298 | } |
1299 | |
1300 | @override |
1301 | SelectionResult handleSelectAll(SelectAllSelectionEvent event) { |
1302 | assert(!_selectionStartsInScrollable); |
1303 | final SelectionResult result = super.handleSelectAll(event); |
1304 | assert((currentSelectionStartIndex == -1) == (currentSelectionEndIndex == -1)); |
1305 | if (currentSelectionStartIndex != -1) { |
1306 | _updateDragLocationsFromGeometries(); |
1307 | } |
1308 | return result; |
1309 | } |
1310 | |
1311 | @override |
1312 | SelectionResult handleSelectWord(SelectWordSelectionEvent event) { |
1313 | _selectionStartsInScrollable = _globalPositionInScrollable(event.globalPosition); |
1314 | final SelectionResult result = super.handleSelectWord(event); |
1315 | _updateDragLocationsFromGeometries(); |
1316 | return result; |
1317 | } |
1318 | |
1319 | @override |
1320 | SelectionResult handleGranularlyExtendSelection(GranularlyExtendSelectionEvent event) { |
1321 | final SelectionResult result = super.handleGranularlyExtendSelection(event); |
1322 | // The selection geometry may not have the accurate offset for the edges |
1323 | // that are outside of the viewport whose transform may not be valid. Only |
1324 | // the edge this event is updating is sure to be accurate. |
1325 | _updateDragLocationsFromGeometries( |
1326 | forceUpdateStart: !event.isEnd, |
1327 | forceUpdateEnd: event.isEnd, |
1328 | ); |
1329 | if (_selectionStartsInScrollable) { |
1330 | _jumpToEdge(event.isEnd); |
1331 | } |
1332 | return result; |
1333 | } |
1334 | |
1335 | @override |
1336 | SelectionResult handleDirectionallyExtendSelection(DirectionallyExtendSelectionEvent event) { |
1337 | final SelectionResult result = super.handleDirectionallyExtendSelection(event); |
1338 | // The selection geometry may not have the accurate offset for the edges |
1339 | // that are outside of the viewport whose transform may not be valid. Only |
1340 | // the edge this event is updating is sure to be accurate. |
1341 | _updateDragLocationsFromGeometries( |
1342 | forceUpdateStart: !event.isEnd, |
1343 | forceUpdateEnd: event.isEnd, |
1344 | ); |
1345 | if (_selectionStartsInScrollable) { |
1346 | _jumpToEdge(event.isEnd); |
1347 | } |
1348 | return result; |
1349 | } |
1350 | |
1351 | void _jumpToEdge(bool isExtent) { |
1352 | final Selectable selectable; |
1353 | final double? lineHeight; |
1354 | final SelectionPoint? edge; |
1355 | if (isExtent) { |
1356 | selectable = selectables[currentSelectionEndIndex]; |
1357 | edge = selectable.value.endSelectionPoint; |
1358 | lineHeight = selectable.value.endSelectionPoint!.lineHeight; |
1359 | } else { |
1360 | selectable = selectables[currentSelectionStartIndex]; |
1361 | edge = selectable.value.startSelectionPoint; |
1362 | lineHeight = selectable.value.startSelectionPoint?.lineHeight; |
1363 | } |
1364 | if (lineHeight == null || edge == null) { |
1365 | return; |
1366 | } |
1367 | final RenderBox scrollableBox = state.context.findRenderObject()! as RenderBox; |
1368 | final Matrix4 transform = selectable.getTransformTo(scrollableBox); |
1369 | final Offset edgeOffsetInScrollableCoordinates = MatrixUtils.transformPoint(transform, edge.localPosition); |
1370 | final Rect scrollableRect = Rect.fromLTRB(0, 0, scrollableBox.size.width, scrollableBox.size.height); |
1371 | switch (state.axisDirection) { |
1372 | case AxisDirection.up: |
1373 | final double edgeBottom = edgeOffsetInScrollableCoordinates.dy; |
1374 | final double edgeTop = edgeOffsetInScrollableCoordinates.dy - lineHeight; |
1375 | if (edgeBottom >= scrollableRect.bottom && edgeTop <= scrollableRect.top) { |
1376 | return; |
1377 | } |
1378 | if (edgeBottom > scrollableRect.bottom) { |
1379 | position.jumpTo(position.pixels + scrollableRect.bottom - edgeBottom); |
1380 | return; |
1381 | } |
1382 | if (edgeTop < scrollableRect.top) { |
1383 | position.jumpTo(position.pixels + scrollableRect.top - edgeTop); |
1384 | } |
1385 | return; |
1386 | case AxisDirection.right: |
1387 | final double edge = edgeOffsetInScrollableCoordinates.dx; |
1388 | if (edge >= scrollableRect.right && edge <= scrollableRect.left) { |
1389 | return; |
1390 | } |
1391 | if (edge > scrollableRect.right) { |
1392 | position.jumpTo(position.pixels + edge - scrollableRect.right); |
1393 | return; |
1394 | } |
1395 | if (edge < scrollableRect.left) { |
1396 | position.jumpTo(position.pixels + edge - scrollableRect.left); |
1397 | } |
1398 | return; |
1399 | case AxisDirection.down: |
1400 | final double edgeBottom = edgeOffsetInScrollableCoordinates.dy; |
1401 | final double edgeTop = edgeOffsetInScrollableCoordinates.dy - lineHeight; |
1402 | if (edgeBottom >= scrollableRect.bottom && edgeTop <= scrollableRect.top) { |
1403 | return; |
1404 | } |
1405 | if (edgeBottom > scrollableRect.bottom) { |
1406 | position.jumpTo(position.pixels + edgeBottom - scrollableRect.bottom); |
1407 | return; |
1408 | } |
1409 | if (edgeTop < scrollableRect.top) { |
1410 | position.jumpTo(position.pixels + edgeTop - scrollableRect.top); |
1411 | } |
1412 | return; |
1413 | case AxisDirection.left: |
1414 | final double edge = edgeOffsetInScrollableCoordinates.dx; |
1415 | if (edge >= scrollableRect.right && edge <= scrollableRect.left) { |
1416 | return; |
1417 | } |
1418 | if (edge > scrollableRect.right) { |
1419 | position.jumpTo(position.pixels + scrollableRect.right - edge); |
1420 | return; |
1421 | } |
1422 | if (edge < scrollableRect.left) { |
1423 | position.jumpTo(position.pixels + scrollableRect.left - edge); |
1424 | } |
1425 | return; |
1426 | } |
1427 | } |
1428 | |
1429 | bool _globalPositionInScrollable(Offset globalPosition) { |
1430 | final RenderBox box = state.context.findRenderObject()! as RenderBox; |
1431 | final Offset localPosition = box.globalToLocal(globalPosition); |
1432 | final Rect rect = Rect.fromLTWH(0, 0, box.size.width, box.size.height); |
1433 | return rect.contains(localPosition); |
1434 | } |
1435 | |
1436 | Rect _dragTargetFromEvent(SelectionEdgeUpdateEvent event) { |
1437 | return Rect.fromCenter(center: event.globalPosition, width: _kDefaultDragTargetSize, height: _kDefaultDragTargetSize); |
1438 | } |
1439 | |
1440 | @override |
1441 | SelectionResult dispatchSelectionEventToChild(Selectable selectable, SelectionEvent event) { |
1442 | switch (event.type) { |
1443 | case SelectionEventType.startEdgeUpdate: |
1444 | _selectableStartEdgeUpdateRecords[selectable] = state.position.pixels; |
1445 | ensureChildUpdated(selectable); |
1446 | case SelectionEventType.endEdgeUpdate: |
1447 | _selectableEndEdgeUpdateRecords[selectable] = state.position.pixels; |
1448 | ensureChildUpdated(selectable); |
1449 | case SelectionEventType.granularlyExtendSelection: |
1450 | case SelectionEventType.directionallyExtendSelection: |
1451 | ensureChildUpdated(selectable); |
1452 | _selectableStartEdgeUpdateRecords[selectable] = state.position.pixels; |
1453 | _selectableEndEdgeUpdateRecords[selectable] = state.position.pixels; |
1454 | case SelectionEventType.clear: |
1455 | _selectableEndEdgeUpdateRecords.remove(selectable); |
1456 | _selectableStartEdgeUpdateRecords.remove(selectable); |
1457 | case SelectionEventType.selectAll: |
1458 | case SelectionEventType.selectWord: |
1459 | _selectableEndEdgeUpdateRecords[selectable] = state.position.pixels; |
1460 | _selectableStartEdgeUpdateRecords[selectable] = state.position.pixels; |
1461 | } |
1462 | return super.dispatchSelectionEventToChild(selectable, event); |
1463 | } |
1464 | |
1465 | @override |
1466 | void ensureChildUpdated(Selectable selectable) { |
1467 | final double newRecord = state.position.pixels; |
1468 | final double? previousStartRecord = _selectableStartEdgeUpdateRecords[selectable]; |
1469 | if (_currentDragStartRelatedToOrigin != null && |
1470 | (previousStartRecord == null || (newRecord - previousStartRecord).abs() > precisionErrorTolerance)) { |
1471 | // Make sure the selectable has up to date events. |
1472 | final Offset deltaToOrigin = _getDeltaToScrollOrigin(state); |
1473 | final Offset startOffset = _currentDragStartRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy); |
1474 | selectable.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forStart(globalPosition: startOffset)); |
1475 | // Make sure we track that we have synthesized a start event for this selectable, |
1476 | // so we don't synthesize events unnecessarily. |
1477 | _selectableStartEdgeUpdateRecords[selectable] = state.position.pixels; |
1478 | } |
1479 | final double? previousEndRecord = _selectableEndEdgeUpdateRecords[selectable]; |
1480 | if (_currentDragEndRelatedToOrigin != null && |
1481 | (previousEndRecord == null || (newRecord - previousEndRecord).abs() > precisionErrorTolerance)) { |
1482 | // Make sure the selectable has up to date events. |
1483 | final Offset deltaToOrigin = _getDeltaToScrollOrigin(state); |
1484 | final Offset endOffset = _currentDragEndRelatedToOrigin!.translate(-deltaToOrigin.dx, -deltaToOrigin.dy); |
1485 | selectable.dispatchSelectionEvent(SelectionEdgeUpdateEvent.forEnd(globalPosition: endOffset)); |
1486 | // Make sure we track that we have synthesized an end event for this selectable, |
1487 | // so we don't synthesize events unnecessarily. |
1488 | _selectableEndEdgeUpdateRecords[selectable] = state.position.pixels; |
1489 | } |
1490 | } |
1491 | |
1492 | @override |
1493 | void dispose() { |
1494 | _selectableStartEdgeUpdateRecords.clear(); |
1495 | _selectableEndEdgeUpdateRecords.clear(); |
1496 | _scheduledLayoutChange = false; |
1497 | _autoScroller.stopAutoScroll(); |
1498 | super.dispose(); |
1499 | } |
1500 | } |
1501 | |
1502 | Offset _getDeltaToScrollOrigin(ScrollableState scrollableState) { |
1503 | switch (scrollableState.axisDirection) { |
1504 | case AxisDirection.down: |
1505 | return Offset(0, scrollableState.position.pixels); |
1506 | case AxisDirection.up: |
1507 | return Offset(0, -scrollableState.position.pixels); |
1508 | case AxisDirection.left: |
1509 | return Offset(-scrollableState.position.pixels, 0); |
1510 | case AxisDirection.right: |
1511 | return Offset(scrollableState.position.pixels, 0); |
1512 | } |
1513 | } |
1514 | |
1515 | /// With [_ScrollSemantics] certain child [SemanticsNode]s can be |
1516 | /// excluded from the scrollable area for semantics purposes. |
1517 | /// |
1518 | /// Nodes, that are to be excluded, have to be tagged with |
1519 | /// [RenderViewport.excludeFromScrolling] and the [RenderAbstractViewport] in |
1520 | /// use has to add the [RenderViewport.useTwoPaneSemantics] tag to its |
1521 | /// [SemanticsConfiguration] by overriding |
1522 | /// [RenderObject.describeSemanticsConfiguration]. |
1523 | /// |
1524 | /// If the tag [RenderViewport.useTwoPaneSemantics] is present on the viewport, |
1525 | /// two semantics nodes will be used to represent the [Scrollable]: The outer |
1526 | /// node will contain all children, that are excluded from scrolling. The inner |
1527 | /// node, which is annotated with the scrolling actions, will house the |
1528 | /// scrollable children. |
1529 | class _ScrollSemantics extends SingleChildRenderObjectWidget { |
1530 | const _ScrollSemantics({ |
1531 | super.key, |
1532 | required this.position, |
1533 | required this.allowImplicitScrolling, |
1534 | required this.semanticChildCount, |
1535 | super.child, |
1536 | }) : assert(semanticChildCount == null || semanticChildCount >= 0); |
1537 | |
1538 | final ScrollPosition position; |
1539 | final bool allowImplicitScrolling; |
1540 | final int? semanticChildCount; |
1541 | |
1542 | @override |
1543 | _RenderScrollSemantics createRenderObject(BuildContext context) { |
1544 | return _RenderScrollSemantics( |
1545 | position: position, |
1546 | allowImplicitScrolling: allowImplicitScrolling, |
1547 | semanticChildCount: semanticChildCount, |
1548 | ); |
1549 | } |
1550 | |
1551 | @override |
1552 | void updateRenderObject(BuildContext context, _RenderScrollSemantics renderObject) { |
1553 | renderObject |
1554 | ..allowImplicitScrolling = allowImplicitScrolling |
1555 | ..position = position |
1556 | ..semanticChildCount = semanticChildCount; |
1557 | } |
1558 | } |
1559 | |
1560 | class _RenderScrollSemantics extends RenderProxyBox { |
1561 | _RenderScrollSemantics({ |
1562 | required ScrollPosition position, |
1563 | required bool allowImplicitScrolling, |
1564 | required int? semanticChildCount, |
1565 | RenderBox? child, |
1566 | }) : _position = position, |
1567 | _allowImplicitScrolling = allowImplicitScrolling, |
1568 | _semanticChildCount = semanticChildCount, |
1569 | super(child) { |
1570 | position.addListener(markNeedsSemanticsUpdate); |
1571 | } |
1572 | |
1573 | /// Whether this render object is excluded from the semantic tree. |
1574 | ScrollPosition get position => _position; |
1575 | ScrollPosition _position; |
1576 | set position(ScrollPosition value) { |
1577 | if (value == _position) { |
1578 | return; |
1579 | } |
1580 | _position.removeListener(markNeedsSemanticsUpdate); |
1581 | _position = value; |
1582 | _position.addListener(markNeedsSemanticsUpdate); |
1583 | markNeedsSemanticsUpdate(); |
1584 | } |
1585 | |
1586 | /// Whether this node can be scrolled implicitly. |
1587 | bool get allowImplicitScrolling => _allowImplicitScrolling; |
1588 | bool _allowImplicitScrolling; |
1589 | set allowImplicitScrolling(bool value) { |
1590 | if (value == _allowImplicitScrolling) { |
1591 | return; |
1592 | } |
1593 | _allowImplicitScrolling = value; |
1594 | markNeedsSemanticsUpdate(); |
1595 | } |
1596 | |
1597 | int? get semanticChildCount => _semanticChildCount; |
1598 | int? _semanticChildCount; |
1599 | set semanticChildCount(int? value) { |
1600 | if (value == semanticChildCount) { |
1601 | return; |
1602 | } |
1603 | _semanticChildCount = value; |
1604 | markNeedsSemanticsUpdate(); |
1605 | } |
1606 | |
1607 | @override |
1608 | void describeSemanticsConfiguration(SemanticsConfiguration config) { |
1609 | super.describeSemanticsConfiguration(config); |
1610 | config.isSemanticBoundary = true; |
1611 | if (position.haveDimensions) { |
1612 | config |
1613 | ..hasImplicitScrolling = allowImplicitScrolling |
1614 | ..scrollPosition = _position.pixels |
1615 | ..scrollExtentMax = _position.maxScrollExtent |
1616 | ..scrollExtentMin = _position.minScrollExtent |
1617 | ..scrollChildCount = semanticChildCount; |
1618 | } |
1619 | } |
1620 | |
1621 | SemanticsNode? _innerNode; |
1622 | |
1623 | @override |
1624 | void assembleSemanticsNode(SemanticsNode node, SemanticsConfiguration config, Iterable<SemanticsNode> children) { |
1625 | if (children.isEmpty || !children.first.isTagged(RenderViewport.useTwoPaneSemantics)) { |
1626 | _innerNode = null; |
1627 | super.assembleSemanticsNode(node, config, children); |
1628 | return; |
1629 | } |
1630 | |
1631 | (_innerNode ??= SemanticsNode(showOnScreen: showOnScreen)).rect = node.rect; |
1632 | |
1633 | int? firstVisibleIndex; |
1634 | final List<SemanticsNode> excluded = <SemanticsNode>[_innerNode!]; |
1635 | final List<SemanticsNode> included = <SemanticsNode>[]; |
1636 | for (final SemanticsNode child in children) { |
1637 | assert(child.isTagged(RenderViewport.useTwoPaneSemantics)); |
1638 | if (child.isTagged(RenderViewport.excludeFromScrolling)) { |
1639 | excluded.add(child); |
1640 | } else { |
1641 | if (!child.hasFlag(SemanticsFlag.isHidden)) { |
1642 | firstVisibleIndex ??= child.indexInParent; |
1643 | } |
1644 | included.add(child); |
1645 | } |
1646 | } |
1647 | config.scrollIndex = firstVisibleIndex; |
1648 | node.updateWith(config: null, childrenInInversePaintOrder: excluded); |
1649 | _innerNode!.updateWith(config: config, childrenInInversePaintOrder: included); |
1650 | } |
1651 | |
1652 | @override |
1653 | void clearSemantics() { |
1654 | super.clearSemantics(); |
1655 | _innerNode = null; |
1656 | } |
1657 | } |
1658 | |
1659 | // Not using a RestorableDouble because we want to allow null values and override |
1660 | // [enabled]. |
1661 | class _RestorableScrollOffset extends RestorableValue<double?> { |
1662 | @override |
1663 | double? createDefaultValue() => null; |
1664 | |
1665 | @override |
1666 | void didUpdateValue(double? oldValue) { |
1667 | notifyListeners(); |
1668 | } |
1669 | |
1670 | @override |
1671 | double fromPrimitives(Object? data) { |
1672 | return data! as double; |
1673 | } |
1674 | |
1675 | @override |
1676 | Object? toPrimitives() { |
1677 | return value; |
1678 | } |
1679 | |
1680 | @override |
1681 | bool get enabled => value != null; |
1682 | } |
1683 | |
1684 | // 2D SCROLLING |
1685 | |
1686 | /// Specifies how to configure the [DragGestureRecognizer]s of a |
1687 | /// [TwoDimensionalScrollable]. |
1688 | // TODO(Piinks): Add sample code, https://github.com/flutter/flutter/issues/126298 |
1689 | enum DiagonalDragBehavior { |
1690 | /// This behavior will not allow for any diagonal scrolling. |
1691 | /// |
1692 | /// Drag gestures in one direction or the other will lock the input axis until |
1693 | /// the gesture is released. |
1694 | none, |
1695 | |
1696 | /// This behavior will only allow diagonal scrolling on a weighted |
1697 | /// scale per gesture event. |
1698 | /// |
1699 | /// This means that after initially evaluating the drag gesture, the weighted |
1700 | /// evaluation (based on [kTouchSlop]) stands until the gesture is released. |
1701 | weightedEvent, |
1702 | |
1703 | /// This behavior will only allow diagonal scrolling on a weighted |
1704 | /// scale that is evaluated throughout a gesture event. |
1705 | /// |
1706 | /// This means that during each update to the drag gesture, the scrolling |
1707 | /// axis will be allowed to scroll diagonally if it exceeds the |
1708 | /// [kTouchSlop]. |
1709 | weightedContinuous, |
1710 | |
1711 | /// This behavior allows free movement in any and all directions when |
1712 | /// dragging. |
1713 | free, |
1714 | } |
1715 | |
1716 | /// A widget that manages scrolling in both the vertical and horizontal |
1717 | /// dimensions and informs the [TwoDimensionalViewport] through which the |
1718 | /// content is viewed. |
1719 | /// |
1720 | /// [TwoDimensionalScrollable] implements the interaction model for a scrollable |
1721 | /// widget in both the vertical and horizontal axes, including gesture |
1722 | /// recognition, but does not have an opinion about how the |
1723 | /// [TwoDimensionalViewport], which actually displays the children, is |
1724 | /// constructed. |
1725 | /// |
1726 | /// It's rare to construct a [TwoDimensionalScrollable] directly. Instead, |
1727 | /// consider subclassing [TwoDimensionalScrollView], which combines scrolling, |
1728 | /// viewporting, and a layout model in both dimensions. |
1729 | /// |
1730 | /// See also: |
1731 | /// |
1732 | /// * [TwoDimensionalScrollView], an abstract base class for displaying a |
1733 | /// scrolling array of children in both directions. |
1734 | /// * [TwoDimensionalViewport], which can be used to customize the child layout |
1735 | /// model. |
1736 | class TwoDimensionalScrollable extends StatefulWidget { |
1737 | /// Creates a widget that scrolls in two dimensions. |
1738 | /// |
1739 | /// The [horizontalDetails], [verticalDetails], and [viewportBuilder] must not |
1740 | /// be null. |
1741 | const TwoDimensionalScrollable({ |
1742 | super.key, |
1743 | required this.horizontalDetails, |
1744 | required this.verticalDetails, |
1745 | required this.viewportBuilder, |
1746 | this.incrementCalculator, |
1747 | this.restorationId, |
1748 | this.excludeFromSemantics = false, |
1749 | this.diagonalDragBehavior = DiagonalDragBehavior.none, |
1750 | this.dragStartBehavior = DragStartBehavior.start, |
1751 | }); |
1752 | |
1753 | /// How scrolling gestures should lock to one axis, or allow free movement |
1754 | /// in both axes. |
1755 | final DiagonalDragBehavior diagonalDragBehavior; |
1756 | |
1757 | /// The configuration of the horizontal [Scrollable]. |
1758 | /// |
1759 | /// These [ScrollableDetails] can be used to set the [AxisDirection], |
1760 | /// [ScrollController], [ScrollPhysics] and more for the horizontal axis. |
1761 | final ScrollableDetails horizontalDetails; |
1762 | |
1763 | /// The configuration of the vertical [Scrollable]. |
1764 | /// |
1765 | /// These [ScrollableDetails] can be used to set the [AxisDirection], |
1766 | /// [ScrollController], [ScrollPhysics] and more for the vertical axis. |
1767 | final ScrollableDetails verticalDetails; |
1768 | |
1769 | /// Builds the viewport through which the scrollable content is displayed. |
1770 | /// |
1771 | /// A [TwoDimensionalViewport] uses two given [ViewportOffset]s to determine |
1772 | /// which part of its content is actually visible through the viewport. |
1773 | /// |
1774 | /// See also: |
1775 | /// |
1776 | /// * [TwoDimensionalViewport], which is a viewport that displays a span of |
1777 | /// widgets in both dimensions. |
1778 | final TwoDimensionalViewportBuilder viewportBuilder; |
1779 | |
1780 | /// {@macro flutter.widgets.Scrollable.incrementCalculator} |
1781 | /// |
1782 | /// This value applies in both axes. |
1783 | final ScrollIncrementCalculator? incrementCalculator; |
1784 | |
1785 | /// {@macro flutter.widgets.scrollable.restorationId} |
1786 | /// |
1787 | /// Internally, the [TwoDimensionalScrollable] will introduce a |
1788 | /// [RestorationScope] that will be assigned this value. The two [Scrollable]s |
1789 | /// within will then be given unique IDs within this scope. |
1790 | final String? restorationId; |
1791 | |
1792 | /// {@macro flutter.widgets.scrollable.excludeFromSemantics} |
1793 | /// |
1794 | /// This value applies to both axes. |
1795 | final bool excludeFromSemantics; |
1796 | |
1797 | /// {@macro flutter.widgets.scrollable.dragStartBehavior} |
1798 | /// |
1799 | /// This value applies in both axes. |
1800 | final DragStartBehavior dragStartBehavior; |
1801 | |
1802 | @override |
1803 | State<TwoDimensionalScrollable> createState() => TwoDimensionalScrollableState(); |
1804 | |
1805 | /// The state from the closest instance of this class that encloses the given |
1806 | /// context, or null if none is found. |
1807 | /// |
1808 | /// Typical usage is as follows: |
1809 | /// |
1810 | /// ```dart |
1811 | /// TwoDimensionalScrollableState? scrollable = TwoDimensionalScrollable.maybeOf(context); |
1812 | /// ``` |
1813 | /// |
1814 | /// Calling this method will create a dependency on the closest |
1815 | /// [TwoDimensionalScrollable] in the [context]. The internal [Scrollable]s |
1816 | /// can be accessed through [TwoDimensionalScrollableState.verticalScrollable] |
1817 | /// and [TwoDimensionalScrollableState.horizontalScrollable]. |
1818 | /// |
1819 | /// Alternatively, [Scrollable.maybeOf] can be used by providing the desired |
1820 | /// [Axis] to the `axis` parameter. |
1821 | /// |
1822 | /// See also: |
1823 | /// |
1824 | /// * [TwoDimensionalScrollable.of], which is similar to this method, but |
1825 | /// asserts if no [Scrollable] ancestor is found. |
1826 | static TwoDimensionalScrollableState? maybeOf(BuildContext context) { |
1827 | final _TwoDimensionalScrollableScope? widget = context.dependOnInheritedWidgetOfExactType<_TwoDimensionalScrollableScope>(); |
1828 | return widget?.twoDimensionalScrollable; |
1829 | } |
1830 | |
1831 | /// The state from the closest instance of this class that encloses the given |
1832 | /// context. |
1833 | /// |
1834 | /// Typical usage is as follows: |
1835 | /// |
1836 | /// ```dart |
1837 | /// TwoDimensionalScrollableState scrollable = TwoDimensionalScrollable.of(context); |
1838 | /// ``` |
1839 | /// |
1840 | /// Calling this method will create a dependency on the closest |
1841 | /// [TwoDimensionalScrollable] in the [context]. The internal [Scrollable]s |
1842 | /// can be accessed through [TwoDimensionalScrollableState.verticalScrollable] |
1843 | /// and [TwoDimensionalScrollableState.horizontalScrollable]. |
1844 | /// |
1845 | /// If no [TwoDimensionalScrollable] ancestor is found, then this method will |
1846 | /// assert in debug mode, and throw an exception in release mode. |
1847 | /// |
1848 | /// Alternatively, [Scrollable.of] can be used by providing the desired [Axis] |
1849 | /// to the `axis` parameter. |
1850 | /// |
1851 | /// See also: |
1852 | /// |
1853 | /// * [TwoDimensionalScrollable.maybeOf], which is similar to this method, |
1854 | /// but returns null if no [TwoDimensionalScrollable] ancestor is found. |
1855 | static TwoDimensionalScrollableState of(BuildContext context) { |
1856 | final TwoDimensionalScrollableState? scrollableState = maybeOf(context); |
1857 | assert(() { |
1858 | if (scrollableState == null) { |
1859 | throw FlutterError.fromParts(<DiagnosticsNode>[ |
1860 | ErrorSummary( |
1861 | 'TwoDimensionalScrollable.of() was called with a context that does ' |
1862 | 'not contain a TwoDimensionalScrollable widget.\n' |
1863 | ), |
1864 | ErrorDescription( |
1865 | 'No TwoDimensionalScrollable widget ancestor could be found starting ' |
1866 | 'from the context that was passed to TwoDimensionalScrollable.of(). ' |
1867 | 'This can happen because you are using a widget that looks for a ' |
1868 | 'TwoDimensionalScrollable ancestor, but no such ancestor exists.\n' |
1869 | 'The context used was:\n' |
1870 | ' $context' , |
1871 | ), |
1872 | ]); |
1873 | } |
1874 | return true; |
1875 | }()); |
1876 | return scrollableState!; |
1877 | } |
1878 | } |
1879 | |
1880 | /// State object for a [TwoDimensionalScrollable] widget. |
1881 | /// |
1882 | /// To manipulate one of the internal [Scrollable] widget's scroll position, use |
1883 | /// the object obtained from the [verticalScrollable] or [horizontalScrollable] |
1884 | /// property. |
1885 | /// |
1886 | /// To be informed of when a [TwoDimensionalScrollable] widget is scrolling, |
1887 | /// use a [NotificationListener] to listen for [ScrollNotification]s. |
1888 | /// Both axes will have the same viewport depth since there is only one |
1889 | /// viewport, and so should be differentiated by the [Axis] of the |
1890 | /// [ScrollMetrics] provided by the notification. |
1891 | class TwoDimensionalScrollableState extends State<TwoDimensionalScrollable> { |
1892 | ScrollController? _verticalFallbackController; |
1893 | ScrollController? _horizontalFallbackController; |
1894 | final GlobalKey<ScrollableState> _verticalOuterScrollableKey = GlobalKey<ScrollableState>(); |
1895 | final GlobalKey<ScrollableState> _horizontalInnerScrollableKey = GlobalKey<ScrollableState>(); |
1896 | |
1897 | /// The [ScrollableState] of the vertical axis. |
1898 | /// |
1899 | /// Accessible by calling [TwoDimensionalScrollable.of]. |
1900 | /// |
1901 | /// Alternatively, [Scrollable.of] can be used by providing [Axis.vertical] |
1902 | /// to the `axis` parameter. |
1903 | ScrollableState get verticalScrollable { |
1904 | assert(_verticalOuterScrollableKey.currentState != null); |
1905 | return _verticalOuterScrollableKey.currentState!; |
1906 | } |
1907 | |
1908 | /// The [ScrollableState] of the horizontal axis. |
1909 | /// |
1910 | /// Accessible by calling [TwoDimensionalScrollable.of]. |
1911 | /// |
1912 | /// Alternatively, [Scrollable.of] can be used by providing [Axis.horizontal] |
1913 | /// to the `axis` parameter. |
1914 | ScrollableState get horizontalScrollable { |
1915 | assert(_horizontalInnerScrollableKey.currentState != null); |
1916 | return _horizontalInnerScrollableKey.currentState!; |
1917 | } |
1918 | |
1919 | @override |
1920 | void initState() { |
1921 | if (widget.verticalDetails.controller == null) { |
1922 | _verticalFallbackController = ScrollController(); |
1923 | } |
1924 | if (widget.horizontalDetails.controller == null) { |
1925 | _horizontalFallbackController = ScrollController(); |
1926 | } |
1927 | super.initState(); |
1928 | } |
1929 | |
1930 | @override |
1931 | void didUpdateWidget(TwoDimensionalScrollable oldWidget) { |
1932 | super.didUpdateWidget(oldWidget); |
1933 | // Handle changes in the provided/fallback scroll controllers |
1934 | |
1935 | // Vertical |
1936 | if (oldWidget.verticalDetails.controller != widget.verticalDetails.controller) { |
1937 | if (oldWidget.verticalDetails.controller == null) { |
1938 | // The old controller was null, meaning the fallback cannot be null. |
1939 | // Dispose of the fallback. |
1940 | assert(_verticalFallbackController != null); |
1941 | assert(widget.verticalDetails.controller != null); |
1942 | _verticalFallbackController!.dispose(); |
1943 | _verticalFallbackController = null; |
1944 | } else if (widget.verticalDetails.controller == null) { |
1945 | // If the new controller is null, we need to set up the fallback |
1946 | // ScrollController. |
1947 | assert(_verticalFallbackController == null); |
1948 | _verticalFallbackController = ScrollController(); |
1949 | } |
1950 | } |
1951 | |
1952 | // Horizontal |
1953 | if (oldWidget.horizontalDetails.controller != widget.horizontalDetails.controller) { |
1954 | if (oldWidget.horizontalDetails.controller == null) { |
1955 | // The old controller was null, meaning the fallback cannot be null. |
1956 | // Dispose of the fallback. |
1957 | assert(_horizontalFallbackController != null); |
1958 | assert(widget.horizontalDetails.controller != null); |
1959 | _horizontalFallbackController!.dispose(); |
1960 | _horizontalFallbackController = null; |
1961 | } else if (widget.horizontalDetails.controller == null) { |
1962 | // If the new controller is null, we need to set up the fallback |
1963 | // ScrollController. |
1964 | assert(_horizontalFallbackController == null); |
1965 | _horizontalFallbackController = ScrollController(); |
1966 | } |
1967 | } |
1968 | } |
1969 | |
1970 | @override |
1971 | Widget build(BuildContext context) { |
1972 | assert( |
1973 | axisDirectionToAxis(widget.verticalDetails.direction) == Axis.vertical, |
1974 | 'TwoDimensionalScrollable.verticalDetails are not Axis.vertical.' |
1975 | ); |
1976 | assert( |
1977 | axisDirectionToAxis(widget.horizontalDetails.direction) == Axis.horizontal, |
1978 | 'TwoDimensionalScrollable.horizontalDetails are not Axis.horizontal.' |
1979 | ); |
1980 | |
1981 | final Widget result = RestorationScope( |
1982 | restorationId: widget.restorationId, |
1983 | child: _VerticalOuterDimension( |
1984 | key: _verticalOuterScrollableKey, |
1985 | // For gesture forwarding |
1986 | horizontalKey: _horizontalInnerScrollableKey, |
1987 | axisDirection: widget.verticalDetails.direction, |
1988 | controller: widget.verticalDetails.controller |
1989 | ?? _verticalFallbackController!, |
1990 | physics: widget.verticalDetails.physics, |
1991 | clipBehavior: widget.verticalDetails.clipBehavior |
1992 | ?? widget.verticalDetails.decorationClipBehavior |
1993 | ?? Clip.hardEdge, |
1994 | incrementCalculator: widget.incrementCalculator, |
1995 | excludeFromSemantics: widget.excludeFromSemantics, |
1996 | restorationId: 'OuterVerticalTwoDimensionalScrollable' , |
1997 | dragStartBehavior: widget.dragStartBehavior, |
1998 | diagonalDragBehavior: widget.diagonalDragBehavior, |
1999 | viewportBuilder: (BuildContext context, ViewportOffset verticalOffset) { |
2000 | return _HorizontalInnerDimension( |
2001 | key: _horizontalInnerScrollableKey, |
2002 | axisDirection: widget.horizontalDetails.direction, |
2003 | controller: widget.horizontalDetails.controller |
2004 | ?? _horizontalFallbackController!, |
2005 | physics: widget.horizontalDetails.physics, |
2006 | clipBehavior: widget.horizontalDetails.clipBehavior |
2007 | ?? widget.horizontalDetails.decorationClipBehavior |
2008 | ?? Clip.hardEdge, |
2009 | incrementCalculator: widget.incrementCalculator, |
2010 | excludeFromSemantics: widget.excludeFromSemantics, |
2011 | restorationId: 'InnerHorizontalTwoDimensionalScrollable' , |
2012 | dragStartBehavior: widget.dragStartBehavior, |
2013 | diagonalDragBehavior: widget.diagonalDragBehavior, |
2014 | viewportBuilder: (BuildContext context, ViewportOffset horizontalOffset) { |
2015 | return widget.viewportBuilder(context, verticalOffset, horizontalOffset); |
2016 | }, |
2017 | ); |
2018 | } |
2019 | ) |
2020 | ); |
2021 | |
2022 | // TODO(Piinks): Build scrollbars for 2 dimensions instead of 1, |
2023 | // https://github.com/flutter/flutter/issues/122348 |
2024 | |
2025 | return _TwoDimensionalScrollableScope( |
2026 | twoDimensionalScrollable: this, |
2027 | child: result, |
2028 | ); |
2029 | } |
2030 | |
2031 | @override |
2032 | void dispose() { |
2033 | _verticalFallbackController?.dispose(); |
2034 | _horizontalFallbackController?.dispose(); |
2035 | super.dispose(); |
2036 | } |
2037 | } |
2038 | |
2039 | // Enable TwoDimensionalScrollable.of() to work as if |
2040 | // TwoDimensionalScrollableState was an inherited widget. |
2041 | // TwoDimensionalScrollableState.build() always rebuilds its |
2042 | // _TwoDimensionalScrollableScope. |
2043 | class _TwoDimensionalScrollableScope extends InheritedWidget { |
2044 | const _TwoDimensionalScrollableScope({ |
2045 | required this.twoDimensionalScrollable, |
2046 | required super.child, |
2047 | }); |
2048 | |
2049 | final TwoDimensionalScrollableState twoDimensionalScrollable; |
2050 | |
2051 | @override |
2052 | bool updateShouldNotify(_TwoDimensionalScrollableScope old) => false; |
2053 | } |
2054 | |
2055 | // Vertical outer scrollable of 2D scrolling |
2056 | class _VerticalOuterDimension extends Scrollable { |
2057 | const _VerticalOuterDimension({ |
2058 | super.key, |
2059 | required this.horizontalKey, |
2060 | required super.viewportBuilder, |
2061 | required super.axisDirection, |
2062 | super.controller, |
2063 | super.physics, |
2064 | super.clipBehavior, |
2065 | super.incrementCalculator, |
2066 | super.excludeFromSemantics, |
2067 | super.dragStartBehavior, |
2068 | super.restorationId, |
2069 | this.diagonalDragBehavior = DiagonalDragBehavior.none, |
2070 | }) : assert(axisDirection == AxisDirection.up || axisDirection == AxisDirection.down); |
2071 | |
2072 | final DiagonalDragBehavior diagonalDragBehavior; |
2073 | final GlobalKey<ScrollableState> horizontalKey; |
2074 | |
2075 | @override |
2076 | _VerticalOuterDimensionState createState() => _VerticalOuterDimensionState(); |
2077 | } |
2078 | |
2079 | class _VerticalOuterDimensionState extends ScrollableState { |
2080 | DiagonalDragBehavior get diagonalDragBehavior => (widget as _VerticalOuterDimension).diagonalDragBehavior; |
2081 | ScrollableState get horizontalScrollable => (widget as _VerticalOuterDimension).horizontalKey.currentState!; |
2082 | |
2083 | Axis? lockedAxis; |
2084 | Offset? lastDragOffset; |
2085 | |
2086 | // Implemented in the _HorizontalInnerDimension instead. |
2087 | @override |
2088 | _EnsureVisibleResults _performEnsureVisible( |
2089 | RenderObject object, { |
2090 | double alignment = 0.0, |
2091 | Duration duration = Duration.zero, |
2092 | Curve curve = Curves.ease, |
2093 | ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, |
2094 | RenderObject? targetRenderObject, |
2095 | }) { |
2096 | assert( |
2097 | false, |
2098 | 'The _performEnsureVisible method was called for the vertical scrollable ' |
2099 | 'of a TwoDimensionalScrollable. This should not happen as the horizontal ' |
2100 | 'scrollable handles both axes.' |
2101 | ); |
2102 | return (<Future<void>>[], this); |
2103 | } |
2104 | |
2105 | void _evaluateLockedAxis(Offset offset) { |
2106 | assert(lastDragOffset != null); |
2107 | final Offset offsetDelta = lastDragOffset! - offset; |
2108 | final double axisDifferential = offsetDelta.dx.abs() - offsetDelta.dy.abs(); |
2109 | if (axisDifferential.abs() >= kTouchSlop) { |
2110 | // We have single axis winner. |
2111 | lockedAxis = axisDifferential > 0.0 ? Axis.horizontal : Axis.vertical; |
2112 | } else { |
2113 | lockedAxis = null; |
2114 | } |
2115 | } |
2116 | |
2117 | @override |
2118 | void _handleDragDown(DragDownDetails details) { |
2119 | switch (diagonalDragBehavior) { |
2120 | case DiagonalDragBehavior.none: |
2121 | break; |
2122 | case DiagonalDragBehavior.weightedEvent: |
2123 | case DiagonalDragBehavior.weightedContinuous: |
2124 | case DiagonalDragBehavior.free: |
2125 | // Initiate hold. If one or the other wins the gesture, cancel the |
2126 | // opposite axis. |
2127 | horizontalScrollable._handleDragDown(details); |
2128 | } |
2129 | super._handleDragDown(details); |
2130 | } |
2131 | |
2132 | @override |
2133 | void _handleDragStart(DragStartDetails details) { |
2134 | lastDragOffset = details.globalPosition; |
2135 | switch (diagonalDragBehavior) { |
2136 | case DiagonalDragBehavior.none: |
2137 | break; |
2138 | case DiagonalDragBehavior.free: |
2139 | // Prepare to scroll both. |
2140 | // vertical - will call super below after switch. |
2141 | horizontalScrollable._handleDragStart(details); |
2142 | case DiagonalDragBehavior.weightedEvent: |
2143 | case DiagonalDragBehavior.weightedContinuous: |
2144 | // See if one axis wins the drag. |
2145 | _evaluateLockedAxis(details.globalPosition); |
2146 | switch (lockedAxis) { |
2147 | case null: |
2148 | // Prepare to scroll both, null means no winner yet. |
2149 | // vertical - will call super below after switch. |
2150 | horizontalScrollable._handleDragStart(details); |
2151 | case Axis.horizontal: |
2152 | // Prepare to scroll horizontally. |
2153 | horizontalScrollable._handleDragStart(details); |
2154 | return; |
2155 | case Axis.vertical: |
2156 | // Prepare to scroll vertically - will call super below after switch. |
2157 | } |
2158 | } |
2159 | super._handleDragStart(details); |
2160 | } |
2161 | |
2162 | @override |
2163 | void _handleDragUpdate(DragUpdateDetails details) { |
2164 | final DragUpdateDetails verticalDragDetails = DragUpdateDetails( |
2165 | sourceTimeStamp: details.sourceTimeStamp, |
2166 | delta: Offset(0.0, details.delta.dy), |
2167 | primaryDelta: details.delta.dy, |
2168 | globalPosition: details.globalPosition, |
2169 | localPosition: details.localPosition, |
2170 | ); |
2171 | final DragUpdateDetails horizontalDragDetails = DragUpdateDetails( |
2172 | sourceTimeStamp: details.sourceTimeStamp, |
2173 | delta: Offset(details.delta.dx, 0.0), |
2174 | primaryDelta: details.delta.dx, |
2175 | globalPosition: details.globalPosition, |
2176 | localPosition: details.localPosition, |
2177 | ); |
2178 | |
2179 | switch (diagonalDragBehavior) { |
2180 | case DiagonalDragBehavior.none: |
2181 | // Default gesture handling from super class. |
2182 | super._handleDragUpdate(verticalDragDetails); |
2183 | return; |
2184 | case DiagonalDragBehavior.free: |
2185 | // Scroll both axes |
2186 | horizontalScrollable._handleDragUpdate(horizontalDragDetails); |
2187 | super._handleDragUpdate(verticalDragDetails); |
2188 | return; |
2189 | case DiagonalDragBehavior.weightedContinuous: |
2190 | // Re-evaluate locked axis for every update. |
2191 | _evaluateLockedAxis(details.globalPosition); |
2192 | lastDragOffset = details.globalPosition; |
2193 | case DiagonalDragBehavior.weightedEvent: |
2194 | // Lock axis only once per gesture. |
2195 | if (lockedAxis == null && lastDragOffset != null) { |
2196 | // A winner has not been declared yet. |
2197 | // See if one axis has won the drag. |
2198 | _evaluateLockedAxis(details.globalPosition); |
2199 | } |
2200 | } |
2201 | switch (lockedAxis) { |
2202 | case null: |
2203 | // Scroll both - vertical after switch |
2204 | horizontalScrollable._handleDragUpdate(horizontalDragDetails); |
2205 | case Axis.horizontal: |
2206 | // Scroll horizontally |
2207 | horizontalScrollable._handleDragUpdate(horizontalDragDetails); |
2208 | return; |
2209 | case Axis.vertical: |
2210 | // Scroll vertically - after switch |
2211 | } |
2212 | super._handleDragUpdate(verticalDragDetails); |
2213 | } |
2214 | |
2215 | @override |
2216 | void _handleDragEnd(DragEndDetails details) { |
2217 | lastDragOffset = null; |
2218 | lockedAxis = null; |
2219 | final double dx = details.velocity.pixelsPerSecond.dx; |
2220 | final double dy = details.velocity.pixelsPerSecond.dy; |
2221 | final DragEndDetails verticalDragDetails = DragEndDetails( |
2222 | velocity: Velocity(pixelsPerSecond: Offset(0.0, dy)), |
2223 | primaryVelocity: dy, |
2224 | ); |
2225 | final DragEndDetails horizontalDragDetails = DragEndDetails( |
2226 | velocity: Velocity(pixelsPerSecond: Offset(dx, 0.0)), |
2227 | primaryVelocity: dx, |
2228 | ); |
2229 | |
2230 | switch (diagonalDragBehavior) { |
2231 | case DiagonalDragBehavior.none: |
2232 | break; |
2233 | case DiagonalDragBehavior.weightedEvent: |
2234 | case DiagonalDragBehavior.weightedContinuous: |
2235 | case DiagonalDragBehavior.free: |
2236 | horizontalScrollable._handleDragEnd(horizontalDragDetails); |
2237 | } |
2238 | super._handleDragEnd(verticalDragDetails); |
2239 | } |
2240 | |
2241 | @override |
2242 | void _handleDragCancel() { |
2243 | lastDragOffset = null; |
2244 | lockedAxis = null; |
2245 | switch (diagonalDragBehavior) { |
2246 | case DiagonalDragBehavior.none: |
2247 | break; |
2248 | case DiagonalDragBehavior.weightedEvent: |
2249 | case DiagonalDragBehavior.weightedContinuous: |
2250 | case DiagonalDragBehavior.free: |
2251 | horizontalScrollable._handleDragCancel(); |
2252 | } |
2253 | super._handleDragCancel(); |
2254 | } |
2255 | |
2256 | @override |
2257 | void setCanDrag(bool value) { |
2258 | switch (diagonalDragBehavior) { |
2259 | case DiagonalDragBehavior.none: |
2260 | // If we aren't scrolling diagonally, the default drag gesture recognizer |
2261 | // is used. |
2262 | super.setCanDrag(value); |
2263 | return; |
2264 | case DiagonalDragBehavior.weightedEvent: |
2265 | case DiagonalDragBehavior.weightedContinuous: |
2266 | case DiagonalDragBehavior.free: |
2267 | if (value) { |
2268 | // Replaces the typical vertical/horizontal drag gesture recognizers |
2269 | // with a pan gesture recognizer to allow bidirectional scrolling. |
2270 | // Based on the diagonalDragBehavior, valid horizontal deltas are |
2271 | // applied to this scrollable, while vertical deltas are routed to |
2272 | // the vertical scrollable. |
2273 | _gestureRecognizers = <Type, GestureRecognizerFactory>{ |
2274 | PanGestureRecognizer: GestureRecognizerFactoryWithHandlers<PanGestureRecognizer>( |
2275 | () => PanGestureRecognizer(supportedDevices: _configuration.dragDevices), |
2276 | (PanGestureRecognizer instance) { |
2277 | instance |
2278 | ..onDown = _handleDragDown |
2279 | ..onStart = _handleDragStart |
2280 | ..onUpdate = _handleDragUpdate |
2281 | ..onEnd = _handleDragEnd |
2282 | ..onCancel = _handleDragCancel |
2283 | ..minFlingDistance = _physics?.minFlingDistance |
2284 | ..minFlingVelocity = _physics?.minFlingVelocity |
2285 | ..maxFlingVelocity = _physics?.maxFlingVelocity |
2286 | ..velocityTrackerBuilder = _configuration.velocityTrackerBuilder(context) |
2287 | ..dragStartBehavior = widget.dragStartBehavior |
2288 | ..gestureSettings = _mediaQueryGestureSettings; |
2289 | }, |
2290 | ), |
2291 | }; |
2292 | // Cancel the active hold/drag (if any) because the gesture recognizers |
2293 | // will soon be disposed by our RawGestureDetector, and we won't be |
2294 | // receiving pointer up events to cancel the hold/drag. |
2295 | _handleDragCancel(); |
2296 | _lastCanDrag = value; |
2297 | _lastAxisDirection = widget.axis; |
2298 | if (_gestureDetectorKey.currentState != null) { |
2299 | _gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers); |
2300 | } |
2301 | } |
2302 | return; |
2303 | } |
2304 | } |
2305 | |
2306 | @override |
2307 | Widget _buildChrome(BuildContext context, Widget child) { |
2308 | final ScrollableDetails details = ScrollableDetails( |
2309 | direction: widget.axisDirection, |
2310 | controller: _effectiveScrollController, |
2311 | clipBehavior: widget.clipBehavior, |
2312 | ); |
2313 | // Skip building a scrollbar here, the dual scrollbar is added in |
2314 | // TwoDimensionalScrollableState. |
2315 | return _configuration.buildOverscrollIndicator(context, child, details); |
2316 | } |
2317 | } |
2318 | |
2319 | // Horizontal inner scrollable of 2D scrolling |
2320 | class _HorizontalInnerDimension extends Scrollable { |
2321 | const _HorizontalInnerDimension({ |
2322 | super.key, |
2323 | required super.viewportBuilder, |
2324 | required super.axisDirection, |
2325 | super.controller, |
2326 | super.physics, |
2327 | super.clipBehavior, |
2328 | super.incrementCalculator, |
2329 | super.excludeFromSemantics, |
2330 | super.dragStartBehavior, |
2331 | super.restorationId, |
2332 | this.diagonalDragBehavior = DiagonalDragBehavior.none, |
2333 | }) : assert(axisDirection == AxisDirection.left || axisDirection == AxisDirection.right); |
2334 | |
2335 | final DiagonalDragBehavior diagonalDragBehavior; |
2336 | |
2337 | @override |
2338 | _HorizontalInnerDimensionState createState() => _HorizontalInnerDimensionState(); |
2339 | } |
2340 | |
2341 | class _HorizontalInnerDimensionState extends ScrollableState { |
2342 | late ScrollableState verticalScrollable; |
2343 | |
2344 | DiagonalDragBehavior get diagonalDragBehavior => (widget as _HorizontalInnerDimension).diagonalDragBehavior; |
2345 | |
2346 | @override |
2347 | void didChangeDependencies() { |
2348 | verticalScrollable = Scrollable.of(context); |
2349 | assert(axisDirectionToAxis(verticalScrollable.axisDirection) == Axis.vertical); |
2350 | super.didChangeDependencies(); |
2351 | } |
2352 | |
2353 | // Returns the Future from calling ensureVisible for the ScrollPosition, as |
2354 | // as well as the vertical ScrollableState instance so its context can be |
2355 | // used to check for other ancestor Scrollables in executing ensureVisible. |
2356 | @override |
2357 | _EnsureVisibleResults _performEnsureVisible( |
2358 | RenderObject object, { |
2359 | double alignment = 0.0, |
2360 | Duration duration = Duration.zero, |
2361 | Curve curve = Curves.ease, |
2362 | ScrollPositionAlignmentPolicy alignmentPolicy = ScrollPositionAlignmentPolicy.explicit, |
2363 | RenderObject? targetRenderObject, |
2364 | }) { |
2365 | final List<Future<void>> newFutures = <Future<void>>[]; |
2366 | |
2367 | newFutures.add(position.ensureVisible( |
2368 | object, |
2369 | alignment: alignment, |
2370 | duration: duration, |
2371 | curve: curve, |
2372 | alignmentPolicy: alignmentPolicy, |
2373 | )); |
2374 | |
2375 | newFutures.add(verticalScrollable.position.ensureVisible( |
2376 | object, |
2377 | alignment: alignment, |
2378 | duration: duration, |
2379 | curve: curve, |
2380 | alignmentPolicy: alignmentPolicy, |
2381 | )); |
2382 | |
2383 | return (newFutures, verticalScrollable); |
2384 | } |
2385 | |
2386 | @override |
2387 | void setCanDrag(bool value) { |
2388 | switch (diagonalDragBehavior) { |
2389 | case DiagonalDragBehavior.none: |
2390 | // If we aren't scrolling diagonally, the default drag gesture |
2391 | // recognizer is used. |
2392 | super.setCanDrag(value); |
2393 | return; |
2394 | case DiagonalDragBehavior.weightedEvent: |
2395 | case DiagonalDragBehavior.weightedContinuous: |
2396 | case DiagonalDragBehavior.free: |
2397 | if (value) { |
2398 | // If a type of diagonal scrolling is enabled, a panning gesture |
2399 | // recognizer will be created for the _InnerDimension. So in this |
2400 | // case, the _OuterDimension does not require a gesture recognizer. |
2401 | _gestureRecognizers = const <Type, GestureRecognizerFactory>{}; |
2402 | // Cancel the active hold/drag (if any) because the gesture recognizers |
2403 | // will soon be disposed by our RawGestureDetector, and we won't be |
2404 | // receiving pointer up events to cancel the hold/drag. |
2405 | _handleDragCancel(); |
2406 | _lastCanDrag = value; |
2407 | _lastAxisDirection = widget.axis; |
2408 | if (_gestureDetectorKey.currentState != null) { |
2409 | _gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers); |
2410 | } |
2411 | } |
2412 | return; |
2413 | } |
2414 | } |
2415 | |
2416 | @override |
2417 | Widget _buildChrome(BuildContext context, Widget child) { |
2418 | final ScrollableDetails details = ScrollableDetails( |
2419 | direction: widget.axisDirection, |
2420 | controller: _effectiveScrollController, |
2421 | clipBehavior: widget.clipBehavior, |
2422 | ); |
2423 | // Skip building a scrollbar here, the dual scrollbar is added in |
2424 | // TwoDimensionalScrollableState. |
2425 | return _configuration.buildOverscrollIndicator(context, child, details); |
2426 | } |
2427 | } |
2428 | |