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