// 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'; import 'dart:collection'; import 'dart:math' as math; import 'package:flutter/widgets.dart'; import 'app_bar.dart'; import 'bottom_sheet.dart'; import 'button_bar.dart'; import 'button.dart'; import 'drawer.dart'; import 'icon.dart'; import 'icon_button.dart'; import 'icons.dart'; import 'material.dart'; import 'snack_bar.dart'; import 'theme.dart'; const double _kFloatingActionButtonMargin = 16.0; // TODO(hmuller): should be device dependent const Duration _kFloatingActionButtonSegue = const Duration(milliseconds: 200); final Tween<double> _kFloatingActionButtonTurnTween = new Tween<double>(begin: -0.125, end: 0.0); const double _kBackGestureWidth = 20.0; /// 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. enum AppBarBehavior { /// The app bar's layout does not respond to scrolling. anchor, /// The app bar's appearance and layout depend on the scrollOffset of the /// Scrollable identified by the Scaffold's scrollableKey. With the scrollOffset /// at 0.0, scrolling downwards causes the toolbar's flexible space to shrink, /// 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. scroll, /// The app bar's appearance and layout depend on the scrollOffset of the /// Scrollable identified by the Scaffold's scrollableKey. With the scrollOffset /// at 0.0, Scrolling downwards causes the toolbar's flexible space to shrink. /// 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]. under, } enum _ScaffoldSlot { body, appBar, bottomSheet, snackBar, persistentFooter, bottomNavigationBar, floatingActionButton, drawer, statusBar, } class _ScaffoldLayout extends MultiChildLayoutDelegate { _ScaffoldLayout({ this.padding, this.statusBarHeight, this.appBarBehavior: AppBarBehavior.anchor }); final EdgeInsets padding; final double statusBarHeight; final AppBarBehavior appBarBehavior; @override void performLayout(Size size) { BoxConstraints looseConstraints = new BoxConstraints.loose(size); // This part of the layout has the same effect as putting the app bar and // body in a column and making the body flexible. What's different is that // 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. final BoxConstraints fullWidthConstraints = looseConstraints.tighten(width: size.width); double contentTop = padding.top; double bottom = size.height - padding.bottom; double contentBottom = bottom; if (hasChild(_ScaffoldSlot.appBar)) { final double appBarHeight = layoutChild(_ScaffoldSlot.appBar, fullWidthConstraints).height; if (appBarBehavior == AppBarBehavior.anchor) contentTop = appBarHeight; positionChild(_ScaffoldSlot.appBar, Offset.zero); } if (hasChild(_ScaffoldSlot.bottomNavigationBar)) { final double bottomNavigationBarHeight = layoutChild(_ScaffoldSlot.bottomNavigationBar, fullWidthConstraints).height; contentBottom -= bottomNavigationBarHeight; positionChild(_ScaffoldSlot.bottomNavigationBar, new Offset(0.0, contentBottom)); } if (hasChild(_ScaffoldSlot.persistentFooter)) { final double persistentFooterHeight = layoutChild(_ScaffoldSlot.persistentFooter, fullWidthConstraints.copyWith(maxHeight: contentBottom - contentTop)).height; contentBottom -= persistentFooterHeight; positionChild(_ScaffoldSlot.persistentFooter, new Offset(0.0, contentBottom)); } if (hasChild(_ScaffoldSlot.body)) { final double bodyHeight = contentBottom - contentTop; final BoxConstraints bodyConstraints = new BoxConstraints( maxWidth: fullWidthConstraints.maxWidth, maxHeight: bodyHeight, ); layoutChild(_ScaffoldSlot.body, bodyConstraints); positionChild(_ScaffoldSlot.body, new Offset(0.0, contentTop)); } // 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. The // only difference is that SnackBar appears on the top side of the // BottomNavigationBar while the BottomSheet is stacked on top of it. // // 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. Size bottomSheetSize = Size.zero; Size snackBarSize = Size.zero; if (hasChild(_ScaffoldSlot.bottomSheet)) { bottomSheetSize = layoutChild(_ScaffoldSlot.bottomSheet, fullWidthConstraints.copyWith(maxHeight: contentBottom - contentTop)); positionChild(_ScaffoldSlot.bottomSheet, new Offset((size.width - bottomSheetSize.width) / 2.0, bottom - bottomSheetSize.height)); } if (hasChild(_ScaffoldSlot.snackBar)) { snackBarSize = layoutChild(_ScaffoldSlot.snackBar, fullWidthConstraints); positionChild(_ScaffoldSlot.snackBar, new Offset(0.0, contentBottom - snackBarSize.height)); } if (hasChild(_ScaffoldSlot.floatingActionButton)) { final Size fabSize = layoutChild(_ScaffoldSlot.floatingActionButton, looseConstraints); final double fabX = size.width - fabSize.width - _kFloatingActionButtonMargin; double fabY = contentBottom - fabSize.height - _kFloatingActionButtonMargin; if (snackBarSize.height > 0.0) fabY = math.min(fabY, contentBottom - snackBarSize.height - fabSize.height - _kFloatingActionButtonMargin); if (bottomSheetSize.height > 0.0) fabY = math.min(fabY, contentBottom - bottomSheetSize.height - fabSize.height / 2.0); positionChild(_ScaffoldSlot.floatingActionButton, new Offset(fabX, fabY)); } if (hasChild(_ScaffoldSlot.statusBar)) { layoutChild(_ScaffoldSlot.statusBar, fullWidthConstraints.tighten(height: statusBarHeight)); positionChild(_ScaffoldSlot.statusBar, Offset.zero); } if (hasChild(_ScaffoldSlot.drawer)) { layoutChild(_ScaffoldSlot.drawer, new BoxConstraints.tight(size)); positionChild(_ScaffoldSlot.drawer, Offset.zero); } } @override bool shouldRelayout(_ScaffoldLayout oldDelegate) { return padding != oldDelegate.padding; } } class _FloatingActionButtonTransition extends StatefulWidget { _FloatingActionButtonTransition({ Key key, this.child }) : super(key: key); final Widget child; @override _FloatingActionButtonTransitionState createState() => new _FloatingActionButtonTransitionState(); } class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTransition> with TickerProviderStateMixin { AnimationController _previousController; AnimationController _currentController; CurvedAnimation _previousAnimation; CurvedAnimation _currentAnimation; Widget _previousChild; @override void initState() { super.initState(); _previousController = new AnimationController( duration: _kFloatingActionButtonSegue, vsync: this, )..addStatusListener(_handleAnimationStatusChanged); _previousAnimation = new CurvedAnimation( parent: _previousController, curve: Curves.easeIn ); _currentController = new AnimationController( duration: _kFloatingActionButtonSegue, vsync: this, ); _currentAnimation = new CurvedAnimation( parent: _currentController, curve: Curves.easeIn ); // 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; } @override void dispose() { _previousController.stop(); _currentController.stop(); super.dispose(); } @override void didUpdateConfig(_FloatingActionButtonTransition oldConfig) { final bool oldChildIsNull = oldConfig.child == null; final bool newChildIsNull = config.child == null; if (oldChildIsNull == newChildIsNull && oldConfig.child?.key == config.child?.key) return; 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(); } }); } @override Widget build(BuildContext context) { final List<Widget> children = new List<Widget>(); if (_previousAnimation.status != AnimationStatus.dismissed) { children.add(new ScaleTransition( 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 ) )); } return new Stack(children: children); } } /// Implements the basic material design visual layout structure. /// /// This class provides APIs for showing drawers, snack bars, and bottom sheets. /// /// 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] /// * [BottomNavigationBar] /// * [SnackBar] /// * [BottomSheet] /// * [ScaffoldState] /// * <https://material.google.com/layout/structure.html> class Scaffold extends StatefulWidget { /// 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). Scaffold({ Key key, this.appBar, this.body, this.floatingActionButton, this.persistentFooterButtons, this.drawer, this.bottomNavigationBar, this.scrollableKey, this.appBarBehavior: AppBarBehavior.anchor, this.resizeToAvoidBottomPadding: true }) : super(key: key); /// An app bar to display at the top of the scaffold. final AppBar appBar; /// 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 /// (e.g., from the onscreen keyboard), see [resizeToAvoidBottomPadding]. /// /// The widget in the body of the scaffold is positioned at the top-left of /// the available space between the app bar and the bottom of the scaffold. To /// center this widget instead, consider putting it in a [Center] widget and /// having that be the body. /// /// If you have a column of widgets that should normally fit on the screen, /// but may overflow and would in such cases need to scroll, consider using a /// [Block] as the body of the scaffold. /// /// If you have a list of items, consider using a [LazyBlock], /// [LazyScrollableList], or [MaterialList] as the body of the scaffold. final Widget body; /// A button displayed on top of the [body]. /// /// Typically a [FloatingActionButton]. final Widget floatingActionButton; /// A set of buttons that are displayed at the bottom of the scaffold. /// /// Typically this is a list of [FlatButton] widgets. These buttons are /// persistently visible, even of the [body] of the scaffold scrolls. /// /// These widgets will be wrapped in a [ButtonBar]. /// /// See also: /// /// * <https://material.google.com/components/buttons.html#buttons-persistent-footer-buttons> final List<Widget> persistentFooterButtons; /// A panel displayed to the side of the [body], often hidden on mobile devices. /// /// Typically a [Drawer]. final Widget drawer; /// A bottom navigation bar to display at the bottom of the scaffold. /// /// Snack bars slide from underneath the botton navigation while bottom sheets /// are stacked on top. final Widget bottomNavigationBar; /// The key of the primary [Scrollable] widget in the [body]. /// /// Used to control scroll-linked effects, such as the collapse of the /// [appBar]. final GlobalKey<ScrollableState> scrollableKey; /// How the [appBar] should respond to scrolling. /// /// By default, the [appBar] does not respond to scrolling. final AppBarBehavior appBarBehavior; /// Whether the [body] (and other floating widgets) should size themselves to /// avoid the window's bottom padding. /// /// 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. final bool resizeToAvoidBottomPadding; /// The state from the closest instance of this class that encloses the given context. /// /// Typical usage is as follows: /// /// ```dart /// @override /// Widget build(BuildContext context) { /// return new RaisedButton( /// child: new Text('SHOW A SNACKBAR'), /// onPressed: () { /// Scaffold.of(context).showSnackBar(new SnackBar( /// content: new Text('Hello!'), /// )); /// }, /// ); /// } /// ``` /// /// When the [Scaffold] is actually created in the same `build` function, the /// `context` argument to the `build` function can't be used to find the /// [Scaffold] (since it's "above" the widget being returned). In such cases, /// the following technique with a [Builder] can be used to provide a new /// scope with a [BuildContext] that is "under" the [Scaffold]: /// /// ```dart /// @override /// Widget build(BuildContext context) { /// return new Scaffold( /// appBar: new AppBar( /// title: new Text('Demo') /// ), /// body: new Builder( /// // Create an inner BuildContext so that the onPressed methods /// // can refer to the Scaffold with Scaffold.of(). /// builder: (BuildContext context) { /// return new Center( /// child: new RaisedButton( /// child: new Text('SHOW A SNACKBAR'), /// onPressed: () { /// Scaffold.of(context).showSnackBar(new SnackBar( /// content: new Text('Hello!'), /// )); /// }, /// ), /// ); /// }, /// ), /// ); /// } /// ``` /// /// If there is no [Scaffold] in scope, then this will throw an exception. /// To return null if there is no [Scaffold], then pass `nullOk: true`. static ScaffoldState of(BuildContext context, { bool nullOk: false }) { assert(nullOk != null); assert(context != null); ScaffoldState result = context.ancestorStateOfType(const TypeMatcher<ScaffoldState>()); if (nullOk || result != null) return result; throw new FlutterError( 'Scaffold.of() called with a context that does not contain a Scaffold.\n' 'No Scaffold ancestor could be found starting from the context that was passed to Scaffold.of(). ' 'This usually happens when the context provided is from the same StatefulWidget as that ' 'whose build function actually creates the Scaffold widget being sought.\n' 'There are several ways to avoid this problem. The simplest is to use a Builder to get a ' 'context that is "under" the Scaffold. For an example of this, please see the ' 'documentation for Scaffold.of():\n' ' https://docs.flutter.io/flutter/material/Scaffold/of.html\n' 'A more efficient solution is to split your build function into several widgets. This ' 'introduces a new context from which you can obtain the Scaffold. In this solution, ' 'you would have an outer widget that creates the Scaffold populated by instances of ' 'your new inner widgets, and then in these inner widgets you would use Scaffold.of().\n' 'A less elegant but more expedient solution is assign a GlobalKey to the Scaffold, ' 'then use the key.currentState property to obtain the ScaffoldState rather than ' 'using the Scaffold.of() function.\n' 'The context used was:\n' ' $context' ); } @override ScaffoldState createState() => new ScaffoldState(); } /// State for a [Scaffold]. /// /// Can display [SnackBar]s and [BottomSheet]s. Retrieve a [ScaffoldState] from /// the current [BuildContext] using [Scaffold.of]. class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin { static final Object _kScaffoldStorageIdentifier = new Object(); // APPBAR API AnimationController _appBarController; /// The animation controlling the size of the app bar. /// /// Useful for linking animation effects to the expansion and collapse of the /// app bar. Animation<double> get appBarAnimation => _appBarController.view; /// The height of the app bar when fully expanded. /// /// See [AppBar.expandedHeight]. double get appBarHeight => config.appBar?.expandedHeight ?? 0.0; // DRAWER API final GlobalKey<DrawerControllerState> _drawerKey = new GlobalKey<DrawerControllerState>(); /// 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. /// /// Normally this is not needed since the [Scaffold] automatically shows an /// appropriate [IconButton], and handles the edge-swipe gesture, to show the /// drawer. /// /// To close the drawer once it is open, use [Navigator.pop]. void openDrawer() { _drawerKey.currentState?.open(); } // SNACKBAR API Queue<ScaffoldFeatureController<SnackBar, Null>> _snackBars = new Queue<ScaffoldFeatureController<SnackBar, Null>>(); AnimationController _snackBarController; Timer _snackBarTimer; /// Shows a [SnackBar] at the bottom of 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. /// /// To control how long a [SnackBar] remains visible, use [SnackBar.duration]. /// /// To remove a [SnackBar] suddenly (without an animation), use /// [removeCurrentSnackBar]. ScaffoldFeatureController<SnackBar, Null> showSnackBar(SnackBar snackbar) { _snackBarController ??= SnackBar.createAnimationController(vsync: this) ..addStatusListener(_handleSnackBarStatusChange); if (_snackBars.isEmpty) { assert(_snackBarController.isDismissed); _snackBarController.forward(); } ScaffoldFeatureController<SnackBar, Null> controller; controller = new ScaffoldFeatureController<SnackBar, Null>._( // 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. snackbar.withAnimation(_snackBarController, fallbackKey: new UniqueKey()), new Completer<Null>(), () { assert(_snackBars.first == controller); _hideSnackBar(); }, null // SnackBar doesn't use a builder function so setState() wouldn't rebuild it ); setState(() { _snackBars.addLast(controller); }); return controller; } void _handleSnackBarStatusChange(AnimationStatus status) { switch (status) { case AnimationStatus.dismissed: assert(_snackBars.isNotEmpty); setState(() { _snackBars.removeFirst(); }); if (_snackBars.isNotEmpty) _snackBarController.forward(); break; case AnimationStatus.completed: setState(() { assert(_snackBarTimer == null); // build will create a new timer if necessary to dismiss the snack bar }); break; case AnimationStatus.forward: case AnimationStatus.reverse: break; } } /// 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. 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; } void _hideSnackBar() { assert(_snackBarController.status == AnimationStatus.forward || _snackBarController.status == AnimationStatus.completed); _snackBars.first._completer.complete(); _snackBarController.reverse(); _snackBarTimer?.cancel(); _snackBarTimer = null; } // PERSISTENT BOTTOM SHEET API final List<_PersistentBottomSheet> _dismissedBottomSheets = <_PersistentBottomSheet>[]; PersistentBottomSheetController<dynamic> _currentBottomSheet; /// 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://material.google.com/components/bottom-sheets.html#bottom-sheets-persistent-bottom-sheets> PersistentBottomSheetController<dynamic/*=T*/> showBottomSheet/*<T>*/(WidgetBuilder builder) { if (_currentBottomSheet != null) { _currentBottomSheet.close(); assert(_currentBottomSheet == null); } Completer<dynamic/*=T*/> completer = new Completer<dynamic/*=T*/>(); GlobalKey<_PersistentBottomSheetState> bottomSheetKey = new GlobalKey<_PersistentBottomSheetState>(); AnimationController controller = BottomSheet.createAnimationController(this) ..forward(); _PersistentBottomSheet bottomSheet; LocalHistoryEntry entry = new LocalHistoryEntry( onRemove: () { assert(_currentBottomSheet._widget == bottomSheet); assert(bottomSheetKey.currentState != null); bottomSheetKey.currentState.close(); if (controller.status != AnimationStatus.dismissed) _dismissedBottomSheets.add(bottomSheet); setState(() { _currentBottomSheet = null; }); completer.complete(); } ); bottomSheet = new _PersistentBottomSheet( key: bottomSheetKey, animationController: controller, onClosing: () { assert(_currentBottomSheet._widget == bottomSheet); entry.remove(); }, onDismissed: () { if (_dismissedBottomSheets.contains(bottomSheet)) { setState(() { _dismissedBottomSheets.remove(bottomSheet); }); } }, builder: builder ); ModalRoute.of(context).addLocalHistoryEntry(entry); setState(() { _currentBottomSheet = new PersistentBottomSheetController<dynamic/*=T*/>._( bottomSheet, completer, () => entry.remove(), (VoidCallback fn) { bottomSheetKey.currentState?.setState(fn); } ); }); return _currentBottomSheet; } // INTERNALS @override void initState() { super.initState(); _appBarController = new AnimationController(vsync: this); // 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 ); if (scrollValues != null) { assert(scrollValues.length == 2); _scrollOffset = scrollValues[0]; _scrollOffsetDelta = scrollValues[1]; } } @override void dispose() { _appBarController.dispose(); _snackBarController?.dispose(); _snackBarController = null; _snackBarTimer?.cancel(); _snackBarTimer = null; for (_PersistentBottomSheet bottomSheet in _dismissedBottomSheets) bottomSheet.animationController.dispose(); if (_currentBottomSheet != null) _currentBottomSheet._widget.animationController.dispose(); PageStorage.of(context)?.writeState(context, <double>[_scrollOffset, _scrollOffsetDelta], identifier: _kScaffoldStorageIdentifier ); super.dispose(); } void _addIfNonNull(List<LayoutId> children, Widget child, Object childId) { if (child != null) children.add(new LayoutId(child: child, id: childId)); } bool _shouldShowBackArrow; Widget _getModifiedAppBar({ EdgeInsets padding, int elevation}) { AppBar appBar = config.appBar; if (appBar == null) return null; Widget leading = appBar.leading; if (leading == null) { if (config.drawer != null) { leading = new IconButton( icon: new Icon(Icons.menu), alignment: FractionalOffset.centerLeft, onPressed: openDrawer, tooltip: 'Open navigation menu' // TODO(ianh): Figure out how to localize this string ); } else { _shouldShowBackArrow ??= Navigator.canPop(context); if (_shouldShowBackArrow) { IconData backIcon; switch (Theme.of(context).platform) { case TargetPlatform.android: case TargetPlatform.fuchsia: backIcon = Icons.arrow_back; break; case TargetPlatform.iOS: backIcon = Icons.arrow_back_ios; break; } assert(backIcon != null); leading = new IconButton( icon: new Icon(backIcon), alignment: FractionalOffset.centerLeft, onPressed: () => Navigator.pop(context), tooltip: 'Back' // TODO(ianh): Figure out how to localize this string ); } } } return appBar.copyWith( elevation: elevation ?? appBar.elevation ?? 4, padding: new EdgeInsets.only(top: padding.top), leading: leading ); } double _scrollOffset = 0.0; double _scrollOffsetDelta = 0.0; double _floatingAppBarHeight = 0.0; bool _handleScrollNotification(ScrollNotification notification) { final ScrollableState scrollable = notification.scrollable; if ((scrollable.config.scrollDirection == Axis.vertical) && (config.scrollableKey == null || config.scrollableKey == scrollable.config.key)) { double newScrollOffset = scrollable.scrollOffset; final ClampOverscrolls clampOverscrolls = ClampOverscrolls.of(context); if (clampOverscrolls != null) newScrollOffset = clampOverscrolls.clampScrollOffset(scrollable); if (_scrollOffset != newScrollOffset) { setState(() { _scrollOffsetDelta = _scrollOffset - newScrollOffset; _scrollOffset = newScrollOffset; }); } } return false; } Widget _buildAnchoredAppBar(double expandedHeight, double height, EdgeInsets padding) { // Drive _appBarController to the point where the flexible space has disappeared. _appBarController.value = (expandedHeight - height) / expandedHeight; return new SizedBox( height: height, child: _getModifiedAppBar(padding: padding) ); } Widget _buildScrollableAppBar(BuildContext context, EdgeInsets padding) { final double expandedHeight = (config.appBar?.expandedHeight ?? 0.0) + padding.top; final double collapsedHeight = (config.appBar?.collapsedHeight ?? 0.0) + padding.top; final double bottomHeight = config.appBar?.bottomHeight + padding.top; final double underHeight = config.appBar.bottom != null ? bottomHeight : collapsedHeight; Widget appBar; if (_scrollOffset <= expandedHeight && _scrollOffset >= expandedHeight - underHeight) { // scrolled to the top, flexible space collapsed, only the toolbar and tabbar are (partially) visible. if (config.appBarBehavior == AppBarBehavior.under) { appBar = _buildAnchoredAppBar(expandedHeight, underHeight, padding); } else { final double height = math.max(_floatingAppBarHeight, expandedHeight - _scrollOffset); _appBarController.value = (expandedHeight - height) / expandedHeight; appBar = new SizedBox( height: height, child: _getModifiedAppBar(padding: padding) ); } } else if (_scrollOffset > expandedHeight) { // scrolled past the entire app bar, maybe show the "floating" toolbar. if (config.appBarBehavior == AppBarBehavior.under) { appBar = _buildAnchoredAppBar(expandedHeight, underHeight, padding); } else { _floatingAppBarHeight = (_floatingAppBarHeight + _scrollOffsetDelta).clamp(0.0, collapsedHeight); _appBarController.value = (expandedHeight - _floatingAppBarHeight) / expandedHeight; appBar = new SizedBox( height: _floatingAppBarHeight, child: _getModifiedAppBar(padding: padding) ); } } else { // _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; appBar = new SizedBox( height: height, child: _getModifiedAppBar(padding: padding, elevation: 0) ); _floatingAppBarHeight = 0.0; } return appBar; } // On iOS, tapping the status bar scrolls the app's primary scrollable to the top. void _handleStatusBarTap() { ScrollableState scrollable = config.scrollableKey?.currentState; if (scrollable == null || scrollable.scrollBehavior is! ExtentScrollBehavior) return; ExtentScrollBehavior behavior = scrollable.scrollBehavior; scrollable.scrollTo( behavior.minScrollOffset, duration: const Duration(milliseconds: 300) ); } // IOS-specific back gesture. final GlobalKey _backGestureKey = new GlobalKey(); NavigationGestureController _backGestureController; bool _shouldHandleBackGesture() { return Theme.of(context).platform == TargetPlatform.iOS && Navigator.canPop(context); } void _handleDragStart(DragStartDetails details) { _backGestureController = Navigator.of(context).startPopGesture(); } void _handleDragUpdate(DragUpdateDetails details) { _backGestureController?.dragUpdate(details.primaryDelta / context.size.width); } void _handleDragEnd(DragEndDetails details) { final bool willPop = _backGestureController?.dragEnd(details.velocity.pixelsPerSecond.dx / context.size.width) ?? false; if (willPop) _currentBottomSheet?.close(); _backGestureController = null; } void _handleDragCancel() { final bool willPop = _backGestureController?.dragEnd(0.0) ?? false; if (willPop) _currentBottomSheet?.close(); _backGestureController = null; } @override Widget build(BuildContext context) { EdgeInsets padding = MediaQuery.of(context).padding; ThemeData themeData = Theme.of(context); if (!config.resizeToAvoidBottomPadding) padding = new EdgeInsets.fromLTRB(padding.left, padding.top, padding.right, 0.0); if (_snackBars.length > 0) { final ModalRoute<dynamic> route = ModalRoute.of(context); if (route == null || route.isCurrent) { if (_snackBarController.isCompleted && _snackBarTimer == null) _snackBarTimer = new Timer(_snackBars.first._widget.duration, _hideSnackBar); } else { _snackBarTimer?.cancel(); _snackBarTimer = null; } } final List<LayoutId> children = new List<LayoutId>(); Widget body; if (config.appBarBehavior != AppBarBehavior.anchor) { body = new NotificationListener<ScrollNotification>( onNotification: _handleScrollNotification, child: config.body, ); } else { body = config.body; } _addIfNonNull(children, body, _ScaffoldSlot.body); if (config.appBarBehavior == AppBarBehavior.anchor) { final double expandedHeight = (config.appBar?.expandedHeight ?? 0.0) + padding.top; final Widget appBar = new ConstrainedBox( constraints: new BoxConstraints(maxHeight: expandedHeight), child: _getModifiedAppBar(padding: padding) ); _addIfNonNull(children, appBar, _ScaffoldSlot.appBar); } else { children.add(new LayoutId(child: _buildScrollableAppBar(context, padding), id: _ScaffoldSlot.appBar)); } // Otherwise the AppBar will be part of a [app bar, body] Stack. See // AppBarBehavior.scroll below. if (_snackBars.isNotEmpty) _addIfNonNull(children, _snackBars.first._widget, _ScaffoldSlot.snackBar); if (config.persistentFooterButtons != null) { children.add(new LayoutId( id: _ScaffoldSlot.persistentFooter, child: new Container( decoration: new BoxDecoration( border: new Border( top: new BorderSide( color: themeData.dividerColor ), ), ), child: new ButtonTheme.bar( child: new ButtonBar( children: config.persistentFooterButtons ), ), ), )); } if (config.bottomNavigationBar != null) { children.add(new LayoutId( id: _ScaffoldSlot.bottomNavigationBar, child: config.bottomNavigationBar )); } if (_currentBottomSheet != null || _dismissedBottomSheets.isNotEmpty) { final List<Widget> bottomSheets = <Widget>[]; if (_dismissedBottomSheets.isNotEmpty) bottomSheets.addAll(_dismissedBottomSheets); if (_currentBottomSheet != null) bottomSheets.add(_currentBottomSheet._widget); Widget stack = new Stack( children: bottomSheets, alignment: FractionalOffset.bottomCenter ); _addIfNonNull(children, stack, _ScaffoldSlot.bottomSheet); } children.add(new LayoutId( id: _ScaffoldSlot.floatingActionButton, child: new _FloatingActionButtonTransition( child: config.floatingActionButton ) )); if (themeData.platform == TargetPlatform.iOS) { children.add(new LayoutId( id: _ScaffoldSlot.statusBar, child: new GestureDetector( behavior: HitTestBehavior.opaque, onTap: _handleStatusBarTap ) )); } if (config.drawer != null) { children.add(new LayoutId( id: _ScaffoldSlot.drawer, child: new DrawerController( key: _drawerKey, child: config.drawer ) )); } else if (_shouldHandleBackGesture()) { // Add a gesture for navigating back. children.add(new LayoutId( id: _ScaffoldSlot.drawer, child: new Align( alignment: FractionalOffset.centerLeft, child: new GestureDetector( key: _backGestureKey, onHorizontalDragStart: _handleDragStart, onHorizontalDragUpdate: _handleDragUpdate, onHorizontalDragEnd: _handleDragEnd, onHorizontalDragCancel: _handleDragCancel, behavior: HitTestBehavior.translucent, excludeFromSemantics: true, child: new Container(width: _kBackGestureWidth) ) ) )); } EdgeInsets appPadding = (config.appBarBehavior != AppBarBehavior.anchor) ? EdgeInsets.zero : padding; Widget application = new CustomMultiChildLayout( children: children, delegate: new _ScaffoldLayout( padding: appPadding, statusBarHeight: padding.top, appBarBehavior: config.appBarBehavior ) ); return new Material(child: application); } } /// An interface for controlling a feature of a [Scaffold]. /// /// Commonly obtained from [Scaffold.showSnackBar] or [Scaffold.showBottomSheet]. class ScaffoldFeatureController<T extends Widget, U> { const ScaffoldFeatureController._(this._widget, this._completer, this.close, this.setState); final T _widget; final Completer<U> _completer; /// Completes when the feature controlled by this object is no longer visible. Future<U> get closed => _completer.future; /// 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. final StateSetter setState; } class _PersistentBottomSheet extends StatefulWidget { _PersistentBottomSheet({ Key key, this.animationController, this.onClosing, this.onDismissed, this.builder }) : super(key: key); final AnimationController animationController; final VoidCallback onClosing; final VoidCallback onDismissed; final WidgetBuilder builder; @override _PersistentBottomSheetState createState() => new _PersistentBottomSheetState(); } class _PersistentBottomSheetState extends State<_PersistentBottomSheet> { // We take ownership of the animation controller given in the first configuration. // We also share control of that animation with out BottomSheet widget. @override void initState() { super.initState(); assert(config.animationController.status == AnimationStatus.forward); config.animationController.addStatusListener(_handleStatusChange); } @override void didUpdateConfig(_PersistentBottomSheet oldConfig) { super.didUpdateConfig(oldConfig); assert(config.animationController == oldConfig.animationController); } @override void dispose() { config.animationController.stop(); super.dispose(); } void close() { config.animationController.reverse(); } void _handleStatusChange(AnimationStatus status) { if (status == AnimationStatus.dismissed && config.onDismissed != null) config.onDismissed(); } @override Widget build(BuildContext context) { return new AnimatedBuilder( animation: config.animationController, builder: (BuildContext context, Widget child) { return new Align( alignment: FractionalOffset.topLeft, heightFactor: config.animationController.value, child: child ); }, child: new Semantics( container: true, child: new BottomSheet( animationController: config.animationController, onClosing: config.onClosing, builder: config.builder ) ) ); } } /// 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); }