| 1 | // Copyright 2014 The Flutter Authors. All rights reserved. |
| 2 | // Use of this source code is governed by a BSD-style license that can be |
| 3 | // found in the LICENSE file. |
| 4 | |
| 5 | /// @docImport 'package:flutter/material.dart'; |
| 6 | /// @docImport 'package:flutter_test/flutter_test.dart'; |
| 7 | /// |
| 8 | /// @docImport 'primary_scroll_controller.dart'; |
| 9 | /// @docImport 'scroll_configuration.dart'; |
| 10 | /// @docImport 'scroll_view.dart'; |
| 11 | /// @docImport 'scrollable.dart'; |
| 12 | /// @docImport 'single_child_scroll_view.dart'; |
| 13 | /// @docImport 'viewport.dart'; |
| 14 | library; |
| 15 | |
| 16 | import 'dart:math' as math; |
| 17 | |
| 18 | import 'package:collection/collection.dart' ; |
| 19 | import 'package:flutter/foundation.dart'; |
| 20 | import 'package:flutter/gestures.dart'; |
| 21 | |
| 22 | import 'basic.dart'; |
| 23 | import 'binding.dart'; |
| 24 | import 'framework.dart'; |
| 25 | import 'inherited_notifier.dart'; |
| 26 | import 'layout_builder.dart'; |
| 27 | import 'notification_listener.dart'; |
| 28 | import 'scroll_activity.dart'; |
| 29 | import 'scroll_context.dart'; |
| 30 | import 'scroll_controller.dart'; |
| 31 | import 'scroll_notification.dart'; |
| 32 | import 'scroll_physics.dart'; |
| 33 | import 'scroll_position.dart'; |
| 34 | import 'scroll_position_with_single_context.dart'; |
| 35 | import 'scroll_simulation.dart'; |
| 36 | import 'value_listenable_builder.dart'; |
| 37 | |
| 38 | /// The signature of a method that provides a [BuildContext] and |
| 39 | /// [ScrollController] for building a widget that may overflow the draggable |
| 40 | /// [Axis] of the containing [DraggableScrollableSheet]. |
| 41 | /// |
| 42 | /// Users should apply the [scrollController] to a [ScrollView] subclass, such |
| 43 | /// as a [SingleChildScrollView], [ListView] or [GridView], to have the whole |
| 44 | /// sheet be draggable. |
| 45 | typedef ScrollableWidgetBuilder = |
| 46 | Widget Function(BuildContext context, ScrollController scrollController); |
| 47 | |
| 48 | /// Controls a [DraggableScrollableSheet]. |
| 49 | /// |
| 50 | /// Draggable scrollable controllers are typically stored as member variables in |
| 51 | /// [State] objects and are reused in each [State.build]. Controllers can only |
| 52 | /// be used to control one sheet at a time. A controller can be reused with a |
| 53 | /// new sheet if the previous sheet has been disposed. |
| 54 | /// |
| 55 | /// The controller's methods cannot be used until after the controller has been |
| 56 | /// passed into a [DraggableScrollableSheet] and the sheet has run initState. |
| 57 | /// |
| 58 | /// A [DraggableScrollableController] is a [Listenable]. It notifies its |
| 59 | /// listeners whenever an attached sheet changes sizes. It does not notify its |
| 60 | /// listeners when a sheet is first attached or when an attached sheet's |
| 61 | /// parameters change without affecting the sheet's current size. It does not |
| 62 | /// fire when [pixels] changes without [size] changing. For example, if the |
| 63 | /// constraints provided to an attached sheet change. |
| 64 | class DraggableScrollableController extends ChangeNotifier { |
| 65 | /// Creates a controller for [DraggableScrollableSheet]. |
| 66 | DraggableScrollableController() { |
| 67 | if (kFlutterMemoryAllocationsEnabled) { |
| 68 | ChangeNotifier.maybeDispatchObjectCreation(this); |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | _DraggableScrollableSheetScrollController? _attachedController; |
| 73 | final Set<AnimationController> _animationControllers = <AnimationController>{}; |
| 74 | |
| 75 | /// Get the current size (as a fraction of the parent height) of the attached sheet. |
| 76 | double get size { |
| 77 | _assertAttached(); |
| 78 | return _attachedController!.extent.currentSize; |
| 79 | } |
| 80 | |
| 81 | /// Get the current pixel height of the attached sheet. |
| 82 | double get pixels { |
| 83 | _assertAttached(); |
| 84 | return _attachedController!.extent.currentPixels; |
| 85 | } |
| 86 | |
| 87 | /// Convert a sheet's size (fractional value of parent container height) to pixels. |
| 88 | double sizeToPixels(double size) { |
| 89 | _assertAttached(); |
| 90 | return _attachedController!.extent.sizeToPixels(size); |
| 91 | } |
| 92 | |
| 93 | /// Returns Whether any [DraggableScrollableController] objects have attached themselves to the |
| 94 | /// [DraggableScrollableSheet]. |
| 95 | /// |
| 96 | /// If this is false, then members that interact with the [ScrollPosition], |
| 97 | /// such as [sizeToPixels], [size], [animateTo], and [jumpTo], must not be |
| 98 | /// called. |
| 99 | bool get isAttached => _attachedController != null && _attachedController!.hasClients; |
| 100 | |
| 101 | /// Convert a sheet's pixel height to size (fractional value of parent container height). |
| 102 | double pixelsToSize(double pixels) { |
| 103 | _assertAttached(); |
| 104 | return _attachedController!.extent.pixelsToSize(pixels); |
| 105 | } |
| 106 | |
| 107 | /// Animates the attached sheet from its current size to the given [size], a |
| 108 | /// fractional value of the parent container's height. |
| 109 | /// |
| 110 | /// Any active sheet animation is canceled. If the sheet's internal scrollable |
| 111 | /// is currently animating (e.g. responding to a user fling), that animation is |
| 112 | /// canceled as well. |
| 113 | /// |
| 114 | /// An animation will be interrupted whenever the user attempts to scroll |
| 115 | /// manually, whenever another activity is started, or when the sheet hits its |
| 116 | /// max or min size (e.g. if you animate to 1 but the max size is .8, the |
| 117 | /// animation will stop playing when it reaches .8). |
| 118 | /// |
| 119 | /// The duration must not be zero. To jump to a particular value without an |
| 120 | /// animation, use [jumpTo]. |
| 121 | /// |
| 122 | /// The sheet will not snap after calling [animateTo] even if [DraggableScrollableSheet.snap] |
| 123 | /// is true. Snapping only occurs after user drags. |
| 124 | /// |
| 125 | /// When calling [animateTo] in widget tests, `await`ing the returned |
| 126 | /// [Future] may cause the test to hang and timeout. Instead, use |
| 127 | /// [WidgetTester.pumpAndSettle]. |
| 128 | Future<void> animateTo(double size, {required Duration duration, required Curve curve}) async { |
| 129 | _assertAttached(); |
| 130 | assert(size >= 0 && size <= 1); |
| 131 | assert(duration != Duration.zero); |
| 132 | final AnimationController animationController = AnimationController.unbounded( |
| 133 | vsync: _attachedController!.position.context.vsync, |
| 134 | value: _attachedController!.extent.currentSize, |
| 135 | ); |
| 136 | _animationControllers.add(animationController); |
| 137 | _attachedController!.position.goIdle(); |
| 138 | // This disables any snapping until the next user interaction with the sheet. |
| 139 | _attachedController!.extent.hasDragged = false; |
| 140 | _attachedController!.extent.hasChanged = true; |
| 141 | _attachedController!.extent.startActivity( |
| 142 | onCanceled: () { |
| 143 | // Don't stop the controller if it's already finished and may have been disposed. |
| 144 | if (animationController.isAnimating) { |
| 145 | animationController.stop(); |
| 146 | } |
| 147 | }, |
| 148 | ); |
| 149 | animationController.addListener(() { |
| 150 | _attachedController!.extent.updateSize( |
| 151 | animationController.value, |
| 152 | _attachedController!.position.context.notificationContext!, |
| 153 | ); |
| 154 | }); |
| 155 | await animationController.animateTo( |
| 156 | clampDouble(size, _attachedController!.extent.minSize, _attachedController!.extent.maxSize), |
| 157 | duration: duration, |
| 158 | curve: curve, |
| 159 | ); |
| 160 | } |
| 161 | |
| 162 | /// Jumps the attached sheet from its current size to the given [size], a |
| 163 | /// fractional value of the parent container's height. |
| 164 | /// |
| 165 | /// If [size] is outside of a the attached sheet's min or max child size, |
| 166 | /// [jumpTo] will jump the sheet to the nearest valid size instead. |
| 167 | /// |
| 168 | /// Any active sheet animation is canceled. If the sheet's inner scrollable |
| 169 | /// is currently animating (e.g. responding to a user fling), that animation is |
| 170 | /// canceled as well. |
| 171 | /// |
| 172 | /// The sheet will not snap after calling [jumpTo] even if [DraggableScrollableSheet.snap] |
| 173 | /// is true. Snapping only occurs after user drags. |
| 174 | void jumpTo(double size) { |
| 175 | _assertAttached(); |
| 176 | assert(size >= 0 && size <= 1); |
| 177 | // Call start activity to interrupt any other playing activities. |
| 178 | _attachedController!.extent.startActivity(onCanceled: () {}); |
| 179 | _attachedController!.position.goIdle(); |
| 180 | _attachedController!.extent.hasDragged = false; |
| 181 | _attachedController!.extent.hasChanged = true; |
| 182 | _attachedController!.extent.updateSize( |
| 183 | size, |
| 184 | _attachedController!.position.context.notificationContext!, |
| 185 | ); |
| 186 | } |
| 187 | |
| 188 | /// Reset the attached sheet to its initial size (see: [DraggableScrollableSheet.initialChildSize]). |
| 189 | void reset() { |
| 190 | _assertAttached(); |
| 191 | _attachedController!.reset(); |
| 192 | } |
| 193 | |
| 194 | void _assertAttached() { |
| 195 | assert( |
| 196 | isAttached, |
| 197 | 'DraggableScrollableController is not attached to a sheet. A DraggableScrollableController ' |
| 198 | 'must be used in a DraggableScrollableSheet before any of its methods are called.' , |
| 199 | ); |
| 200 | } |
| 201 | |
| 202 | void _attach(_DraggableScrollableSheetScrollController scrollController) { |
| 203 | assert( |
| 204 | _attachedController == null, |
| 205 | 'Draggable scrollable controller is already attached to a sheet.' , |
| 206 | ); |
| 207 | _attachedController = scrollController; |
| 208 | _attachedController!.extent._currentSize.addListener(notifyListeners); |
| 209 | _attachedController!.onPositionDetached = _disposeAnimationControllers; |
| 210 | } |
| 211 | |
| 212 | void _onExtentReplaced(_DraggableSheetExtent previousExtent) { |
| 213 | // When the extent has been replaced, the old extent is already disposed and |
| 214 | // the controller will point to a new extent. We have to add our listener to |
| 215 | // the new extent. |
| 216 | _attachedController!.extent._currentSize.addListener(notifyListeners); |
| 217 | if (previousExtent.currentSize != _attachedController!.extent.currentSize) { |
| 218 | // The listener won't fire for a change in size between two extent |
| 219 | // objects so we have to fire it manually here. |
| 220 | notifyListeners(); |
| 221 | } |
| 222 | } |
| 223 | |
| 224 | void _detach({bool disposeExtent = false}) { |
| 225 | if (disposeExtent) { |
| 226 | _attachedController?.extent.dispose(); |
| 227 | } else { |
| 228 | _attachedController?.extent._currentSize.removeListener(notifyListeners); |
| 229 | } |
| 230 | _disposeAnimationControllers(); |
| 231 | _attachedController = null; |
| 232 | } |
| 233 | |
| 234 | void _disposeAnimationControllers() { |
| 235 | for (final AnimationController animationController in _animationControllers) { |
| 236 | animationController.dispose(); |
| 237 | } |
| 238 | _animationControllers.clear(); |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | /// A container for a [Scrollable] that responds to drag gestures by resizing |
| 243 | /// the scrollable until a limit is reached, and then scrolling. |
| 244 | /// |
| 245 | /// {@youtube 560 315 https://www.youtube.com/watch?v=Hgw819mL_78} |
| 246 | /// |
| 247 | /// This widget can be dragged along the vertical axis between its |
| 248 | /// [minChildSize], which defaults to `0.25` and [maxChildSize], which defaults |
| 249 | /// to `1.0`. These sizes are percentages of the height of the parent container. |
| 250 | /// |
| 251 | /// The widget coordinates resizing and scrolling of the widget returned by |
| 252 | /// builder as the user drags along the horizontal axis. |
| 253 | /// |
| 254 | /// The widget will initially be displayed at its initialChildSize which |
| 255 | /// defaults to `0.5`, meaning half the height of its parent. Dragging will work |
| 256 | /// between the range of minChildSize and maxChildSize (as percentages of the |
| 257 | /// parent container's height) as long as the builder creates a widget which |
| 258 | /// uses the provided [ScrollController]. If the widget created by the |
| 259 | /// [ScrollableWidgetBuilder] does not use the provided [ScrollController], the |
| 260 | /// sheet will remain at the initialChildSize. |
| 261 | /// |
| 262 | /// By default, the widget will stay at whatever size the user drags it to. To |
| 263 | /// make the widget snap to specific sizes whenever they lift their finger |
| 264 | /// during a drag, set [snap] to `true`. The sheet will snap between |
| 265 | /// [minChildSize] and [maxChildSize]. Use [snapSizes] to add more sizes for |
| 266 | /// the sheet to snap between. |
| 267 | /// |
| 268 | /// The snapping effect is only applied on user drags. Programmatically |
| 269 | /// manipulating the sheet size via [DraggableScrollableController.animateTo] or |
| 270 | /// [DraggableScrollableController.jumpTo] will ignore [snap] and [snapSizes]. |
| 271 | /// |
| 272 | /// By default, the widget will expand its non-occupied area to fill available |
| 273 | /// space in the parent. If this is not desired, e.g. because the parent wants |
| 274 | /// to position sheet based on the space it is taking, the [expand] property |
| 275 | /// may be set to false. |
| 276 | /// |
| 277 | /// {@tool dartpad} |
| 278 | /// |
| 279 | /// This is a sample widget which shows a [ListView] that has 25 [ListTile]s. |
| 280 | /// It starts out as taking up half the body of the [Scaffold], and can be |
| 281 | /// dragged up to the full height of the scaffold or down to 25% of the height |
| 282 | /// of the scaffold. Upon reaching full height, the list contents will be |
| 283 | /// scrolled up or down, until they reach the top of the list again and the user |
| 284 | /// drags the sheet back down. |
| 285 | /// |
| 286 | /// On desktop and web running on desktop platforms, dragging to scroll with a mouse is disabled by default |
| 287 | /// to align with the natural behavior found in other desktop applications. |
| 288 | /// |
| 289 | /// This behavior is dictated by the [ScrollBehavior], and can be changed by adding |
| 290 | /// [PointerDeviceKind.mouse] to [ScrollBehavior.dragDevices]. |
| 291 | /// For more info on this, please refer to https://docs.flutter.dev/release/breaking-changes/default-scroll-behavior-drag |
| 292 | /// |
| 293 | /// Alternatively, this example illustrates how to add a drag handle for desktop applications. |
| 294 | /// |
| 295 | /// ** See code in examples/api/lib/widgets/draggable_scrollable_sheet/draggable_scrollable_sheet.0.dart ** |
| 296 | /// {@end-tool} |
| 297 | class DraggableScrollableSheet extends StatefulWidget { |
| 298 | /// Creates a widget that can be dragged and scrolled in a single gesture. |
| 299 | const DraggableScrollableSheet({ |
| 300 | super.key, |
| 301 | this.initialChildSize = 0.5, |
| 302 | this.minChildSize = 0.25, |
| 303 | this.maxChildSize = 1.0, |
| 304 | this.expand = true, |
| 305 | this.snap = false, |
| 306 | this.snapSizes, |
| 307 | this.snapAnimationDuration, |
| 308 | this.controller, |
| 309 | this.shouldCloseOnMinExtent = true, |
| 310 | required this.builder, |
| 311 | }) : assert(minChildSize >= 0.0), |
| 312 | assert(maxChildSize <= 1.0), |
| 313 | assert(minChildSize <= initialChildSize), |
| 314 | assert(initialChildSize <= maxChildSize), |
| 315 | assert(snapAnimationDuration == null || snapAnimationDuration > Duration.zero); |
| 316 | |
| 317 | /// The initial fractional value of the parent container's height to use when |
| 318 | /// displaying the widget. |
| 319 | /// |
| 320 | /// Rebuilding the sheet with a new [initialChildSize] will only move |
| 321 | /// the sheet to the new value if the sheet has not yet been dragged since it |
| 322 | /// was first built or since the last call to [DraggableScrollableActuator.reset]. |
| 323 | /// |
| 324 | /// The default value is `0.5`. |
| 325 | final double initialChildSize; |
| 326 | |
| 327 | /// The minimum fractional value of the parent container's height to use when |
| 328 | /// displaying the widget. |
| 329 | /// |
| 330 | /// The default value is `0.25`. |
| 331 | final double minChildSize; |
| 332 | |
| 333 | /// The maximum fractional value of the parent container's height to use when |
| 334 | /// displaying the widget. |
| 335 | /// |
| 336 | /// The default value is `1.0`. |
| 337 | final double maxChildSize; |
| 338 | |
| 339 | /// Whether the widget should expand to fill the available space in its parent |
| 340 | /// or not. |
| 341 | /// |
| 342 | /// In most cases, this should be true. However, in the case of a parent |
| 343 | /// widget that will position this one based on its desired size (such as a |
| 344 | /// [Center]), this should be set to false. |
| 345 | /// |
| 346 | /// The default value is true. |
| 347 | final bool expand; |
| 348 | |
| 349 | /// Whether the widget should snap between [snapSizes] when the user lifts |
| 350 | /// their finger during a drag. |
| 351 | /// |
| 352 | /// If the user's finger was still moving when they lifted it, the widget will |
| 353 | /// snap to the next snap size (see [snapSizes]) in the direction of the drag. |
| 354 | /// If their finger was still, the widget will snap to the nearest snap size. |
| 355 | /// |
| 356 | /// Snapping is not applied when the sheet is programmatically moved by |
| 357 | /// calling [DraggableScrollableController.animateTo] or [DraggableScrollableController.jumpTo]. |
| 358 | /// |
| 359 | /// Rebuilding the sheet with snap newly enabled will immediately trigger a |
| 360 | /// snap unless the sheet has not yet been dragged away from |
| 361 | /// [initialChildSize] since first being built or since the last call to |
| 362 | /// [DraggableScrollableActuator.reset]. |
| 363 | final bool snap; |
| 364 | |
| 365 | /// A list of target sizes that the widget should snap to. |
| 366 | /// |
| 367 | /// Snap sizes are fractional values of the parent container's height. They |
| 368 | /// must be listed in increasing order and be between [minChildSize] and |
| 369 | /// [maxChildSize]. |
| 370 | /// |
| 371 | /// The [minChildSize] and [maxChildSize] are implicitly included in snap |
| 372 | /// sizes and do not need to be specified here. For example, `snapSizes = [.5]` |
| 373 | /// will result in a sheet that snaps between [minChildSize], `.5`, and |
| 374 | /// [maxChildSize]. |
| 375 | /// |
| 376 | /// Any modifications to the [snapSizes] list will not take effect until the |
| 377 | /// `build` function containing this widget is run again. |
| 378 | /// |
| 379 | /// Rebuilding with a modified or new list will trigger a snap unless the |
| 380 | /// sheet has not yet been dragged away from [initialChildSize] since first |
| 381 | /// being built or since the last call to [DraggableScrollableActuator.reset]. |
| 382 | final List<double>? snapSizes; |
| 383 | |
| 384 | /// Defines a duration for the snap animations. |
| 385 | /// |
| 386 | /// If it's not set, then the animation duration is the distance to the snap |
| 387 | /// target divided by the velocity of the widget. |
| 388 | final Duration? snapAnimationDuration; |
| 389 | |
| 390 | /// A controller that can be used to programmatically control this sheet. |
| 391 | final DraggableScrollableController? controller; |
| 392 | |
| 393 | /// Whether the sheet, when dragged (or flung) to its minimum size, should |
| 394 | /// cause its parent sheet to close. |
| 395 | /// |
| 396 | /// Set on emitted [DraggableScrollableNotification]s. It is up to parent |
| 397 | /// classes to properly read and handle this value. |
| 398 | final bool shouldCloseOnMinExtent; |
| 399 | |
| 400 | /// The builder that creates a child to display in this widget, which will |
| 401 | /// use the provided [ScrollController] to enable dragging and scrolling |
| 402 | /// of the contents. |
| 403 | final ScrollableWidgetBuilder builder; |
| 404 | |
| 405 | @override |
| 406 | State<DraggableScrollableSheet> createState() => _DraggableScrollableSheetState(); |
| 407 | } |
| 408 | |
| 409 | /// A [Notification] related to the extent, which is the size, and scroll |
| 410 | /// offset, which is the position of the child list, of the |
| 411 | /// [DraggableScrollableSheet]. |
| 412 | /// |
| 413 | /// [DraggableScrollableSheet] widgets notify their ancestors when the size of |
| 414 | /// the sheet changes. When the extent of the sheet changes via a drag, |
| 415 | /// this notification bubbles up through the tree, which means a given |
| 416 | /// [NotificationListener] will receive notifications for all descendant |
| 417 | /// [DraggableScrollableSheet] widgets. To focus on notifications from the |
| 418 | /// nearest [DraggableScrollableSheet] descendant, check that the [depth] |
| 419 | /// property of the notification is zero. |
| 420 | /// |
| 421 | /// When an extent notification is received by a [NotificationListener], the |
| 422 | /// listener will already have completed build and layout, and it is therefore |
| 423 | /// too late for that widget to call [State.setState]. Any attempt to adjust the |
| 424 | /// build or layout based on an extent notification would result in a layout |
| 425 | /// that lagged one frame behind, which is a poor user experience. Extent |
| 426 | /// notifications are used primarily to drive animations. The [Scaffold] widget |
| 427 | /// listens for extent notifications and responds by driving animations for the |
| 428 | /// [FloatingActionButton] as the bottom sheet scrolls up. |
| 429 | class DraggableScrollableNotification extends Notification with ViewportNotificationMixin { |
| 430 | /// Creates a notification that the extent of a [DraggableScrollableSheet] has |
| 431 | /// changed. |
| 432 | /// |
| 433 | /// All parameters are required. The [minExtent] must be >= 0. The [maxExtent] |
| 434 | /// must be <= 1.0. The [extent] must be between [minExtent] and [maxExtent]. |
| 435 | DraggableScrollableNotification({ |
| 436 | required this.extent, |
| 437 | required this.minExtent, |
| 438 | required this.maxExtent, |
| 439 | required this.initialExtent, |
| 440 | required this.context, |
| 441 | this.shouldCloseOnMinExtent = true, |
| 442 | }) : assert(0.0 <= minExtent), |
| 443 | assert(maxExtent <= 1.0), |
| 444 | assert(minExtent <= extent), |
| 445 | assert(minExtent <= initialExtent), |
| 446 | assert(extent <= maxExtent), |
| 447 | assert(initialExtent <= maxExtent); |
| 448 | |
| 449 | /// The current value of the extent, between [minExtent] and [maxExtent]. |
| 450 | final double extent; |
| 451 | |
| 452 | /// The minimum value of [extent], which is >= 0. |
| 453 | final double minExtent; |
| 454 | |
| 455 | /// The maximum value of [extent]. |
| 456 | final double maxExtent; |
| 457 | |
| 458 | /// The initially requested value for [extent]. |
| 459 | final double initialExtent; |
| 460 | |
| 461 | /// The build context of the widget that fired this notification. |
| 462 | /// |
| 463 | /// This can be used to find the sheet's render objects to determine the size |
| 464 | /// of the viewport, for instance. A listener can only assume this context |
| 465 | /// is live when it first gets the notification. |
| 466 | final BuildContext context; |
| 467 | |
| 468 | /// Whether the widget that fired this notification, when dragged (or flung) |
| 469 | /// to minExtent, should cause its parent sheet to close. |
| 470 | /// |
| 471 | /// It is up to parent classes to properly read and handle this value. |
| 472 | final bool shouldCloseOnMinExtent; |
| 473 | |
| 474 | @override |
| 475 | void debugFillDescription(List<String> description) { |
| 476 | super.debugFillDescription(description); |
| 477 | description.add( |
| 478 | 'minExtent: $minExtent, extent: $extent, maxExtent: $maxExtent, initialExtent: $initialExtent' , |
| 479 | ); |
| 480 | } |
| 481 | } |
| 482 | |
| 483 | /// Manages state between [_DraggableScrollableSheetState], |
| 484 | /// [_DraggableScrollableSheetScrollController], and |
| 485 | /// [_DraggableScrollableSheetScrollPosition]. |
| 486 | /// |
| 487 | /// The State knows the pixels available along the axis the widget wants to |
| 488 | /// scroll, but expects to get a fraction of those pixels to render the sheet. |
| 489 | /// |
| 490 | /// The ScrollPosition knows the number of pixels a user wants to move the sheet. |
| 491 | /// |
| 492 | /// The [currentSize] will never be null. |
| 493 | /// The [availablePixels] will never be null, but may be `double.infinity`. |
| 494 | class _DraggableSheetExtent { |
| 495 | _DraggableSheetExtent({ |
| 496 | required this.minSize, |
| 497 | required this.maxSize, |
| 498 | required this.snap, |
| 499 | required this.snapSizes, |
| 500 | required this.initialSize, |
| 501 | this.snapAnimationDuration, |
| 502 | ValueNotifier<double>? currentSize, |
| 503 | bool? hasDragged, |
| 504 | bool? hasChanged, |
| 505 | this.shouldCloseOnMinExtent = true, |
| 506 | }) : assert(minSize >= 0), |
| 507 | assert(maxSize <= 1), |
| 508 | assert(minSize <= initialSize), |
| 509 | assert(initialSize <= maxSize), |
| 510 | _currentSize = currentSize ?? ValueNotifier<double>(initialSize), |
| 511 | availablePixels = double.infinity, |
| 512 | hasDragged = hasDragged ?? false, |
| 513 | hasChanged = hasChanged ?? false { |
| 514 | assert(debugMaybeDispatchCreated('widgets' , '_DraggableSheetExtent' , this)); |
| 515 | } |
| 516 | |
| 517 | VoidCallback? _cancelActivity; |
| 518 | |
| 519 | final double minSize; |
| 520 | final double maxSize; |
| 521 | final bool snap; |
| 522 | final List<double> snapSizes; |
| 523 | final Duration? snapAnimationDuration; |
| 524 | final double initialSize; |
| 525 | final bool shouldCloseOnMinExtent; |
| 526 | final ValueNotifier<double> _currentSize; |
| 527 | double availablePixels; |
| 528 | |
| 529 | // Used to disable snapping until the user has dragged on the sheet. |
| 530 | bool hasDragged; |
| 531 | |
| 532 | // Used to determine if the sheet should move to a new initial size when it |
| 533 | // changes. |
| 534 | // We need both `hasChanged` and `hasDragged` to achieve the following |
| 535 | // behavior: |
| 536 | // 1. The sheet should only snap following user drags (as opposed to |
| 537 | // programmatic sheet changes). See docs for `animateTo` and `jumpTo`. |
| 538 | // 2. The sheet should move to a new initial child size on rebuild iff the |
| 539 | // sheet has not changed, either by drag or programmatic control. See |
| 540 | // docs for `initialChildSize`. |
| 541 | bool hasChanged; |
| 542 | |
| 543 | bool get isAtMin => minSize >= _currentSize.value; |
| 544 | bool get isAtMax => maxSize <= _currentSize.value; |
| 545 | |
| 546 | double get currentSize => _currentSize.value; |
| 547 | double get currentPixels => sizeToPixels(_currentSize.value); |
| 548 | |
| 549 | List<double> get pixelSnapSizes => snapSizes.map(sizeToPixels).toList(); |
| 550 | |
| 551 | /// Start an activity that affects the sheet and register a cancel call back |
| 552 | /// that will be called if another activity starts. |
| 553 | /// |
| 554 | /// The `onCanceled` callback will get called even if the subsequent activity |
| 555 | /// started after this one finished, so `onCanceled` must be safe to call at |
| 556 | /// any time. |
| 557 | void startActivity({required VoidCallback onCanceled}) { |
| 558 | _cancelActivity?.call(); |
| 559 | _cancelActivity = onCanceled; |
| 560 | } |
| 561 | |
| 562 | /// The scroll position gets inputs in terms of pixels, but the size is |
| 563 | /// expected to be expressed as a number between 0..1. |
| 564 | /// |
| 565 | /// This should only be called to respond to a user drag. To update the |
| 566 | /// size in response to a programmatic call, use [updateSize] directly. |
| 567 | void addPixelDelta(double delta, BuildContext context) { |
| 568 | // Stop any playing sheet animations. |
| 569 | _cancelActivity?.call(); |
| 570 | _cancelActivity = null; |
| 571 | // The user has interacted with the sheet, set `hasDragged` to true so that |
| 572 | // we'll snap if applicable. |
| 573 | hasDragged = true; |
| 574 | hasChanged = true; |
| 575 | if (availablePixels == 0) { |
| 576 | return; |
| 577 | } |
| 578 | updateSize(currentSize + pixelsToSize(delta), context); |
| 579 | } |
| 580 | |
| 581 | /// Set the size to the new value. [newSize] should be a number between |
| 582 | /// [minSize] and [maxSize]. |
| 583 | /// |
| 584 | /// This can be triggered by a programmatic (e.g. controller triggered) change |
| 585 | /// or a user drag. |
| 586 | void updateSize(double newSize, BuildContext context) { |
| 587 | final double clampedSize = clampDouble(newSize, minSize, maxSize); |
| 588 | if (_currentSize.value == clampedSize) { |
| 589 | return; |
| 590 | } |
| 591 | _currentSize.value = clampedSize; |
| 592 | DraggableScrollableNotification( |
| 593 | minExtent: minSize, |
| 594 | maxExtent: maxSize, |
| 595 | extent: currentSize, |
| 596 | initialExtent: initialSize, |
| 597 | context: context, |
| 598 | shouldCloseOnMinExtent: shouldCloseOnMinExtent, |
| 599 | ).dispatch(context); |
| 600 | } |
| 601 | |
| 602 | double pixelsToSize(double pixels) { |
| 603 | return pixels / availablePixels * maxSize; |
| 604 | } |
| 605 | |
| 606 | double sizeToPixels(double size) { |
| 607 | return size / maxSize * availablePixels; |
| 608 | } |
| 609 | |
| 610 | void dispose() { |
| 611 | assert(debugMaybeDispatchDisposed(this)); |
| 612 | _currentSize.dispose(); |
| 613 | } |
| 614 | |
| 615 | _DraggableSheetExtent copyWith({ |
| 616 | required double minSize, |
| 617 | required double maxSize, |
| 618 | required bool snap, |
| 619 | required List<double> snapSizes, |
| 620 | required double initialSize, |
| 621 | required Duration? snapAnimationDuration, |
| 622 | required bool shouldCloseOnMinExtent, |
| 623 | }) { |
| 624 | return _DraggableSheetExtent( |
| 625 | minSize: minSize, |
| 626 | maxSize: maxSize, |
| 627 | snap: snap, |
| 628 | snapSizes: snapSizes, |
| 629 | snapAnimationDuration: snapAnimationDuration, |
| 630 | initialSize: initialSize, |
| 631 | // Set the current size to the possibly updated initial size if the sheet |
| 632 | // hasn't changed yet. |
| 633 | currentSize: ValueNotifier<double>( |
| 634 | hasChanged ? clampDouble(_currentSize.value, minSize, maxSize) : initialSize, |
| 635 | ), |
| 636 | hasDragged: hasDragged, |
| 637 | hasChanged: hasChanged, |
| 638 | shouldCloseOnMinExtent: shouldCloseOnMinExtent, |
| 639 | ); |
| 640 | } |
| 641 | } |
| 642 | |
| 643 | class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> { |
| 644 | late _DraggableScrollableSheetScrollController _scrollController; |
| 645 | late _DraggableSheetExtent _extent; |
| 646 | |
| 647 | @override |
| 648 | void initState() { |
| 649 | super.initState(); |
| 650 | _extent = _DraggableSheetExtent( |
| 651 | minSize: widget.minChildSize, |
| 652 | maxSize: widget.maxChildSize, |
| 653 | snap: widget.snap, |
| 654 | snapSizes: _impliedSnapSizes(), |
| 655 | snapAnimationDuration: widget.snapAnimationDuration, |
| 656 | initialSize: widget.initialChildSize, |
| 657 | shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent, |
| 658 | ); |
| 659 | _scrollController = _DraggableScrollableSheetScrollController(extent: _extent); |
| 660 | widget.controller?._attach(_scrollController); |
| 661 | } |
| 662 | |
| 663 | List<double> _impliedSnapSizes() { |
| 664 | for (int index = 0; index < (widget.snapSizes?.length ?? 0); index += 1) { |
| 665 | final double snapSize = widget.snapSizes![index]; |
| 666 | assert( |
| 667 | snapSize >= widget.minChildSize && snapSize <= widget.maxChildSize, |
| 668 | ' ${_snapSizeErrorMessage(index)}\nSnap sizes must be between `minChildSize` and `maxChildSize`. ' , |
| 669 | ); |
| 670 | assert( |
| 671 | index == 0 || snapSize > widget.snapSizes![index - 1], |
| 672 | ' ${_snapSizeErrorMessage(index)}\nSnap sizes must be in ascending order. ' , |
| 673 | ); |
| 674 | } |
| 675 | // Ensure the snap sizes start and end with the min and max child sizes. |
| 676 | if (widget.snapSizes == null || widget.snapSizes!.isEmpty) { |
| 677 | return <double>[widget.minChildSize, widget.maxChildSize]; |
| 678 | } |
| 679 | return <double>[ |
| 680 | if (widget.snapSizes!.first != widget.minChildSize) widget.minChildSize, |
| 681 | ...widget.snapSizes!, |
| 682 | if (widget.snapSizes!.last != widget.maxChildSize) widget.maxChildSize, |
| 683 | ]; |
| 684 | } |
| 685 | |
| 686 | @override |
| 687 | void didUpdateWidget(covariant DraggableScrollableSheet oldWidget) { |
| 688 | super.didUpdateWidget(oldWidget); |
| 689 | if (widget.controller != oldWidget.controller) { |
| 690 | oldWidget.controller?._detach(); |
| 691 | widget.controller?._attach(_scrollController); |
| 692 | } |
| 693 | _replaceExtent(oldWidget); |
| 694 | } |
| 695 | |
| 696 | @override |
| 697 | void didChangeDependencies() { |
| 698 | super.didChangeDependencies(); |
| 699 | if (_InheritedResetNotifier.shouldReset(context)) { |
| 700 | _scrollController.reset(); |
| 701 | } |
| 702 | } |
| 703 | |
| 704 | @override |
| 705 | Widget build(BuildContext context) { |
| 706 | return ValueListenableBuilder<double>( |
| 707 | valueListenable: _extent._currentSize, |
| 708 | builder: (BuildContext context, double currentSize, Widget? child) => LayoutBuilder( |
| 709 | builder: (BuildContext context, BoxConstraints constraints) { |
| 710 | _extent.availablePixels = widget.maxChildSize * constraints.biggest.height; |
| 711 | final Widget sheet = FractionallySizedBox( |
| 712 | heightFactor: currentSize, |
| 713 | alignment: Alignment.bottomCenter, |
| 714 | child: child, |
| 715 | ); |
| 716 | return widget.expand ? SizedBox.expand(child: sheet) : sheet; |
| 717 | }, |
| 718 | ), |
| 719 | child: widget.builder(context, _scrollController), |
| 720 | ); |
| 721 | } |
| 722 | |
| 723 | @override |
| 724 | void dispose() { |
| 725 | if (widget.controller == null) { |
| 726 | _extent.dispose(); |
| 727 | } else { |
| 728 | widget.controller!._detach(disposeExtent: true); |
| 729 | } |
| 730 | _scrollController.dispose(); |
| 731 | super.dispose(); |
| 732 | } |
| 733 | |
| 734 | void _replaceExtent(covariant DraggableScrollableSheet oldWidget) { |
| 735 | final _DraggableSheetExtent previousExtent = _extent; |
| 736 | _extent = previousExtent.copyWith( |
| 737 | minSize: widget.minChildSize, |
| 738 | maxSize: widget.maxChildSize, |
| 739 | snap: widget.snap, |
| 740 | snapSizes: _impliedSnapSizes(), |
| 741 | snapAnimationDuration: widget.snapAnimationDuration, |
| 742 | initialSize: widget.initialChildSize, |
| 743 | shouldCloseOnMinExtent: widget.shouldCloseOnMinExtent, |
| 744 | ); |
| 745 | // Modify the existing scroll controller instead of replacing it so that |
| 746 | // developers listening to the controller do not have to rebuild their listeners. |
| 747 | _scrollController.extent = _extent; |
| 748 | // If an external facing controller was provided, let it know that the |
| 749 | // extent has been replaced. |
| 750 | widget.controller?._onExtentReplaced(previousExtent); |
| 751 | previousExtent.dispose(); |
| 752 | if (widget.snap && |
| 753 | (widget.snap != oldWidget.snap || widget.snapSizes != oldWidget.snapSizes) && |
| 754 | _scrollController.hasClients) { |
| 755 | // Trigger a snap in case snap or snapSizes has changed and there is a |
| 756 | // scroll position currently attached. We put this in a post frame |
| 757 | // callback so that `build` can update `_extent.availablePixels` before |
| 758 | // this runs-we can't use the previous extent's available pixels as it may |
| 759 | // have changed when the widget was updated. |
| 760 | WidgetsBinding.instance.addPostFrameCallback((Duration timeStamp) { |
| 761 | for (int index = 0; index < _scrollController.positions.length; index++) { |
| 762 | final _DraggableScrollableSheetScrollPosition position = |
| 763 | _scrollController.positions.elementAt(index) |
| 764 | as _DraggableScrollableSheetScrollPosition; |
| 765 | position.goBallistic(0); |
| 766 | } |
| 767 | }, debugLabel: 'DraggableScrollableSheet.snap' ); |
| 768 | } |
| 769 | } |
| 770 | |
| 771 | String _snapSizeErrorMessage(int invalidIndex) { |
| 772 | final List<String> snapSizesWithIndicator = widget.snapSizes!.asMap().keys.map((int index) { |
| 773 | final String snapSizeString = widget.snapSizes![index].toString(); |
| 774 | if (index == invalidIndex) { |
| 775 | return '>>> $snapSizeString <<<' ; |
| 776 | } |
| 777 | return snapSizeString; |
| 778 | }).toList(); |
| 779 | return "Invalid snapSize ' ${widget.snapSizes![invalidIndex]}' at index $invalidIndex of:\n" |
| 780 | ' $snapSizesWithIndicator' ; |
| 781 | } |
| 782 | } |
| 783 | |
| 784 | /// A [ScrollController] suitable for use in a [ScrollableWidgetBuilder] created |
| 785 | /// by a [DraggableScrollableSheet]. |
| 786 | /// |
| 787 | /// If a [DraggableScrollableSheet] contains content that is exceeds the height |
| 788 | /// of its container, this controller will allow the sheet to both be dragged to |
| 789 | /// fill the container and then scroll the child content. |
| 790 | /// |
| 791 | /// See also: |
| 792 | /// |
| 793 | /// * [_DraggableScrollableSheetScrollPosition], which manages the positioning logic for |
| 794 | /// this controller. |
| 795 | /// * [PrimaryScrollController], which can be used to establish a |
| 796 | /// [_DraggableScrollableSheetScrollController] as the primary controller for |
| 797 | /// descendants. |
| 798 | class _DraggableScrollableSheetScrollController extends ScrollController { |
| 799 | _DraggableScrollableSheetScrollController({required this.extent}); |
| 800 | |
| 801 | _DraggableSheetExtent extent; |
| 802 | VoidCallback? onPositionDetached; |
| 803 | |
| 804 | @override |
| 805 | _DraggableScrollableSheetScrollPosition createScrollPosition( |
| 806 | ScrollPhysics physics, |
| 807 | ScrollContext context, |
| 808 | ScrollPosition? oldPosition, |
| 809 | ) { |
| 810 | return _DraggableScrollableSheetScrollPosition( |
| 811 | physics: physics.applyTo(const AlwaysScrollableScrollPhysics()), |
| 812 | context: context, |
| 813 | oldPosition: oldPosition, |
| 814 | getExtent: () => extent, |
| 815 | ); |
| 816 | } |
| 817 | |
| 818 | @override |
| 819 | void debugFillDescription(List<String> description) { |
| 820 | super.debugFillDescription(description); |
| 821 | description.add('extent: $extent' ); |
| 822 | } |
| 823 | |
| 824 | @override |
| 825 | _DraggableScrollableSheetScrollPosition get position => |
| 826 | super.position as _DraggableScrollableSheetScrollPosition; |
| 827 | |
| 828 | void reset() { |
| 829 | extent._cancelActivity?.call(); |
| 830 | extent.hasDragged = false; |
| 831 | extent.hasChanged = false; |
| 832 | // jumpTo can result in trying to replace semantics during build. |
| 833 | // Just animate really fast. |
| 834 | // Avoid doing it at all if the offset is already 0.0. |
| 835 | if (offset != 0.0) { |
| 836 | animateTo(0.0, duration: const Duration(milliseconds: 1), curve: Curves.linear); |
| 837 | } |
| 838 | extent.updateSize(extent.initialSize, position.context.notificationContext!); |
| 839 | } |
| 840 | |
| 841 | @override |
| 842 | void detach(ScrollPosition position) { |
| 843 | onPositionDetached?.call(); |
| 844 | super.detach(position); |
| 845 | } |
| 846 | } |
| 847 | |
| 848 | /// A scroll position that manages scroll activities for |
| 849 | /// [_DraggableScrollableSheetScrollController]. |
| 850 | /// |
| 851 | /// This class is a concrete subclass of [ScrollPosition] logic that handles a |
| 852 | /// single [ScrollContext], such as a [Scrollable]. An instance of this class |
| 853 | /// manages [ScrollActivity] instances, which changes the |
| 854 | /// [_DraggableSheetExtent.currentSize] or visible content offset in the |
| 855 | /// [Scrollable]'s [Viewport] |
| 856 | /// |
| 857 | /// See also: |
| 858 | /// |
| 859 | /// * [_DraggableScrollableSheetScrollController], which uses this as its [ScrollPosition]. |
| 860 | class _DraggableScrollableSheetScrollPosition extends ScrollPositionWithSingleContext { |
| 861 | _DraggableScrollableSheetScrollPosition({ |
| 862 | required super.physics, |
| 863 | required super.context, |
| 864 | super.oldPosition, |
| 865 | required this.getExtent, |
| 866 | }); |
| 867 | |
| 868 | VoidCallback? _dragCancelCallback; |
| 869 | final _DraggableSheetExtent Function() getExtent; |
| 870 | final Set<AnimationController> _ballisticControllers = <AnimationController>{}; |
| 871 | bool get listShouldScroll => pixels > 0.0; |
| 872 | |
| 873 | _DraggableSheetExtent get extent => getExtent(); |
| 874 | |
| 875 | @override |
| 876 | void absorb(ScrollPosition other) { |
| 877 | super.absorb(other); |
| 878 | assert(_dragCancelCallback == null); |
| 879 | |
| 880 | if (other is! _DraggableScrollableSheetScrollPosition) { |
| 881 | return; |
| 882 | } |
| 883 | |
| 884 | if (other._dragCancelCallback != null) { |
| 885 | _dragCancelCallback = other._dragCancelCallback; |
| 886 | other._dragCancelCallback = null; |
| 887 | } |
| 888 | } |
| 889 | |
| 890 | @override |
| 891 | void beginActivity(ScrollActivity? newActivity) { |
| 892 | // Cancel the running ballistic simulations |
| 893 | for (final AnimationController ballisticController in _ballisticControllers) { |
| 894 | ballisticController.stop(); |
| 895 | } |
| 896 | super.beginActivity(newActivity); |
| 897 | } |
| 898 | |
| 899 | @override |
| 900 | void applyUserOffset(double delta) { |
| 901 | if (!listShouldScroll && |
| 902 | (!(extent.isAtMin || extent.isAtMax) || |
| 903 | (extent.isAtMin && delta < 0) || |
| 904 | (extent.isAtMax && delta > 0))) { |
| 905 | extent.addPixelDelta(-delta, context.notificationContext!); |
| 906 | } else { |
| 907 | super.applyUserOffset(delta); |
| 908 | } |
| 909 | } |
| 910 | |
| 911 | // Checks if the sheet's current size is close to a snap size, returning the |
| 912 | // snap size if so; returns null otherwise. |
| 913 | double? _getCurrentSnapSize() { |
| 914 | return extent.snapSizes.firstWhereOrNull((double snapSize) { |
| 915 | return (extent.currentSize - snapSize).abs() <= |
| 916 | extent.pixelsToSize(physics.toleranceFor(this).distance); |
| 917 | }); |
| 918 | } |
| 919 | |
| 920 | bool _isAtSnapSize() => _getCurrentSnapSize() != null; |
| 921 | |
| 922 | bool _shouldSnap() => extent.snap && extent.hasDragged && !_isAtSnapSize(); |
| 923 | |
| 924 | @override |
| 925 | void dispose() { |
| 926 | for (final AnimationController ballisticController in _ballisticControllers) { |
| 927 | ballisticController.dispose(); |
| 928 | } |
| 929 | _ballisticControllers.clear(); |
| 930 | super.dispose(); |
| 931 | } |
| 932 | |
| 933 | @override |
| 934 | void goBallistic(double velocity) { |
| 935 | if ((velocity == 0.0 && !_shouldSnap()) || |
| 936 | (velocity < 0.0 && listShouldScroll) || |
| 937 | (velocity > 0.0 && extent.isAtMax)) { |
| 938 | super.goBallistic(velocity); |
| 939 | return; |
| 940 | } |
| 941 | // Scrollable expects that we will dispose of its current _dragCancelCallback |
| 942 | _dragCancelCallback?.call(); |
| 943 | _dragCancelCallback = null; |
| 944 | |
| 945 | late final Simulation simulation; |
| 946 | if (extent.snap) { |
| 947 | // Snap is enabled, simulate snapping instead of clamping scroll. |
| 948 | simulation = _SnappingSimulation( |
| 949 | position: extent.currentPixels, |
| 950 | initialVelocity: velocity, |
| 951 | pixelSnapSize: extent.pixelSnapSizes, |
| 952 | snapAnimationDuration: extent.snapAnimationDuration, |
| 953 | tolerance: physics.toleranceFor(this), |
| 954 | ); |
| 955 | } else { |
| 956 | // The iOS bouncing simulation just isn't right here - once we delegate |
| 957 | // the ballistic back to the ScrollView, it will use the right simulation. |
| 958 | simulation = ClampingScrollSimulation( |
| 959 | // Run the simulation in terms of pixels, not extent. |
| 960 | position: extent.currentPixels, |
| 961 | velocity: velocity, |
| 962 | tolerance: physics.toleranceFor(this), |
| 963 | ); |
| 964 | } |
| 965 | |
| 966 | final AnimationController ballisticController = AnimationController.unbounded( |
| 967 | debugLabel: objectRuntimeType(this, '_DraggableScrollableSheetPosition' ), |
| 968 | vsync: context.vsync, |
| 969 | ); |
| 970 | _ballisticControllers.add(ballisticController); |
| 971 | |
| 972 | double lastPosition = extent.currentPixels; |
| 973 | void tick() { |
| 974 | final double delta = ballisticController.value - lastPosition; |
| 975 | lastPosition = ballisticController.value; |
| 976 | extent.addPixelDelta(delta, context.notificationContext!); |
| 977 | if ((velocity > 0 && extent.isAtMax) || (velocity < 0 && extent.isAtMin)) { |
| 978 | // Make sure we pass along enough velocity to keep scrolling - otherwise |
| 979 | // we just "bounce" off the top making it look like the list doesn't |
| 980 | // have more to scroll. |
| 981 | velocity = |
| 982 | ballisticController.velocity + |
| 983 | (physics.toleranceFor(this).velocity * ballisticController.velocity.sign); |
| 984 | super.goBallistic(velocity); |
| 985 | ballisticController.stop(); |
| 986 | } else if (ballisticController.isCompleted) { |
| 987 | // Update the extent value after the snap animation completes to |
| 988 | // avoid rounding errors that could prevent the sheet from closing when |
| 989 | // it reaches minSize. |
| 990 | final double? snapSize = _getCurrentSnapSize(); |
| 991 | if (snapSize != null) { |
| 992 | extent.updateSize(snapSize, context.notificationContext!); |
| 993 | } |
| 994 | super.goBallistic(0); |
| 995 | } |
| 996 | } |
| 997 | |
| 998 | ballisticController |
| 999 | ..addListener(tick) |
| 1000 | ..animateWith(simulation).whenCompleteOrCancel(() { |
| 1001 | if (_ballisticControllers.contains(ballisticController)) { |
| 1002 | _ballisticControllers.remove(ballisticController); |
| 1003 | ballisticController.dispose(); |
| 1004 | } |
| 1005 | }); |
| 1006 | } |
| 1007 | |
| 1008 | @override |
| 1009 | Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) { |
| 1010 | // Save this so we can call it later if we have to [goBallistic] on our own. |
| 1011 | _dragCancelCallback = dragCancelCallback; |
| 1012 | return super.drag(details, dragCancelCallback); |
| 1013 | } |
| 1014 | } |
| 1015 | |
| 1016 | /// A widget that can notify a descendent [DraggableScrollableSheet] that it |
| 1017 | /// should reset its position to the initial state. |
| 1018 | /// |
| 1019 | /// The [Scaffold] uses this widget to notify a persistent bottom sheet that |
| 1020 | /// the user has tapped back if the sheet has started to cover more of the body |
| 1021 | /// than when at its initial position. This is important for users of assistive |
| 1022 | /// technology, where dragging may be difficult to communicate. |
| 1023 | /// |
| 1024 | /// This is just a wrapper on top of [DraggableScrollableController]. It is |
| 1025 | /// primarily useful for controlling a sheet in a part of the widget tree that |
| 1026 | /// the current code does not control (e.g. library code trying to affect a sheet |
| 1027 | /// in library users' code). Generally, it's easier to control the sheet |
| 1028 | /// directly by creating a controller and passing the controller to the sheet in |
| 1029 | /// its constructor (see [DraggableScrollableSheet.controller]). |
| 1030 | class DraggableScrollableActuator extends StatefulWidget { |
| 1031 | /// Creates a widget that can notify descendent [DraggableScrollableSheet]s |
| 1032 | /// to reset to their initial position. |
| 1033 | /// |
| 1034 | /// The [child] parameter is required. |
| 1035 | const DraggableScrollableActuator({super.key, required this.child}); |
| 1036 | |
| 1037 | /// This child's [DraggableScrollableSheet] descendant will be reset when the |
| 1038 | /// [reset] method is applied to a context that includes it. |
| 1039 | final Widget child; |
| 1040 | |
| 1041 | /// Notifies any descendant [DraggableScrollableSheet] that it should reset |
| 1042 | /// to its initial position. |
| 1043 | /// |
| 1044 | /// Returns `true` if a [DraggableScrollableActuator] is available and |
| 1045 | /// some [DraggableScrollableSheet] is listening for updates, `false` |
| 1046 | /// otherwise. |
| 1047 | static bool reset(BuildContext context) { |
| 1048 | final _InheritedResetNotifier? notifier = context |
| 1049 | .dependOnInheritedWidgetOfExactType<_InheritedResetNotifier>(); |
| 1050 | return notifier?._sendReset() ?? false; |
| 1051 | } |
| 1052 | |
| 1053 | @override |
| 1054 | State<DraggableScrollableActuator> createState() => _DraggableScrollableActuatorState(); |
| 1055 | } |
| 1056 | |
| 1057 | class _DraggableScrollableActuatorState extends State<DraggableScrollableActuator> { |
| 1058 | final _ResetNotifier _notifier = _ResetNotifier(); |
| 1059 | |
| 1060 | @override |
| 1061 | Widget build(BuildContext context) { |
| 1062 | return _InheritedResetNotifier(notifier: _notifier, child: widget.child); |
| 1063 | } |
| 1064 | |
| 1065 | @override |
| 1066 | void dispose() { |
| 1067 | _notifier.dispose(); |
| 1068 | super.dispose(); |
| 1069 | } |
| 1070 | } |
| 1071 | |
| 1072 | /// A [ChangeNotifier] to use with [_InheritedResetNotifier] to notify |
| 1073 | /// descendants that they should reset to initial state. |
| 1074 | class _ResetNotifier extends ChangeNotifier { |
| 1075 | _ResetNotifier() { |
| 1076 | if (kFlutterMemoryAllocationsEnabled) { |
| 1077 | ChangeNotifier.maybeDispatchObjectCreation(this); |
| 1078 | } |
| 1079 | } |
| 1080 | |
| 1081 | /// Whether someone called [sendReset] or not. |
| 1082 | /// |
| 1083 | /// This flag should be reset after checking it. |
| 1084 | bool _wasCalled = false; |
| 1085 | |
| 1086 | /// Fires a reset notification to descendants. |
| 1087 | /// |
| 1088 | /// Returns false if there are no listeners. |
| 1089 | bool sendReset() { |
| 1090 | if (!hasListeners) { |
| 1091 | return false; |
| 1092 | } |
| 1093 | _wasCalled = true; |
| 1094 | notifyListeners(); |
| 1095 | return true; |
| 1096 | } |
| 1097 | } |
| 1098 | |
| 1099 | class _InheritedResetNotifier extends InheritedNotifier<_ResetNotifier> { |
| 1100 | /// Creates an [InheritedNotifier] that the [DraggableScrollableSheet] will |
| 1101 | /// listen to for an indication that it should reset itself back to [DraggableScrollableSheet.initialChildSize]. |
| 1102 | const _InheritedResetNotifier({required super.child, required _ResetNotifier super.notifier}); |
| 1103 | |
| 1104 | bool _sendReset() => notifier!.sendReset(); |
| 1105 | |
| 1106 | /// Specifies whether the [DraggableScrollableSheet] should reset to its |
| 1107 | /// initial position. |
| 1108 | /// |
| 1109 | /// Returns true if the notifier requested a reset, false otherwise. |
| 1110 | static bool shouldReset(BuildContext context) { |
| 1111 | final InheritedWidget? widget = context |
| 1112 | .dependOnInheritedWidgetOfExactType<_InheritedResetNotifier>(); |
| 1113 | if (widget == null) { |
| 1114 | return false; |
| 1115 | } |
| 1116 | assert(widget is _InheritedResetNotifier); |
| 1117 | final _InheritedResetNotifier inheritedNotifier = widget as _InheritedResetNotifier; |
| 1118 | final bool wasCalled = inheritedNotifier.notifier!._wasCalled; |
| 1119 | inheritedNotifier.notifier!._wasCalled = false; |
| 1120 | return wasCalled; |
| 1121 | } |
| 1122 | } |
| 1123 | |
| 1124 | class _SnappingSimulation extends Simulation { |
| 1125 | _SnappingSimulation({ |
| 1126 | required this.position, |
| 1127 | required double initialVelocity, |
| 1128 | required List<double> pixelSnapSize, |
| 1129 | Duration? snapAnimationDuration, |
| 1130 | super.tolerance, |
| 1131 | }) { |
| 1132 | _pixelSnapSize = _getSnapSize(initialVelocity, pixelSnapSize); |
| 1133 | |
| 1134 | if (snapAnimationDuration != null && snapAnimationDuration.inMilliseconds > 0) { |
| 1135 | velocity = (_pixelSnapSize - position) * 1000 / snapAnimationDuration.inMilliseconds; |
| 1136 | } |
| 1137 | // Check the direction of the target instead of the sign of the velocity because |
| 1138 | // we may snap in the opposite direction of velocity if velocity is very low. |
| 1139 | else if (_pixelSnapSize < position) { |
| 1140 | velocity = math.min(-minimumSpeed, initialVelocity); |
| 1141 | } else { |
| 1142 | velocity = math.max(minimumSpeed, initialVelocity); |
| 1143 | } |
| 1144 | } |
| 1145 | |
| 1146 | final double position; |
| 1147 | late final double velocity; |
| 1148 | |
| 1149 | // A minimum speed to snap at. Used to ensure that the snapping animation |
| 1150 | // does not play too slowly. |
| 1151 | static const double minimumSpeed = 1600.0; |
| 1152 | |
| 1153 | late final double _pixelSnapSize; |
| 1154 | |
| 1155 | @override |
| 1156 | double dx(double time) { |
| 1157 | if (isDone(time)) { |
| 1158 | return 0; |
| 1159 | } |
| 1160 | return velocity; |
| 1161 | } |
| 1162 | |
| 1163 | @override |
| 1164 | bool isDone(double time) { |
| 1165 | return x(time) == _pixelSnapSize; |
| 1166 | } |
| 1167 | |
| 1168 | @override |
| 1169 | double x(double time) { |
| 1170 | final double newPosition = position + velocity * time; |
| 1171 | if ((velocity >= 0 && newPosition > _pixelSnapSize) || |
| 1172 | (velocity < 0 && newPosition < _pixelSnapSize)) { |
| 1173 | // We're passed the snap size, return it instead. |
| 1174 | return _pixelSnapSize; |
| 1175 | } |
| 1176 | return newPosition; |
| 1177 | } |
| 1178 | |
| 1179 | // Find the two closest snap sizes to the position. If the velocity is |
| 1180 | // non-zero, select the size in the velocity's direction. Otherwise, |
| 1181 | // the nearest snap size. |
| 1182 | double _getSnapSize(double initialVelocity, List<double> pixelSnapSizes) { |
| 1183 | final int indexOfNextSize = pixelSnapSizes.indexWhere((double size) => size >= position); |
| 1184 | if (indexOfNextSize == 0) { |
| 1185 | return pixelSnapSizes.first; |
| 1186 | } |
| 1187 | final double nextSize = pixelSnapSizes[indexOfNextSize]; |
| 1188 | final double previousSize = pixelSnapSizes[indexOfNextSize - 1]; |
| 1189 | if (initialVelocity.abs() <= tolerance.velocity) { |
| 1190 | // If velocity is zero, snap to the nearest snap size with the minimum velocity. |
| 1191 | if (position - previousSize < nextSize - position) { |
| 1192 | return previousSize; |
| 1193 | } else { |
| 1194 | return nextSize; |
| 1195 | } |
| 1196 | } |
| 1197 | // Snap forward or backward depending on current velocity. |
| 1198 | if (initialVelocity < 0.0) { |
| 1199 | return pixelSnapSizes[indexOfNextSize - 1]; |
| 1200 | } |
| 1201 | return pixelSnapSizes[indexOfNextSize]; |
| 1202 | } |
| 1203 | } |
| 1204 | |