bottom_sheet.dart 16.3 KB
Newer Older
1 2 3 4 5 6
// Copyright 2015 The Chromium Authors. All rights reserved.
// 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
    @required this.onClosing,
64
    @required this.builder,
65 66
  }) : assert(enableDrag != null),
       assert(onClosing != null),
67
       assert(builder != null),
68
       assert(elevation == null || elevation >= 0.0),
69
       super(key: key);
70

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

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

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

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

97 98 99 100 101 102 103
  /// 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;

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

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

118
  @override
119
  _BottomSheetState createState() => _BottomSheetState();
120

121 122 123 124 125 126
  /// 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.
127
  static AnimationController createAnimationController(TickerProvider vsync) {
128
    return AnimationController(
129
      duration: _bottomSheetDuration,
130 131
      debugLabel: 'BottomSheet',
      vsync: vsync,
132 133
    );
  }
134 135 136 137
}

class _BottomSheetState extends State<BottomSheet> {

138
  final GlobalKey _childKey = GlobalKey(debugLabel: 'BottomSheet child');
139 140 141 142 143

  double get _childHeight {
    final RenderBox renderBox = _childKey.currentContext.findRenderObject();
    return renderBox.size.height;
  }
144

145
  bool get _dismissUnderway => widget.animationController.status == AnimationStatus.reverse;
146

147
  void _handleDragUpdate(DragUpdateDetails details) {
148
    assert(widget.enableDrag);
149 150
    if (_dismissUnderway)
      return;
151
    widget.animationController.value -= details.primaryDelta / (_childHeight ?? details.primaryDelta);
152 153
  }

154
  void _handleDragEnd(DragEndDetails details) {
155
    assert(widget.enableDrag);
156 157
    if (_dismissUnderway)
      return;
158
    if (details.velocity.pixelsPerSecond.dy > _minFlingVelocity) {
159
      final double flingVelocity = -details.velocity.pixelsPerSecond.dy / _childHeight;
160
      if (widget.animationController.value > 0.0) {
161
        widget.animationController.fling(velocity: flingVelocity);
162 163
      }
      if (flingVelocity < 0.0) {
164
        widget.onClosing();
165 166
      }
    } else if (widget.animationController.value < _closeProgressThreshold) {
167 168 169
      if (widget.animationController.value > 0.0)
        widget.animationController.fling(velocity: -1.0);
      widget.onClosing();
170
    } else {
171
      widget.animationController.forward();
172 173 174 175 176 177
   }
  }

  bool extentChanged(DraggableScrollableNotification notification) {
    if (notification.extent == notification.minExtent) {
      widget.onClosing();
178
    }
179
    return false;
180 181
  }

182
  @override
183
  Widget build(BuildContext context) {
184 185 186 187 188
    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;

189
    final Widget bottomSheet = Material(
190
      key: _childKey,
191 192 193
      color: color,
      elevation: elevation,
      shape: shape,
194 195 196 197
      child: NotificationListener<DraggableScrollableNotification>(
        onNotification: extentChanged,
        child: widget.builder(context),
      ),
198
    );
199
    return !widget.enableDrag ? bottomSheet : GestureDetector(
200
      onVerticalDragUpdate: _handleDragUpdate,
201
      onVerticalDragEnd: _handleDragEnd,
202
      child: bottomSheet,
203
      excludeFromSemantics: true,
204 205 206 207
    );
  }
}

208
// PERSISTENT BOTTOM SHEETS
209

210
// See scaffold.dart
211 212


213
// MODAL BOTTOM SHEETS
214
class _ModalBottomSheetLayout extends SingleChildLayoutDelegate {
215
  _ModalBottomSheetLayout(this.progress, this.isScrollControlled);
216 217

  final double progress;
218
  final bool isScrollControlled;
219

220
  @override
221
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
222
    return BoxConstraints(
223 224 225
      minWidth: constraints.maxWidth,
      maxWidth: constraints.maxWidth,
      minHeight: 0.0,
226 227 228
      maxHeight: isScrollControlled
        ? constraints.maxHeight
        : constraints.maxHeight * 9.0 / 16.0,
229 230 231
    );
  }

232
  @override
233
  Offset getPositionForChild(Size size, Size childSize) {
234
    return Offset(0.0, size.height - childSize.height * progress);
235 236
  }

237
  @override
238 239
  bool shouldRelayout(_ModalBottomSheetLayout oldDelegate) {
    return progress != oldDelegate.progress;
240 241 242
  }
}

243
class _ModalBottomSheet<T> extends StatefulWidget {
244 245 246
  const _ModalBottomSheet({
    Key key,
    this.route,
247 248 249
    this.backgroundColor,
    this.elevation,
    this.shape,
250 251 252
    this.isScrollControlled = false,
  }) : assert(isScrollControlled != null),
       super(key: key);
253

254
  final _ModalBottomSheetRoute<T> route;
255
  final bool isScrollControlled;
256 257 258
  final Color backgroundColor;
  final double elevation;
  final ShapeBorder shape;
259

260
  @override
261
  _ModalBottomSheetState<T> createState() => _ModalBottomSheetState<T>();
262 263
}

264
class _ModalBottomSheetState<T> extends State<_ModalBottomSheet<T>> {
265
  String _getRouteLabel(MaterialLocalizations localizations) {
266 267
    switch (defaultTargetPlatform) {
      case TargetPlatform.iOS:
268
        return '';
269 270
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
271
        return localizations.dialogLabel;
272
    }
273 274 275 276 277 278 279 280 281 282
    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);
283

284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308
    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,
309 310
              ),
            ),
311 312 313
          ),
        );
      },
314 315 316 317
    );
  }
}

Hixie's avatar
Hixie committed
318 319
class _ModalBottomSheetRoute<T> extends PopupRoute<T> {
  _ModalBottomSheetRoute({
320 321
    this.builder,
    this.theme,
322
    this.barrierLabel,
323
    this.backgroundColor,
324 325 326
    this.elevation,
    this.shape,
    @required this.isScrollControlled,
327
    RouteSettings settings,
328 329
  }) : assert(isScrollControlled != null),
       super(settings: settings);
330 331

  final WidgetBuilder builder;
332
  final ThemeData theme;
333 334
  final bool isScrollControlled;
  final Color backgroundColor;
335 336
  final double elevation;
  final ShapeBorder shape;
337

338
  @override
339
  Duration get transitionDuration => _bottomSheetDuration;
340 341

  @override
342
  bool get barrierDismissible => true;
343

344 345 346
  @override
  final String barrierLabel;

347
  @override
Hixie's avatar
Hixie committed
348
  Color get barrierColor => Colors.black54;
349

350 351
  AnimationController _animationController;

352

353
  @override
354
  AnimationController createAnimationController() {
355
    assert(_animationController == null);
356
    _animationController = BottomSheet.createAnimationController(navigator.overlay);
357
    return _animationController;
358 359
  }

360
  @override
361
  Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
362 363
    // By definition, the bottom sheet is aligned to the bottom of the page
    // and isn't exposed to the top padding of the MediaQuery.
364
    Widget bottomSheet = MediaQuery.removePadding(
365 366
      context: context,
      removeTop: true,
367 368 369 370 371 372 373
      child: _ModalBottomSheet<T>(
        route: this,
        backgroundColor: backgroundColor,
        elevation: elevation,
        shape: shape,
        isScrollControlled: isScrollControlled
      ),
374
    );
375
    if (theme != null)
376
      bottomSheet = Theme(data: theme, child: bottomSheet);
377
    return bottomSheet;
378 379 380
  }
}

381 382 383 384 385 386 387 388
/// 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
389 390
/// can be created and displayed with the [showBottomSheet] function or the
/// [ScaffoldState.showBottomSheet] method.
391
///
Ian Hickson's avatar
Ian Hickson committed
392 393 394 395 396
/// 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.
///
397 398 399 400 401 402
/// 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.
///
403
/// Returns a `Future` that resolves to the value (if any) that was passed to
404
/// [Navigator.pop] when the modal bottom sheet was closed.
405 406 407
///
/// See also:
///
408 409
///  * [BottomSheet], which is the widget normally returned by the function
///    passed as the `builder` argument to [showModalBottomSheet].
410 411
///  * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing
///    non-modal bottom sheets.
412
///  * <https://material.io/design/components/sheets-bottom.html#modal-bottom-sheet>
413 414 415
Future<T> showModalBottomSheet<T>({
  @required BuildContext context,
  @required WidgetBuilder builder,
416
  Color backgroundColor,
417 418 419
  double elevation,
  ShapeBorder shape,
  bool isScrollControlled = false,
420
}) {
421 422
  assert(context != null);
  assert(builder != null);
423 424
  assert(isScrollControlled != null);
  assert(debugCheckHasMediaQuery(context));
425
  assert(debugCheckHasMaterialLocalizations(context));
426

427
  return Navigator.push(context, _ModalBottomSheetRoute<T>(
428 429
    builder: builder,
    theme: Theme.of(context, shadowThemeOnly: true),
430
    isScrollControlled: isScrollControlled,
431
    barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
432 433 434
    backgroundColor: backgroundColor,
    elevation: elevation,
    shape: shape,
435 436
  ));
}
437

438 439
/// Shows a material design bottom sheet in the nearest [Scaffold] ancestor. If
/// you wish to show a persistent bottom sheet, use [Scaffold.bottomSheet].
440
///
441 442 443 444 445 446 447 448 449 450 451 452 453 454 455
/// Returns a controller that can be used to close and otherwise manipulate the
/// bottom sheet.
///
/// 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
/// [ModalRoute] and a back button is added to the appbar of the [Scaffold]
/// that closes the bottom sheet.
///
/// To create a persistent bottom sheet that is not a [LocalHistoryEntry] and
/// does not add a back button to the enclosing Scaffold's appbar, use the
/// [Scaffold.bottomSheet] constructor parameter.
///
456 457 458 459 460 461 462 463 464 465 466 467 468 469 470
/// 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:
///
///  * [BottomSheet], which is the widget typically returned by the `builder`.
///  * [showModalBottomSheet], which can be used to display a modal bottom
///    sheet.
///  * [Scaffold.of], for information about how to obtain the [BuildContext].
471
///  * <https://material.io/design/components/sheets-bottom.html#standard-bottom-sheet>
472 473 474
PersistentBottomSheetController<T> showBottomSheet<T>({
  @required BuildContext context,
  @required WidgetBuilder builder,
475
  Color backgroundColor,
476 477
  double elevation,
  ShapeBorder shape,
478 479 480
}) {
  assert(context != null);
  assert(builder != null);
481 482 483 484 485
  assert(debugCheckHasScaffold(context));

  return Scaffold.of(context).showBottomSheet<T>(
    builder,
    backgroundColor: backgroundColor,
486 487
    elevation: elevation,
    shape: shape,
488
  );
489
}