bottom_sheet.dart 20.9 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

7
import 'package:flutter/foundation.dart';
8
import 'package:flutter/scheduler.dart';
9 10
import 'package:flutter/widgets.dart';

11
import 'bottom_sheet_theme.dart';
12
import 'colors.dart';
13
import 'debug.dart';
14
import 'material.dart';
15
import 'material_localizations.dart';
16
import 'scaffold.dart';
17
import 'theme.dart';
18

19 20 21
const Duration _bottomSheetDuration = Duration(milliseconds: 200);
const double _minFlingVelocity = 700.0;
const double _closeProgressThreshold = 0.5;
22

23 24 25 26 27 28 29 30
/// A material design bottom sheet.
///
/// There are two kinds of bottom sheets in material design:
///
///  * _Persistent_. A persistent bottom sheet shows information that
///    supplements the primary content of the app. A persistent bottom sheet
///    remains visible even when the user interacts with other parts of the app.
///    Persistent bottom sheets can be created and displayed with the
31 32
///    [ScaffoldState.showBottomSheet] function or by specifying the
///    [Scaffold.bottomSheet] constructor parameter.
33 34 35 36 37 38 39
///
///  * _Modal_. A modal bottom sheet is an alternative to a menu or a dialog and
///    prevents the user from interacting with the rest of the app. Modal bottom
///    sheets can be created and displayed with the [showModalBottomSheet]
///    function.
///
/// The [BottomSheet] widget itself is rarely used directly. Instead, prefer to
40 41
/// create a persistent bottom sheet with [ScaffoldState.showBottomSheet] or
/// [Scaffold.bottomSheet], and a modal bottom sheet with [showModalBottomSheet].
42 43 44
///
/// See also:
///
45 46 47 48 49
///  * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing
///    non-modal "persistent" bottom sheets.
///  * [showModalBottomSheet], which can be used to display a modal bottom
///    sheet.
///  * <https://material.io/design/components/sheets-bottom.html>
50
class BottomSheet extends StatefulWidget {
51 52 53
  /// Creates a bottom sheet.
  ///
  /// Typically, bottom sheets are created implicitly by
54
  /// [ScaffoldState.showBottomSheet], for persistent bottom sheets, or by
55
  /// [showModalBottomSheet], for modal bottom sheets.
56
  const BottomSheet({
57
    Key key,
58
    this.animationController,
59
    this.enableDrag = true,
60
    this.backgroundColor,
61 62
    this.elevation,
    this.shape,
63
    this.clipBehavior,
64
    @required this.onClosing,
65
    @required this.builder,
66 67
  }) : assert(enableDrag != null),
       assert(onClosing != null),
68
       assert(builder != null),
69
       assert(elevation == null || elevation >= 0.0),
70
       super(key: key);
71

72 73
  /// The animation controller that controls the bottom sheet's entrance and
  /// exit animations.
74 75 76
  ///
  /// The BottomSheet widget will manipulate the position of this animation, it
  /// is not just a passive observer.
77
  final AnimationController animationController;
78 79 80

  /// Called when the bottom sheet begins to close.
  ///
81
  /// A bottom sheet might be prevented from closing (e.g., by user
82 83
  /// interaction) even after this callback is called. For this reason, this
  /// callback might be call multiple times for a given bottom sheet.
84
  final VoidCallback onClosing;
85 86 87 88 89

  /// A builder for the contents of the sheet.
  ///
  /// The bottom sheet will wrap the widget produced by this builder in a
  /// [Material] widget.
90 91
  final WidgetBuilder builder;

92
  /// If true, the bottom sheet can be dragged up and down and dismissed by
93
  /// swiping downwards.
94 95 96 97
  ///
  /// Default is true.
  final bool enableDrag;

98 99 100 101 102 103 104
  /// The bottom sheet's background color.
  ///
  /// Defines the bottom sheet's [Material.color].
  ///
  /// Defaults to null and falls back to [Material]'s default.
  final Color backgroundColor;

105
  /// The z-coordinate at which to place this material relative to its parent.
106
  ///
107 108 109
  /// This controls the size of the shadow below the material.
  ///
  /// Defaults to 0. The value is non-negative.
110 111
  final double elevation;

112
  /// The shape of the bottom sheet.
113
  ///
114 115 116 117
  /// Defines the bottom sheet's [Material.shape].
  ///
  /// Defaults to null and falls back to [Material]'s default.
  final ShapeBorder shape;
118

119 120 121 122 123 124 125 126 127 128 129 130 131
  /// {@macro flutter.widgets.Clip}
  ///
  /// Defines the bottom sheet's [Material.clipBehavior].
  ///
  /// Use this property to enable clipping of content when the bottom sheet has
  /// a custom [shape] and the content can extend past this shape. For example,
  /// a bottom sheet with rounded corners and an edge-to-edge [Image] at the
  /// top.
  ///
  /// If this property is null then [ThemeData.bottomSheetTheme.clipBehavior] is
  /// used. If that's null then the behavior will be [Clip.none].
  final Clip clipBehavior;

132
  @override
133
  _BottomSheetState createState() => _BottomSheetState();
134

135 136 137 138 139 140
  /// Creates an [AnimationController] suitable for a
  /// [BottomSheet.animationController].
  ///
  /// This API available as a convenience for a Material compliant bottom sheet
  /// animation. If alternative animation durations are required, a different
  /// animation controller could be provided.
141
  static AnimationController createAnimationController(TickerProvider vsync) {
142
    return AnimationController(
143
      duration: _bottomSheetDuration,
144 145
      debugLabel: 'BottomSheet',
      vsync: vsync,
146 147
    );
  }
148 149 150 151
}

class _BottomSheetState extends State<BottomSheet> {

152
  final GlobalKey _childKey = GlobalKey(debugLabel: 'BottomSheet child');
153 154

  double get _childHeight {
155
    final RenderBox renderBox = _childKey.currentContext.findRenderObject() as RenderBox;
156 157
    return renderBox.size.height;
  }
158

159
  bool get _dismissUnderway => widget.animationController.status == AnimationStatus.reverse;
160

161
  void _handleDragUpdate(DragUpdateDetails details) {
162
    assert(widget.enableDrag);
163 164
    if (_dismissUnderway)
      return;
165
    widget.animationController.value -= details.primaryDelta / (_childHeight ?? details.primaryDelta);
166 167
  }

168
  void _handleDragEnd(DragEndDetails details) {
169
    assert(widget.enableDrag);
170 171
    if (_dismissUnderway)
      return;
172
    if (details.velocity.pixelsPerSecond.dy > _minFlingVelocity) {
173
      final double flingVelocity = -details.velocity.pixelsPerSecond.dy / _childHeight;
174
      if (widget.animationController.value > 0.0) {
175
        widget.animationController.fling(velocity: flingVelocity);
176 177
      }
      if (flingVelocity < 0.0) {
178
        widget.onClosing();
179 180
      }
    } else if (widget.animationController.value < _closeProgressThreshold) {
181 182 183
      if (widget.animationController.value > 0.0)
        widget.animationController.fling(velocity: -1.0);
      widget.onClosing();
184
    } else {
185
      widget.animationController.forward();
186 187 188 189 190 191
   }
  }

  bool extentChanged(DraggableScrollableNotification notification) {
    if (notification.extent == notification.minExtent) {
      widget.onClosing();
192
    }
193
    return false;
194 195
  }

196
  @override
197
  Widget build(BuildContext context) {
198 199 200 201
    final BottomSheetThemeData bottomSheetTheme = Theme.of(context).bottomSheetTheme;
    final Color color = widget.backgroundColor ?? bottomSheetTheme.backgroundColor;
    final double elevation = widget.elevation ?? bottomSheetTheme.elevation ?? 0;
    final ShapeBorder shape = widget.shape ?? bottomSheetTheme.shape;
202
    final Clip clipBehavior = widget.clipBehavior ?? bottomSheetTheme.clipBehavior ?? Clip.none;
203

204
    final Widget bottomSheet = Material(
205
      key: _childKey,
206 207 208
      color: color,
      elevation: elevation,
      shape: shape,
209
      clipBehavior: clipBehavior,
210 211 212 213
      child: NotificationListener<DraggableScrollableNotification>(
        onNotification: extentChanged,
        child: widget.builder(context),
      ),
214
    );
215
    return !widget.enableDrag ? bottomSheet : GestureDetector(
216
      onVerticalDragUpdate: _handleDragUpdate,
217
      onVerticalDragEnd: _handleDragEnd,
218
      child: bottomSheet,
219
      excludeFromSemantics: true,
220 221 222 223
    );
  }
}

224
// PERSISTENT BOTTOM SHEETS
225

226
// See scaffold.dart
227 228


229
// MODAL BOTTOM SHEETS
230
class _ModalBottomSheetLayout extends SingleChildLayoutDelegate {
231
  _ModalBottomSheetLayout(this.progress, this.isScrollControlled);
232 233

  final double progress;
234
  final bool isScrollControlled;
235

236
  @override
237
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
238
    return BoxConstraints(
239 240 241
      minWidth: constraints.maxWidth,
      maxWidth: constraints.maxWidth,
      minHeight: 0.0,
242 243 244
      maxHeight: isScrollControlled
        ? constraints.maxHeight
        : constraints.maxHeight * 9.0 / 16.0,
245 246 247
    );
  }

248
  @override
249
  Offset getPositionForChild(Size size, Size childSize) {
250
    return Offset(0.0, size.height - childSize.height * progress);
251 252
  }

253
  @override
254 255
  bool shouldRelayout(_ModalBottomSheetLayout oldDelegate) {
    return progress != oldDelegate.progress;
256 257 258
  }
}

259
class _ModalBottomSheet<T> extends StatefulWidget {
260 261 262
  const _ModalBottomSheet({
    Key key,
    this.route,
263 264 265
    this.backgroundColor,
    this.elevation,
    this.shape,
266
    this.clipBehavior,
267
    this.isScrollControlled = false,
268
    this.enableDrag = true,
269
  }) : assert(isScrollControlled != null),
270
       assert(enableDrag != null),
271
       super(key: key);
272

273
  final _ModalBottomSheetRoute<T> route;
274
  final bool isScrollControlled;
275 276 277
  final Color backgroundColor;
  final double elevation;
  final ShapeBorder shape;
278
  final Clip clipBehavior;
279
  final bool enableDrag;
280

281
  @override
282
  _ModalBottomSheetState<T> createState() => _ModalBottomSheetState<T>();
283 284
}

285
class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> {
286
  String _getRouteLabel(MaterialLocalizations localizations) {
287
    switch (Theme.of(context).platform) {
288
      case TargetPlatform.iOS:
289
      case TargetPlatform.macOS:
290
        return '';
291 292
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
293
        return localizations.dialogLabel;
294
    }
295 296 297 298 299 300 301 302 303 304
    return null;
  }

  @override
  Widget build(BuildContext context) {
    assert(debugCheckHasMediaQuery(context));
    assert(debugCheckHasMaterialLocalizations(context));
    final MediaQueryData mediaQuery = MediaQuery.of(context);
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
    final String routeLabel = _getRouteLabel(localizations);
305

306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330
    return AnimatedBuilder(
      animation: widget.route.animation,
      builder: (BuildContext context, Widget child) {
        // Disable the initial animation when accessible navigation is on so
        // that the semantics are added to the tree at the correct time.
        final double animationValue = mediaQuery.accessibleNavigation ? 1.0 : widget.route.animation.value;
        return Semantics(
          scopesRoute: true,
          namesRoute: true,
          label: routeLabel,
          explicitChildNodes: true,
          child: ClipRect(
            child: CustomSingleChildLayout(
              delegate: _ModalBottomSheetLayout(animationValue, widget.isScrollControlled),
              child: BottomSheet(
                animationController: widget.route._animationController,
                onClosing: () {
                  if (widget.route.isCurrent) {
                    Navigator.pop(context);
                  }
                },
                builder: widget.route.builder,
                backgroundColor: widget.backgroundColor,
                elevation: widget.elevation,
                shape: widget.shape,
331
                clipBehavior: widget.clipBehavior,
332
                enableDrag: widget.enableDrag,
333 334
              ),
            ),
335 336 337
          ),
        );
      },
338 339 340 341
    );
  }
}

Hixie's avatar
Hixie committed
342 343
class _ModalBottomSheetRoute<T> extends PopupRoute<T> {
  _ModalBottomSheetRoute({
344 345
    this.builder,
    this.theme,
346
    this.barrierLabel,
347
    this.backgroundColor,
348 349
    this.elevation,
    this.shape,
350
    this.clipBehavior,
351
    this.modalBarrierColor,
352
    this.isDismissible = true,
353
    this.enableDrag = true,
354
    @required this.isScrollControlled,
355
    RouteSettings settings,
356
  }) : assert(isScrollControlled != null),
357
       assert(isDismissible != null),
358
       assert(enableDrag != null),
359
       super(settings: settings);
360 361

  final WidgetBuilder builder;
362
  final ThemeData theme;
363 364
  final bool isScrollControlled;
  final Color backgroundColor;
365 366
  final double elevation;
  final ShapeBorder shape;
367
  final Clip clipBehavior;
368
  final Color modalBarrierColor;
369
  final bool isDismissible;
370
  final bool enableDrag;
371

372
  @override
373
  Duration get transitionDuration => _bottomSheetDuration;
374 375

  @override
376
  bool get barrierDismissible => isDismissible;
377

378 379 380
  @override
  final String barrierLabel;

381
  @override
382
  Color get barrierColor => modalBarrierColor ?? Colors.black54;
383

384 385
  AnimationController _animationController;

386

387
  @override
388
  AnimationController createAnimationController() {
389
    assert(_animationController == null);
390
    _animationController = BottomSheet.createAnimationController(navigator.overlay);
391
    return _animationController;
392 393
  }

394
  @override
395
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
396
    final BottomSheetThemeData sheetTheme = theme?.bottomSheetTheme ?? Theme.of(context).bottomSheetTheme;
397 398
    // By definition, the bottom sheet is aligned to the bottom of the page
    // and isn't exposed to the top padding of the MediaQuery.
399
    Widget bottomSheet = MediaQuery.removePadding(
400 401
      context: context,
      removeTop: true,
402 403
      child: _ModalBottomSheet<T>(
        route: this,
404 405
        backgroundColor: backgroundColor ?? sheetTheme?.modalBackgroundColor ?? sheetTheme?.backgroundColor,
        elevation: elevation ?? sheetTheme?.modalElevation ?? sheetTheme?.elevation,
406
        shape: shape,
407
        clipBehavior: clipBehavior,
408
        isScrollControlled: isScrollControlled,
409
        enableDrag: enableDrag,
410
      ),
411
    );
412
    if (theme != null)
413
      bottomSheet = Theme(data: theme, child: bottomSheet);
414
    return bottomSheet;
415 416 417
  }
}

418 419 420 421 422 423 424 425
/// Shows a modal material design bottom sheet.
///
/// A modal bottom sheet is an alternative to a menu or a dialog and prevents
/// the user from interacting with the rest of the app.
///
/// A closely related widget is a persistent bottom sheet, which shows
/// information that supplements the primary content of the app without
/// preventing the use from interacting with the app. Persistent bottom sheets
426 427
/// can be created and displayed with the [showBottomSheet] function or the
/// [ScaffoldState.showBottomSheet] method.
428
///
Ian Hickson's avatar
Ian Hickson committed
429 430 431 432 433
/// The `context` argument is used to look up the [Navigator] and [Theme] for
/// the bottom sheet. It is only used when the method is called. Its
/// corresponding widget can be safely removed from the tree before the bottom
/// sheet is closed.
///
434 435 436 437 438 439
/// The `isScrollControlled` parameter specifies whether this is a route for
/// a bottom sheet that will utilize [DraggableScrollableSheet]. If you wish
/// to have a bottom sheet that has a scrollable child such as a [ListView] or
/// a [GridView] and have the bottom sheet be draggable, you should set this
/// parameter to true.
///
440 441 442 443 444
/// The `useRootNavigator` parameter ensures that the root navigator is used to
/// display the [BottomSheet] when set to `true`. This is useful in the case
/// that a modal [BottomSheet] needs to be displayed above all other content
/// but the caller is inside another [Navigator].
///
445 446 447
/// The [isDismissible] parameter specifies whether the bottom sheet will be
/// dismissed when user taps on the scrim.
///
448 449 450
/// The [enableDrag] parameter specifies whether the bottom sheet can be
/// dragged up and down and dismissed by swiping downards.
///
451 452 453 454
/// The optional [backgroundColor], [elevation], [shape], and [clipBehavior]
/// parameters can be passed in to customize the appearance and behavior of
/// modal bottom sheets.
///
455
/// Returns a `Future` that resolves to the value (if any) that was passed to
456
/// [Navigator.pop] when the modal bottom sheet was closed.
457
///
458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500
/// {@animation 350 622 https://flutter.github.io/assets-for-api-docs/assets/material/show_modal_bottom_sheet.mp4}
///
/// {@tool snippet --template=stateless_widget_scaffold}
///
/// This example demonstrates how to use `showModalBottomSheet` to display a
/// bottom sheet that obscures the content behind it when a user taps a button.
/// It also demonstrates how to close the bottom sheet using the [Navigator]
/// when a user taps on a button inside the bottom sheet.
///
/// ```dart
/// Widget build(BuildContext context) {
///   return Center(
///     child: RaisedButton(
///       child: const Text('showModalBottomSheet'),
///       onPressed: () {
///         showModalBottomSheet<void>(
///           context: context,
///           builder: (BuildContext context) {
///             return Container(
///               height: 200,
///               color: Colors.amber,
///               child: Center(
///                 child: Column(
///                   mainAxisAlignment: MainAxisAlignment.center,
///                   mainAxisSize: MainAxisSize.min,
///                   children: <Widget>[
///                     const Text('Modal BottomSheet'),
///                     RaisedButton(
///                       child: const Text('Close BottomSheet'),
///                       onPressed: () => Navigator.pop(context),
///                     )
///                   ],
///                 ),
///               ),
///             );
///           },
///         );
///       },
///     ),
///   );
/// }
/// ```
/// {@end-tool}
501 502
/// See also:
///
503 504
///  * [BottomSheet], which becomes the parent of the widget returned by the
///    function passed as the `builder` argument to [showModalBottomSheet].
505 506
///  * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing
///    non-modal bottom sheets.
507 508
///  * [DraggableScrollableSheet], which allows you to create a bottom sheet
///    that grows and then becomes scrollable once it reaches its maximum size.
509
///  * <https://material.io/design/components/sheets-bottom.html#modal-bottom-sheet>
510 511 512
Future<T> showModalBottomSheet<T>({
  @required BuildContext context,
  @required WidgetBuilder builder,
513
  Color backgroundColor,
514 515
  double elevation,
  ShapeBorder shape,
516
  Clip clipBehavior,
517
  Color barrierColor,
518
  bool isScrollControlled = false,
519
  bool useRootNavigator = false,
520
  bool isDismissible = true,
521
  bool enableDrag = true,
522
}) {
523 524
  assert(context != null);
  assert(builder != null);
525
  assert(isScrollControlled != null);
526
  assert(useRootNavigator != null);
527
  assert(isDismissible != null);
528
  assert(enableDrag != null);
529
  assert(debugCheckHasMediaQuery(context));
530
  assert(debugCheckHasMaterialLocalizations(context));
531

532
  return Navigator.of(context, rootNavigator: useRootNavigator).push(_ModalBottomSheetRoute<T>(
533 534
    builder: builder,
    theme: Theme.of(context, shadowThemeOnly: true),
535
    isScrollControlled: isScrollControlled,
536
    barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
537 538
    backgroundColor: backgroundColor,
    elevation: elevation,
539
    shape: shape,
540
    clipBehavior: clipBehavior,
541
    isDismissible: isDismissible,
542 543
    modalBarrierColor: barrierColor,
    enableDrag: enableDrag,
544 545
  ));
}
546

547 548
/// Shows a material design bottom sheet in the nearest [Scaffold] ancestor. If
/// you wish to show a persistent bottom sheet, use [Scaffold.bottomSheet].
549
///
550 551 552
/// Returns a controller that can be used to close and otherwise manipulate the
/// bottom sheet.
///
553 554 555 556
/// The optional [backgroundColor], [elevation], [shape], and [clipBehavior]
/// parameters can be passed in to customize the appearance and behavior of
/// persistent bottom sheets.
///
557 558 559 560 561
/// To rebuild the bottom sheet (e.g. if it is stateful), call
/// [PersistentBottomSheetController.setState] on the controller returned by
/// this method.
///
/// The new bottom sheet becomes a [LocalHistoryEntry] for the enclosing
562
/// [ModalRoute] and a back button is added to the app bar of the [Scaffold]
563 564 565
/// that closes the bottom sheet.
///
/// To create a persistent bottom sheet that is not a [LocalHistoryEntry] and
566
/// does not add a back button to the enclosing Scaffold's app bar, use the
567 568
/// [Scaffold.bottomSheet] constructor parameter.
///
569 570 571 572 573 574 575 576 577 578 579
/// A closely related widget is a modal bottom sheet, which is an alternative
/// to a menu or a dialog and prevents the user from interacting with the rest
/// of the app. Modal bottom sheets can be created and displayed with the
/// [showModalBottomSheet] function.
///
/// The `context` argument is used to look up the [Scaffold] for the bottom
/// sheet. It is only used when the method is called. Its corresponding widget
/// can be safely removed from the tree before the bottom sheet is closed.
///
/// See also:
///
580 581
///  * [BottomSheet], which becomes the parent of the widget returned by the
///    `builder`.
582 583 584
///  * [showModalBottomSheet], which can be used to display a modal bottom
///    sheet.
///  * [Scaffold.of], for information about how to obtain the [BuildContext].
585
///  * <https://material.io/design/components/sheets-bottom.html#standard-bottom-sheet>
586 587 588
PersistentBottomSheetController<T> showBottomSheet<T>({
  @required BuildContext context,
  @required WidgetBuilder builder,
589
  Color backgroundColor,
590 591
  double elevation,
  ShapeBorder shape,
592
  Clip clipBehavior,
593 594 595
}) {
  assert(context != null);
  assert(builder != null);
596 597 598 599 600
  assert(debugCheckHasScaffold(context));

  return Scaffold.of(context).showBottomSheet<T>(
    builder,
    backgroundColor: backgroundColor,
601 602
    elevation: elevation,
    shape: shape,
603
    clipBehavior: clipBehavior,
604
  );
605
}