scaffold.dart 30.1 KB
Newer Older
1 2 3 4
// 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.

Hixie's avatar
Hixie committed
5 6
import 'dart:async';
import 'dart:collection';
7
import 'dart:math' as math;
8

9
import 'package:flutter/widgets.dart';
10

11
import 'app_bar.dart';
12
import 'bottom_sheet.dart';
13
import 'drawer.dart';
Ian Hickson's avatar
Ian Hickson committed
14
import 'icon.dart';
15
import 'icon_button.dart';
Ian Hickson's avatar
Ian Hickson committed
16
import 'icons.dart';
17
import 'material.dart';
Hixie's avatar
Hixie committed
18
import 'snack_bar.dart';
19

20
const double _kFloatingActionButtonMargin = 16.0; // TODO(hmuller): should be device dependent
21 22
const Duration _kFloatingActionButtonSegue = const Duration(milliseconds: 200);
final Tween<double> _kFloatingActionButtonTurnTween = new Tween<double>(begin: -0.125, end: 0.0);
23

24 25 26
/// The Scaffold's appbar is the toolbar, bottom, and the "flexible space"
/// that's stacked behind them. The Scaffold's appBarBehavior defines how
/// its layout responds to scrolling the application's body.
27
enum AppBarBehavior {
28
  /// The app bar's layout does not respond to scrolling.
29
  anchor,
30

31
  /// The app bar's appearance and layout depend on the scrollOffset of the
32 33
  /// Scrollable identified by the Scaffold's scrollableKey. With the scrollOffset
  /// at 0.0, scrolling downwards causes the toolbar's flexible space to shrink,
34 35 36
  /// and then the app bar fades out and scrolls off the top of the screen.
  /// Scrolling upwards always causes the app bar's bottom widget to reappear
  /// if the bottom widget isn't null, otherwise the app bar's toolbar reappears.
37
  scroll,
38

39
  /// The app bar's appearance and layout depend on the scrollOffset of the
40 41
  /// Scrollable identified by the Scaffold's scrollableKey. With the scrollOffset
  /// at 0.0, Scrolling downwards causes the toolbar's flexible space to shrink.
42 43 44
  /// If the bottom widget isn't null the app bar shrinks to the bottom widget's
  /// [AppBarBottomWidget.bottomHeight], otherwise the app bar shrinks to its
  /// [AppBar.collapsedHeight].
45
  under,
46 47
}

48
enum _ScaffoldSlot {
49
  body,
50
  appBar,
51 52 53 54 55
  bottomSheet,
  snackBar,
  floatingActionButton,
  drawer,
}
Hans Muller's avatar
Hans Muller committed
56

57
class _ScaffoldLayout extends MultiChildLayoutDelegate {
58
  _ScaffoldLayout({ this.padding, this.appBarBehavior: AppBarBehavior.anchor });
59

60
  final EdgeInsets padding;
61
  final AppBarBehavior appBarBehavior;
62

63
  @override
64 65
  void performLayout(Size size) {
    BoxConstraints looseConstraints = new BoxConstraints.loose(size);
66

67
    // This part of the layout has the same effect as putting the app bar and
68
    // body in a column and making the body flexible. What's different is that
69 70
    // in this case the app bar appears -after- the body in the stacking order,
    // so the app bar's shadow is drawn on top of the body.
71

72
    final BoxConstraints fullWidthConstraints = looseConstraints.tighten(width: size.width);
73 74
    double contentTop = padding.top;
    double contentBottom = size.height - padding.bottom;
75

76
    if (hasChild(_ScaffoldSlot.appBar)) {
77 78 79
      final double appBarHeight = layoutChild(_ScaffoldSlot.appBar, fullWidthConstraints).height;
      if (appBarBehavior == AppBarBehavior.anchor)
        contentTop = appBarHeight;
80
      positionChild(_ScaffoldSlot.appBar, Offset.zero);
81 82
    }

83
    if (hasChild(_ScaffoldSlot.body)) {
84
      final double bodyHeight = contentBottom - contentTop;
85
      final BoxConstraints bodyConstraints = fullWidthConstraints.tighten(height: bodyHeight);
86
      layoutChild(_ScaffoldSlot.body, bodyConstraints);
87
      positionChild(_ScaffoldSlot.body, new Offset(0.0, contentTop));
88
    }
89 90 91 92 93 94 95 96 97 98

    // The BottomSheet and the SnackBar are anchored to the bottom of the parent,
    // they're as wide as the parent and are given their intrinsic height.
    // If all three elements are present then either the center of the FAB straddles
    // the top edge of the BottomSheet or the bottom of the FAB is
    // _kFloatingActionButtonMargin above the SnackBar, whichever puts the FAB
    // the farthest above the bottom of the parent. If only the FAB is has a
    // non-zero height then it's inset from the parent's right and bottom edges
    // by _kFloatingActionButtonMargin.

99 100 101
    Size bottomSheetSize = Size.zero;
    Size snackBarSize = Size.zero;

102
    if (hasChild(_ScaffoldSlot.bottomSheet)) {
103
      bottomSheetSize = layoutChild(_ScaffoldSlot.bottomSheet, fullWidthConstraints);
104
      positionChild(_ScaffoldSlot.bottomSheet, new Offset((size.width - bottomSheetSize.width) / 2.0, contentBottom - bottomSheetSize.height));
105 106
    }

107
    if (hasChild(_ScaffoldSlot.snackBar)) {
108
      snackBarSize = layoutChild(_ScaffoldSlot.snackBar, fullWidthConstraints);
109
      positionChild(_ScaffoldSlot.snackBar, new Offset(0.0, contentBottom - snackBarSize.height));
110 111
    }

112
    if (hasChild(_ScaffoldSlot.floatingActionButton)) {
113
      final Size fabSize = layoutChild(_ScaffoldSlot.floatingActionButton, looseConstraints);
114
      final double fabX = size.width - fabSize.width - _kFloatingActionButtonMargin;
115
      double fabY = contentBottom - fabSize.height - _kFloatingActionButtonMargin;
116
      if (snackBarSize.height > 0.0)
117
        fabY = math.min(fabY, contentBottom - snackBarSize.height - fabSize.height - _kFloatingActionButtonMargin);
118
      if (bottomSheetSize.height > 0.0)
119
        fabY = math.min(fabY, contentBottom - bottomSheetSize.height - fabSize.height / 2.0);
120
      positionChild(_ScaffoldSlot.floatingActionButton, new Offset(fabX, fabY));
121
    }
122

123
    if (hasChild(_ScaffoldSlot.drawer)) {
124 125
      layoutChild(_ScaffoldSlot.drawer, new BoxConstraints.tight(size));
      positionChild(_ScaffoldSlot.drawer, Offset.zero);
126
    }
Hans Muller's avatar
Hans Muller committed
127
  }
128

129
  @override
130 131 132
  bool shouldRelayout(_ScaffoldLayout oldDelegate) {
    return padding != oldDelegate.padding;
  }
Hans Muller's avatar
Hans Muller committed
133 134
}

135
class _FloatingActionButtonTransition extends StatefulWidget {
136 137 138
  _FloatingActionButtonTransition({
    Key key,
    this.child
139
  }) : super(key: key);
140 141 142

  final Widget child;

143
  @override
144 145 146 147
  _FloatingActionButtonTransitionState createState() => new _FloatingActionButtonTransitionState();
}

class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTransition> {
148 149 150 151 152 153
  final AnimationController _previousController = new AnimationController(duration: _kFloatingActionButtonSegue);
  final AnimationController _currentController = new AnimationController(duration: _kFloatingActionButtonSegue);

  CurvedAnimation _previousAnimation;
  CurvedAnimation _currentAnimation;
  Widget _previousChild;
154

155
  @override
156 157
  void initState() {
    super.initState();
158 159 160 161 162
    // If we start out with a child, have the child appear fully visible instead
    // of animating in.
    if (config.child != null)
      _currentController.value = 1.0;

163 164 165 166 167 168 169 170 171 172
    _previousAnimation = new CurvedAnimation(
      parent: _previousController,
      curve: Curves.easeIn
    );
    _currentAnimation = new CurvedAnimation(
      parent: _currentController,
      curve: Curves.easeIn
    );

    _previousController.addStatusListener(_handleAnimationStatusChanged);
173 174
  }

175
  @override
176
  void dispose() {
177 178
    _previousController.stop();
    _currentController.stop();
179 180 181
    super.dispose();
  }

182
  @override
183
  void didUpdateConfig(_FloatingActionButtonTransition oldConfig) {
184 185 186
    final bool oldChildIsNull = oldConfig.child == null;
    final bool newChildIsNull = config.child == null;
    if (oldChildIsNull == newChildIsNull && oldConfig.child?.key == config.child?.key)
187
      return;
188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216
    if (_previousController.status == AnimationStatus.dismissed) {
      final double currentValue = _currentController.value;
      if (currentValue == 0.0 || oldConfig.child == null) {
        // The current child hasn't started its entrance animation yet. We can
        // just skip directly to the new child's entrance.
        _previousChild = null;
        if (config.child != null)
          _currentController.forward();
      } else {
        // Otherwise, we need to copy the state from the current controller to
        // the previous controller and run an exit animation for the previous
        // widget before running the entrance animation for the new child.
        _previousChild = oldConfig.child;
        _previousController
          ..value = currentValue
          ..reverse();
        _currentController.value = 0.0;
      }
    }
  }

  void _handleAnimationStatusChanged(AnimationStatus status) {
    setState(() {
      if (status == AnimationStatus.dismissed) {
        assert(_currentController.status == AnimationStatus.dismissed);
        if (config.child != null)
          _currentController.forward();
      }
    });
217 218
  }

219
  @override
220 221
  Widget build(BuildContext context) {
    final List<Widget> children = new List<Widget>();
222
    if (_previousAnimation.status != AnimationStatus.dismissed) {
223
      children.add(new ScaleTransition(
224 225 226 227 228 229 230 231 232 233 234
        scale: _previousAnimation,
        child: _previousChild
      ));
    }
    if (_currentAnimation.status != AnimationStatus.dismissed) {
      children.add(new ScaleTransition(
        scale: _currentAnimation,
        child: new RotationTransition(
          turns: _kFloatingActionButtonTurnTween.animate(_currentAnimation),
          child: config.child
        )
235 236 237 238 239 240
      ));
    }
    return new Stack(children: children);
  }
}

241 242
/// Implements the basic material design visual layout structure.
///
243
/// This class provides APIs for showing drawers, snack bars, and bottom sheets.
244
///
245 246 247 248 249 250 251 252 253 254 255 256 257
/// To display a snackbar or a persistent bottom sheet, obtain the
/// [ScaffoldState] for the current [BuildContext] via [Scaffold.of] and use the
/// [ScaffoldState.showSnackBar] and [ScaffoldState.showBottomSheet] functions.
///
/// See also:
///
///  * [AppBar]
///  * [FloatingActionButton]
///  * [Drawer]
///  * [SnackBar]
///  * [BottomSheet]
///  * [ScaffoldState]
///  * <https://www.google.com/design/spec/layout/structure.html>
258
class Scaffold extends StatefulWidget {
259 260 261 262 263
  /// Creates a visual scaffold for material design widgets.
  ///
  /// By default, the [appBarBehavior] causes the [appBar] not to respond to
  /// scrolling and the [body] is resized to avoid the window padding (e.g., to
  /// to avoid being obscured by an onscreen keyboard).
Adam Barth's avatar
Adam Barth committed
264 265
  Scaffold({
    Key key,
266
    this.appBar,
Hixie's avatar
Hixie committed
267
    this.body,
268
    this.floatingActionButton,
269 270
    this.drawer,
    this.scrollableKey,
271
    this.appBarBehavior: AppBarBehavior.anchor,
272
    this.resizeToAvoidBottomPadding: true
273
  }) : super(key: key) {
274
    assert(scrollableKey != null ? (appBarBehavior != AppBarBehavior.anchor) : true);
275
  }
Adam Barth's avatar
Adam Barth committed
276

277
  /// An app bar to display at the top of the scaffold.
278
  final AppBar appBar;
279 280 281 282 283

  /// The primary content of the scaffold.
  ///
  /// Displayed below the app bar and behind the [floatingActionButton] and
  /// [drawer]. To avoid the body being resized to avoid the window padding
284
  /// (e.g., from the onscreen keyboard), see [resizeToAvoidBottomPadding].
Hixie's avatar
Hixie committed
285
  final Widget body;
286 287 288 289

  /// A button displayed on top of the body.
  ///
  /// Typically a [FloatingActionButton].
Adam Barth's avatar
Adam Barth committed
290
  final Widget floatingActionButton;
291 292 293 294

  /// A panel displayed to the side of the body, often hidden on mobile devices.
  ///
  /// Typically a [Drawer].
295
  final Widget drawer;
296 297 298 299 300

  /// The key of the primary [Scrollable] widget in the [body].
  ///
  /// Used to control scroll-linked effects, such as the collapse of the
  /// [appBar].
301
  final Key scrollableKey;
302 303 304 305

  /// How the [appBar] should respond to scrolling.
  ///
  /// By default, the [appBar] does not respond to scrolling.
306
  final AppBarBehavior appBarBehavior;
Hixie's avatar
Hixie committed
307

308 309
  /// Whether the [body] (and other floating widgets) should size themselves to
  /// avoid the window's bottom padding.
310 311 312 313 314 315
  ///
  /// For example, if there is an onscreen keyboard displayed above the
  /// scaffold, the body can be resized to avoid overlapping the keyboard, which
  /// prevents widgets inside the body from being obscured by the keyboard.
  ///
  /// Defaults to true.
316
  final bool resizeToAvoidBottomPadding;
317

318
  /// The state from the closest instance of this class that encloses the given context.
Ian Hickson's avatar
Ian Hickson committed
319
  static ScaffoldState of(BuildContext context) => context.ancestorStateOfType(const TypeMatcher<ScaffoldState>());
Hixie's avatar
Hixie committed
320

321
  @override
Hixie's avatar
Hixie committed
322 323 324
  ScaffoldState createState() => new ScaffoldState();
}

325 326 327 328
/// State for a [Scaffold].
///
/// Can display [SnackBar]s and [BottomSheet]s. Retrieve a [ScaffoldState] from
/// the current [BuildContext] using [Scaffold.of].
Hixie's avatar
Hixie committed
329 330
class ScaffoldState extends State<Scaffold> {

331 332
  static final Object _kScaffoldStorageIdentifier = new Object();

333 334 335 336
  // APPBAR API

  AnimationController _appBarController;

337 338 339 340
  /// The animation controlling the size of the app bar.
  ///
  /// Useful for linking animation effects to the expansion and collapse of the
  /// app bar.
341 342
  Animation<double> get appBarAnimation => _appBarController.view;

343 344 345
  /// The height of the app bar when fully expanded.
  ///
  /// See [AppBar.expandedHeight].
346
  double get appBarHeight => config.appBar?.expandedHeight ?? 0.0;
347

348 349 350 351
  // DRAWER API

  final GlobalKey<DrawerControllerState> _drawerKey = new GlobalKey<DrawerControllerState>();

352 353 354 355
  /// Opens the [Drawer] (if any).
  ///
  /// If the scaffold has a non-null [Scaffold.drawer], this function will cause
  /// the drawer to begin its entrance animation.
356
  void openDrawer() {
357
    _drawerKey.currentState?.open();
358 359
  }

360 361
  // SNACKBAR API

362
  Queue<ScaffoldFeatureController<SnackBar, Null>> _snackBars = new Queue<ScaffoldFeatureController<SnackBar, Null>>();
363
  AnimationController _snackBarController;
Hixie's avatar
Hixie committed
364 365
  Timer _snackBarTimer;

366 367 368 369 370 371
  /// Shows a [SnackBar] at the bottom fo the scaffold.
  ///
  /// A scaffold can show at most one snack bar at a time. If this function is
  /// called while another snack bar is already visible, the given snack bar
  /// will be added to a queue and displayed after the earlier snack bars have
  /// closed.
372
  ScaffoldFeatureController<SnackBar, Null> showSnackBar(SnackBar snackbar) {
373
    _snackBarController ??= SnackBar.createAnimationController()
Hixie's avatar
Hixie committed
374
      ..addStatusListener(_handleSnackBarStatusChange);
375
    if (_snackBars.isEmpty) {
376 377
      assert(_snackBarController.isDismissed);
      _snackBarController.forward();
378
    }
379 380
    ScaffoldFeatureController<SnackBar, Null> controller;
    controller = new ScaffoldFeatureController<SnackBar, Null>._(
381 382 383
      // We provide a fallback key so that if back-to-back snackbars happen to
      // match in structure, material ink splashes and highlights don't survive
      // from one to the next.
384
      snackbar.withAnimation(_snackBarController, fallbackKey: new UniqueKey()),
385
      new Completer<Null>(),
386 387 388 389 390 391
      () {
        assert(_snackBars.first == controller);
        _hideSnackBar();
      },
      null // SnackBar doesn't use a builder function so setState() wouldn't rebuild it
    );
Hixie's avatar
Hixie committed
392
    setState(() {
393
      _snackBars.addLast(controller);
Hixie's avatar
Hixie committed
394
    });
395
    return controller;
Hixie's avatar
Hixie committed
396 397
  }

398
  void _handleSnackBarStatusChange(AnimationStatus status) {
Hixie's avatar
Hixie committed
399
    switch (status) {
400
      case AnimationStatus.dismissed:
Hixie's avatar
Hixie committed
401 402 403 404
        assert(_snackBars.isNotEmpty);
        setState(() {
          _snackBars.removeFirst();
        });
405
        if (_snackBars.isNotEmpty)
406
          _snackBarController.forward();
Hixie's avatar
Hixie committed
407
        break;
408
      case AnimationStatus.completed:
Hixie's avatar
Hixie committed
409 410 411 412 413
        setState(() {
          assert(_snackBarTimer == null);
          // build will create a new timer if necessary to dismiss the snack bar
        });
        break;
414 415
      case AnimationStatus.forward:
      case AnimationStatus.reverse:
Hixie's avatar
Hixie committed
416 417 418 419
        break;
    }
  }

420 421 422 423
  /// Removes the current [SnackBar] (if any) immediately.
  ///
  /// The removed snack bar does not run its normal exit animation. If there are
  /// any queued snack bars, they begin their entrance animation immediately.
424 425 426 427 428 429 430 431 432 433 434
  void removeCurrentSnackBar() {
    if (_snackBars.isEmpty)
      return;
    Completer<Null> completer = _snackBars.first._completer;
    if (!completer.isCompleted)
      completer.complete();
    _snackBarTimer?.cancel();
    _snackBarTimer = null;
    _snackBarController.value = 0.0;
  }

Hixie's avatar
Hixie committed
435
  void _hideSnackBar() {
436 437
    assert(_snackBarController.status == AnimationStatus.forward ||
           _snackBarController.status == AnimationStatus.completed);
438
    _snackBars.first._completer.complete();
439
    _snackBarController.reverse();
440
    _snackBarTimer?.cancel();
Hixie's avatar
Hixie committed
441 442 443
    _snackBarTimer = null;
  }

444 445 446 447

  // PERSISTENT BOTTOM SHEET API

  List<Widget> _dismissedBottomSheets;
448
  PersistentBottomSheetController<dynamic> _currentBottomSheet;
449

450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468
  /// Shows a persistent material design bottom sheet.
  ///
  /// 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.
  ///
  /// 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.
  ///
  /// Returns a contoller that can be used to close and otherwise manipulate the
  /// button sheet.
  ///
  /// See also:
  ///
  ///  * [BottomSheet]
  ///  * [showModalBottomSheet]
  ///  * <https://www.google.com/design/spec/components/bottom-sheets.html#bottom-sheets-persistent-bottom-sheets>
469
  PersistentBottomSheetController<dynamic/*=T*/> showBottomSheet/*<T>*/(WidgetBuilder builder) {
470 471 472 473
    if (_currentBottomSheet != null) {
      _currentBottomSheet.close();
      assert(_currentBottomSheet == null);
    }
474
    Completer<dynamic/*=T*/> completer = new Completer<dynamic/*=T*/>();
475
    GlobalKey<_PersistentBottomSheetState> bottomSheetKey = new GlobalKey<_PersistentBottomSheetState>();
476
    AnimationController controller = BottomSheet.createAnimationController()
477 478
      ..forward();
    _PersistentBottomSheet bottomSheet;
Hixie's avatar
Hixie committed
479 480
    LocalHistoryEntry entry = new LocalHistoryEntry(
      onRemove: () {
481 482 483 484 485 486 487 488 489 490 491
        assert(_currentBottomSheet._widget == bottomSheet);
        assert(bottomSheetKey.currentState != null);
        bottomSheetKey.currentState.close();
        _dismissedBottomSheets ??= <Widget>[];
        _dismissedBottomSheets.add(bottomSheet);
        _currentBottomSheet = null;
        completer.complete();
      }
    );
    bottomSheet = new _PersistentBottomSheet(
      key: bottomSheetKey,
492
      animationController: controller,
493 494
      onClosing: () {
        assert(_currentBottomSheet._widget == bottomSheet);
Hixie's avatar
Hixie committed
495
        entry.remove();
496 497 498 499 500 501 502 503 504
      },
      onDismissed: () {
        assert(_dismissedBottomSheets != null);
        setState(() {
          _dismissedBottomSheets.remove(bottomSheet);
        });
      },
      builder: builder
    );
Hixie's avatar
Hixie committed
505
    ModalRoute.of(context).addLocalHistoryEntry(entry);
506
    setState(() {
507
      _currentBottomSheet = new PersistentBottomSheetController<dynamic/*=T*/>._(
508
        bottomSheet,
509
        completer,
Hixie's avatar
Hixie committed
510
        () => entry.remove(),
Hans Muller's avatar
Hans Muller committed
511
        (VoidCallback fn) { bottomSheetKey.currentState?.setState(fn); }
512 513 514 515 516 517 518 519
      );
    });
    return _currentBottomSheet;
  }


  // INTERNALS

520
  @override
521 522 523
  void initState() {
    super.initState();
    _appBarController = new AnimationController();
524 525 526 527 528
    // Use an explicit identifier to guard against the possibility that the
    // Scaffold's key is recreated by the Widget that creates the Scaffold.
    List<double> scrollValues = PageStorage.of(context)?.readState(context,
      identifier: _kScaffoldStorageIdentifier
    );
529 530 531 532 533
    if (scrollValues != null) {
      assert(scrollValues.length == 2);
      _scrollOffset = scrollValues[0];
      _scrollOffsetDelta = scrollValues[1];
    }
534 535
  }

536
  @override
Hixie's avatar
Hixie committed
537
  void dispose() {
538
    _appBarController.stop();
539 540
    _snackBarController?.stop();
    _snackBarController = null;
Hixie's avatar
Hixie committed
541 542
    _snackBarTimer?.cancel();
    _snackBarTimer = null;
543 544 545
    PageStorage.of(context)?.writeState(context, <double>[_scrollOffset, _scrollOffsetDelta],
      identifier: _kScaffoldStorageIdentifier
    );
Hixie's avatar
Hixie committed
546 547 548 549 550 551 552
    super.dispose();
  }

  void _addIfNonNull(List<LayoutId> children, Widget child, Object childId) {
    if (child != null)
      children.add(new LayoutId(child: child, id: childId));
  }
Adam Barth's avatar
Adam Barth committed
553

554 555
  bool _shouldShowBackArrow;

556
  Widget _getModifiedAppBar({ EdgeInsets padding, int elevation}) {
557 558
    AppBar appBar = config.appBar;
    if (appBar == null)
559
      return null;
560 561
    Widget leading = appBar.leading;
    if (leading == null) {
562
      if (config.drawer != null) {
563
        leading = new IconButton(
Ian Hickson's avatar
Ian Hickson committed
564
          icon: new Icon(Icons.menu),
565
          alignment: FractionalOffset.centerLeft,
Hixie's avatar
Hixie committed
566 567
          onPressed: openDrawer,
          tooltip: 'Open navigation menu' // TODO(ianh): Figure out how to localize this string
568 569 570 571
        );
      } else {
        _shouldShowBackArrow ??= Navigator.canPop(context);
        if (_shouldShowBackArrow) {
572
          leading = new IconButton(
Ian Hickson's avatar
Ian Hickson committed
573
            icon: new Icon(Icons.arrow_back),
574
            alignment: FractionalOffset.centerLeft,
Hixie's avatar
Hixie committed
575 576
            onPressed: () => Navigator.pop(context),
            tooltip: 'Back' // TODO(ianh): Figure out how to localize this string
577 578 579 580
          );
        }
      }
    }
581 582
    return appBar.copyWith(
      elevation: elevation ?? appBar.elevation ?? 4,
583
      padding: new EdgeInsets.only(top: padding.top),
584
      leading: leading
585 586 587
    );
  }

588 589 590 591 592
  double _scrollOffset = 0.0;
  double _scrollOffsetDelta = 0.0;
  double _floatingAppBarHeight = 0.0;

  bool _handleScrollNotification(ScrollNotification notification) {
593 594 595
    final ScrollableState scrollable = notification.scrollable;
    if ((scrollable.config.scrollDirection == Axis.vertical) &&
        (config.scrollableKey == null || config.scrollableKey == scrollable.config.key)) {
596 597 598 599 600 601 602 603 604 605 606
      double newScrollOffset = scrollable.scrollOffset;
      if (ClampOverscrolls.of(scrollable.context)) {
        ExtentScrollBehavior limits = scrollable.scrollBehavior;
        newScrollOffset = newScrollOffset.clamp(limits.minScrollOffset, limits.maxScrollOffset);
      }
      if (_scrollOffset != newScrollOffset) {
        setState(() {
          _scrollOffsetDelta = _scrollOffset - newScrollOffset;
          _scrollOffset = newScrollOffset;
        });
      }
607
    }
608 609 610
    return false;
  }

611
  Widget _buildAnchoredAppBar(double expandedHeight, double height, EdgeInsets padding) {
612
    // Drive _appBarController to the point where the flexible space has disappeared.
613
    _appBarController.value = (expandedHeight - height) / expandedHeight;
614
    return new SizedBox(
615
      height: height,
616
      child: _getModifiedAppBar(padding: padding)
617 618 619
    );
  }

620
  Widget _buildScrollableAppBar(BuildContext context, EdgeInsets padding) {
621 622
    final double expandedHeight = (config.appBar?.expandedHeight ?? 0.0) + padding.top;
    final double collapsedHeight = (config.appBar?.collapsedHeight ?? 0.0) + padding.top;
623 624
    final double bottomHeight = config.appBar?.bottomHeight + padding.top;
    final double underHeight = config.appBar.bottom != null ? bottomHeight : collapsedHeight;
625 626
    Widget appBar;

627
    if (_scrollOffset <= expandedHeight && _scrollOffset >= expandedHeight - underHeight) {
628
      // scrolled to the top, flexible space collapsed, only the toolbar and tabbar are (partially) visible.
629
      if (config.appBarBehavior == AppBarBehavior.under) {
630
        appBar = _buildAnchoredAppBar(expandedHeight, underHeight, padding);
631
      } else {
632 633
        final double height = math.max(_floatingAppBarHeight, expandedHeight - _scrollOffset);
        _appBarController.value = (expandedHeight - height) / expandedHeight;
634 635
        appBar = new SizedBox(
          height: height,
636
          child: _getModifiedAppBar(padding: padding)
637 638
        );
      }
639
    } else if (_scrollOffset > expandedHeight) {
640 641
      // scrolled past the entire app bar, maybe show the "floating" toolbar.
      if (config.appBarBehavior == AppBarBehavior.under) {
642
        appBar = _buildAnchoredAppBar(expandedHeight, underHeight, padding);
643
      } else {
644 645
        _floatingAppBarHeight = (_floatingAppBarHeight + _scrollOffsetDelta).clamp(0.0, collapsedHeight);
        _appBarController.value = (expandedHeight - _floatingAppBarHeight) / expandedHeight;
646 647
        appBar = new SizedBox(
          height: _floatingAppBarHeight,
648
          child: _getModifiedAppBar(padding: padding)
649 650
        );
      }
651
    } else {
652 653 654
      // _scrollOffset < expandedHeight - collapsedHeight, scrolled to the top, flexible space is visible]
      final double height = expandedHeight - _scrollOffset.clamp(0.0, expandedHeight);
      _appBarController.value = (expandedHeight - height) / expandedHeight;
655 656
      appBar = new SizedBox(
        height: height,
657
        child: _getModifiedAppBar(padding: padding, elevation: 0)
658 659
      );
      _floatingAppBarHeight = 0.0;
660

661 662 663 664 665
    }

    return appBar;
  }

666
  @override
Adam Barth's avatar
Adam Barth committed
667
  Widget build(BuildContext context) {
668
    EdgeInsets padding = MediaQuery.of(context).padding;
669 670
    if (!config.resizeToAvoidBottomPadding)
      padding = new EdgeInsets.fromLTRB(padding.left, padding.top, padding.right, 0.0);
Hixie's avatar
Hixie committed
671 672

    if (_snackBars.length > 0) {
673
      final ModalRoute<dynamic> route = ModalRoute.of(context);
Hixie's avatar
Hixie committed
674
      if (route == null || route.isCurrent) {
675
        if (_snackBarController.isCompleted && _snackBarTimer == null)
676
          _snackBarTimer = new Timer(_snackBars.first._widget.duration, _hideSnackBar);
Hixie's avatar
Hixie committed
677 678 679 680
      } else {
        _snackBarTimer?.cancel();
        _snackBarTimer = null;
      }
681
    }
682

683 684
    final List<LayoutId> children = new List<LayoutId>();
    _addIfNonNull(children, config.body, _ScaffoldSlot.body);
685
    if (config.appBarBehavior == AppBarBehavior.anchor) {
686
      final double expandedHeight = (config.appBar?.expandedHeight ?? 0.0) + padding.top;
687
      final Widget appBar = new ConstrainedBox(
688 689
        constraints: new BoxConstraints(maxHeight: expandedHeight),
        child: _getModifiedAppBar(padding: padding)
690
      );
691
      _addIfNonNull(children, appBar, _ScaffoldSlot.appBar);
692
    } else {
693
      children.add(new LayoutId(child: _buildScrollableAppBar(context, padding), id: _ScaffoldSlot.appBar));
694
    }
695
    // Otherwise the AppBar will be part of a [app bar, body] Stack. See AppBarBehavior.scroll below.
696 697 698

    if (_currentBottomSheet != null ||
        (_dismissedBottomSheets != null && _dismissedBottomSheets.isNotEmpty)) {
699
      final List<Widget> bottomSheets = <Widget>[];
700 701 702 703 704
      if (_dismissedBottomSheets != null && _dismissedBottomSheets.isNotEmpty)
        bottomSheets.addAll(_dismissedBottomSheets);
      if (_currentBottomSheet != null)
        bottomSheets.add(_currentBottomSheet._widget);
      Widget stack = new Stack(
705
        children: bottomSheets,
706
        alignment: FractionalOffset.bottomCenter
707
      );
708
      _addIfNonNull(children, stack, _ScaffoldSlot.bottomSheet);
709 710
    }

Hixie's avatar
Hixie committed
711
    if (_snackBars.isNotEmpty)
712
      _addIfNonNull(children, _snackBars.first._widget, _ScaffoldSlot.snackBar);
713

714 715 716
    children.add(new LayoutId(
      id: _ScaffoldSlot.floatingActionButton,
      child: new _FloatingActionButtonTransition(
717
        child: config.floatingActionButton
718 719
      )
    ));
720

721 722
    if (config.drawer != null) {
      children.add(new LayoutId(
723
        id: _ScaffoldSlot.drawer,
724 725 726 727 728 729 730
        child: new DrawerController(
          key: _drawerKey,
          child: config.drawer
        )
      ));
    }

731 732
    Widget application;

733
    if (config.appBarBehavior != AppBarBehavior.anchor) {
734 735
      application = new NotificationListener<ScrollNotification>(
        onNotification: _handleScrollNotification,
736 737 738 739 740 741
        child: new CustomMultiChildLayout(
          children: children,
          delegate: new _ScaffoldLayout(
            padding: EdgeInsets.zero,
            appBarBehavior: config.appBarBehavior
          )
742 743 744 745
        )
      );
    } else {
      application = new CustomMultiChildLayout(
746 747 748 749
        children: children,
        delegate: new _ScaffoldLayout(
          padding: padding
        )
750 751 752 753
      );
    }

    return new Material(child: application);
754
  }
755
}
756

757 758 759
/// An interface for controlling a feature of a [Scaffold].
///
/// Commonly obtained from [Scaffold.showSnackBar] or [Scaffold.showBottomSheet].
760
class ScaffoldFeatureController<T extends Widget, U> {
761 762
  const ScaffoldFeatureController._(this._widget, this._completer, this.close, this.setState);
  final T _widget;
763
  final Completer<U> _completer;
764 765

  /// Completes when the feature controlled by this object is no longer visible.
766
  Future<U> get closed => _completer.future;
767 768 769 770 771

  /// Remove the feature (e.g., bottom sheet or snack bar) from the scaffold.
  final VoidCallback close;

  /// Mark the feature (e.g., bottom sheet or snack bar) as needing to rebuild.
772 773 774
  final StateSetter setState;
}

775
class _PersistentBottomSheet extends StatefulWidget {
776 777
  _PersistentBottomSheet({
    Key key,
778
    this.animationController,
779 780 781 782 783
    this.onClosing,
    this.onDismissed,
    this.builder
  }) : super(key: key);

784
  final AnimationController animationController;
785 786 787 788
  final VoidCallback onClosing;
  final VoidCallback onDismissed;
  final WidgetBuilder builder;

789
  @override
790 791 792 793 794
  _PersistentBottomSheetState createState() => new _PersistentBottomSheetState();
}

class _PersistentBottomSheetState extends State<_PersistentBottomSheet> {

795 796
  // We take ownership of the animation controller given in the first configuration.
  // We also share control of that animation with out BottomSheet widget.
797

798
  @override
799 800
  void initState() {
    super.initState();
801
    assert(config.animationController.status == AnimationStatus.forward);
802
    config.animationController.addStatusListener(_handleStatusChange);
803 804
  }

805
  @override
806 807
  void didUpdateConfig(_PersistentBottomSheet oldConfig) {
    super.didUpdateConfig(oldConfig);
808
    assert(config.animationController == oldConfig.animationController);
809 810
  }

811
  @override
812
  void dispose() {
813
    config.animationController.stop();
814 815 816 817
    super.dispose();
  }

  void close() {
818
    config.animationController.reverse();
819 820
  }

821 822
  void _handleStatusChange(AnimationStatus status) {
    if (status == AnimationStatus.dismissed && config.onDismissed != null)
823 824 825
      config.onDismissed();
  }

826
  @override
827
  Widget build(BuildContext context) {
828 829
    return new AnimatedBuilder(
      animation: config.animationController,
830
      builder: (BuildContext context, Widget child) {
831
        return new Align(
832
          alignment: FractionalOffset.topLeft,
833 834 835
          heightFactor: config.animationController.value,
          child: child
        );
836
      },
Hixie's avatar
Hixie committed
837 838 839 840 841 842 843
      child: new Semantics(
        container: true,
        child: new BottomSheet(
          animationController: config.animationController,
          onClosing: config.onClosing,
          builder: config.builder
        )
844
      )
845 846 847 848
    );
  }

}
849 850 851 852 853 854 855 856 857 858 859 860

/// A [ScaffoldFeatureController] for persistent bottom sheets.
///
/// This is the type of objects returned by [Scaffold.showBottomSheet].
class PersistentBottomSheetController<T> extends ScaffoldFeatureController<_PersistentBottomSheet, T> {
  const PersistentBottomSheetController._(
    _PersistentBottomSheet widget,
    Completer<T> completer,
    VoidCallback close,
    StateSetter setState
  ) : super._(widget, completer, close, setState);
}