Commit 83a4cf26 authored by Ian Hickson's avatar Ian Hickson Committed by GitHub

Port AppBar to Scrollable2 (#7996)

Move the back button and drawer opening logic into the app bar.

Move the tap-status-bar-to-scroll-to-top logic to using
ScrollControllers. Provide a PrimaryScrollController and a `primary`
flag on scroll views.

Make it possible to track when a route becomes or stops being poppable.
parent 8838a8fb
......@@ -87,15 +87,16 @@ class ContactsDemo extends StatefulWidget {
ContactsDemoState createState() => new ContactsDemoState();
}
enum AppBarBehavior { normal, pinned, floating }
class ContactsDemoState extends State<ContactsDemo> {
static final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
static final GlobalKey<ScrollableState> _scrollableKey = new GlobalKey<ScrollableState>();
final double _appBarHeight = 256.0;
AppBarBehavior _appBarBehavior = AppBarBehavior.under;
AppBarBehavior _appBarBehavior;
@override
Widget build(BuildContext context) {
final double statusBarHeight = MediaQuery.of(context).padding.top;
return new Theme(
data: new ThemeData(
brightness: Brightness.light,
......@@ -104,220 +105,226 @@ class ContactsDemoState extends State<ContactsDemo> {
),
child: new Scaffold(
key: _scaffoldKey,
scrollableKey: _scrollableKey,
appBarBehavior: _appBarBehavior,
appBar: new AppBar(
expandedHeight: _appBarHeight,
actions: <Widget>[
new IconButton(
icon: new Icon(Icons.create),
tooltip: 'Edit',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('This is actually just a demo. Editing isn\'t supported.')
));
}
),
new PopupMenuButton<AppBarBehavior>(
onSelected: (AppBarBehavior value) {
setState(() {
_appBarBehavior = value;
});
},
itemBuilder: (BuildContext context) => <PopupMenuItem<AppBarBehavior>>[
new PopupMenuItem<AppBarBehavior>(
value: AppBarBehavior.scroll,
child: new Text('App bar scrolls away')
),
new PopupMenuItem<AppBarBehavior>(
value: AppBarBehavior.under,
child: new Text('App bar stays put')
)
]
)
],
flexibleSpace: new FlexibleSpaceBar(
title : new Text('Ali Connors'),
background: new Stack(
children: <Widget>[
new Image.asset(
'packages/flutter_gallery_assets/ali_connors.jpg',
fit: ImageFit.cover,
height: _appBarHeight
),
// This gradient ensures that the toolbar icons are distinct
// against the background image.
new DecoratedBox(
decoration: new BoxDecoration(
gradient: new LinearGradient(
begin: const FractionalOffset(0.5, 0.0),
end: const FractionalOffset(0.5, 0.30),
colors: <Color>[const Color(0x60000000), const Color(0x00000000)]
)
)
)
]
)
)
),
body: new Block(
padding: new EdgeInsets.only(top: _appBarHeight + statusBarHeight),
scrollableKey: _scrollableKey,
children: <Widget>[
new _ContactCategory(
icon: Icons.call,
children: <Widget>[
new _ContactItem(
icon: Icons.message,
tooltip: 'Send message',
body: new CustomScrollView(
slivers: <Widget>[
new SliverAppBar(
expandedHeight: _appBarHeight,
pinned: _appBarBehavior == AppBarBehavior.pinned,
floating: _appBarBehavior == AppBarBehavior.floating,
actions: <Widget>[
new IconButton(
icon: new Icon(Icons.create),
tooltip: 'Edit',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('Pretend that this opened your SMS application.')
content: new Text('This is actually just a demo. Editing isn\'t supported.')
));
},
lines: <String>[
'(650) 555-1234',
'Mobile'
]
),
new _ContactItem(
icon: Icons.message,
tooltip: 'Send message',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('In this demo, this button doesn\'t do anything.')
));
new PopupMenuButton<AppBarBehavior>(
onSelected: (AppBarBehavior value) {
setState(() {
_appBarBehavior = value;
});
},
lines: <String>[
'(323) 555-6789',
'Work'
]
itemBuilder: (BuildContext context) => <PopupMenuItem<AppBarBehavior>>[
new PopupMenuItem<AppBarBehavior>(
value: AppBarBehavior.normal,
child: new Text('App bar scrolls away')
),
new PopupMenuItem<AppBarBehavior>(
value: AppBarBehavior.pinned,
child: new Text('App bar stays put')
),
new PopupMenuItem<AppBarBehavior>(
value: AppBarBehavior.floating,
child: new Text('App bar floats')
),
],
),
new _ContactItem(
icon: Icons.message,
tooltip: 'Send message',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('Imagine if you will, a messaging application.')
));
},
lines: <String>[
'(650) 555-6789',
'Home'
]
],
flexibleSpace: new FlexibleSpaceBar(
title: new Text('Ali Connors'),
background: new Stack(
children: <Widget>[
new Image.asset(
'packages/flutter_gallery_assets/ali_connors.jpg',
fit: ImageFit.cover,
height: _appBarHeight,
),
// This gradient ensures that the toolbar icons are distinct
// against the background image.
new DecoratedBox(
decoration: const BoxDecoration(
gradient: const LinearGradient(
begin: const FractionalOffset(0.5, 0.0),
end: const FractionalOffset(0.5, 0.30),
colors: const <Color>[const Color(0x60000000), const Color(0x00000000)],
),
),
),
],
),
]
),
new _ContactCategory(
icon: Icons.contact_mail,
children: <Widget>[
new _ContactItem(
icon: Icons.email,
tooltip: 'Send personal e-mail',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('Here, your e-mail application would open.')
));
},
lines: <String>[
'ali_connors@example.com',
'Personal'
]
),
new _ContactItem(
icon: Icons.email,
tooltip: 'Send work e-mail',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('This is a demo, so this button does not actually work.')
));
},
lines: <String>[
'aliconnors@example.com',
'Work'
]
)
]
),
),
new _ContactCategory(
icon: Icons.location_on,
children: <Widget>[
new _ContactItem(
icon: Icons.map,
tooltip: 'Open map',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('This would show a map of San Francisco.')
));
},
lines: <String>[
'2000 Main Street',
'San Francisco, CA',
'Home'
]
new SliverList(
delegate: new SliverChildListDelegate(<Widget>[
new _ContactCategory(
icon: Icons.call,
children: <Widget>[
new _ContactItem(
icon: Icons.message,
tooltip: 'Send message',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('Pretend that this opened your SMS application.')
));
},
lines: <String>[
'(650) 555-1234',
'Mobile',
],
),
new _ContactItem(
icon: Icons.message,
tooltip: 'Send message',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('In this demo, this button doesn\'t do anything.')
));
},
lines: <String>[
'(323) 555-6789',
'Work',
],
),
new _ContactItem(
icon: Icons.message,
tooltip: 'Send message',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('Imagine if you will, a messaging application.')
));
},
lines: <String>[
'(650) 555-6789',
'Home',
],
),
],
),
new _ContactItem(
icon: Icons.map,
tooltip: 'Open map',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('This would show a map of Mountain View.')
));
},
lines: <String>[
'1600 Amphitheater Parkway',
'Mountain View, CA',
'Work'
]
new _ContactCategory(
icon: Icons.contact_mail,
children: <Widget>[
new _ContactItem(
icon: Icons.email,
tooltip: 'Send personal e-mail',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('Here, your e-mail application would open.')
));
},
lines: <String>[
'ali_connors@example.com',
'Personal',
],
),
new _ContactItem(
icon: Icons.email,
tooltip: 'Send work e-mail',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('This is a demo, so this button does not actually work.')
));
},
lines: <String>[
'aliconnors@example.com',
'Work',
],
),
],
),
new _ContactItem(
icon: Icons.map,
tooltip: 'Open map',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('This would also show a map, if this was not a demo.')
));
},
lines: <String>[
'126 Severyns Ave',
'Mountain View, CA',
'Jet Travel'
]
)
]
),
new _ContactCategory(
icon: Icons.today,
children: <Widget>[
new _ContactItem(
lines: <String>[
'Birthday',
'January 9th, 1989'
]
new _ContactCategory(
icon: Icons.location_on,
children: <Widget>[
new _ContactItem(
icon: Icons.map,
tooltip: 'Open map',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('This would show a map of San Francisco.')
));
},
lines: <String>[
'2000 Main Street',
'San Francisco, CA',
'Home',
],
),
new _ContactItem(
icon: Icons.map,
tooltip: 'Open map',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('This would show a map of Mountain View.')
));
},
lines: <String>[
'1600 Amphitheater Parkway',
'Mountain View, CA',
'Work',
],
),
new _ContactItem(
icon: Icons.map,
tooltip: 'Open map',
onPressed: () {
_scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('This would also show a map, if this was not a demo.')
));
},
lines: <String>[
'126 Severyns Ave',
'Mountain View, CA',
'Jet Travel',
],
),
],
),
new _ContactItem(
lines: <String>[
'Wedding anniversary',
'June 21st, 2014'
]
new _ContactCategory(
icon: Icons.today,
children: <Widget>[
new _ContactItem(
lines: <String>[
'Birthday',
'January 9th, 1989',
],
),
new _ContactItem(
lines: <String>[
'Wedding anniversary',
'June 21st, 2014',
],
),
new _ContactItem(
lines: <String>[
'First day in office',
'January 20th, 2015',
],
),
new _ContactItem(
lines: <String>[
'Last day in office',
'August 9th, 2015',
],
),
],
),
new _ContactItem(
lines: <String>[
'First day in office',
'January 20th, 2015'
]
),
new _ContactItem(
lines: <String>[
'Last day in office',
'August 9th, 2015'
]
)
]
)
]
)
)
]),
),
],
),
),
);
}
}
......@@ -86,24 +86,27 @@ class _RecipeGridPageState extends State<RecipeGridPage> {
data: _kTheme.copyWith(platform: Theme.of(context).platform),
child: new Scaffold(
key: scaffoldKey,
scrollableKey: config.scrollableKey,
appBarBehavior: AppBarBehavior.under,
appBar: _buildAppBar(context, statusBarHeight),
floatingActionButton: new FloatingActionButton(
child: new Icon(Icons.edit),
onPressed: () {
scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('Not supported.')
content: new Text('Not supported.'),
));
},
),
body: _buildBody(context, statusBarHeight),
body: new CustomScrollView(
slivers: <Widget>[
_buildAppBar(context, statusBarHeight),
_buildBody(context, statusBarHeight),
],
),
)
);
}
Widget _buildAppBar(BuildContext context, double statusBarHeight) {
return new AppBar(
return new SliverAppBar(
pinned: true,
expandedHeight: _kAppBarHeight,
actions: <Widget>[
new IconButton(
......@@ -111,7 +114,7 @@ class _RecipeGridPageState extends State<RecipeGridPage> {
tooltip: 'Search',
onPressed: () {
scaffoldKey.currentState.showSnackBar(new SnackBar(
content: new Text('Not supported.')
content: new Text('Not supported.'),
));
},
),
......@@ -138,22 +141,25 @@ class _RecipeGridPageState extends State<RecipeGridPage> {
}
Widget _buildBody(BuildContext context, double statusBarHeight) {
final EdgeInsets padding = new EdgeInsets.fromLTRB(8.0, 8.0 + _kAppBarHeight + statusBarHeight, 8.0, 8.0);
return new ScrollableGrid(
scrollableKey: config.scrollableKey,
delegate: new MaxTileWidthGridDelegate(
maxTileWidth: _kRecipePageMaxWidth,
rowSpacing: 8.0,
columnSpacing: 8.0,
padding: padding
final EdgeInsets padding = const EdgeInsets.all(8.0);
return new SliverPadding(
padding: padding,
child: new SliverGrid(
gridDelegate: new SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _kRecipePageMaxWidth,
crossAxisSpacing: 8.0,
mainAxisSpacing: 8.0,
),
delegate: new SliverChildBuilderDelegate(
(BuildContext context, int index) {
Recipe recipe = config.recipes[index];
return new RecipeCard(
recipe: recipe,
onTap: () { showRecipePage(context, recipe); },
);
}
),
),
children: config.recipes.map((Recipe recipe) {
return new RecipeCard(
recipe: recipe,
onTap: () { showRecipePage(context, recipe); }
);
}),
);
}
......@@ -297,84 +303,69 @@ class RecipePage extends StatefulWidget {
class _RecipePageState extends State<RecipePage> {
final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
final GlobalKey<ScrollableState> _scrollableKey = new GlobalKey<ScrollableState>();
final TextStyle menuItemStyle = new PestoStyle(fontSize: 15.0, color: Colors.black54, height: 24.0/15.0);
final Object _disableHeroTransition = new Object();
double _getAppBarHeight(BuildContext context) => MediaQuery.of(context).size.height * 0.3;
@override
Widget build(BuildContext context) {
return new Scaffold(
key: _scaffoldKey,
scrollableKey: _scrollableKey,
appBarBehavior: AppBarBehavior.scroll,
appBar: new AppBar(
heroTag: _disableHeroTransition,
expandedHeight: _getAppBarHeight(context) - _kFabHalfSize,
backgroundColor: Colors.transparent,
elevation: 0,
actions: <Widget>[
new PopupMenuButton<String>(
onSelected: (String item) {},
itemBuilder: (BuildContext context) => <PopupMenuItem<String>>[
_buildMenuItem(Icons.share, 'Tweet recipe'),
_buildMenuItem(Icons.email, 'Email recipe'),
_buildMenuItem(Icons.message, 'Message recipe'),
_buildMenuItem(Icons.people, 'Share on Facebook'),
]
)
],
flexibleSpace: new FlexibleSpaceBar(
background: new DecoratedBox(
decoration: new BoxDecoration(
gradient: new LinearGradient(
begin: const FractionalOffset(0.5, 0.0),
end: const FractionalOffset(0.5, 0.40),
colors: <Color>[const Color(0x60000000), const Color(0x00000000)],
)
)
)
),
),
body: _buildBody(context),
);
}
// The full page content with the recipe's image behind it. This
// adjusts based on the size of the screen. If the recipe sheet touches
// the edge of the screen, use a slightly different layout.
Widget _buildBody(BuildContext context) {
final bool isFavorite = _favoriteRecipes.contains(config.recipe);
// The full page content with the recipe's image behind it. This
// adjusts based on the size of the screen. If the recipe sheet touches
// the edge of the screen, use a slightly different layout.
final double appBarHeight = _getAppBarHeight(context);
final Size screenSize = MediaQuery.of(context).size;
final bool fullWidth = (screenSize.width < _kRecipePageMaxWidth);
final double appBarHeight = _getAppBarHeight(context);
return new Stack(
children: <Widget>[
new Positioned(
top: 0.0,
left: 0.0,
right: 0.0,
height: appBarHeight + _kFabHalfSize,
child: new Hero(
tag: config.recipe.imagePath,
child: new Image.asset(
config.recipe.imagePath,
fit: fullWidth ? ImageFit.fitWidth : ImageFit.cover,
final bool isFavorite = _favoriteRecipes.contains(config.recipe);
return new Scaffold(
key: _scaffoldKey,
body: new Stack(
children: <Widget>[
new Positioned(
top: 0.0,
left: 0.0,
right: 0.0,
height: appBarHeight + _kFabHalfSize,
child: new Hero(
tag: config.recipe.imagePath,
child: new Image.asset(
config.recipe.imagePath,
fit: fullWidth ? ImageFit.fitWidth : ImageFit.cover,
),
),
),
),
new ClampOverscrolls(
edge: ScrollableEdge.both,
child: new ScrollableViewport(
scrollableKey: _scrollableKey,
child: new RepaintBoundary(
child: new Padding(
padding: new EdgeInsets.only(top: appBarHeight),
new CustomScrollView(
slivers: <Widget>[
new SliverAppBar(
expandedHeight: appBarHeight - _kFabHalfSize,
backgroundColor: Colors.transparent,
actions: <Widget>[
new PopupMenuButton<String>(
onSelected: (String item) {},
itemBuilder: (BuildContext context) => <PopupMenuItem<String>>[
_buildMenuItem(Icons.share, 'Tweet recipe'),
_buildMenuItem(Icons.email, 'Email recipe'),
_buildMenuItem(Icons.message, 'Message recipe'),
_buildMenuItem(Icons.people, 'Share on Facebook'),
],
),
],
flexibleSpace: new FlexibleSpaceBar(
background: new DecoratedBox(
decoration: new BoxDecoration(
gradient: new LinearGradient(
begin: const FractionalOffset(0.5, 0.0),
end: const FractionalOffset(0.5, 0.40),
colors: <Color>[const Color(0x60000000), const Color(0x00000000)],
),
),
),
),
),
new SliverToBoxAdapter(
child: new Stack(
children: <Widget>[
new Container(
padding: new EdgeInsets.only(top: _kFabHalfSize),
padding: const EdgeInsets.only(top: _kFabHalfSize),
width: fullWidth ? null : _kRecipePageMaxWidth,
child: new RecipeSheet(recipe: config.recipe),
),
......@@ -386,12 +377,12 @@ class _RecipePageState extends State<RecipePage> {
),
),
],
),
)
),
),
],
),
),
],
],
),
);
}
......
......@@ -40,8 +40,8 @@ class ShrinePage extends StatefulWidget {
class ShrinePageState extends State<ShrinePage> {
int _appBarElevation = 0;
bool _handleScrollNotification(ScrollNotification notification) {
int elevation = notification.scrollable.scrollOffset <= 0.0 ? 0 : 1;
bool _handleScrollNotification(ScrollNotification2 notification) {
int elevation = notification.metrics.extentBefore <= 0.0 ? 0 : 1;
if (elevation != _appBarElevation) {
setState(() {
_appBarElevation = elevation;
......@@ -141,7 +141,7 @@ class ShrinePageState extends State<ShrinePage> {
]
),
floatingActionButton: config.floatingActionButton,
body: new NotificationListener<ScrollNotification>(
body: new NotificationListener<ScrollNotification2>(
onNotification: _handleScrollNotification,
child: config.body
)
......
......@@ -41,16 +41,13 @@ class _AppBarBackground extends StatelessWidget {
@override
Widget build(BuildContext context) {
// TODO(abarth): Wire up to the parallax of the FlexibleSpaceBar in a way
// that doesn't pop during hero transition.
Animation<double> effectiveAnimation = kAlwaysDismissedAnimation;
return new AnimatedBuilder(
animation: effectiveAnimation,
animation: animation,
builder: (BuildContext context, Widget child) {
return new Stack(
children: _kBackgroundLayers.map((_BackgroundLayer layer) {
return new Positioned(
top: -layer.parallaxTween.evaluate(effectiveAnimation),
top: -layer.parallaxTween.evaluate(animation),
left: 0.0,
right: 0.0,
bottom: 0.0,
......@@ -107,7 +104,6 @@ class GalleryHome extends StatefulWidget {
class GalleryHomeState extends State<GalleryHome> with SingleTickerProviderStateMixin {
static final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
static final GlobalKey<ScrollableState> _scrollableKey = new GlobalKey<ScrollableState>();
AnimationController _controller;
......@@ -153,10 +149,8 @@ class GalleryHomeState extends State<GalleryHome> with SingleTickerProviderState
@override
Widget build(BuildContext context) {
final double statusBarHeight = MediaQuery.of(context).padding.top;
Widget home = new Scaffold(
key: _scaffoldKey,
scrollableKey: _scrollableKey,
drawer: new GalleryDrawer(
useLightTheme: config.useLightTheme,
onThemeChanged: config.onThemeChanged,
......@@ -169,27 +163,19 @@ class GalleryHomeState extends State<GalleryHome> with SingleTickerProviderState
onPlatformChanged: config.onPlatformChanged,
onSendFeedback: config.onSendFeedback,
),
appBar: new AppBar(
expandedHeight: _kFlexibleSpaceMaxHeight,
flexibleSpace: new FlexibleSpaceBar(
title: new Text('Flutter Gallery'),
background: new Builder(
builder: (BuildContext context) {
return new _AppBarBackground(
animation: _scaffoldKey.currentState.appBarAnimation
);
}
)
)
),
appBarBehavior: AppBarBehavior.under,
// The block's padding just exists to occupy the space behind the flexible app bar.
// As the block's padded region is scrolled upwards, the app bar's height will
// shrink keep it above the block content's and over the padded region.
body: new Block(
scrollableKey: _scrollableKey,
padding: new EdgeInsets.only(top: _kFlexibleSpaceMaxHeight + statusBarHeight),
children: _galleryListItems()
body: new CustomScrollView(
slivers: <Widget>[
new SliverAppBar(
pinned: true,
expandedHeight: _kFlexibleSpaceMaxHeight,
flexibleSpace: new FlexibleSpaceBar(
title: new Text('Flutter Gallery'),
// TODO(abarth): Wire up to the parallax in a way that doesn't pop during hero transition.
background: new _AppBarBackground(animation: kAlwaysDismissedAnimation),
),
),
new SliverList(delegate: new SliverChildListDelegate(_galleryListItems())),
],
)
);
......
......@@ -21,11 +21,12 @@ void main() {
// Scroll the Buttons demo into view so that a tap will succeed
final Point allDemosOrigin = tester.getTopRight(find.text('Demos'));
final Point buttonsDemoOrigin = tester.getTopRight(find.text('Buttons'));
final double scrollDelta = buttonsDemoOrigin.y - allDemosOrigin.y;
await tester.scrollAt(allDemosOrigin, new Offset(0.0, -scrollDelta));
await tester.pump(); // start the scroll
await tester.pump(const Duration(seconds: 1));
final Finder button = find.text('Buttons');
while (button.evaluate().isEmpty) {
await tester.scrollAt(allDemosOrigin, new Offset(0.0, -100.0));
await tester.pump(); // start the scroll
await tester.pump(const Duration(seconds: 1));
}
// Launch the buttons demo and then prove that showing the example
// code dialog does not crash.
......
......@@ -38,6 +38,7 @@ Future<Null> smokeDemo(WidgetTester tester, String routeName) async {
await tester.pump(const Duration(seconds: 1)); // Wait until the demo has opened.
expect(find.text(kCaption), findsNothing);
await tester.pump(const Duration(seconds: 1)); // Leave the demo on the screen briefly for manual testing.
// Go back
Finder backButton = find.byTooltip('Back');
......@@ -61,22 +62,12 @@ Future<Null> runSmokeTest(WidgetTester tester) async {
expect(find.text(kCaption), findsOneWidget);
final List<double> scrollDeltas = new List<double>();
double previousY = tester.getTopRight(find.text(demoCategories[0])).y;
for (String routeName in routeNames) {
final double y = tester.getTopRight(findGalleryItemByRouteName(tester, routeName)).y;
scrollDeltas.add(previousY - y);
previousY = y;
}
// Launch each demo and then scroll that item out of the way.
for (int i = 0; i < routeNames.length; i += 1) {
final String routeName = routeNames[i];
Finder finder = findGalleryItemByRouteName(tester, routeName);
Scrollable2.ensureVisible(tester.element(finder), alignment: 0.5);
await tester.pump();
await tester.pumpUntilNoTransientCallbacks();
await smokeDemo(tester, routeName);
await tester.scroll(findGalleryItemByRouteName(tester, routeName), new Offset(0.0, scrollDeltas[i]));
await tester.pump(); // start the scroll
await tester.pump(const Duration(milliseconds: 500)); // wait for overscroll to timeout, if necessary
await tester.pump(const Duration(seconds: 3)); // wait for overscroll to fade away, if necessary
tester.binding.debugAssertNoTransientCallbacks('A transient callback was still active after leaving route $routeName');
}
......
......@@ -4,22 +4,28 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'constants.dart';
import 'flexible_space_bar.dart';
import 'icon.dart';
import 'icon_button.dart';
import 'icon_theme.dart';
import 'icon_theme_data.dart';
import 'icons.dart';
import 'material.dart';
import 'scaffold.dart';
import 'tabs.dart';
import 'theme.dart';
import 'typography.dart';
final Object _kDefaultHeroTag = new Object();
/// A widget that can appear at the bottom of an [AppBar]. The [Scaffold] uses
/// the bottom widget's [bottomHeight] to handle layout for
/// [AppBarBehavior.scroll] and [AppBarBehavior.under].
/// An interface for widgets that can appear at the bottom of an [AppBar] or
/// [SliverAppBar].
///
/// This interface exposes the height of the widget, so that the [Scaffold] and
/// [SliverAppBar] widgets can correctly size an [AppBar].
abstract class AppBarBottomWidget extends Widget {
/// Defines the height of the app bar's optional bottom widget.
double get bottomHeight;
......@@ -93,22 +99,6 @@ class _ToolbarLayout extends MultiChildLayoutDelegate {
// Mobile Portrait: 56dp
// Tablet/Desktop: 64dp
class _AppBarExpandedHeight extends InheritedWidget {
_AppBarExpandedHeight({
this.expandedHeight,
Widget child
}) : super(child: child) {
assert(expandedHeight != null);
}
final double expandedHeight;
@override
bool updateShouldNotify(_AppBarExpandedHeight oldWidget) {
return expandedHeight != oldWidget.expandedHeight;
}
}
/// A material design app bar.
///
/// An app bar consists of a toolbar and potentially other widgets, such as a
......@@ -116,21 +106,20 @@ class _AppBarExpandedHeight extends InheritedWidget {
/// common actions with [IconButton]s which are optionally followed by a
/// [PopupMenuButton] for less common operations.
///
/// App bars are most commonly used in the [Scaffold.appBar] property, which
/// places the app bar at the top of the app.
/// App bars are typically used in the [Scaffold.appBar] property, which places
/// the app bar as a fixed-height widget at the top of the screen. For a
/// scrollable app bar, see [SliverAppBar], which embeds an [AppBar] in a sliver
/// for use in a [CustomScrollView].
///
/// The AppBar displays the toolbar widgets, [leading], [title], and
/// [actions], above the [bottom] (if any). If a [flexibleSpace] widget is
/// specified then it is stacked behind the toolbar and the bottom widget.
///
/// The [Scaffold] typically creates the app bar with an initial height equal to
/// [expandedHeight]. If the [Scaffold.appBarBehavior] is set then the
/// AppBar's [collapsedHeight] and [bottomHeight] define how small the app bar
/// will become when the application is scrolled.
///
/// See also:
///
/// * [Scaffold], which displays the [AppBar] in its [Scaffold.appBar] slot.
/// * [SliverAppBar], which uses [AppBar] to provide a flexible app bar that
/// can be used in a [CustomScrollView].
/// * [TabBar], which is typically placed in the [bottom] slot of the [AppBar]
/// if the screen has multiple pages arranged in tabs.
/// * [IconButton], which is used with [actions] to show buttons on the app bar.
......@@ -138,7 +127,7 @@ class _AppBarExpandedHeight extends InheritedWidget {
/// * [FlexibleSpaceBar], which is used with [flexibleSpace] when the app bar
/// can expand and collapse.
/// * <https://material.google.com/layout/structure.html#structure-toolbars>
class AppBar extends StatelessWidget {
class AppBar extends StatefulWidget {
/// Creates a material design app bar.
///
/// Typically used in the [Scaffold.appBar] property.
......@@ -148,29 +137,32 @@ class AppBar extends StatelessWidget {
this.title,
this.actions,
this.flexibleSpace,
this.bottom,
AppBarBottomWidget bottom,
this.elevation: 4,
this.backgroundColor,
this.brightness,
this.iconTheme,
this.textTheme,
this.padding: EdgeInsets.zero,
this.primary: true,
this.centerTitle,
this.heroTag,
double expandedHeight,
double collapsedHeight
}) : _expandedHeight = expandedHeight,
_collapsedHeight = collapsedHeight,
super(key: key);
this.toolbarOpacity: 1.0,
this.bottomOpacity: 1.0,
}) : bottom = bottom,
_bottomHeight = bottom?.bottomHeight ?? 0.0,
super(key: key) {
assert(elevation != null);
assert(primary != null);
assert(toolbarOpacity != null);
assert(bottomOpacity != null);
}
/// A widget to display before the [title].
///
/// If this field is null and this app bar is used in a [Scaffold], the
/// [Scaffold] will fill this field with an appropriate widget. For example,
/// if the [Scaffold] also has a [Drawer], the [Scaffold] will fill this
/// widget with an [IconButton] that opens the drawer. If there's no [Drawer]
/// and the parent [Navigator] can go back, the [Scaffold] will fill this
/// field with an [IconButton] that calls [Navigator.pop].
/// If this is null, the [AppBar] will imply an appropriate widget. For
/// example, if the [AppBar] is in a [Scaffold] that also has a [Drawer], the
/// [Scaffold] will fill this widget with an [IconButton] that opens the
/// drawer. If there's no [Drawer] and the parent [Navigator] can go back, the
/// [AppBar] will use an [IconButton] that calls [Navigator.pop].
final Widget leading;
/// The primary widget displayed in the appbar.
......@@ -197,23 +189,28 @@ class AppBar extends StatelessWidget {
/// tooltip: 'Open shopping cart',
/// onPressed: _openCart,
/// ),
/// ]
/// ],
/// ),
/// body: _buildBody(),
/// );
/// ```
final List<Widget> actions;
/// This widget is stacked behind the toolbar and the tabbar and it is not
/// inset by the specified [padding]. It's height will be the same as the
/// the app bar's overall height.
/// This widget is stacked behind the toolbar and the tabbar. It's height will
/// be the same as the the app bar's overall height.
///
/// A flexible space isn't actually flexible unless the [AppBar]'s container
/// changes the [AppBar]'s size. A [SliverAppBar] in a [CustomScrollView]
/// changes the [AppBar]'s height when scrolled. A [Scaffold] always sets the
/// [AppBar] to the [minExtent].
///
/// Typically a [FlexibleSpaceBar]. See [FlexibleSpaceBar] for details.
final Widget flexibleSpace;
/// This widget appears across the bottom of the appbar.
/// This widget appears across the bottom of the app bar.
///
/// Typically a [TabBar].
/// Typically a [TabBar]. Only widgets that implement [AppBarBottomWidget] can
/// be used at the bottom of an app bar.
final AppBarBottomWidget bottom;
/// The z-coordinate at which to place this app bar.
......@@ -247,100 +244,32 @@ class AppBar extends StatelessWidget {
/// Defaults to [ThemeData.primaryTextTheme].
final TextTheme textTheme;
/// The amount of space by which to inset the contents of the app bar.
/// The [Scaffold] increases [padding.top] by the height of the system
/// status bar so that the toolbar appears below the status bar.
final EdgeInsets padding;
/// Whether this app bar is being displayed at the top of the screen.
///
/// If this is true, the top padding specified by the [MediaQuery] will be
/// added to the top of the toolbar. See also [minExtent].
final bool primary;
/// Whether the title should be centered.
///
/// Defaults to being adapted to the current [TargetPlatform].
final bool centerTitle;
/// The tag to apply to the app bar's [Hero] widget.
///
/// Defaults to a tag that matches other app bars.
final Object heroTag;
final double _expandedHeight;
final double _collapsedHeight;
/// Returns the [expandedHeight] of the [AppBar] nearest to the given
/// [BuildContext].
///
/// Calling this function sets up an inheritance relationship, so that the
/// widget corresponding to the given [BuildContext] will rebuild whenever
/// that height changes.
static double getExpandedHeightFor(BuildContext context) {
_AppBarExpandedHeight marker = context.inheritFromWidgetOfExactType(_AppBarExpandedHeight);
return marker?.expandedHeight ?? 0.0;
}
/// Creates a copy of this app bar but with the given fields replaced with the new values.
AppBar copyWith({
Key key,
Widget leading,
Widget title,
List<Widget> actions,
Widget flexibleSpace,
AppBarBottomWidget bottom,
int elevation,
Color backgroundColor,
Brightness brightness,
TextTheme textTheme,
EdgeInsets padding,
Object heroTag,
double expandedHeight,
double collapsedHeight
}) {
return new AppBar(
key: key ?? this.key,
leading: leading ?? this.leading,
title: title ?? this.title,
actions: actions ?? this.actions,
flexibleSpace: flexibleSpace ?? this.flexibleSpace,
bottom: bottom ?? this.bottom,
elevation: elevation ?? this.elevation,
backgroundColor: backgroundColor ?? this.backgroundColor,
brightness: brightness ?? this.brightness,
iconTheme: iconTheme ?? this.iconTheme,
textTheme: textTheme ?? this.textTheme,
padding: padding ?? this.padding,
centerTitle: centerTitle ?? this.centerTitle,
heroTag: heroTag ?? this.heroTag,
expandedHeight: expandedHeight ?? this._expandedHeight,
collapsedHeight: collapsedHeight ?? this._collapsedHeight
);
}
final double toolbarOpacity;
double get _toolbarHeight => kToolbarHeight;
final double bottomOpacity;
/// The height of the bottom widget. The [Scaffold] uses this value to control
/// the size of the app bar when its appBarBehavior is [AppBarBehavior.scroll]
/// or [AppBarBehavior.under].
double get bottomHeight => bottom?.bottomHeight ?? 0.0;
final double _bottomHeight;
/// By default, the total height of the toolbar and the bottom widget (if any).
/// The [Scaffold] gives its app bar this height initially. If a
/// [flexibleSpace] widget is specified this height should be big
/// enough to accommodate whatever that widget contains.
/// The height of the toolbar and the [bottom] widget.
///
/// See also [getExpandedHeightFor].
double get expandedHeight => _expandedHeight ?? (_toolbarHeight + bottomHeight);
/// By default, the height of the toolbar and the bottom widget (if any).
/// If the height of the app bar is constrained to be less than this value
/// then the toolbar and bottom widget are scrolled upwards, out of view.
double get collapsedHeight => _collapsedHeight ?? (_toolbarHeight + bottomHeight);
// Defines the opacity of the toolbar's text and icons.
double _toolbarOpacity(double appBarHeight, double statusBarHeight) {
return ((appBarHeight - bottomHeight - statusBarHeight) / _toolbarHeight).clamp(0.0, 1.0);
}
double _bottomOpacity(double appBarHeight, double statusBarHeight) {
return ((appBarHeight - statusBarHeight) / bottomHeight).clamp(0.0, 1.0);
}
/// The parent widget should constrain the [AppBar] to a height between this
/// and whatever maximum size it wants the [AppBar] to have.
///
/// If [primary] is true, the parent should increase this height by the height
/// of the top padding specified by the [MediaQuery] in scope for the
/// [AppBar].
double get minExtent => kToolbarHeight + _bottomHeight;
bool _getEffectiveCenterTitle(ThemeData themeData) {
if (centerTitle != null)
......@@ -356,24 +285,45 @@ class AppBar extends StatelessWidget {
return null;
}
Widget _buildForSize(BuildContext context, BoxConstraints constraints) {
assert(constraints.maxHeight < double.INFINITY);
final Size size = constraints.biggest;
final double statusBarHeight = MediaQuery.of(context).padding.top;
@override
AppBarState createState() => new AppBarState();
}
class AppBarState extends State<AppBar> {
bool _hasDrawer = false;
bool _canPop = false;
@override
void dependenciesChanged() {
super.dependenciesChanged();
ScaffoldState scaffold = Scaffold.of(context);
_hasDrawer = scaffold?.hasDrawer ?? false;
_canPop = ModalRoute.of(context)?.canPop() ?? false;
}
void _handleDrawerButton() {
Scaffold.of(context).openDrawer();
}
void _handleBackButton() {
Navigator.of(context).maybePop();
}
@override
Widget build(BuildContext context) {
final ThemeData themeData = Theme.of(context);
IconThemeData appBarIconTheme = iconTheme ?? themeData.primaryIconTheme;
TextStyle centerStyle = textTheme?.title ?? themeData.primaryTextTheme.title;
TextStyle sideStyle = textTheme?.body1 ?? themeData.primaryTextTheme.body1;
IconThemeData appBarIconTheme = config.iconTheme ?? themeData.primaryIconTheme;
TextStyle centerStyle = config.textTheme?.title ?? themeData.primaryTextTheme.title;
TextStyle sideStyle = config.textTheme?.body1 ?? themeData.primaryTextTheme.body1;
Brightness brightness = this.brightness ?? themeData.primaryColorBrightness;
Brightness brightness = config.brightness ?? themeData.primaryColorBrightness;
SystemChrome.setSystemUIOverlayStyle(brightness == Brightness.dark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark);
final double toolbarOpacity = _toolbarOpacity(size.height, statusBarHeight);
if (toolbarOpacity != 1.0) {
final double opacity = const Interval(0.25, 1.0, curve: Curves.fastOutSlowIn).transform(toolbarOpacity);
if (config.toolbarOpacity != 1.0) {
final double opacity = const Interval(0.25, 1.0, curve: Curves.fastOutSlowIn).transform(config.toolbarOpacity);
if (centerStyle?.color != null)
centerStyle = centerStyle.copyWith(color: centerStyle.color.withOpacity(opacity));
if (sideStyle?.color != null)
......@@ -384,6 +334,37 @@ class AppBar extends StatelessWidget {
}
final List<Widget> toolbarChildren = <Widget>[];
Widget leading = config.leading;
if (leading == null) {
if (_hasDrawer) {
leading = new IconButton(
icon: new Icon(Icons.menu),
alignment: FractionalOffset.centerLeft,
onPressed: _handleDrawerButton,
tooltip: 'Open navigation menu' // TODO(ianh): Figure out how to localize this string
);
} else {
if (_canPop) {
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: _handleBackButton,
tooltip: 'Back' // TODO(ianh): Figure out how to localize this string
);
}
}
}
if (leading != null) {
toolbarChildren.add(
new LayoutId(
......@@ -392,7 +373,8 @@ class AppBar extends StatelessWidget {
)
);
}
if (title != null) {
if (config.title != null) {
toolbarChildren.add(
new LayoutId(
id: _ToolbarSlot.title,
......@@ -400,20 +382,20 @@ class AppBar extends StatelessWidget {
style: centerStyle,
softWrap: false,
overflow: TextOverflow.ellipsis,
child: title
)
)
child: config.title,
),
),
);
}
if (actions != null && actions.isNotEmpty) {
if (config.actions != null && config.actions.isNotEmpty) {
toolbarChildren.add(
new LayoutId(
id: _ToolbarSlot.actions,
child: new Row(
mainAxisSize: MainAxisSize.min,
children: actions
)
)
children: config.actions,
),
),
);
}
......@@ -421,10 +403,10 @@ class AppBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: new CustomMultiChildLayout(
delegate: new _ToolbarLayout(
centerTitle: _getEffectiveCenterTitle(themeData)
centerTitle: config._getEffectiveCenterTitle(themeData),
),
children: toolbarChildren
)
children: toolbarChildren,
),
);
Widget appBar = new SizedBox(
......@@ -434,75 +416,313 @@ class AppBar extends StatelessWidget {
data: appBarIconTheme,
child: new DefaultTextStyle(
style: sideStyle,
child: toolbar
)
)
child: toolbar,
),
),
);
final double bottomOpacity = _bottomOpacity(size.height, statusBarHeight);
if (bottom != null) {
if (config.bottom != null) {
appBar = new Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
appBar,
bottomOpacity == 1.0 ? bottom : new Opacity(
child: bottom,
opacity: const Interval(0.25, 1.0, curve: Curves.fastOutSlowIn).transform(bottomOpacity)
)
]
config.bottomOpacity == 1.0 ? config.bottom : new Opacity(
opacity: const Interval(0.25, 1.0, curve: Curves.fastOutSlowIn).transform(config.bottomOpacity),
child: config.bottom,
),
],
);
}
// The padding applies to the toolbar and tabbar, not the flexible space.
// The incoming padding parameter's top value typically equals the height
// of the status bar - so that the toolbar appears below the status bar.
appBar = new Padding(
padding: padding,
child: appBar
);
// If the appBar's height shrinks below collapsedHeight, it will be clipped and bottom
// justified. This is so that the toolbar and the tabbar appear to move upwards as
// the appBar's height is reduced below collapsedHeight.
final double paddedCollapsedHeight = collapsedHeight + padding.top + padding.bottom;
if (size.height < paddedCollapsedHeight) {
appBar = new ClipRect(
child: new OverflowBox(
alignment: FractionalOffset.bottomLeft,
minHeight: paddedCollapsedHeight,
maxHeight: paddedCollapsedHeight,
child: appBar
)
if (config.primary) {
appBar = new Padding(
padding: new EdgeInsets.only(top: MediaQuery.of(context).padding.top),
child: appBar,
);
} else if (flexibleSpace != null) {
appBar = new Positioned(top: 0.0, left: 0.0, right: 0.0, child: appBar);
}
if (flexibleSpace != null) {
if (config.flexibleSpace != null) {
appBar = new Stack(
children: <Widget>[
flexibleSpace,
appBar
]
config.flexibleSpace,
new Positioned(top: 0.0, left: 0.0, right: 0.0, child: appBar),
],
);
}
return new Hero(
tag: heroTag ?? _kDefaultHeroTag,
child: new _AppBarExpandedHeight(
expandedHeight: expandedHeight,
child: new Material(
color: backgroundColor ?? themeData.primaryColor,
elevation: elevation,
child: new Align(
alignment: FractionalOffset.topCenter,
child: appBar
)
)
)
return new Material(
color: config.backgroundColor ?? themeData.primaryColor,
elevation: config.elevation,
child: new Align(
alignment: FractionalOffset.topCenter,
child: appBar,
),
);
}
}
class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
_SliverAppBarDelegate({
@required this.leading,
@required this.title,
@required this.actions,
@required this.flexibleSpace,
@required AppBarBottomWidget bottom,
@required this.elevation,
@required this.backgroundColor,
@required this.brightness,
@required this.iconTheme,
@required this.textTheme,
@required this.primary,
@required this.centerTitle,
@required this.expandedHeight,
@required this.topPadding,
@required this.pinned,
}) : bottom = bottom,
_bottomHeight = bottom?.bottomHeight ?? 0.0 {
assert(primary || topPadding == 0.0);
}
final Widget leading;
final Widget title;
final List<Widget> actions;
final Widget flexibleSpace;
final AppBarBottomWidget bottom;
final int elevation;
final Color backgroundColor;
final Brightness brightness;
final IconThemeData iconTheme;
final TextTheme textTheme;
final bool primary;
final bool centerTitle;
final double expandedHeight;
final double topPadding;
final bool pinned;
final double _bottomHeight;
@override
double get minExtent => topPadding + kToolbarHeight + _bottomHeight;
@override
Widget build(BuildContext context) => new LayoutBuilder(builder: _buildForSize);
double get maxExtent => math.max(topPadding + (expandedHeight ?? kToolbarHeight), minExtent);
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
double visibleMainHeight = maxExtent - shrinkOffset - topPadding;
double toolbarOpacity = pinned ? 1.0 : ((visibleMainHeight - _bottomHeight) / kToolbarHeight).clamp(0.0, 1.0);
return FlexibleSpaceBar.createSettings(
minExtent: minExtent,
maxExtent: maxExtent,
currentExtent: math.max(minExtent, maxExtent - shrinkOffset),
toolbarOpacity: toolbarOpacity,
child: new AppBar(
leading: leading,
title: title,
actions: actions,
flexibleSpace: flexibleSpace,
bottom: bottom,
elevation: overlapsContent || (pinned && shrinkOffset > maxExtent - minExtent) ? elevation ?? 4 : 0,
backgroundColor: backgroundColor,
brightness: brightness,
iconTheme: iconTheme,
textTheme: textTheme,
primary: primary,
centerTitle: centerTitle,
toolbarOpacity: toolbarOpacity,
bottomOpacity: pinned ? 1.0 : (visibleMainHeight / _bottomHeight).clamp(0.0, 1.0),
),
);
}
@override
bool shouldRebuild(@checked _SliverAppBarDelegate oldDelegate) {
return leading != oldDelegate.leading
|| title != oldDelegate.title
|| actions != oldDelegate.actions
|| flexibleSpace != oldDelegate.flexibleSpace
|| bottom != oldDelegate.bottom
|| _bottomHeight != oldDelegate._bottomHeight
|| elevation != oldDelegate.elevation
|| backgroundColor != oldDelegate.backgroundColor
|| brightness != oldDelegate.brightness
|| iconTheme != oldDelegate.iconTheme
|| textTheme != oldDelegate.textTheme
|| primary != oldDelegate.primary
|| centerTitle != oldDelegate.centerTitle
|| expandedHeight != oldDelegate.expandedHeight
|| topPadding != oldDelegate.topPadding;
}
}
class SliverAppBar extends StatelessWidget {
/// Creates a material design app bar that can be placed in a [CustomScrollView].
SliverAppBar({
Key key,
this.leading,
this.title,
this.actions,
this.flexibleSpace,
this.bottom,
this.elevation,
this.backgroundColor,
this.brightness,
this.iconTheme,
this.textTheme,
this.primary: true,
this.centerTitle,
this.expandedHeight,
this.floating: false,
this.pinned: false,
}) : super(key: key) {
assert(primary != null);
assert(floating != null);
assert(pinned != null);
}
/// A widget to display before the [title].
///
/// If this is null, the [AppBar] will imply an appropriate widget. For
/// example, if the [AppBar] is in a [Scaffold] that also has a [Drawer], the
/// [Scaffold] will fill this widget with an [IconButton] that opens the
/// drawer. If there's no [Drawer] and the parent [Navigator] can go back, the
/// [AppBar] will use an [IconButton] that calls [Navigator.pop].
final Widget leading;
/// The primary widget displayed in the appbar.
///
/// Typically a [Text] widget containing a description of the current contents
/// of the app.
final Widget title;
/// Widgets to display after the [title] widget.
///
/// Typically these widgets are [IconButton]s representing common operations.
/// For less common operations, consider using a [PopupMenuButton] as the
/// last action.
///
/// For example:
///
/// ```dart
/// return new Scaffold(
/// body: new CustomView(
/// primary: true,
/// slivers: <Widget>[
/// new SliverAppBar(
/// title: new Text('Hello World'),
/// actions: <Widget>[
/// new IconButton(
/// icon: new Icon(Icons.shopping_cart),
/// tooltip: 'Open shopping cart',
/// onPressed: _openCart,
/// ),
/// ],
/// ),
/// // ...rest of body...
/// ],
/// ),
/// );
/// ```
final List<Widget> actions;
/// This widget is stacked behind the toolbar and the tabbar. It's height will
/// be the same as the the app bar's overall height.
///
/// Typically a [FlexibleSpaceBar]. See [FlexibleSpaceBar] for details.
final Widget flexibleSpace;
/// This widget appears across the bottom of the appbar.
///
/// Typically a [TabBar]. This widget must be a widget that implements the
/// [AppBarBottomWidget] interface.
final AppBarBottomWidget bottom;
/// The z-coordinate at which to place this app bar.
///
/// The following elevations have defined shadows: 1, 2, 3, 4, 6, 8, 9, 12, 16, 24
///
/// Defaults to 4, the appropriate elevation for app bars.
///
/// The elevation is ignored when the app bar has no content underneath it.
/// For example, if the app bar is [pinned] but no content is scrolled under
/// it, or if it scrolls with the content.
final int elevation;
/// The color to use for the app bar's material. Typically this should be set
/// along with [brightness], [iconTheme], [textTheme].
///
/// Defaults to [ThemeData.primaryColor].
final Color backgroundColor;
/// The brightness of the app bar's material. Typically this is set along
/// with [backgroundColor], [iconTheme], [textTheme].
///
/// Defaults to [ThemeData.primaryColorBrightness].
final Brightness brightness;
/// The color, opacity, and size to use for app bar icons. Typically this
/// is set along with [backgroundColor], [brightness], [textTheme].
///
/// Defaults to [ThemeData.primaryIconTheme].
final IconThemeData iconTheme;
/// The typographic styles to use for text in the app bar. Typically this is
/// set along with [brightness] [backgroundColor], [iconTheme].
///
/// Defaults to [ThemeData.primaryTextTheme].
final TextTheme textTheme;
/// Whether this app bar is being displayed at the top of the screen.
///
/// If this is true, the top padding specified by the [MediaQuery] will be
/// added to the top of the toolbar.
final bool primary;
/// Whether the title should be centered.
///
/// Defaults to being adapted to the current [TargetPlatform].
final bool centerTitle;
/// The size of the app bar when it is fully expanded.
///
/// By default, the total height of the toolbar and the bottom widget (if
/// any). If a [flexibleSpace] widget is specified this height should be big
/// enough to accommodate whatever that widget contains.
///
/// This does not include the status bar height (which will be automatically
/// included if [primary] is true).
///
/// See also [AppBar.getExpandedHeightFor].
final double expandedHeight;
final bool floating;
final bool pinned;
@override
Widget build(BuildContext context) {
return new SliverPersistentHeader(
floating: floating,
pinned: pinned,
delegate: new _SliverAppBarDelegate(
leading: leading,
title: title,
actions: actions,
flexibleSpace: flexibleSpace,
bottom: bottom,
elevation: elevation,
backgroundColor: backgroundColor,
brightness: brightness,
iconTheme: iconTheme,
textTheme: textTheme,
primary: primary,
centerTitle: centerTitle,
expandedHeight: expandedHeight,
topPadding: primary ? MediaQuery.of(context).padding.top : 0.0,
pinned: pinned,
),
);
}
}
......@@ -4,33 +4,32 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'app_bar.dart';
import 'constants.dart';
import 'scaffold.dart';
import 'theme.dart';
/// The part of a material design [AppBar] that expands and collapses.
///
/// Most commonly used in in the [AppBar.flexibleSpace] field, a flexible space
/// bar expands and contracts as the app scrolls so that the [AppBar] reaches
/// from the top of the app to the top of the scrolling contents of the app.
/// Most commonly used in in the [SliverAppBar.flexibleSpace] field, a flexible
/// space bar expands and contracts as the app scrolls so that the [AppBar]
/// reaches from the top of the app to the top of the scrolling contents of the
/// app.
///
/// Requires one of its ancestors to be a [Scaffold] widget because the
/// [Scaffold] coordinates the scrolling effect between the flexible space and
/// its body.
/// The widget that sizes the [AppBar] must wrap it in the widget returned by
/// [FlexibleSpaceBar.createSettings], to convey sizing information down to the
/// [FlexibleSpaceBar].
///
/// See also:
///
/// * [AppBar]
/// * [Scaffold]
/// * [SliverAppBar], which implements the expanding and contracting.
/// * [AppBar], which is used by [SliverAppBar].
/// * <https://material.google.com/patterns/scrolling-techniques.html>
class FlexibleSpaceBar extends StatefulWidget {
/// Creates a flexible space bar.
///
/// Most commonly used in the [AppBar.flexibleSpace] field. Requires one of
/// its ancestors to be a [Scaffold] widget.
/// Most commonly used in the [AppBar.flexibleSpace] field.
FlexibleSpaceBar({
Key key,
this.title,
......@@ -53,6 +52,23 @@ class FlexibleSpaceBar extends StatefulWidget {
/// Defaults to being adapted to the current [TargetPlatform].
final bool centerTitle;
static Widget createSettings({
double toolbarOpacity,
double minExtent,
double maxExtent,
@required double currentExtent,
@required Widget child,
}) {
assert(currentExtent != null);
return new _FlexibleSpaceBarSettings(
toolbarOpacity: toolbarOpacity ?? 1.0,
minExtent: minExtent ?? currentExtent,
maxExtent: maxExtent ?? currentExtent,
currentExtent: currentExtent,
child: child,
);
}
@override
_FlexibleSpaceBarState createState() => new _FlexibleSpaceBarState();
}
......@@ -72,34 +88,32 @@ class _FlexibleSpaceBarState extends State<FlexibleSpaceBar> {
return null;
}
Widget _buildContent(BuildContext context, BoxConstraints constraints) {
final Size size = constraints.biggest;
final double statusBarHeight = MediaQuery.of(context).padding.top;
@override
Widget build(BuildContext context) {
_FlexibleSpaceBarSettings settings = context.inheritFromWidgetOfExactType(_FlexibleSpaceBarSettings);
assert(settings != null, 'A FlexibleSpaceBar must be wrapped in the widget returned by FlexibleSpaceBar.createSettings().');
final List<Widget> children = <Widget>[];
final double currentHeight = size.height;
final double maxHeight = statusBarHeight + AppBar.getExpandedHeightFor(context);
final double minHeight = statusBarHeight + kToolbarHeight;
final double deltaHeight = maxHeight - minHeight;
final double deltaExtent = settings.maxExtent - settings.minExtent;
// 0.0 -> Expanded
// 1.0 -> Collapsed to toolbar
final double t = (1.0 - (currentHeight - minHeight) / deltaHeight).clamp(0.0, 1.0);
final List<Widget> children = <Widget>[];
final double t = (1.0 - (settings.currentExtent - settings.minExtent) / (deltaExtent)).clamp(0.0, 1.0);
// background image
if (config.background != null) {
final double fadeStart = math.max(0.0, 1.0 - kToolbarHeight / deltaHeight);
final double fadeStart = math.max(0.0, 1.0 - kToolbarHeight / deltaExtent);
final double fadeEnd = 1.0;
assert(fadeStart <= fadeEnd);
final double opacity = 1.0 - new Interval(fadeStart, fadeEnd).transform(t);
final double parallax = new Tween<double>(begin: 0.0, end: deltaHeight / 4.0).lerp(t);
final double parallax = new Tween<double>(begin: 0.0, end: deltaExtent / 4.0).lerp(t);
if (opacity > 0.0) {
children.add(new Positioned(
top: -parallax,
left: 0.0,
right: 0.0,
height: maxHeight,
height: settings.maxExtent,
child: new Opacity(
opacity: opacity,
child: config.background
......@@ -110,7 +124,7 @@ class _FlexibleSpaceBarState extends State<FlexibleSpaceBar> {
if (config.title != null) {
final ThemeData theme = Theme.of(context);
final double opacity = (1.0 - (minHeight - currentHeight) / (kToolbarHeight - statusBarHeight)).clamp(0.0, 1.0);
final double opacity = settings.toolbarOpacity;
if (opacity > 0.0) {
TextStyle titleStyle = theme.primaryTextTheme.title;
titleStyle = titleStyle.copyWith(
......@@ -140,9 +154,28 @@ class _FlexibleSpaceBarState extends State<FlexibleSpaceBar> {
return new ClipRect(child: new Stack(children: children));
}
}
class _FlexibleSpaceBarSettings extends InheritedWidget {
_FlexibleSpaceBarSettings({
Key key,
this.toolbarOpacity,
this.minExtent,
this.maxExtent,
this.currentExtent,
Widget child,
}) : super(key: key, child: child);
final double toolbarOpacity;
final double minExtent;
final double maxExtent;
final double currentExtent;
@override
Widget build(BuildContext context) {
return new LayoutBuilder(builder: _buildContent);
bool updateShouldNotify(_FlexibleSpaceBarSettings oldWidget) {
return toolbarOpacity != oldWidget.toolbarOpacity
|| minExtent != oldWidget.minExtent
|| maxExtent != oldWidget.maxExtent
|| currentExtent != oldWidget.currentExtent;
}
}
......@@ -6,16 +6,15 @@ import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'app_bar.dart';
import 'bottom_sheet.dart';
import 'button_bar.dart';
import 'button.dart';
import 'button_bar.dart';
import 'drawer.dart';
import 'icon.dart';
import 'icon_button.dart';
import 'icons.dart';
import 'flexible_space_bar.dart';
import 'material.dart';
import 'snack_bar.dart';
import 'theme.dart';
......@@ -26,30 +25,6 @@ final Tween<double> _kFloatingActionButtonTurnTween = new Tween<double>(begin: -
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,
......@@ -66,12 +41,10 @@ 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) {
......@@ -83,14 +56,12 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate {
// 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 contentTop = 0.0;
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;
contentTop = layoutChild(_ScaffoldSlot.appBar, fullWidthConstraints).height;
positionChild(_ScaffoldSlot.appBar, Offset.zero);
}
......@@ -165,7 +136,8 @@ class _ScaffoldLayout extends MultiChildLayoutDelegate {
@override
bool shouldRelayout(_ScaffoldLayout oldDelegate) {
return padding != oldDelegate.padding;
return padding != oldDelegate.padding
|| statusBarHeight != oldDelegate.statusBarHeight;
}
}
......@@ -312,10 +284,6 @@ class _FloatingActionButtonTransitionState extends State<_FloatingActionButtonTr
/// * <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,
......@@ -325,8 +293,6 @@ class Scaffold extends StatefulWidget {
this.drawer,
this.bottomNavigationBar,
this.backgroundColor,
this.scrollableKey,
this.appBarBehavior: AppBarBehavior.anchor,
this.resizeToAvoidBottomPadding: true
}) : super(key: key);
......@@ -371,10 +337,6 @@ class Scaffold extends StatefulWidget {
/// A panel displayed to the side of the [body], often hidden on mobile
/// devices.
///
/// If the [appBar] lacks an [AppBar.leading] widget, the scaffold will add a
/// button that opens the drawer. The scaffold will also open the drawer if
/// the user drags from the left edge of the scaffold.
///
/// In the uncommon case that you wish to open the drawer manually, use the
/// [ScaffoldState.openDrawer] function.
///
......@@ -392,17 +354,6 @@ class Scaffold extends StatefulWidget {
/// 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.
///
......@@ -479,7 +430,7 @@ class Scaffold extends StatefulWidget {
static ScaffoldState of(BuildContext context, { bool nullOk: false }) {
assert(nullOk != null);
assert(context != null);
ScaffoldState result = context.ancestorStateOfType(const TypeMatcher<ScaffoldState>());
final ScaffoldState result = context.ancestorStateOfType(const TypeMatcher<ScaffoldState>());
if (nullOk || result != null)
return result;
throw new FlutterError(
......@@ -503,6 +454,30 @@ class Scaffold extends StatefulWidget {
);
}
/// Whether the Scaffold that most tightly encloses the given context has a
/// drawer.
///
/// If this is being used during a build (for example to decide whether to
/// show an "open drawer" button), set the `registerForUpdates` argument to
/// true. This will then set up an [InheritedWidget] relationship with the
/// [Scaffold] so that the client widget gets rebuilt whenever the [hasDrawer]
/// value changes.
///
/// See also:
/// * [Scaffold.of], which provides access to the [ScaffoldState] object as a
/// whole, from which you can show snackbars, bottom sheets, and so forth.
static bool hasDrawer(BuildContext context, { bool registerForUpdates: true }) {
assert(registerForUpdates != null);
assert(context != null);
if (registerForUpdates) {
_ScaffoldScope scaffold = context.inheritFromWidgetOfExactType(_ScaffoldScope);
return scaffold?.hasDrawer ?? false;
} else {
ScaffoldState scaffold = context.ancestorStateOfType(const TypeMatcher<ScaffoldState>());
return scaffold?.hasDrawer ?? false;
}
}
@override
ScaffoldState createState() => new ScaffoldState();
}
......@@ -513,27 +488,12 @@ class Scaffold extends StatefulWidget {
/// 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>();
bool get hasDrawer => config.drawer != null;
/// Opens the [Drawer] (if any).
///
/// If the scaffold has a non-null [Scaffold.drawer], this function will cause
......@@ -648,6 +608,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
_snackBarTimer = null;
}
// PERSISTENT BOTTOM SHEET API
final List<_PersistentBottomSheet> _dismissedBottomSheets = <_PersistentBottomSheet>[];
......@@ -726,188 +687,24 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
}
// INTERNALS
// iOS FEATURES - status bar tap, back gesture
@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];
}
}
// On iOS, tapping the status bar scrolls the app's primary scrollable to the
// top. We implement this by providing a primary scroll controller and
// scrolling it to the top when tapped.
@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();
}
final ScrollController _primaryScrollController = new ScrollController();
void _addIfNonNull(List<LayoutId> children, Widget child, Object childId) {
if (child != null)
children.add(new LayoutId(child: child, id: childId));
}
bool _shouldShowBackArrow;
Future<Null> _back() async {
if (await Navigator.willPop(context) && mounted)
Navigator.pop(context);
}
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: _back,
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)
void _handleStatusBarTap() {
if (_primaryScrollController.hasClients) {
_primaryScrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 300),
curve: Curves.linear, // TODO(ianh): Use a more appropriate curve.
);
_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;
......@@ -937,6 +734,27 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
_backGestureController = null;
}
// INTERNALS
@override
void dispose() {
_snackBarController?.dispose();
_snackBarController = null;
_snackBarTimer?.cancel();
_snackBarTimer = null;
for (_PersistentBottomSheet bottomSheet in _dismissedBottomSheets)
bottomSheet.animationController.dispose();
if (_currentBottomSheet != null)
_currentBottomSheet._widget.animationController.dispose();
super.dispose();
}
void _addIfNonNull(List<LayoutId> children, Widget child, Object childId) {
if (child != null)
children.add(new LayoutId(child: child, id: childId));
}
@override
Widget build(BuildContext context) {
EdgeInsets padding = MediaQuery.of(context).padding;
......@@ -961,29 +779,28 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
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, config.body, _ScaffoldSlot.body);
if (config.appBar != null) {
assert(config.appBar.primary || padding.top == 0.0, 'A non-primary AppBar was passed to a Scaffold but the MediaQuery in scope has top padding.');
double topPadding = config.appBar.primary ? padding.top : 0.0;
Widget appBar = config.appBar;
double extent = config.appBar.minExtent + topPadding;
if (config.appBar.flexibleSpace != null) {
appBar = FlexibleSpaceBar.createSettings(
currentExtent: extent,
child: appBar,
);
}
_addIfNonNull(
children,
new ConstrainedBox(
constraints: new BoxConstraints(maxHeight: extent),
child: appBar,
),
_ScaffoldSlot.appBar,
);
_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);
......@@ -1011,7 +828,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
if (config.bottomNavigationBar != null) {
children.add(new LayoutId(
id: _ScaffoldSlot.bottomNavigationBar,
child: config.bottomNavigationBar
child: config.bottomNavigationBar,
));
}
......@@ -1023,7 +840,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
bottomSheets.add(_currentBottomSheet._widget);
Widget stack = new Stack(
children: bottomSheets,
alignment: FractionalOffset.bottomCenter
alignment: FractionalOffset.bottomCenter,
);
_addIfNonNull(children, stack, _ScaffoldSlot.bottomSheet);
}
......@@ -1031,7 +848,7 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
children.add(new LayoutId(
id: _ScaffoldSlot.floatingActionButton,
child: new _FloatingActionButtonTransition(
child: config.floatingActionButton
child: config.floatingActionButton,
)
));
......@@ -1040,20 +857,22 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
id: _ScaffoldSlot.statusBar,
child: new GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleStatusBarTap
onTap: _handleStatusBarTap,
)
));
}
if (config.drawer != null) {
assert(hasDrawer);
children.add(new LayoutId(
id: _ScaffoldSlot.drawer,
child: new DrawerController(
key: _drawerKey,
child: config.drawer
child: config.drawer,
)
));
} else if (_shouldHandleBackGesture()) {
assert(!hasDrawer);
// Add a gesture for navigating back.
children.add(new LayoutId(
id: _ScaffoldSlot.drawer,
......@@ -1073,19 +892,21 @@ class ScaffoldState extends State<Scaffold> with TickerProviderStateMixin {
));
}
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(
color: config.backgroundColor ?? themeData.scaffoldBackgroundColor,
child: application,
return new _ScaffoldScope(
hasDrawer: hasDrawer,
child: new PrimaryScrollController(
controller: _primaryScrollController,
child: new Material(
color: config.backgroundColor ?? themeData.scaffoldBackgroundColor,
child: new CustomMultiChildLayout(
children: children,
delegate: new _ScaffoldLayout(
padding: padding,
statusBarHeight: padding.top,
),
),
),
),
);
}
}
......@@ -1194,3 +1015,19 @@ class PersistentBottomSheetController<T> extends ScaffoldFeatureController<_Pers
StateSetter setState
) : super._(widget, completer, close, setState);
}
class _ScaffoldScope extends InheritedWidget {
_ScaffoldScope({
@required this.hasDrawer,
@required Widget child,
}) : super(child: child) {
assert(hasDrawer != null);
}
final bool hasDrawer;
@override
bool updateShouldNotify(_ScaffoldScope oldWidget) {
return hasDrawer != oldWidget.hasDrawer;
}
}
......@@ -473,6 +473,7 @@ class SemanticsNode extends AbstractNode {
owner._nodes.remove(id);
owner._detachedNodes.add(this);
super.detach();
assert(owner == null);
if (_children != null) {
for (SemanticsNode child in _children) {
// The list of children may be stale and may contain nodes that have
......@@ -481,6 +482,10 @@ class SemanticsNode extends AbstractNode {
child.detach();
}
}
// The other side will have forgotten this node if we ever send
// it again, so make sure to mark it dirty so that it'll get
// sent if it is resurrected.
_markDirty();
}
bool _dirty = false;
......@@ -563,7 +568,7 @@ class SemanticsNode extends AbstractNode {
StringBuffer buffer = new StringBuffer();
buffer.write('$runtimeType($id');
if (_dirty)
buffer.write(" (${ owner != null && owner._dirtyNodes.contains(this) ? 'dirty' : 'STALE' })");
buffer.write(' (${ owner != null && owner._dirtyNodes.contains(this) ? "dirty" : "STALE; owner=$owner" })');
if (_shouldMergeAllDescendantsIntoThisNode)
buffer.write(' (leaf merge)');
buffer.write('; $rect');
......@@ -624,19 +629,13 @@ class SemanticsOwner extends ChangeNotifier {
/// Update the semantics using [ui.window.updateSemantics].
void sendSemanticsUpdate() {
for (SemanticsNode oldNode in _detachedNodes) {
// The other side will have forgotten this node if we even send
// it again, so make sure to mark it dirty so that it'll get
// sent if it is resurrected.
oldNode._dirty = true;
}
_detachedNodes.clear();
if (_dirtyNodes.isEmpty)
return;
List<SemanticsNode> visitedNodes = <SemanticsNode>[];
while (_dirtyNodes.isNotEmpty) {
List<SemanticsNode> localDirtyNodes = _dirtyNodes.toList();
List<SemanticsNode> localDirtyNodes = _dirtyNodes.where((SemanticsNode node) => !_detachedNodes.contains(node)).toList();
_dirtyNodes.clear();
_detachedNodes.clear();
localDirtyNodes.sort((SemanticsNode a, SemanticsNode b) => a.depth - b.depth);
visitedNodes.addAll(localDirtyNodes);
for (SemanticsNode node in localDirtyNodes) {
......@@ -759,4 +758,7 @@ class SemanticsOwner extends ChangeNotifier {
SemanticsActionHandler handler = _getSemanticsActionHandlerForPosition(node, position, action);
handler?.performAction(action);
}
@override
String toString() => '$runtimeType@$hashCode';
}
......@@ -20,16 +20,19 @@ abstract class RenderSliverPersistentHeader extends RenderSliver with RenderObje
this.child = child;
}
/// The biggest that this render object can become, in the main axis direction.
///
/// This value should not be based on the child. If it changes, call
/// [markNeedsLayout].
double get maxExtent;
/// The intrinsic size of the child as of the last time the sliver was laid out.
/// The smallest that this render object can become, in the main axis direction.
///
/// If the render object is dirty (i.e. if [markNeedsLayout] has been called,
/// or if the object was newly created), then the returned value will be stale
/// until [layoutChild] has been called.
@protected
double get minExtent => _minExtent;
double _minExtent;
/// If this is based on the intrinsic dimensions of the child, the child
/// should be measured during [updateChild] and the value cached and returned
/// here. The [updateChild] method will automatically be invoked any time the
/// child changes its intrinsic dimensions.
double get minExtent;
@protected
double get childExtent {
......@@ -46,69 +49,65 @@ abstract class RenderSliverPersistentHeader extends RenderSliver with RenderObje
return null;
}
@protected
double _getChildIntrinsicExtent() {
if (child == null)
return 0.0;
assert(child != null);
assert(constraints.axis != null);
switch (constraints.axis) {
case Axis.vertical:
return child.getMinIntrinsicHeight(constraints.crossAxisExtent);
case Axis.horizontal:
return child.getMinIntrinsicWidth(constraints.crossAxisExtent);
}
return null;
}
bool _needsUpdateChild = true;
double _lastShrinkOffset = 0.0;
bool _lastOverlapsContent = false;
/// The last value that we passed to updateChild().
double _lastShrinkOffset;
/// Called during layout if the shrink offset has changed.
/// Update the child render object if necessary.
///
/// During this callback, the [child] can be set, mutated, or replaced.
@protected
void updateChild(double shrinkOffset) { }
/// Flag the current child as stale and needing updating even if the shrink
/// offset has not changed.
/// Called before the first layout, any time [markNeedsLayout] is called, and
/// any time the scroll offset changes. The `shrinkOffset` is the difference
/// between the [maxExtent] and the current size. Zero means the header is
/// fully expanded, any greater number up to [maxExtent] means that the header
/// has been scrolled by that much. The `overlapsContent` argument is true if
/// the sliver's leading edge is beyond its normal place in the viewport
/// contents, and false otherwise. It may still paint beyond its normal place
/// if the [minExtent] after this call is greater than the amount of space that
/// would normally be left.
///
/// The render object will size itself to the larger of (a) the [maxExtent]
/// minus the child's intrinsic height and (b) the [maxExtent] minus the
/// shrink offset.
///
/// Call this whenever [updateChild] would change or mutate the child even if
/// given the same `shrinkOffset` as the last time it was called.
/// When this method is called by [layoutChild], the [child] can be set,
/// mutated, or replaced. (It should not be called outside [layoutChild].)
///
/// This must be implemented by [RenderSliverPersistentHeader] subclasses such
/// that the next layout after this call will result in [updateChild] being
/// called.
/// Any time this method would mutate the child, call [markNeedsLayout].
@protected
void markNeedsUpdate() {
markNeedsLayout();
_lastShrinkOffset = null;
void updateChild(double shrinkOffset, bool overlapsContent) { }
@override
void markNeedsLayout() {
// This is automatically called whenever the child's intrinsic dimensions
// change, at which point we should remeasure them during the next layout.
_needsUpdateChild = true;
super.markNeedsLayout();
}
void layoutChild(double scrollOffset, double maxExtent) {
void layoutChild(double scrollOffset, double maxExtent, { bool overlapsContent: false }) {
assert(maxExtent != null);
final double shrinkOffset = math.min(scrollOffset, maxExtent);
if (shrinkOffset != _lastShrinkOffset) {
if (_needsUpdateChild || _lastShrinkOffset != shrinkOffset || _lastOverlapsContent != overlapsContent) {
invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
assert(constraints == this.constraints);
updateChild(shrinkOffset);
_minExtent = _getChildIntrinsicExtent();
updateChild(shrinkOffset, overlapsContent);
});
_lastShrinkOffset = shrinkOffset;
_lastOverlapsContent = overlapsContent;
_needsUpdateChild = false;
}
assert(_minExtent != null);
assert(minExtent != null);
assert(() {
if (_minExtent <= maxExtent)
if (minExtent <= maxExtent)
return true;
throw new FlutterError(
'The maxExtent for this $runtimeType is less than the child\'s intrinsic extent.\n'
'The maxExtent for this $runtimeType is less than its minExtent.\n'
'The specified maxExtent was: ${maxExtent.toStringAsFixed(1)}\n'
'The child was updated with shrink offset: ${shrinkOffset.toStringAsFixed(1)}\n'
'The actual measured intrinsic extent of the child was: ${_minExtent.toStringAsFixed(1)}\n'
'The specified minExtent was: ${minExtent.toStringAsFixed(1)}\n'
);
});
child?.layout(
constraints.asBoxConstraints(maxExtent: math.max(_minExtent, maxExtent - shrinkOffset)),
constraints.asBoxConstraints(maxExtent: math.max(minExtent, maxExtent - shrinkOffset)),
parentUsesSize: true,
);
}
......@@ -237,7 +236,7 @@ abstract class RenderSliverPinnedPersistentHeader extends RenderSliverPersistent
@override
void performLayout() {
final double maxExtent = this.maxExtent;
layoutChild(constraints.scrollOffset + constraints.overlap, maxExtent);
layoutChild(constraints.scrollOffset + constraints.overlap, maxExtent, overlapsContent: constraints.overlap > 0.0);
geometry = new SliverGeometry(
scrollExtent: maxExtent,
paintExtent: math.min(constraints.overlap + childExtent, constraints.remainingPaintExtent),
......@@ -285,7 +284,7 @@ abstract class RenderSliverFloatingPersistentHeader extends RenderSliverPersiste
} else {
_effectiveScrollOffset = constraints.scrollOffset;
}
layoutChild(_effectiveScrollOffset, maxExtent);
layoutChild(_effectiveScrollOffset, maxExtent, overlapsContent: _effectiveScrollOffset < constraints.scrollOffset);
final double paintExtent = maxExtent - _effectiveScrollOffset;
final double layoutExtent = (maxExtent - constraints.scrollOffset).clamp(0.0, constraints.remainingPaintExtent);
geometry = new SliverGeometry(
......
......@@ -155,9 +155,7 @@ class _WidgetsAppState extends State<WidgetsApp> implements WidgetsBindingObserv
assert(mounted);
NavigatorState navigator = _navigator.currentState;
assert(navigator != null);
if (!await navigator.willPop())
return true;
return mounted && navigator.pop();
return await navigator.maybePop();
}
@override
......
......@@ -52,7 +52,7 @@ abstract class Route<T> {
@mustCallSuper
void install(OverlayEntry insertionPoint) { }
/// Called after install() when the route is pushed onto the navigator.
/// Called after [install] when the route is pushed onto the navigator.
///
/// The returned value resolves when the push transition is complete.
@protected
......@@ -62,7 +62,7 @@ abstract class Route<T> {
/// specified or if it's null, this value will be used instead.
T get currentResult => null;
/// Called after install() when the route replaced another in the navigator.
/// Called after [install] when the route replaced another in the navigator.
@protected
@mustCallSuper
void didReplace(Route<dynamic> oldRoute) { }
......@@ -78,7 +78,7 @@ abstract class Route<T> {
/// A request was made to pop this route. If the route can handle it
/// internally (e.g. because it has its own stack of internal state) then
/// return false, otherwise return true. Returning false will prevent the
/// default behavior of NavigatorState.pop().
/// default behavior of [NavigatorState.pop].
///
/// When this function returns true, the navigator removes this route from
/// the history but does not yet call [dispose]. Instead, it is the route's
......@@ -89,11 +89,11 @@ abstract class Route<T> {
@protected
@mustCallSuper
bool didPop(T result) {
_popCompleter.complete(result);
didComplete(result);
return true;
}
/// Whether calling didPop() would return false.
/// Whether calling [didPop] would return false.
bool get willHandlePopInternally => false;
/// The given route, which came after this one, has been popped off the
......@@ -104,12 +104,30 @@ abstract class Route<T> {
/// This route's next route has changed to the given new route. This is called
/// on a route whenever the next route changes for any reason, except for
/// cases when didPopNext() would be called, so long as it is in the history.
/// nextRoute will be null if there's no next route.
/// cases when [didPopNext] would be called, so long as it is in the history.
/// `nextRoute` will be null if there's no next route.
@protected
@mustCallSuper
void didChangeNext(Route<dynamic> nextRoute) { }
/// This route's previous route has changed to the given new route. This is
/// called on a route whenever the previous route changes for any reason, so
/// long as it is in the history, except for immediately after the route has
/// been pushed (in which wase [didPush] or [didReplace] will be called
/// instead). `previousRoute` will be null if there's no previous route.
@protected
@mustCallSuper
void didChangePrevious(Route<dynamic> previousRoute) { }
/// The route was popped or is otherwise being removed somewhat gracefully.
///
/// This is called by [didPop] and in response to [Navigator.pushReplacement].
@protected
@mustCallSuper
void didComplete(T result) {
_popCompleter.complete(result);
}
/// The route should remove its overlays and free any other resources.
///
/// This route is no longer referenced by the navigator.
......@@ -117,7 +135,7 @@ abstract class Route<T> {
@protected
void dispose() {
assert(() {
if (_navigator == null) {
if (navigator == null) {
throw new FlutterError(
'$runtimeType.dipose() called more than once.\n'
'A given route cannot be disposed more than once.'
......@@ -146,10 +164,22 @@ abstract class Route<T> {
return _navigator != null && _navigator._history.last == this;
}
/// Whether this route is the bottom-most route on the navigator.
///
/// If this is true, then [Navigator.canPop] will return false if this route's
/// [willHandlePopInternally] returns false.
///
/// If [isFirst] and [isCurrent] are both true then this is the only route on
/// the navigator (and [isActive] will also be true).
bool get isFirst {
return _navigator != null && _navigator._history.first == this;
}
/// Whether this route is on the navigator.
///
/// If the route is not only active, but also the current route (the top-most
/// route), then [isCurrent] will also be true.
/// route), then [isCurrent] will also be true. If it is the first route (the
/// bottom-most route), then [isFirst] will also be true.
///
/// If a later route is entirely opaque, then the route will be active but not
/// rendered. It is even possible for the route to be active but for the stateful
......@@ -516,8 +546,8 @@ class Navigator extends StatefulWidget {
/// to veto a [pop] initiated by the app's back button.
/// * [ModalRoute], which provides a `scopedWillPopCallback` that can be used
/// to define the route's `willPop` method.
static Future<bool> willPop(BuildContext context) {
return Navigator.of(context).willPop();
static Future<bool> maybePop(BuildContext context, [ dynamic result ]) {
return Navigator.of(context).maybePop(result);
}
/// Pop a route off the navigator that most tightly encloses the given context.
......@@ -562,7 +592,8 @@ class Navigator extends StatefulWidget {
Navigator.of(context).popUntil(predicate);
}
/// Whether the navigator that most tightly encloses the given context can be popped.
/// Whether the navigator that most tightly encloses the given context can be
/// popped.
///
/// The initial route cannot be popped off the navigator, which implies that
/// this function returns true only if popping the navigator would not remove
......@@ -809,10 +840,12 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
newRoute.install(oldRoute.overlayEntries.last);
_history[index] = newRoute;
newRoute.didReplace(oldRoute);
if (index + 1 < _history.length)
if (index + 1 < _history.length) {
newRoute.didChangeNext(_history[index + 1]);
else
_history[index + 1].didChangePrevious(newRoute);
} else {
newRoute.didChangeNext(null);
}
if (index > 0)
_history[index - 1].didChangeNext(newRoute);
oldRoute.dispose();
......@@ -825,7 +858,8 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
/// The new route and the route below the new route (if any) are notified
/// (see [Route.didPush] and [Route.didChangeNext]). The navigator observer
/// is not notified about the old route. The old route is disposed (see
/// [Route.dispose]).
/// [Route.dispose]). The new route is not notified when the old route
/// is removed (which happens when the new route's animation completes).
///
/// If a [result] is provided, it will be the return value of the old route,
/// as if the old route had been popped.
......@@ -837,7 +871,6 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
assert(oldRoute.overlayEntries.isNotEmpty);
assert(newRoute._navigator == null);
assert(newRoute.overlayEntries.isEmpty);
setState(() {
int index = _history.length - 1;
assert(index >= 0);
......@@ -845,12 +878,12 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
newRoute._navigator = this;
newRoute.install(_currentOverlayEntry);
_history[index] = newRoute;
newRoute.didPush().then<dynamic>((Null _) {
newRoute.didPush().then<Null>((Null value) {
// The old route's exit is not animated. We're assuming that the
// new route completely obscures the old one.
if (mounted) {
oldRoute
.._popCompleter.complete(result ?? oldRoute.currentResult)
..didComplete(result ?? oldRoute.currentResult)
..dispose();
}
});
......@@ -895,7 +928,8 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
///
/// The removed route is disposed (see [Route.dispose]). The route prior to
/// the removed route, if any, is notified (see [Route.didChangeNext]). The
/// navigator observer is not notified.
/// route above the removed route, if any, is also notified (see
/// [Route.didChangePrevious]). The navigator observer is not notified.
void removeRouteBelow(Route<dynamic> anchorRoute) {
assert(!_debugLocked);
assert(() { _debugLocked = true; return true; });
......@@ -907,28 +941,36 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
assert(targetRoute.overlayEntries.isEmpty || !overlay.debugIsVisible(targetRoute.overlayEntries.last));
setState(() {
_history.removeAt(index);
Route<dynamic> newRoute = index < _history.length ? _history[index] : null;
if (index > 0)
_history[index - 1].didChangeNext(newRoute);
Route<dynamic> nextRoute = index < _history.length ? _history[index] : null;
Route<dynamic> previousRoute = index > 0 ? _history[index - 1] : null;
if (previousRoute != null)
previousRoute.didChangeNext(nextRoute);
if (nextRoute != null)
nextRoute.didChangePrevious(previousRoute);
targetRoute.dispose();
});
assert(() { _debugLocked = false; return true; });
}
/// Returns the value of the current route's `willPop` method. This method is
/// typically called before a user-initiated [pop]. For example on Android it's
/// called by the binding for the system's back button.
/// Tries to pop the current route, first giving the active route the chance
/// to veto the operation using [Route.willPop]. This method is typically
/// called instead of [pop] when the user uses a back button. For example on
/// Android it's called by the binding for the system's back button.
///
/// See also:
///
/// * [Form], which provides an `onWillPop` callback that enables the form
/// to veto a [pop] initiated by the app's back button.
/// * [ModalRoute], which has as a `willPop` method that can be defined
/// by a list of [WillPopCallback]s.
Future<bool> willPop() async {
/// * [Form], which provides a [Form.onWillPop] callback that enables the form
/// to veto a [maybePop] initiated by the app's back button.
/// * [ModalRoute], which has as a [ModalRoute.willPop] method that can be
/// defined by a list of [WillPopCallback]s.
Future<bool> maybePop([dynamic result]) async {
final Route<dynamic> route = _history.last;
assert(route._navigator == this);
return route.willPop();
if (await route.willPop() && mounted) {
pop(result);
return true;
}
return false;
}
/// Removes the top route in the [Navigator]'s history.
......@@ -1086,10 +1128,10 @@ class NavigatorState extends State<Navigator> with TickerProviderStateMixin {
initiallyFocusedScope: initialRoute.focusKey,
child: new Overlay(
key: _overlayKey,
initialEntries: initialRoute.overlayEntries
)
)
)
initialEntries: initialRoute.overlayEntries,
),
),
),
);
}
}
// 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 'package:flutter/foundation.dart';
import 'framework.dart';
import 'scroll_controller.dart';
class PrimaryScrollController extends InheritedWidget {
PrimaryScrollController({
Key key,
@required this.controller,
@required Widget child
}) : super(key: key, child: child) {
assert(controller != null);
}
final ScrollController controller;
static ScrollController of(BuildContext context) {
PrimaryScrollController result = context.inheritFromWidgetOfExactType(PrimaryScrollController);
return result?.controller;
}
@override
bool updateShouldNotify(PrimaryScrollController old) => controller != old.controller;
@override
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('$controller');
}
}
......@@ -295,7 +295,10 @@ abstract class LocalHistoryRoute<T> extends Route<T> {
assert(entry._owner == null);
entry._owner = this;
_localHistory ??= <LocalHistoryEntry>[];
final bool wasEmpty = _localHistory.isEmpty;
_localHistory.add(entry);
if (wasEmpty)
changedInternalState();
}
/// Remove a local history entry from this route.
......@@ -308,6 +311,8 @@ abstract class LocalHistoryRoute<T> extends Route<T> {
_localHistory.remove(entry);
entry._owner = null;
entry._notifyRemoved();
if (_localHistory.isEmpty)
changedInternalState();
}
@override
......@@ -317,6 +322,8 @@ abstract class LocalHistoryRoute<T> extends Route<T> {
assert(entry._owner == this);
entry._owner = null;
entry._notifyRemoved();
if (_localHistory.isEmpty)
changedInternalState();
return false;
}
return super.didPop(result);
......@@ -326,26 +333,40 @@ abstract class LocalHistoryRoute<T> extends Route<T> {
bool get willHandlePopInternally {
return _localHistory != null && _localHistory.isNotEmpty;
}
/// Called whenever the internal state of the route has changed.
///
/// This should be called whenever [willHandlePopInternally] and [didPop]
/// might change the value they return. It is used by [ModalRoute], for
/// example, to report the new information via its inherited widget to any
/// children of the route.
@protected
@mustCallSuper
void changedInternalState() { }
}
class _ModalScopeStatus extends InheritedWidget {
_ModalScopeStatus({
Key key,
this.isCurrent,
this.route,
@required this.isCurrent,
@required this.canPop,
@required this.route,
@required Widget child
}) : super(key: key, child: child) {
assert(isCurrent != null);
assert(canPop != null);
assert(route != null);
assert(child != null);
}
final bool isCurrent;
final bool canPop;
final Route<dynamic> route;
@override
bool updateShouldNotify(_ModalScopeStatus old) {
return isCurrent != old.isCurrent ||
canPop != old.canPop ||
route != old.route;
}
......@@ -353,6 +374,8 @@ class _ModalScopeStatus extends InheritedWidget {
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('${isCurrent ? "active" : "inactive"}');
if (canPop)
description.add('can pop');
}
}
......@@ -396,7 +419,6 @@ class _ModalScopeState extends State<_ModalScope> {
void dispose() {
config.route.animation?.removeStatusListener(_animationStatusChanged);
config.route.forwardAnimation?.removeStatusListener(_animationStatusChanged);
willPopCallbacks = null;
super.dispose();
}
......@@ -439,13 +461,14 @@ class _ModalScopeState extends State<_ModalScope> {
child: new _ModalScopeStatus(
route: config.route,
isCurrent: config.route.isCurrent,
child: config.page
)
)
)
)
)
)
canPop: config.route.canPop(),
child: config.page,
),
),
),
),
),
),
);
}
}
......@@ -734,6 +757,24 @@ abstract class ModalRoute<T> extends TransitionRoute<T> with LocalHistoryRoute<T
return _scopeKey.currentState == null || _scopeKey.currentState.willPopCallbacks.length > 0;
}
@override
void changedInternalState() {
super.changedInternalState();
setState(() { /* internal state already changed */ });
}
@override
void didChangePrevious(Route<dynamic> route) {
super.didChangePrevious(route);
setState(() { /* this might affect canPop */ });
}
/// Whether this route can be popped.
///
/// When this changes, the route will rebuild, and any widgets that used
/// [ModalRoute.of] will be notified.
bool canPop() => !isFirst || willHandlePopInternally;
// Internals
final GlobalKey<_ModalScopeState> _scopeKey = new GlobalKey<_ModalScopeState>();
......
......@@ -22,6 +22,14 @@ class ScrollController {
final List<ScrollPosition> _positions = <ScrollPosition>[];
/// Whether any [ScrollPosition] objects have attached themselves to the
/// [ScrollController] using the [attach] method.
///
/// If this is false, then members that interact with the [ScrollPosition],
/// such as [position], [offset], [animateTo], and [jumpTo], must not be
/// called.
bool get hasClients => _positions.isNotEmpty;
ScrollPosition get position {
assert(_positions.isNotEmpty, 'ScrollController not attached to any scroll views.');
assert(_positions.length == 1, 'ScrollController attached to multiple scroll views.');
......
......@@ -5,8 +5,9 @@
import 'package:flutter/rendering.dart';
import 'package:meta/meta.dart';
import 'framework.dart';
import 'basic.dart';
import 'framework.dart';
import 'primary_scroll_controller.dart';
import 'scroll_controller.dart';
import 'scroll_physics.dart';
import 'scroll_position.dart';
......@@ -20,11 +21,16 @@ abstract class ScrollView extends StatelessWidget {
this.scrollDirection: Axis.vertical,
this.reverse: false,
this.controller,
this.primary: false,
this.physics,
this.shrinkWrap: false,
}) : super(key: key) {
assert(reverse != null);
assert(shrinkWrap != null);
assert(primary != null);
assert(controller == null || !primary,
'Primary ScrollViews obtain their ScrollController via inheritance from a PrimaryScrollController widget. '
'You cannot both set primary to true and pass an explicit controller.');
}
final Axis scrollDirection;
......@@ -33,6 +39,8 @@ abstract class ScrollView extends StatelessWidget {
final ScrollController controller;
final bool primary;
final ScrollPhysics physics;
final bool shrinkWrap;
......@@ -58,7 +66,7 @@ abstract class ScrollView extends StatelessWidget {
AxisDirection axisDirection = getDirection(context);
return new Scrollable2(
axisDirection: axisDirection,
controller: controller,
controller: controller ?? (primary ? PrimaryScrollController.of(context) : null),
physics: physics,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
if (shrinkWrap) {
......@@ -82,6 +90,14 @@ abstract class ScrollView extends StatelessWidget {
void debugFillDescription(List<String> description) {
super.debugFillDescription(description);
description.add('$scrollDirection');
if (reverse)
description.add('reversed');
if (controller != null)
description.add('$controller');
if (primary)
description.add('using primary controller');
if (physics != null)
description.add('$physics');
if (shrinkWrap)
description.add('shrink-wrapping');
}
......@@ -93,6 +109,7 @@ class CustomScrollView extends ScrollView {
Axis scrollDirection: Axis.vertical,
bool reverse: false,
ScrollController controller,
bool primary: false,
ScrollPhysics physics,
bool shrinkWrap: false,
this.slivers: const <Widget>[],
......@@ -101,6 +118,7 @@ class CustomScrollView extends ScrollView {
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
primary: primary,
physics: physics,
shrinkWrap: shrinkWrap,
);
......@@ -117,6 +135,7 @@ abstract class BoxScrollView extends ScrollView {
Axis scrollDirection: Axis.vertical,
bool reverse: false,
ScrollController controller,
bool primary: false,
ScrollPhysics physics,
bool shrinkWrap: false,
this.padding,
......@@ -125,6 +144,7 @@ abstract class BoxScrollView extends ScrollView {
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
primary: primary,
physics: physics,
shrinkWrap: shrinkWrap,
);
......@@ -164,6 +184,7 @@ class ListView extends BoxScrollView {
Axis scrollDirection: Axis.vertical,
bool reverse: false,
ScrollController controller,
bool primary: false,
ScrollPhysics physics,
bool shrinkWrap: false,
EdgeInsets padding,
......@@ -174,6 +195,7 @@ class ListView extends BoxScrollView {
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
primary: primary,
physics: physics,
shrinkWrap: shrinkWrap,
padding: padding,
......@@ -184,6 +206,7 @@ class ListView extends BoxScrollView {
Axis scrollDirection: Axis.vertical,
bool reverse: false,
ScrollController controller,
bool primary: false,
ScrollPhysics physics,
bool shrinkWrap: false,
EdgeInsets padding,
......@@ -195,6 +218,7 @@ class ListView extends BoxScrollView {
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
primary: primary,
physics: physics,
shrinkWrap: shrinkWrap,
padding: padding,
......@@ -205,6 +229,7 @@ class ListView extends BoxScrollView {
Axis scrollDirection: Axis.vertical,
bool reverse: false,
ScrollController controller,
bool primary: false,
ScrollPhysics physics,
bool shrinkWrap: false,
EdgeInsets padding,
......@@ -215,6 +240,7 @@ class ListView extends BoxScrollView {
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
primary: primary,
physics: physics,
shrinkWrap: shrinkWrap,
padding: padding,
......@@ -259,6 +285,7 @@ class GridView extends BoxScrollView {
Axis scrollDirection: Axis.vertical,
bool reverse: false,
ScrollController controller,
bool primary: false,
ScrollPhysics physics,
bool shrinkWrap: false,
EdgeInsets padding,
......@@ -269,6 +296,7 @@ class GridView extends BoxScrollView {
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
primary: primary,
physics: physics,
shrinkWrap: shrinkWrap,
padding: padding,
......@@ -281,6 +309,7 @@ class GridView extends BoxScrollView {
Axis scrollDirection: Axis.vertical,
bool reverse: false,
ScrollController controller,
bool primary: false,
ScrollPhysics physics,
bool shrinkWrap: false,
EdgeInsets padding,
......@@ -291,6 +320,7 @@ class GridView extends BoxScrollView {
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
primary: primary,
physics: physics,
shrinkWrap: shrinkWrap,
padding: padding,
......@@ -304,6 +334,7 @@ class GridView extends BoxScrollView {
Axis scrollDirection: Axis.vertical,
bool reverse: false,
ScrollController controller,
bool primary: false,
ScrollPhysics physics,
bool shrinkWrap: false,
EdgeInsets padding,
......@@ -323,6 +354,7 @@ class GridView extends BoxScrollView {
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
primary: primary,
physics: physics,
shrinkWrap: shrinkWrap,
padding: padding,
......@@ -333,6 +365,7 @@ class GridView extends BoxScrollView {
Axis scrollDirection: Axis.vertical,
bool reverse: false,
ScrollController controller,
bool primary: false,
ScrollPhysics physics,
bool shrinkWrap: false,
EdgeInsets padding,
......@@ -352,6 +385,7 @@ class GridView extends BoxScrollView {
scrollDirection: scrollDirection,
reverse: reverse,
controller: controller,
primary: primary,
physics: physics,
shrinkWrap: shrinkWrap,
padding: padding,
......
......@@ -86,7 +86,12 @@ class Scrollable2 extends StatefulWidget {
Scrollable2State scrollable = Scrollable2.of(context);
while (scrollable != null) {
futures.add(scrollable.position.ensureVisible(context.findRenderObject(), alignment: alignment));
futures.add(scrollable.position.ensureVisible(
context.findRenderObject(),
alignment: alignment,
duration: duration,
curve: curve,
));
context = scrollable.context;
scrollable = Scrollable2.of(context);
}
......
......@@ -12,7 +12,9 @@ abstract class SliverPersistentHeaderDelegate {
/// const constructors so that they can be used in const expressions.
const SliverPersistentHeaderDelegate();
Widget build(BuildContext context, double shrinkOffset);
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent);
double get minExtent;
double get maxExtent;
......@@ -101,9 +103,9 @@ class _SliverPersistentHeaderElement extends RenderObjectElement {
Element child;
void _build(double shrinkOffset) {
void _build(double shrinkOffset, bool overlapsContent) {
owner.buildScope(this, () {
child = updateChild(child, widget.delegate.build(this, shrinkOffset), null);
child = updateChild(child, widget.delegate.build(this, shrinkOffset, overlapsContent), null);
});
}
......@@ -160,18 +162,21 @@ abstract class _SliverPersistentHeaderRenderObjectWidget extends RenderObjectWid
abstract class _RenderSliverPersistentHeaderForWidgetsMixin implements RenderSliverPersistentHeader {
_SliverPersistentHeaderElement _element;
@override
double get minExtent => _element.widget.delegate.minExtent;
@override
double get maxExtent => _element.widget.delegate.maxExtent;
@override
void updateChild(double shrinkOffset) {
void updateChild(double shrinkOffset, bool overlapsContent) {
assert(_element != null);
_element._build(shrinkOffset);
_element._build(shrinkOffset, overlapsContent);
}
@protected
void triggerRebuild() {
markNeedsUpdate();
markNeedsLayout();
}
}
......
......@@ -43,6 +43,7 @@ export 'src/widgets/page_view.dart';
export 'src/widgets/pages.dart';
export 'src/widgets/performance_overlay.dart';
export 'src/widgets/placeholder.dart';
export 'src/widgets/primary_scroll_controller.dart';
export 'src/widgets/raw_keyboard_listener.dart';
export 'src/widgets/routes.dart';
export 'src/widgets/scroll_behavior.dart';
......
......@@ -80,32 +80,39 @@ void main() {
testWidgets('Drawer scrolling', (WidgetTester tester) async {
GlobalKey<ScrollableState<Scrollable>> drawerKey =
new GlobalKey<ScrollableState<Scrollable>>(debugLabel: 'drawer');
Key appBarKey = new Key('appBar');
const double appBarHeight = 256.0;
ScrollController scrollOffset = new ScrollController();
await tester.pumpWidget(
new MaterialApp(
home: new Scaffold(
appBarBehavior: AppBarBehavior.under,
appBar: new AppBar(
key: appBarKey,
expandedHeight: appBarHeight,
title: new Text('Title'),
flexibleSpace: new FlexibleSpaceBar(title: new Text('Title')),
),
drawer: new Drawer(
child: new Block(
scrollableKey: drawerKey,
key: drawerKey,
child: new ListView(
controller: scrollOffset,
children: new List<Widget>.generate(10,
(int index) => new SizedBox(height: 100.0, child: new Text('D$index'))
)
)
),
body: new Block(
padding: const EdgeInsets.only(top: appBarHeight),
children: new List<Widget>.generate(10,
(int index) => new SizedBox(height: 100.0, child: new Text('B$index'))
),
body: new CustomScrollView(
slivers: <Widget>[
new SliverAppBar(
pinned: true,
expandedHeight: appBarHeight,
title: new Text('Title'),
flexibleSpace: new FlexibleSpaceBar(title: new Text('Title')),
),
new SliverPadding(
padding: const EdgeInsets.only(top: appBarHeight),
child: new SliverList(
delegate: new SliverChildListDelegate(new List<Widget>.generate(
10, (int index) => new SizedBox(height: 100.0, child: new Text('B$index')),
)),
),
),
],
),
)
)
......@@ -117,86 +124,62 @@ void main() {
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(drawerKey.currentState.scrollOffset, equals(0));
expect(scrollOffset.offset, 0.0);
const double scrollDelta = 80.0;
await tester.scroll(find.byKey(drawerKey), const Offset(0.0, -scrollDelta));
await tester.pump();
expect(drawerKey.currentState.scrollOffset, equals(scrollDelta));
expect(scrollOffset.offset, scrollDelta);
RenderBox renderBox = tester.renderObject(find.byKey(appBarKey));
RenderBox renderBox = tester.renderObject(find.byType(AppBar));
expect(renderBox.size.height, equals(appBarHeight));
});
testWidgets('Tapping the status bar scrolls to top on iOS', (WidgetTester tester) async {
final GlobalKey<ScrollableState> scrollableKey = new GlobalKey<ScrollableState>();
final Key appBarKey = new UniqueKey();
await tester.pumpWidget(
new MaterialApp(
theme: new ThemeData(platform: TargetPlatform.iOS),
home: new MediaQuery(
data: const MediaQueryData(padding: const EdgeInsets.only(top: 25.0)), // status bar
child: new Scaffold(
scrollableKey: scrollableKey,
appBar: new AppBar(
key: appBarKey,
title: new Text('Title')
),
body: new Block(
scrollableKey: scrollableKey,
initialScrollOffset: 500.0,
children: new List<Widget>.generate(20,
(int index) => new SizedBox(height: 100.0, child: new Text('$index'))
)
)
)
)
)
Widget _buildStatusBarTestApp(TargetPlatform platform) {
return new MaterialApp(
theme: new ThemeData(platform: platform),
home: new MediaQuery(
data: const MediaQueryData(padding: const EdgeInsets.only(top: 25.0)), // status bar
child: new Scaffold(
body: new CustomScrollView(
primary: true,
slivers: <Widget>[
new SliverAppBar(
title: new Text('Title')
),
new SliverList(
delegate: new SliverChildListDelegate(new List<Widget>.generate(
20, (int index) => new SizedBox(height: 100.0, child: new Text('$index')),
)),
),
],
),
),
),
);
}
final ScrollableState scrollable = tester.state(find.byType(Scrollable));
expect(scrollable.scrollOffset, equals(500.0));
testWidgets('Tapping the status bar scrolls to top on iOS', (WidgetTester tester) async {
await tester.pumpWidget(_buildStatusBarTestApp(TargetPlatform.iOS));
final Scrollable2State scrollable = tester.state(find.byType(Scrollable2));
scrollable.position.jumpTo(500.0);
expect(scrollable.position.pixels, equals(500.0));
await tester.tapAt(const Point(100.0, 10.0));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(scrollable.scrollOffset, equals(0.0));
expect(scrollable.position.pixels, equals(0.0));
});
testWidgets('Tapping the status bar does not scroll to top on Android', (WidgetTester tester) async {
final GlobalKey<ScrollableState> scrollableKey = new GlobalKey<ScrollableState>();
final Key appBarKey = new UniqueKey();
await tester.pumpWidget(
new MaterialApp(
theme: new ThemeData(platform: TargetPlatform.android),
home: new MediaQuery(
data: const MediaQueryData(padding: const EdgeInsets.only(top: 25.0)), // status bar
child: new Scaffold(
scrollableKey: scrollableKey,
appBar: new AppBar(
key: appBarKey,
title: new Text('Title')
),
body: new Block(
scrollableKey: scrollableKey,
initialScrollOffset: 500.0,
children: new List<Widget>.generate(20,
(int index) => new SizedBox(height: 100.0, child: new Text('$index'))
)
)
)
)
)
);
final ScrollableState scrollable = tester.state(find.byType(Scrollable));
expect(scrollable.scrollOffset, equals(500.0));
await tester.pumpWidget(_buildStatusBarTestApp(TargetPlatform.android));
final Scrollable2State scrollable = tester.state(find.byType(Scrollable2));
scrollable.position.jumpTo(500.0);
expect(scrollable.position.pixels, equals(500.0));
await tester.tapAt(const Point(100.0, 10.0));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(scrollable.scrollOffset, equals(500.0));
expect(scrollable.position.pixels, equals(500.0));
});
testWidgets('Bottom sheet cannot overlap app bar', (WidgetTester tester) async {
......
......@@ -13,12 +13,22 @@ class SamplePage extends StatefulWidget {
}
class SamplePageState extends State<SamplePage> {
ModalRoute<Null> _route;
Future<bool> _callback() async => willPopValue;
@override
void dependenciesChanged() {
super.dependenciesChanged();
final ModalRoute<Null> route = ModalRoute.of(context);
if (route.isCurrent)
route.addScopedWillPopCallback(() async => willPopValue);
_route?.removeScopedWillPopCallback(_callback);
_route = ModalRoute.of(context);
_route?.addScopedWillPopCallback(_callback);
}
@override
void dispose() {
super.dispose();
_route?.removeScopedWillPopCallback(_callback);
}
@override
......@@ -87,6 +97,9 @@ void main() {
),
);
expect(find.byTooltip('Back'), findsNothing);
expect(find.text('Sample Page'), findsNothing);
await tester.tap(find.text('X'));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
......
......@@ -189,8 +189,11 @@ class TestDelegate extends SliverPersistentHeaderDelegate {
double get maxExtent => 200.0;
@override
Widget build(BuildContext context, double shrinkOffset) {
return new Container(constraints: new BoxConstraints(minHeight: maxExtent / 2.0, maxHeight: maxExtent));
double get minExtent => 100.0;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return new Container(constraints: new BoxConstraints(minHeight: minExtent, maxHeight: maxExtent));
}
@override
......
......@@ -184,8 +184,11 @@ class TestDelegate extends SliverPersistentHeaderDelegate {
double get maxExtent => 200.0;
@override
Widget build(BuildContext context, double shrinkOffset) {
return new Container(constraints: new BoxConstraints(minHeight: maxExtent / 2.0, maxHeight: maxExtent));
double get minExtent => 100.0;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return new Container(constraints: new BoxConstraints(minHeight: minExtent, maxHeight: maxExtent));
}
@override
......
......@@ -74,7 +74,10 @@ class TestDelegate extends SliverPersistentHeaderDelegate {
double get maxExtent => 200.0;
@override
Widget build(BuildContext context, double shrinkOffset) {
double get minExtent => 200.0;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return new Container(height: maxExtent);
}
......
......@@ -16,10 +16,13 @@ class TestSliverPersistentHeaderDelegate extends SliverPersistentHeaderDelegate
double get maxExtent => _maxExtent;
@override
Widget build(BuildContext context, double shrinkOffset) {
double get minExtent => 16.0;
@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return new Column(
children: <Widget>[
new Container(height: 16.0),
new Container(height: minExtent),
new Expanded(child: new Container()),
],
);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment