blob: faf2cc86aad622781e4f745c2d10a9cfa41a3766 [file]
// 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 'constants.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 });
final EdgeInsets padding;
@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)) {
contentTop = layoutChild(_ScaffoldSlot.appBar, fullWidthConstraints).height;
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,
this.appBarHeight
}) : super(key: key) {
assert((appBarBehavior == AppBarBehavior.scroll) ? scrollableKey != null : true);
assert((appBarBehavior == AppBarBehavior.scroll) ? appBarHeight != null && appBarHeight > kToolBarHeight : true);
}
final AppBar appBar;
final Widget body;
final Widget floatingActionButton;
final Widget drawer;
final Key scrollableKey;
final AppBarBehavior appBarBehavior;
final double appBarHeight;
/// 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.appBarHeight;
// 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, double foregroundOpacity: 1.0, int elevation }) {
AppBar appBar = config.appBar;
if (appBar == null)
return null;
EdgeInsets appBarPadding = new EdgeInsets.only(top: padding.top);
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: appBarPadding,
foregroundOpacity: foregroundOpacity,
leading: leading
);
}
double _scrollOffset = 0.0;
double _scrollOffsetDelta = 0.0;
double _floatingAppBarHeight = 0.0;
bool _handleScrollNotification(ScrollNotification notification) {
final double newScrollOffset = notification.scrollable.scrollOffset;
if (config.scrollableKey != null && config.scrollableKey == notification.scrollable.config.key)
setState(() {
_scrollOffsetDelta = _scrollOffset - newScrollOffset;
_scrollOffset = newScrollOffset;
});
return false;
}
double _appBarOpacity(double progress) {
// The value of progress is 1.0 if the entire (padded) app bar is visible, 0.0
// if the app bar's height is zero.
return new Tween<double>(begin: 0.0, end: 1.0).evaluate(new CurvedAnimation(
parent: new AnimationController()..value = progress.clamp(0.0, 1.0),
curve: new Interval(0.50, 1.0)
));
}
Widget _buildAnchoredAppBar(double toolBarHeight, EdgeInsets toolBarPadding) {
// Drive _appBarController to the point where the flexible space has disappeared.
_appBarController.value = (appBarHeight - toolBarHeight) / appBarHeight;
return new SizedBox(
height: toolBarHeight,
child: _getModifiedAppBar(padding: toolBarPadding)
);
}
Widget _buildScrollableAppBar(BuildContext context) {
final EdgeInsets toolBarPadding = MediaQuery.of(context)?.padding ?? EdgeInsets.zero;
final double toolBarHeight = kToolBarHeight + toolBarPadding.top;
Widget appBar;
if (_scrollOffset <= appBarHeight && _scrollOffset >= appBarHeight - toolBarHeight) {
// scrolled to the top, only the toolbar is (partially) visible.
if (config.appBarBehavior == AppBarBehavior.under) {
appBar = _buildAnchoredAppBar(toolBarHeight, toolBarPadding);
} else {
final double height = math.max(_floatingAppBarHeight, appBarHeight - _scrollOffset);
final double opacity = _appBarOpacity(1.0 - ((toolBarHeight - height) / toolBarHeight));
_appBarController.value = (appBarHeight - height) / appBarHeight;
appBar = new SizedBox(
height: height,
child: _getModifiedAppBar(padding: toolBarPadding, foregroundOpacity: opacity)
);
}
} else if (_scrollOffset > appBarHeight) {
// scrolled past the entire app bar, maybe show the "floating" toolbar.
if (config.appBarBehavior == AppBarBehavior.under) {
appBar = _buildAnchoredAppBar(toolBarHeight, toolBarPadding);
} else {
_floatingAppBarHeight = (_floatingAppBarHeight + _scrollOffsetDelta).clamp(0.0, toolBarHeight);
final double toolBarOpacity = _appBarOpacity(_floatingAppBarHeight / toolBarHeight);
_appBarController.value = (appBarHeight - _floatingAppBarHeight) / appBarHeight;
appBar = new SizedBox(
height: _floatingAppBarHeight,
child: _getModifiedAppBar(padding: toolBarPadding, foregroundOpacity: toolBarOpacity)
);
}
} else {
// _scrollOffset < appBarHeight - appBarHeight, scrolled to the top, flexible space is visible
final double height = appBarHeight - _scrollOffset.clamp(0.0, appBarHeight);
_appBarController.value = (appBarHeight - height) / appBarHeight;
appBar = new SizedBox(
height: height,
child: _getModifiedAppBar(padding: toolBarPadding, elevation: 0)
);
_floatingAppBarHeight = 0.0;
}
return appBar;
}
@override
Widget build(BuildContext context) {
final EdgeInsets padding = MediaQuery.of(context)?.padding ?? EdgeInsets.zero;
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 Widget appBar = new ConstrainedBox(
child: _getModifiedAppBar(padding: padding),
constraints: new BoxConstraints(maxHeight: config.appBarHeight ?? kExtendedAppBarHeight + padding.top)
);
_addIfNonNull(children, appBar, _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: const FractionalOffset(0.5, 1.0) // bottom-aligned, centered
);
_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 Stack(
children: <Widget> [
new CustomMultiChildLayout(
children: children,
delegate: new _ScaffoldLayout(
padding: EdgeInsets.zero
)
),
new Positioned(
top: 0.0,
left: 0.0,
right: 0.0,
child: _buildScrollableAppBar(context)
)
]
)
);
} 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: const FractionalOffset(0.0, 0.0),
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);
}