1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6import 'dart:math' as math;
7
8import 'package:flutter/foundation.dart';
9import 'package:flutter/gestures.dart';
10import 'package:flutter/rendering.dart';
11
12import 'basic.dart';
13import 'framework.dart';
14import 'scroll_metrics.dart';
15import 'scroll_notification.dart';
16
17/// A backend for a [ScrollActivity].
18///
19/// Used by subclasses of [ScrollActivity] to manipulate the scroll view that
20/// they are acting upon.
21///
22/// See also:
23///
24/// * [ScrollActivity], which uses this class as its delegate.
25/// * [ScrollPositionWithSingleContext], the main implementation of this interface.
26abstract class ScrollActivityDelegate {
27 /// The direction in which the scroll view scrolls.
28 AxisDirection get axisDirection;
29
30 /// Update the scroll position to the given pixel value.
31 ///
32 /// Returns the overscroll, if any. See [ScrollPosition.setPixels] for more
33 /// information.
34 double setPixels(double pixels);
35
36 /// Updates the scroll position by the given amount.
37 ///
38 /// Appropriate for when the user is directly manipulating the scroll
39 /// position, for example by dragging the scroll view. Typically applies
40 /// [ScrollPhysics.applyPhysicsToUserOffset] and other transformations that
41 /// are appropriate for user-driving scrolling.
42 void applyUserOffset(double delta);
43
44 /// Terminate the current activity and start an idle activity.
45 void goIdle();
46
47 /// Terminate the current activity and start a ballistic activity with the
48 /// given velocity.
49 void goBallistic(double velocity);
50}
51
52/// Base class for scrolling activities like dragging and flinging.
53///
54/// See also:
55///
56/// * [ScrollPosition], which uses [ScrollActivity] objects to manage the
57/// [ScrollPosition] of a [Scrollable].
58abstract class ScrollActivity {
59 /// Initializes [delegate] for subclasses.
60 ScrollActivity(this._delegate) {
61 // TODO(polina-c): stop duplicating code across disposables
62 // https://github.com/flutter/flutter/issues/137435
63 if (kFlutterMemoryAllocationsEnabled) {
64 FlutterMemoryAllocations.instance.dispatchObjectCreated(
65 library: 'package:flutter/widgets.dart',
66 className: '$ScrollActivity',
67 object: this,
68 );
69 }
70 }
71
72 /// The delegate that this activity will use to actuate the scroll view.
73 ScrollActivityDelegate get delegate => _delegate;
74 ScrollActivityDelegate _delegate;
75
76 bool _isDisposed = false;
77
78 /// Updates the activity's link to the [ScrollActivityDelegate].
79 ///
80 /// This should only be called when an activity is being moved from a defunct
81 /// (or about-to-be defunct) [ScrollActivityDelegate] object to a new one.
82 void updateDelegate(ScrollActivityDelegate value) {
83 assert(_delegate != value);
84 _delegate = value;
85 }
86
87 /// Called by the [ScrollActivityDelegate] when it has changed type (for
88 /// example, when changing from an Android-style scroll position to an
89 /// iOS-style scroll position). If this activity can differ between the two
90 /// modes, then it should tell the position to restart that activity
91 /// appropriately.
92 ///
93 /// For example, [BallisticScrollActivity]'s implementation calls
94 /// [ScrollActivityDelegate.goBallistic].
95 void resetActivity() { }
96
97 /// Dispatch a [ScrollStartNotification] with the given metrics.
98 void dispatchScrollStartNotification(ScrollMetrics metrics, BuildContext? context) {
99 ScrollStartNotification(metrics: metrics, context: context).dispatch(context);
100 }
101
102 /// Dispatch a [ScrollUpdateNotification] with the given metrics and scroll delta.
103 void dispatchScrollUpdateNotification(ScrollMetrics metrics, BuildContext context, double scrollDelta) {
104 ScrollUpdateNotification(metrics: metrics, context: context, scrollDelta: scrollDelta).dispatch(context);
105 }
106
107 /// Dispatch an [OverscrollNotification] with the given metrics and overscroll.
108 void dispatchOverscrollNotification(ScrollMetrics metrics, BuildContext context, double overscroll) {
109 OverscrollNotification(metrics: metrics, context: context, overscroll: overscroll).dispatch(context);
110 }
111
112 /// Dispatch a [ScrollEndNotification] with the given metrics and overscroll.
113 void dispatchScrollEndNotification(ScrollMetrics metrics, BuildContext context) {
114 ScrollEndNotification(metrics: metrics, context: context).dispatch(context);
115 }
116
117 /// Called when the scroll view that is performing this activity changes its metrics.
118 void applyNewDimensions() { }
119
120 /// Whether the scroll view should ignore pointer events while performing this
121 /// activity.
122 ///
123 /// See also:
124 ///
125 /// * [isScrolling], which describes whether the activity is considered
126 /// to represent user interaction or not.
127 bool get shouldIgnorePointer;
128
129 /// Whether performing this activity constitutes scrolling.
130 ///
131 /// Used, for example, to determine whether the user scroll
132 /// direction (see [ScrollPosition.userScrollDirection]) is
133 /// [ScrollDirection.idle].
134 ///
135 /// See also:
136 ///
137 /// * [shouldIgnorePointer], which controls whether pointer events
138 /// are allowed while the activity is live.
139 /// * [UserScrollNotification], which exposes this status.
140 bool get isScrolling;
141
142 /// If applicable, the velocity at which the scroll offset is currently
143 /// independently changing (i.e. without external stimuli such as a dragging
144 /// gestures) in logical pixels per second for this activity.
145 double get velocity;
146
147 /// Called when the scroll view stops performing this activity.
148 @mustCallSuper
149 void dispose() {
150 // TODO(polina-c): stop duplicating code across disposables
151 // https://github.com/flutter/flutter/issues/137435
152 if (kFlutterMemoryAllocationsEnabled) {
153 FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this);
154 }
155
156 _isDisposed = true;
157 }
158
159 @override
160 String toString() => describeIdentity(this);
161}
162
163/// A scroll activity that does nothing.
164///
165/// When a scroll view is not scrolling, it is performing the idle activity.
166///
167/// If the [Scrollable] changes dimensions, this activity triggers a ballistic
168/// activity to restore the view.
169class IdleScrollActivity extends ScrollActivity {
170 /// Creates a scroll activity that does nothing.
171 IdleScrollActivity(super.delegate);
172
173 @override
174 void applyNewDimensions() {
175 delegate.goBallistic(0.0);
176 }
177
178 @override
179 bool get shouldIgnorePointer => false;
180
181 @override
182 bool get isScrolling => false;
183
184 @override
185 double get velocity => 0.0;
186}
187
188/// Interface for holding a [Scrollable] stationary.
189///
190/// An object that implements this interface is returned by
191/// [ScrollPosition.hold]. It holds the scrollable stationary until an activity
192/// is started or the [cancel] method is called.
193abstract class ScrollHoldController {
194 /// Release the [Scrollable], potentially letting it go ballistic if
195 /// necessary.
196 void cancel();
197}
198
199/// A scroll activity that does nothing but can be released to resume
200/// normal idle behavior.
201///
202/// This is used while the user is touching the [Scrollable] but before the
203/// touch has become a [Drag].
204///
205/// For the purposes of [ScrollNotification]s, this activity does not constitute
206/// scrolling, and does not prevent the user from interacting with the contents
207/// of the [Scrollable] (unlike when a drag has begun or there is a scroll
208/// animation underway).
209class HoldScrollActivity extends ScrollActivity implements ScrollHoldController {
210 /// Creates a scroll activity that does nothing.
211 HoldScrollActivity({
212 required ScrollActivityDelegate delegate,
213 this.onHoldCanceled,
214 }) : super(delegate);
215
216 /// Called when [dispose] is called.
217 final VoidCallback? onHoldCanceled;
218
219 @override
220 bool get shouldIgnorePointer => false;
221
222 @override
223 bool get isScrolling => false;
224
225 @override
226 double get velocity => 0.0;
227
228 @override
229 void cancel() {
230 delegate.goBallistic(0.0);
231 }
232
233 @override
234 void dispose() {
235 onHoldCanceled?.call();
236 super.dispose();
237 }
238}
239
240/// Scrolls a scroll view as the user drags their finger across the screen.
241///
242/// See also:
243///
244/// * [DragScrollActivity], which is the activity the scroll view performs
245/// while a drag is underway.
246class ScrollDragController implements Drag {
247 /// Creates an object that scrolls a scroll view as the user drags their
248 /// finger across the screen.
249 ScrollDragController({
250 required ScrollActivityDelegate delegate,
251 required DragStartDetails details,
252 this.onDragCanceled,
253 this.carriedVelocity,
254 this.motionStartDistanceThreshold,
255 }) : assert(
256 motionStartDistanceThreshold == null || motionStartDistanceThreshold > 0.0,
257 'motionStartDistanceThreshold must be a positive number or null',
258 ),
259 _delegate = delegate,
260 _lastDetails = details,
261 _retainMomentum = carriedVelocity != null && carriedVelocity != 0.0,
262 _lastNonStationaryTimestamp = details.sourceTimeStamp,
263 _kind = details.kind,
264 _offsetSinceLastStop = motionStartDistanceThreshold == null ? null : 0.0 {
265 // TODO(polina-c): stop duplicating code across disposables
266 // https://github.com/flutter/flutter/issues/137435
267 if (kFlutterMemoryAllocationsEnabled) {
268 FlutterMemoryAllocations.instance.dispatchObjectCreated(
269 library: 'package:flutter/widgets.dart',
270 className: '$ScrollDragController',
271 object: this,
272 );
273 }
274 }
275
276 /// The object that will actuate the scroll view as the user drags.
277 ScrollActivityDelegate get delegate => _delegate;
278 ScrollActivityDelegate _delegate;
279
280 /// Called when [dispose] is called.
281 final VoidCallback? onDragCanceled;
282
283 /// Velocity that was present from a previous [ScrollActivity] when this drag
284 /// began.
285 final double? carriedVelocity;
286
287 /// Amount of pixels in either direction the drag has to move by to start
288 /// scroll movement again after each time scrolling came to a stop.
289 final double? motionStartDistanceThreshold;
290
291 Duration? _lastNonStationaryTimestamp;
292 bool _retainMomentum;
293 /// Null if already in motion or has no [motionStartDistanceThreshold].
294 double? _offsetSinceLastStop;
295
296 /// Maximum amount of time interval the drag can have consecutive stationary
297 /// pointer update events before losing the momentum carried from a previous
298 /// scroll activity.
299 static const Duration momentumRetainStationaryDurationThreshold =
300 Duration(milliseconds: 20);
301
302 /// The minimum amount of velocity needed to apply the [carriedVelocity] at
303 /// the end of a drag. Expressed as a factor. For example with a
304 /// [carriedVelocity] of 2000, we will need a velocity of at least 1000 to
305 /// apply the [carriedVelocity] as well. If the velocity does not meet the
306 /// threshold, the [carriedVelocity] is lost. Decided by fair eyeballing
307 /// with the scroll_overlay platform test.
308 static const double momentumRetainVelocityThresholdFactor = 0.5;
309
310 /// Maximum amount of time interval the drag can have consecutive stationary
311 /// pointer update events before needing to break the
312 /// [motionStartDistanceThreshold] to start motion again.
313 static const Duration motionStoppedDurationThreshold =
314 Duration(milliseconds: 50);
315
316 /// The drag distance past which, a [motionStartDistanceThreshold] breaking
317 /// drag is considered a deliberate fling.
318 static const double _bigThresholdBreakDistance = 24.0;
319
320 bool get _reversed => axisDirectionIsReversed(delegate.axisDirection);
321
322 /// Updates the controller's link to the [ScrollActivityDelegate].
323 ///
324 /// This should only be called when a controller is being moved from a defunct
325 /// (or about-to-be defunct) [ScrollActivityDelegate] object to a new one.
326 void updateDelegate(ScrollActivityDelegate value) {
327 assert(_delegate != value);
328 _delegate = value;
329 }
330
331 /// Determines whether to lose the existing incoming velocity when starting
332 /// the drag.
333 void _maybeLoseMomentum(double offset, Duration? timestamp) {
334 if (_retainMomentum &&
335 offset == 0.0 &&
336 (timestamp == null || // If drag event has no timestamp, we lose momentum.
337 timestamp - _lastNonStationaryTimestamp! > momentumRetainStationaryDurationThreshold)) {
338 // If pointer is stationary for too long, we lose momentum.
339 _retainMomentum = false;
340 }
341 }
342
343 /// If a motion start threshold exists, determine whether the threshold needs
344 /// to be broken to scroll. Also possibly apply an offset adjustment when
345 /// threshold is first broken.
346 ///
347 /// Returns `0.0` when stationary or within threshold. Returns `offset`
348 /// transparently when already in motion.
349 double _adjustForScrollStartThreshold(double offset, Duration? timestamp) {
350 if (timestamp == null) {
351 // If we can't track time, we can't apply thresholds.
352 // May be null for proxied drags like via accessibility.
353 return offset;
354 }
355 if (offset == 0.0) {
356 if (motionStartDistanceThreshold != null &&
357 _offsetSinceLastStop == null &&
358 timestamp - _lastNonStationaryTimestamp! > motionStoppedDurationThreshold) {
359 // Enforce a new threshold.
360 _offsetSinceLastStop = 0.0;
361 }
362 // Not moving can't break threshold.
363 return 0.0;
364 } else {
365 if (_offsetSinceLastStop == null) {
366 // Already in motion or no threshold behavior configured such as for
367 // Android. Allow transparent offset transmission.
368 return offset;
369 } else {
370 _offsetSinceLastStop = _offsetSinceLastStop! + offset;
371 if (_offsetSinceLastStop!.abs() > motionStartDistanceThreshold!) {
372 // Threshold broken.
373 _offsetSinceLastStop = null;
374 if (offset.abs() > _bigThresholdBreakDistance) {
375 // This is heuristically a very deliberate fling. Leave the motion
376 // unaffected.
377 return offset;
378 } else {
379 // This is a normal speed threshold break.
380 return math.min(
381 // Ease into the motion when the threshold is initially broken
382 // to avoid a visible jump.
383 motionStartDistanceThreshold! / 3.0,
384 offset.abs(),
385 ) * offset.sign;
386 }
387 } else {
388 return 0.0;
389 }
390 }
391 }
392 }
393
394 @override
395 void update(DragUpdateDetails details) {
396 assert(details.primaryDelta != null);
397 _lastDetails = details;
398 double offset = details.primaryDelta!;
399 if (offset != 0.0) {
400 _lastNonStationaryTimestamp = details.sourceTimeStamp;
401 }
402 // By default, iOS platforms carries momentum and has a start threshold
403 // (configured in [BouncingScrollPhysics]). The 2 operations below are
404 // no-ops on Android.
405 _maybeLoseMomentum(offset, details.sourceTimeStamp);
406 offset = _adjustForScrollStartThreshold(offset, details.sourceTimeStamp);
407 if (offset == 0.0) {
408 return;
409 }
410 if (_reversed) {
411 offset = -offset;
412 }
413 delegate.applyUserOffset(offset);
414 }
415
416 @override
417 void end(DragEndDetails details) {
418 assert(details.primaryVelocity != null);
419 // We negate the velocity here because if the touch is moving downwards,
420 // the scroll has to move upwards. It's the same reason that update()
421 // above negates the delta before applying it to the scroll offset.
422 double velocity = -details.primaryVelocity!;
423 if (_reversed) {
424 velocity = -velocity;
425 }
426 _lastDetails = details;
427
428 if (_retainMomentum) {
429 // Build momentum only if dragging in the same direction.
430 final bool isFlingingInSameDirection = velocity.sign == carriedVelocity!.sign;
431 // Build momentum only if the velocity of the last drag was not
432 // substantially lower than the carried momentum.
433 final bool isVelocityNotSubstantiallyLessThanCarriedMomentum =
434 velocity.abs() > carriedVelocity!.abs() * momentumRetainVelocityThresholdFactor;
435 if (isFlingingInSameDirection && isVelocityNotSubstantiallyLessThanCarriedMomentum) {
436 velocity += carriedVelocity!;
437 }
438 }
439 delegate.goBallistic(velocity);
440 }
441
442 @override
443 void cancel() {
444 delegate.goBallistic(0.0);
445 }
446
447 /// Called by the delegate when it is no longer sending events to this object.
448 @mustCallSuper
449 void dispose() {
450 // TODO(polina-c): stop duplicating code across disposables
451 // https://github.com/flutter/flutter/issues/137435
452 if (kFlutterMemoryAllocationsEnabled) {
453 FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this);
454 }
455 _lastDetails = null;
456 onDragCanceled?.call();
457 }
458
459 /// The type of input device driving the drag.
460 final PointerDeviceKind? _kind;
461 /// The most recently observed [DragStartDetails], [DragUpdateDetails], or
462 /// [DragEndDetails] object.
463 dynamic get lastDetails => _lastDetails;
464 dynamic _lastDetails;
465
466 @override
467 String toString() => describeIdentity(this);
468}
469
470/// The activity a scroll view performs when the user drags their finger
471/// across the screen.
472///
473/// See also:
474///
475/// * [ScrollDragController], which listens to the [Drag] and actually scrolls
476/// the scroll view.
477class DragScrollActivity extends ScrollActivity {
478 /// Creates an activity for when the user drags their finger across the
479 /// screen.
480 DragScrollActivity(
481 super.delegate,
482 ScrollDragController controller,
483 ) : _controller = controller;
484
485 ScrollDragController? _controller;
486
487 @override
488 void dispatchScrollStartNotification(ScrollMetrics metrics, BuildContext? context) {
489 final dynamic lastDetails = _controller!.lastDetails;
490 assert(lastDetails is DragStartDetails);
491 ScrollStartNotification(metrics: metrics, context: context, dragDetails: lastDetails as DragStartDetails).dispatch(context);
492 }
493
494 @override
495 void dispatchScrollUpdateNotification(ScrollMetrics metrics, BuildContext context, double scrollDelta) {
496 final dynamic lastDetails = _controller!.lastDetails;
497 assert(lastDetails is DragUpdateDetails);
498 ScrollUpdateNotification(metrics: metrics, context: context, scrollDelta: scrollDelta, dragDetails: lastDetails as DragUpdateDetails).dispatch(context);
499 }
500
501 @override
502 void dispatchOverscrollNotification(ScrollMetrics metrics, BuildContext context, double overscroll) {
503 final dynamic lastDetails = _controller!.lastDetails;
504 assert(lastDetails is DragUpdateDetails);
505 OverscrollNotification(metrics: metrics, context: context, overscroll: overscroll, dragDetails: lastDetails as DragUpdateDetails).dispatch(context);
506 }
507
508 @override
509 void dispatchScrollEndNotification(ScrollMetrics metrics, BuildContext context) {
510 // We might not have DragEndDetails yet if we're being called from beginActivity.
511 final dynamic lastDetails = _controller!.lastDetails;
512 ScrollEndNotification(
513 metrics: metrics,
514 context: context,
515 dragDetails: lastDetails is DragEndDetails ? lastDetails : null,
516 ).dispatch(context);
517 }
518
519 @override
520 bool get shouldIgnorePointer => _controller?._kind != PointerDeviceKind.trackpad;
521
522 @override
523 bool get isScrolling => true;
524
525 // DragScrollActivity is not independently changing velocity yet
526 // until the drag is ended.
527 @override
528 double get velocity => 0.0;
529
530 @override
531 void dispose() {
532 _controller = null;
533 super.dispose();
534 }
535
536 @override
537 String toString() {
538 return '${describeIdentity(this)}($_controller)';
539 }
540}
541
542/// An activity that animates a scroll view based on a physics [Simulation].
543///
544/// A [BallisticScrollActivity] is typically used when the user lifts their
545/// finger off the screen to continue the scrolling gesture with the current velocity.
546///
547/// [BallisticScrollActivity] is also used to restore a scroll view to a valid
548/// scroll offset when the geometry of the scroll view changes. In these
549/// situations, the [Simulation] typically starts with a zero velocity.
550///
551/// See also:
552///
553/// * [DrivenScrollActivity], which animates a scroll view based on a set of
554/// animation parameters.
555class BallisticScrollActivity extends ScrollActivity {
556 /// Creates an activity that animates a scroll view based on a [simulation].
557 BallisticScrollActivity(
558 super.delegate,
559 Simulation simulation,
560 TickerProvider vsync,
561 this.shouldIgnorePointer,
562 ) {
563 _controller = AnimationController.unbounded(
564 debugLabel: kDebugMode ? objectRuntimeType(this, 'BallisticScrollActivity') : null,
565 vsync: vsync,
566 )
567 ..addListener(_tick)
568 ..animateWith(simulation)
569 .whenComplete(_end); // won't trigger if we dispose _controller before it completes.
570 }
571
572 late AnimationController _controller;
573
574 @override
575 void resetActivity() {
576 delegate.goBallistic(velocity);
577 }
578
579 @override
580 void applyNewDimensions() {
581 delegate.goBallistic(velocity);
582 }
583
584 void _tick() {
585 if (!applyMoveTo(_controller.value)) {
586 delegate.goIdle();
587 }
588 }
589
590 /// Move the position to the given location.
591 ///
592 /// If the new position was fully applied, returns true. If there was any
593 /// overflow, returns false.
594 ///
595 /// The default implementation calls [ScrollActivityDelegate.setPixels]
596 /// and returns true if the overflow was zero.
597 @protected
598 bool applyMoveTo(double value) {
599 return delegate.setPixels(value).abs() < precisionErrorTolerance;
600 }
601
602 void _end() {
603 // Check if the activity was disposed before going ballistic because _end might be called
604 // if _controller is disposed just after completion.
605 if (!_isDisposed) {
606 delegate.goBallistic(0.0);
607 }
608 }
609
610 @override
611 void dispatchOverscrollNotification(ScrollMetrics metrics, BuildContext context, double overscroll) {
612 OverscrollNotification(metrics: metrics, context: context, overscroll: overscroll, velocity: velocity).dispatch(context);
613 }
614
615 @override
616 final bool shouldIgnorePointer;
617
618 @override
619 bool get isScrolling => true;
620
621 @override
622 double get velocity => _controller.velocity;
623
624 @override
625 void dispose() {
626 _controller.dispose();
627 super.dispose();
628 }
629
630 @override
631 String toString() {
632 return '${describeIdentity(this)}($_controller)';
633 }
634}
635
636/// An activity that animates a scroll view based on animation parameters.
637///
638/// For example, a [DrivenScrollActivity] is used to implement
639/// [ScrollController.animateTo].
640///
641/// See also:
642///
643/// * [BallisticScrollActivity], which animates a scroll view based on a
644/// physics [Simulation].
645class DrivenScrollActivity extends ScrollActivity {
646 /// Creates an activity that animates a scroll view based on animation
647 /// parameters.
648 DrivenScrollActivity(
649 super.delegate, {
650 required double from,
651 required double to,
652 required Duration duration,
653 required Curve curve,
654 required TickerProvider vsync,
655 }) : assert(duration > Duration.zero) {
656 _completer = Completer<void>();
657 _controller = AnimationController.unbounded(
658 value: from,
659 debugLabel: objectRuntimeType(this, 'DrivenScrollActivity'),
660 vsync: vsync,
661 )
662 ..addListener(_tick)
663 ..animateTo(to, duration: duration, curve: curve)
664 .whenComplete(_end); // won't trigger if we dispose _controller before it completes.
665 }
666
667 late final Completer<void> _completer;
668 late final AnimationController _controller;
669
670 /// A [Future] that completes when the activity stops.
671 ///
672 /// For example, this [Future] will complete if the animation reaches the end
673 /// or if the user interacts with the scroll view in way that causes the
674 /// animation to stop before it reaches the end.
675 Future<void> get done => _completer.future;
676
677 void _tick() {
678 if (delegate.setPixels(_controller.value) != 0.0) {
679 delegate.goIdle();
680 }
681 }
682
683 void _end() {
684 // Check if the activity was disposed before going ballistic because _end might be called
685 // if _controller is disposed just after completion.
686 if (!_isDisposed) {
687 delegate.goBallistic(velocity);
688 }
689 }
690
691 @override
692 void dispatchOverscrollNotification(ScrollMetrics metrics, BuildContext context, double overscroll) {
693 OverscrollNotification(metrics: metrics, context: context, overscroll: overscroll, velocity: velocity).dispatch(context);
694 }
695
696 @override
697 bool get shouldIgnorePointer => true;
698
699 @override
700 bool get isScrolling => true;
701
702 @override
703 double get velocity => _controller.velocity;
704
705 @override
706 void dispose() {
707 _completer.complete();
708 _controller.dispose();
709 super.dispose();
710 }
711
712 @override
713 String toString() {
714 return '${describeIdentity(this)}($_controller)';
715 }
716}
717