// 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 'drawer.dart'; import 'icons.dart'; import 'icon_button.dart'; import 'material.dart'; import 'snack_bar.dart'; const double _kFloatingActionButtonMargin = 16.0; // TODO(hmuller): should be device dependent const Duration _kFloatingActionButtonSegue = const Duration(milliseconds: 400); /// The Scaffold's appbar is the toolbar, tabbar, and the "flexible space" that's /// stacked behind them. The Scaffold's appBarBehavior defines how the appbar /// responds to scrolling the application. enum AppBarBehavior { /// The tool bar's layout does not respond to scrolling. anchor, /// The tool 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 entire toolbar fade outs and scrolls off the top of the screen. /// Scrolling upwards always causes the toolbar to reappear. scroll, /// The tool 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. /// Other than that, the toolbar remains anchored at the top. under, } enum _ScaffoldSlot { body, appBar, bottomSheet, snackBar, floatingActionButton, drawer, } class _ScaffoldLayout extends MultiChildLayoutDelegate { _ScaffoldLayout({ this.padding, this.appBarBehavior: AppBarBehavior.anchor }); final EdgeInsets padding; 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 contentBottom = size.height - padding.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.body)) { final double bodyHeight = contentBottom - contentTop; final BoxConstraints bodyConstraints = fullWidthConstraints.tighten(height: 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. // 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); positionChild(_ScaffoldSlot.bottomSheet, new Offset((size.width - bottomSheetSize.width) / 2.0, contentBottom - 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.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) { assert(child != null); } final Widget child; @override _FloatingActionButtonTransitionState createState() => new _FloatingActionButtonTransitionState(); } class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTransition> { final AnimationController controller = new AnimationController(duration: _kFloatingActionButtonSegue); Widget oldChild; @override void initState() { super.initState(); controller.forward().then((_) { oldChild = null; }); } @override void dispose() { controller.stop(); super.dispose(); } @override void didUpdateConfig(_FloatingActionButtonTransition oldConfig) { if (Widget.canUpdate(oldConfig.child, config.child)) return; oldChild = oldConfig.child; controller ..value = 0.0 ..forward().then((_) { oldChild = null; }); } @override Widget build(BuildContext context) { final List<Widget> children = new List<Widget>(); if (oldChild != null) { children.add(new ScaleTransition( // TODO(abarth): We should use ReversedAnimation here. scale: new Tween<double>( begin: 1.0, end: 0.0 ).animate(new CurvedAnimation( parent: controller, curve: const Interval(0.0, 0.5, curve: Curves.easeIn) )), child: oldChild )); } children.add(new ScaleTransition( scale: new CurvedAnimation( parent: controller, curve: const Interval(0.5, 1.0, curve: Curves.easeIn) ), child: config.child )); return new Stack(children: children); } } /// Implements the basic material design visual layout structure. /// /// This class provides APIs for showing drawers, snackbars, and bottom sheets. /// /// See: <https://www.google.com/design/spec/layout/structure.html> class Scaffold extends StatefulWidget { Scaffold({ Key key, this.appBar, this.body, this.floatingActionButton, this.drawer, this.scrollableKey, this.appBarBehavior: AppBarBehavior.anchor }) : super(key: key) { assert((appBarBehavior == AppBarBehavior.scroll) ? scrollableKey != null : true); } final AppBar appBar; final Widget body; final Widget floatingActionButton; final Widget drawer; final Key scrollableKey; final AppBarBehavior appBarBehavior; /// The state from the closest instance of this class that encloses the given context. static ScaffoldState of(BuildContext context) => context.ancestorStateOfType(const TypeMatcher<ScaffoldState>()); @override ScaffoldState createState() => new ScaffoldState(); } class ScaffoldState extends State<Scaffold> { // APPBAR API AnimationController _appBarController; Animation<double> get appBarAnimation => _appBarController.view; double get appBarHeight => config.appBar?.expandedHeight ?? 0.0; // DRAWER API final GlobalKey<DrawerControllerState> _drawerKey = new GlobalKey<DrawerControllerState>(); void openDrawer() { _drawerKey.currentState.open(); } // SNACKBAR API Queue<ScaffoldFeatureController<SnackBar, Null>> _snackBars = new Queue<ScaffoldFeatureController<SnackBar, Null>>(); AnimationController _snackBarController; Timer _snackBarTimer; ScaffoldFeatureController<SnackBar, Null> showSnackBar(SnackBar snackbar) { _snackBarController ??= SnackBar.createAnimationController() ..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; } } 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 List<Widget> _dismissedBottomSheets; PersistentBottomSheetController<dynamic> _currentBottomSheet; 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() ..forward(); _PersistentBottomSheet bottomSheet; LocalHistoryEntry entry = new LocalHistoryEntry( onRemove: () { 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, animationController: controller, onClosing: () { assert(_currentBottomSheet._widget == bottomSheet); entry.remove(); }, onDismissed: () { assert(_dismissedBottomSheets != null); 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(); List<double> scrollValues = PageStorage.of(context)?.readState(context); if (scrollValues != null) { assert(scrollValues.length == 2); _scrollOffset = scrollValues[0]; _scrollOffsetDelta = scrollValues[1]; } } @override void dispose() { _appBarController.stop(); _snackBarController?.stop(); _snackBarController = null; _snackBarTimer?.cancel(); _snackBarTimer = null; PageStorage.of(context)?.writeState(context, <double>[_scrollOffset, _scrollOffsetDelta]); 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, double actualHeight}) { AppBar appBar = config.appBar; if (appBar == null) return null; Widget leading = appBar.leading; if (leading == null) { if (config.drawer != null) { leading = new IconButton( icon: Icons.menu, onPressed: openDrawer, tooltip: 'Open navigation menu' // TODO(ianh): Figure out how to localize this string ); } else { _shouldShowBackArrow ??= Navigator.canPop(context); if (_shouldShowBackArrow) { leading = new IconButton( icon: Icons.arrow_back, 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, actualHeight: actualHeight ); } double _scrollOffset = 0.0; double _scrollOffsetDelta = 0.0; double _floatingAppBarHeight = 0.0; bool _handleScrollNotification(ScrollNotification notification) { if (config.scrollableKey != null && config.scrollableKey == notification.scrollable.config.key) { final double newScrollOffset = notification.scrollable.scrollOffset; 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, actualHeight: height) ); } Widget _buildScrollableAppBar(BuildContext context) { final EdgeInsets padding = MediaQuery.of(context).padding; final double expandedHeight = (config.appBar?.expandedHeight ?? 0.0) + padding.top; final double collapsedHeight = (config.appBar?.collapsedHeight ?? 0.0) + padding.top; final double minimumHeight = (config.appBar?.minimumHeight ?? 0.0) + padding.top; Widget appBar; if (_scrollOffset <= expandedHeight && _scrollOffset >= expandedHeight - minimumHeight) { // scrolled to the top, flexible space collapsed, only the toolbar and tabbar are (partially) visible. if (config.appBarBehavior == AppBarBehavior.under) { appBar = _buildAnchoredAppBar(expandedHeight, minimumHeight, padding); } else { final double height = math.max(_floatingAppBarHeight, expandedHeight - _scrollOffset); _appBarController.value = (expandedHeight - height) / expandedHeight; appBar = new SizedBox( height: height, child: _getModifiedAppBar(padding: padding, actualHeight: height) ); } } else if (_scrollOffset > expandedHeight) { // scrolled past the entire app bar, maybe show the "floating" toolbar. if (config.appBarBehavior == AppBarBehavior.under) { appBar = _buildAnchoredAppBar(expandedHeight, minimumHeight, padding); } else { _floatingAppBarHeight = (_floatingAppBarHeight + _scrollOffsetDelta).clamp(0.0, collapsedHeight); _appBarController.value = (expandedHeight - _floatingAppBarHeight) / expandedHeight; appBar = new SizedBox( height: _floatingAppBarHeight, child: _getModifiedAppBar(padding: padding, actualHeight: _floatingAppBarHeight) ); } } 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, actualHeight: height) ); _floatingAppBarHeight = 0.0; } return appBar; } @override Widget build(BuildContext context) { final EdgeInsets padding = MediaQuery.of(context).padding; 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>(); _addIfNonNull(children, config.body, _ScaffoldSlot.body); if (config.appBarBehavior == AppBarBehavior.anchor) { final double expandedHeight = (config.appBar?.expandedHeight ?? 0.0) + padding.top; final Widget appBar = new ConstrainedBox( child: _getModifiedAppBar(padding: padding, actualHeight: expandedHeight), constraints: new BoxConstraints(maxHeight: expandedHeight) ); _addIfNonNull(children, appBar, _ScaffoldSlot.appBar); } else { children.add(new LayoutId(child: _buildScrollableAppBar(context), id: _ScaffoldSlot.appBar)); } // Otherwise the AppBar will be part of a [app bar, body] Stack. See AppBarBehavior.scroll below. if (_currentBottomSheet != null || (_dismissedBottomSheets != null && _dismissedBottomSheets.isNotEmpty)) { final List<Widget> bottomSheets = <Widget>[]; if (_dismissedBottomSheets != null && _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); } if (_snackBars.isNotEmpty) _addIfNonNull(children, _snackBars.first._widget, _ScaffoldSlot.snackBar); if (config.floatingActionButton != null) { final Widget fab = new _FloatingActionButtonTransition( key: new ValueKey<Key>(config.floatingActionButton.key), child: config.floatingActionButton ); children.add(new LayoutId(child: fab, id: _ScaffoldSlot.floatingActionButton)); } if (config.drawer != null) { children.add(new LayoutId( id: _ScaffoldSlot.drawer, child: new DrawerController( key: _drawerKey, child: config.drawer ) )); } Widget application; if (config.appBarBehavior != AppBarBehavior.anchor) { application = new NotificationListener<ScrollNotification>( onNotification: _handleScrollNotification, child: new CustomMultiChildLayout( children: children, delegate: new _ScaffoldLayout( padding: EdgeInsets.zero, appBarBehavior: config.appBarBehavior ) ) ); } else { application = new CustomMultiChildLayout( children: children, delegate: new _ScaffoldLayout( padding: padding ) ); } return new Material(child: application); } } class ScaffoldFeatureController<T extends Widget, U> { const ScaffoldFeatureController._(this._widget, this._completer, this.close, this.setState); final T _widget; final Completer<U> _completer; Future<U> get closed => _completer.future; final VoidCallback close; // call this to close the bottom sheet or snack bar 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); }