|
| 1 | +// Copyright 2019 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 'package:flutter/gestures.dart'; |
| 6 | + |
| 7 | +import 'basic.dart'; |
| 8 | +import 'framework.dart'; |
| 9 | +import 'layout_builder.dart'; |
| 10 | +import 'scroll_context.dart'; |
| 11 | +import 'scroll_controller.dart'; |
| 12 | +import 'scroll_physics.dart'; |
| 13 | +import 'scroll_position.dart'; |
| 14 | +import 'scroll_position_with_single_context.dart'; |
| 15 | +import 'scroll_simulation.dart'; |
| 16 | + |
| 17 | +/// The signature of a method that provides a [BuildContext] and |
| 18 | +/// [ScrollController] for building a widget that may overflow the draggable |
| 19 | +/// [Axis] of the containing [DraggableScrollSheet]. |
| 20 | +/// |
| 21 | +/// Users should apply the [scrollController] to a [ScrollView] subclass, such |
| 22 | +/// as a [SingleChildScrollView], [ListView] or [GridView], to have the whole |
| 23 | +/// sheet be draggable. |
| 24 | +typedef ScrollableWidgetBuilder = Widget Function( |
| 25 | + BuildContext context, |
| 26 | + ScrollController scrollController, |
| 27 | +); |
| 28 | + |
| 29 | +/// A container for a [Scrollable] that responds to drag gestures by resizing |
| 30 | +/// the scrollable until a limit is reached, and then scrolling. |
| 31 | +/// |
| 32 | +/// This widget can be dragged along the vertical axis between its |
| 33 | +/// [minChildSize], which defaults to `0.25` and [maxChildSize], which defaults |
| 34 | +/// to `1.0`. These sizes are percentages of the height of the parent container. |
| 35 | +/// |
| 36 | +/// The widget coordinates resizing and scrolling of the widget returned by |
| 37 | +/// builder as the user drags along the horizontal axis. |
| 38 | +/// |
| 39 | +/// The widget will initially be displayed at its initialChildSize which |
| 40 | +/// defaults to `0.5`, meaning half the height of its parent. Dragging will work |
| 41 | +/// between the range of minChildSize and maxChildSize (as percentages of the |
| 42 | +/// parent container's height) as long as the builder creates a widget which |
| 43 | +/// uses the provided [ScrollController]. If the widget created by the |
| 44 | +/// [ScrollableWidgetBuilder] does not use provided [ScrollController], the |
| 45 | +/// sheet will remain at the initialChildSize. |
| 46 | +/// |
| 47 | +/// {@tool sample} |
| 48 | +/// |
| 49 | +/// This is a sample widget which shows a [ListView] that has 25 [ListTile]s. |
| 50 | +/// It starts out as taking up half the body of the [Scaffold], and can be |
| 51 | +/// dragged up to the full height of the scaffold or down to 25% of the height |
| 52 | +/// of the scaffold. Upon reaching full height, the list contents will be |
| 53 | +/// scrolled up or down, until they reach the top of the list again and the user |
| 54 | +/// drags the sheet back down. |
| 55 | +/// |
| 56 | +/// ```dart |
| 57 | +/// class HomePage extends StatelessWidget { |
| 58 | +/// @override |
| 59 | +/// Widget build(BuildContext context) { |
| 60 | +/// return Scaffold( |
| 61 | +/// appBar: AppBar( |
| 62 | +/// title: const Text('DraggableScrollableSheet'), |
| 63 | +/// ), |
| 64 | +/// body: SizedBox.expand( |
| 65 | +/// child: DraggableScrollableSheet( |
| 66 | +/// builder: (BuildContext context, ScrollController scrollController) { |
| 67 | +/// return Container( |
| 68 | +/// color: Colors.blue[100], |
| 69 | +/// child: ListView.builder( |
| 70 | +/// controller: scrollController, |
| 71 | +/// itemCount: 25, |
| 72 | +/// itemBuilder: (BuildContext context, int index) { |
| 73 | +/// return ListTile(title: Text('Item $index')); |
| 74 | +/// }, |
| 75 | +/// ), |
| 76 | +/// ); |
| 77 | +/// }, |
| 78 | +/// ), |
| 79 | +/// ), |
| 80 | +/// ); |
| 81 | +/// } |
| 82 | +/// } |
| 83 | +/// ``` |
| 84 | +/// {@end-tool} |
| 85 | +class DraggableScrollableSheet extends StatefulWidget { |
| 86 | + /// Creates a widget that can be dragged and scrolled in a single gesture. |
| 87 | + /// |
| 88 | + /// The [builder], [initialChildSize], [minChildSize], and [maxChildSize] |
| 89 | + /// parameters must not be null. |
| 90 | + const DraggableScrollableSheet({ |
| 91 | + Key key, |
| 92 | + this.initialChildSize = 0.5, |
| 93 | + this.minChildSize = 0.25, |
| 94 | + this.maxChildSize = 1.0, |
| 95 | + @required this.builder, |
| 96 | + }) : assert(initialChildSize != null), |
| 97 | + assert(minChildSize != null), |
| 98 | + assert(maxChildSize != null), |
| 99 | + assert(minChildSize >= 0.0), |
| 100 | + assert(maxChildSize <= 1.0), |
| 101 | + assert(minChildSize <= initialChildSize), |
| 102 | + assert(initialChildSize <= maxChildSize), |
| 103 | + assert(builder != null), |
| 104 | + super(key: key); |
| 105 | + |
| 106 | + /// The initial fractional value of the parent container's height to use when |
| 107 | + /// displaying the widget. |
| 108 | + /// |
| 109 | + /// The default value is `0.5`. |
| 110 | + final double initialChildSize; |
| 111 | + |
| 112 | + /// The minimum fractional value of the parent container's height to use when |
| 113 | + /// displaying the widget. |
| 114 | + /// |
| 115 | + /// The default value is `0.25`. |
| 116 | + final double minChildSize; |
| 117 | + |
| 118 | + /// The maximum fractional value of the parent container's height to use when |
| 119 | + /// displaying the widget. |
| 120 | + /// |
| 121 | + /// The default value is `1.0`. |
| 122 | + final double maxChildSize; |
| 123 | + |
| 124 | + /// The builder that creates a child to display in this widget, which will |
| 125 | + /// use the provided [ScrollController] to enable dragging and scrolling |
| 126 | + /// of the contents. |
| 127 | + final ScrollableWidgetBuilder builder; |
| 128 | + |
| 129 | + @override |
| 130 | + _DraggableScrollableSheetState createState() => _DraggableScrollableSheetState(); |
| 131 | +} |
| 132 | + |
| 133 | +/// Manages state between [_DraggableScrollableSheetState], |
| 134 | +/// [_DraggableScrollableSheetScrollController], and |
| 135 | +/// [_DraggableScrollableSheetScrollPosition]. |
| 136 | +/// |
| 137 | +/// The State knows the pixels available along the axis the widget wants to |
| 138 | +/// scroll, but expects to get a fraction of those pixels to render the sheet. |
| 139 | +/// |
| 140 | +/// The ScrollPosition knows the number of pixels a user wants to move the sheet. |
| 141 | +/// |
| 142 | +/// The [currentExtent] will never be null. |
| 143 | +/// The [availablePixels] will never be null, but may be `double.infinity`. |
| 144 | +class _DraggableSheetExtent { |
| 145 | + _DraggableSheetExtent({ |
| 146 | + @required this.minExtent, |
| 147 | + @required this.maxExtent, |
| 148 | + @required this.initialExtent, |
| 149 | + @required VoidCallback listener, |
| 150 | + }) : assert(minExtent != null), |
| 151 | + assert(maxExtent != null), |
| 152 | + assert(initialExtent != null), |
| 153 | + assert(minExtent >= 0), |
| 154 | + assert(maxExtent <= 1), |
| 155 | + assert(minExtent <= initialExtent), |
| 156 | + assert(initialExtent <= maxExtent), |
| 157 | + _currentExtent = ValueNotifier<double>(initialExtent)..addListener(listener), |
| 158 | + availablePixels = double.infinity; |
| 159 | + |
| 160 | + final double minExtent; |
| 161 | + final double maxExtent; |
| 162 | + final double initialExtent; |
| 163 | + final ValueNotifier<double> _currentExtent; |
| 164 | + double availablePixels; |
| 165 | + |
| 166 | + bool get isAtMin => minExtent >= _currentExtent.value; |
| 167 | + bool get isAtMax => maxExtent <= _currentExtent.value; |
| 168 | + |
| 169 | + set currentExtent(double value) { |
| 170 | + assert(value != null); |
| 171 | + _currentExtent.value = value.clamp(minExtent, maxExtent); |
| 172 | + } |
| 173 | + double get currentExtent => _currentExtent.value; |
| 174 | + |
| 175 | + /// The scroll position gets inputs in terms of pixels, but the extent is |
| 176 | + /// expected to be expressed as a number between 0..1. |
| 177 | + void addPixelDelta(double delta) { |
| 178 | + currentExtent += delta / availablePixels; |
| 179 | + } |
| 180 | +} |
| 181 | + |
| 182 | +class _DraggableScrollableSheetState extends State<DraggableScrollableSheet> { |
| 183 | + _DraggableScrollableSheetScrollController _scrollController; |
| 184 | + _DraggableSheetExtent _extent; |
| 185 | + |
| 186 | + @override |
| 187 | + void initState() { |
| 188 | + super.initState(); |
| 189 | + _extent = _DraggableSheetExtent( |
| 190 | + minExtent: widget.minChildSize, |
| 191 | + maxExtent: widget.maxChildSize, |
| 192 | + initialExtent: widget.initialChildSize, |
| 193 | + listener: _setExtent, |
| 194 | + ); |
| 195 | + _scrollController = _DraggableScrollableSheetScrollController(extent: _extent); |
| 196 | + } |
| 197 | + |
| 198 | + void _setExtent() { |
| 199 | + setState(() { |
| 200 | + // _extent has been updated when this is called. |
| 201 | + }); |
| 202 | + } |
| 203 | + |
| 204 | + @override |
| 205 | + Widget build(BuildContext context) { |
| 206 | + return LayoutBuilder( |
| 207 | + builder: (BuildContext context, BoxConstraints constraints) { |
| 208 | + _extent.availablePixels = widget.maxChildSize * constraints.biggest.height; |
| 209 | + return SizedBox.expand( |
| 210 | + child: FractionallySizedBox( |
| 211 | + heightFactor: _extent.currentExtent, |
| 212 | + child: widget.builder(context, _scrollController), |
| 213 | + alignment: Alignment.bottomCenter, |
| 214 | + ), |
| 215 | + ); |
| 216 | + }, |
| 217 | + ); |
| 218 | + } |
| 219 | + |
| 220 | + @override |
| 221 | + void dispose() { |
| 222 | + _scrollController.dispose(); |
| 223 | + super.dispose(); |
| 224 | + } |
| 225 | +} |
| 226 | + |
| 227 | +/// A [ScrollController] suitable for use in a [ScrollableWidgetBuilder] created |
| 228 | +/// by a [DraggableScrollableSheet]. |
| 229 | +/// |
| 230 | +/// If a [DraggableScrollableSheet] contains content that is exceeds the height |
| 231 | +/// of its container, this controller will allow the sheet to both be dragged to |
| 232 | +/// fill the container and then scroll the child content. |
| 233 | +/// |
| 234 | +/// See also: |
| 235 | +/// |
| 236 | +/// * [_DraggableScrollableSheetScrollPosition], which manages the positioning logic for |
| 237 | +/// this controller. |
| 238 | +/// * [PrimaryScrollController], which can be used to establish a |
| 239 | +/// [_DraggableScrollableSheetScrollController] as the primary controller for |
| 240 | +/// descendants. |
| 241 | +class _DraggableScrollableSheetScrollController extends ScrollController { |
| 242 | + _DraggableScrollableSheetScrollController({ |
| 243 | + double initialScrollOffset = 0.0, |
| 244 | + String debugLabel, |
| 245 | + @required this.extent, |
| 246 | + }) : assert(extent != null), |
| 247 | + super( |
| 248 | + debugLabel: debugLabel, |
| 249 | + initialScrollOffset: initialScrollOffset, |
| 250 | + ); |
| 251 | + |
| 252 | + final _DraggableSheetExtent extent; |
| 253 | + |
| 254 | + @override |
| 255 | + _DraggableScrollableSheetScrollPosition createScrollPosition( |
| 256 | + ScrollPhysics physics, |
| 257 | + ScrollContext context, |
| 258 | + ScrollPosition oldPosition, |
| 259 | + ) { |
| 260 | + return _DraggableScrollableSheetScrollPosition( |
| 261 | + physics: physics, |
| 262 | + context: context, |
| 263 | + oldPosition: oldPosition, |
| 264 | + extent: extent, |
| 265 | + ); |
| 266 | + } |
| 267 | + |
| 268 | + @override |
| 269 | + void debugFillDescription(List<String> description) { |
| 270 | + super.debugFillDescription(description); |
| 271 | + description.add('extent: $extent'); |
| 272 | + } |
| 273 | +} |
| 274 | + |
| 275 | +/// A scroll position that manages scroll activities for |
| 276 | +/// [_DraggableScrollableSheetScrollController]. |
| 277 | +/// |
| 278 | +/// This class is a concrete subclass of [ScrollPosition] logic that handles a |
| 279 | +/// single [ScrollContext], such as a [Scrollable]. An instance of this class |
| 280 | +/// manages [ScrollActivity] instances, which changes the |
| 281 | +/// [_DraggableSheetExtent.currentExtent] or visible content offset in the |
| 282 | +/// [Scrollable]'s [Viewport] |
| 283 | +/// |
| 284 | +/// See also: |
| 285 | +/// |
| 286 | +/// * [_DraggableScrollableSheetScrollController], which uses this as its [ScrollPosition]. |
| 287 | +class _DraggableScrollableSheetScrollPosition |
| 288 | + extends ScrollPositionWithSingleContext { |
| 289 | + _DraggableScrollableSheetScrollPosition({ |
| 290 | + @required ScrollPhysics physics, |
| 291 | + @required ScrollContext context, |
| 292 | + double initialPixels = 0.0, |
| 293 | + bool keepScrollOffset = true, |
| 294 | + ScrollPosition oldPosition, |
| 295 | + String debugLabel, |
| 296 | + @required this.extent, |
| 297 | + }) : assert(extent != null), |
| 298 | + super( |
| 299 | + physics: physics, |
| 300 | + context: context, |
| 301 | + initialPixels: initialPixels, |
| 302 | + keepScrollOffset: keepScrollOffset, |
| 303 | + oldPosition: oldPosition, |
| 304 | + debugLabel: debugLabel, |
| 305 | + ); |
| 306 | + |
| 307 | + VoidCallback _dragCancelCallback; |
| 308 | + final _DraggableSheetExtent extent; |
| 309 | + bool get listShouldScroll => pixels > 0.0; |
| 310 | + |
| 311 | + @override |
| 312 | + void applyUserOffset(double delta) { |
| 313 | + if (!listShouldScroll && |
| 314 | + !(extent.isAtMin || extent.isAtMax) || |
| 315 | + (extent.isAtMin && delta < 0) || |
| 316 | + (extent.isAtMax && delta > 0)) { |
| 317 | + extent.addPixelDelta(-delta); |
| 318 | + } else { |
| 319 | + super.applyUserOffset(delta); |
| 320 | + } |
| 321 | + } |
| 322 | + |
| 323 | + @override |
| 324 | + void goBallistic(double velocity) { |
| 325 | + if (velocity == 0.0 || |
| 326 | + (velocity < 0.0 && listShouldScroll) || |
| 327 | + (velocity > 0.0 && extent.isAtMax)) { |
| 328 | + super.goBallistic(velocity); |
| 329 | + return; |
| 330 | + } |
| 331 | + // Scrollable expects that we will dispose of its current _dragCancelCallback |
| 332 | + _dragCancelCallback?.call(); |
| 333 | + _dragCancelCallback = null; |
| 334 | + |
| 335 | + // The iOS bouncing simulation just isn't right here - once we delegate |
| 336 | + // the ballistic back to the ScrollView, it will use the right simulation. |
| 337 | + final Simulation simulation = ClampingScrollSimulation( |
| 338 | + position: extent.currentExtent, |
| 339 | + velocity: velocity, |
| 340 | + tolerance: physics.tolerance, |
| 341 | + ); |
| 342 | + |
| 343 | + final AnimationController ballisticController = AnimationController.unbounded( |
| 344 | + debugLabel: '$runtimeType', |
| 345 | + vsync: context.vsync, |
| 346 | + ); |
| 347 | + double lastDelta = 0; |
| 348 | + void _tick() { |
| 349 | + final double delta = ballisticController.value - lastDelta; |
| 350 | + lastDelta = ballisticController.value; |
| 351 | + extent.addPixelDelta(delta); |
| 352 | + if ((velocity > 0 && extent.isAtMax) || (velocity < 0 && extent.isAtMin)) { |
| 353 | + // Make sure we pass along enough velocity to keep scrolling - otherwise |
| 354 | + // we just "bounce" off the top making it look like the list doesn't |
| 355 | + // have more to scroll. |
| 356 | + velocity = ballisticController.velocity + (physics.tolerance.velocity * ballisticController.velocity.sign); |
| 357 | + super.goBallistic(velocity); |
| 358 | + ballisticController.stop(); |
| 359 | + } |
| 360 | + } |
| 361 | + |
| 362 | + ballisticController |
| 363 | + ..addListener(_tick) |
| 364 | + ..animateWith(simulation).whenCompleteOrCancel( |
| 365 | + ballisticController.dispose, |
| 366 | + ); |
| 367 | + } |
| 368 | + |
| 369 | + @override |
| 370 | + Drag drag(DragStartDetails details, VoidCallback dragCancelCallback) { |
| 371 | + // Save this so we can call it later if we have to [goBallistic] on our own. |
| 372 | + _dragCancelCallback = dragCancelCallback; |
| 373 | + return super.drag(details, dragCancelCallback); |
| 374 | + } |
| 375 | +} |
0 commit comments