Added a Backdrop demo to the Gallery (#15579)


diff --git a/examples/flutter_gallery/lib/demo/material/backdrop_demo.dart b/examples/flutter_gallery/lib/demo/material/backdrop_demo.dart
new file mode 100644
index 0000000..553ee96
--- /dev/null
+++ b/examples/flutter_gallery/lib/demo/material/backdrop_demo.dart
@@ -0,0 +1,402 @@
+// Copyright 2018 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+
+// This demo displays one Category at a time. The backdrop show a list
+// of all of the categories and the selected category is displayed
+// (CategoryView) on top of the backdrop.
+
+class Category {
+  const Category({ this.title, this.assets });
+  final String title;
+  final List<String> assets;
+}
+
+const List<Category> allCategories = const <Category>[
+  const Category(
+    title: 'Home',
+    assets: const <String>[
+      'shrine/products/clock.png',
+      'shrine/products/teapot.png',
+      'shrine/products/radio.png',
+      'shrine/products/lawn_chair.png',
+      'shrine/products/chair.png',
+    ],
+  ),
+  const Category(
+    title: 'Red',
+    assets: const <String>[
+      'shrine/products/popsicle.png',
+      'shrine/products/brush.png',
+      'shrine/products/lipstick.png',
+      'shrine/products/backpack.png',
+    ],
+  ),
+  const Category(
+    title: 'Sport',
+    assets: const <String>[
+      'shrine/products/helmet.png',
+      'shrine/products/beachball.png',
+      'shrine/products/flippers.png',
+      'shrine/products/surfboard.png',
+    ],
+  ),
+  const Category(
+    title: 'Shoes',
+    assets: const <String>[
+      'shrine/products/chucks.png',
+      'shrine/products/green-shoes.png',
+      'shrine/products/heels.png',
+      'shrine/products/flippers.png',
+    ],
+  ),
+  const Category(
+    title: 'Vision',
+    assets: const <String>[
+      'shrine/products/sunnies.png',
+      'shrine/products/binoculars.png',
+      'shrine/products/fish_bowl.png',
+    ],
+  ),
+  const Category(
+    title: 'Everything',
+    assets: const <String>[
+      'shrine/products/radio.png',
+      'shrine/products/sunnies.png',
+      'shrine/products/clock.png',
+      'shrine/products/popsicle.png',
+      'shrine/products/lawn_chair.png',
+      'shrine/products/chair.png',
+      'shrine/products/heels.png',
+      'shrine/products/green-shoes.png',
+      'shrine/products/teapot.png',
+      'shrine/products/chucks.png',
+      'shrine/products/brush.png',
+      'shrine/products/fish_bowl.png',
+      'shrine/products/lipstick.png',
+      'shrine/products/backpack.png',
+      'shrine/products/helmet.png',
+      'shrine/products/beachball.png',
+      'shrine/products/binoculars.png',
+      'shrine/products/flippers.png',
+      'shrine/products/surfboard.png',
+    ],
+  ),
+];
+
+class CategoryView extends StatelessWidget {
+  const CategoryView({ Key key, this.category }) : super(key: key);
+
+  final Category category;
+
+  @override
+  Widget build(BuildContext context) {
+    final ThemeData theme = Theme.of(context);
+    return new ListView(
+      key: new PageStorageKey<Category>(category),
+      padding: const EdgeInsets.symmetric(
+        vertical: 16.0,
+        horizontal: 64.0,
+      ),
+      children: category.assets.map<Widget>((String asset) {
+        return new Column(
+          crossAxisAlignment: CrossAxisAlignment.stretch,
+          children: <Widget>[
+            new Card(
+              child: new Container(
+                width: 144.0,
+                alignment: Alignment.center,
+                child: new Column(
+                  children: <Widget>[
+                    new Image.asset(
+                      asset,
+                      package: 'flutter_gallery_assets',
+                      fit: BoxFit.contain,
+                    ),
+                    new Container(
+                      padding: const EdgeInsets.only(bottom: 16.0),
+                      alignment: AlignmentDirectional.center,
+                      child: new Text(
+                        asset,
+                        style: theme.textTheme.caption,
+                      ),
+                    ),
+                  ],
+                ),
+              ),
+            ),
+            const SizedBox(height: 24.0),
+          ],
+        );
+      }).toList(),
+    );
+  }
+}
+
+// One BackdropPanel is visible at a time. It's stacked on top of the
+// the BackdropDemo.
+class BackdropPanel extends StatelessWidget {
+  const BackdropPanel({
+    Key key,
+    this.onTap,
+    this.onVerticalDragUpdate,
+    this.onVerticalDragEnd,
+    this.title,
+    this.child,
+  }) : super(key: key);
+
+  final VoidCallback onTap;
+  final GestureDragUpdateCallback onVerticalDragUpdate;
+  final GestureDragEndCallback onVerticalDragEnd;
+  final Widget title;
+  final Widget child;
+
+  @override
+  Widget build(BuildContext context) {
+    final ThemeData theme = Theme.of(context);
+    return new Material(
+      elevation: 2.0,
+      borderRadius: const BorderRadius.only(
+        topLeft: const Radius.circular(16.0),
+        topRight: const Radius.circular(16.0),
+      ),
+      child: new Column(
+        crossAxisAlignment: CrossAxisAlignment.stretch,
+        children: <Widget>[
+          new GestureDetector(
+            behavior: HitTestBehavior.opaque,
+            onVerticalDragUpdate: onVerticalDragUpdate,
+            onVerticalDragEnd: onVerticalDragEnd,
+            onTap: onTap,
+            child: new Container(
+              height: 48.0,
+              padding: const EdgeInsetsDirectional.only(start: 16.0),
+              alignment: AlignmentDirectional.centerStart,
+              child: new DefaultTextStyle(
+                style: theme.textTheme.subhead,
+                child: title,
+              ),
+            ),
+          ),
+          const Divider(height: 1.0),
+          new Expanded(child: child),
+        ],
+      ),
+    );
+  }
+}
+
+// Cross fades between 'Select a Category' and 'Asset Viewer'.
+class BackdropTitle extends AnimatedWidget {
+  const BackdropTitle({
+    Key key,
+    Listenable listenable,
+  }) : super(key: key, listenable: listenable);
+
+  @override
+  Widget build(BuildContext context) {
+    final Animation<double> animation = listenable;
+    return new DefaultTextStyle(
+      style: Theme.of(context).primaryTextTheme.title,
+      softWrap: false,
+      overflow: TextOverflow.ellipsis,
+      child: new Stack(
+        children: <Widget>[
+          new Opacity(
+            opacity: new CurvedAnimation(
+              parent: new ReverseAnimation(animation),
+              curve: const Interval(0.5, 1.0),
+            ).value,
+            child: const Text('Select a Category'),
+          ),
+          new Opacity(
+            opacity: new CurvedAnimation(
+              parent: animation,
+              curve: const Interval(0.5, 1.0),
+            ).value,
+            child: const Text('Asset Viewer'),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+// This widget is essentially the backdrop itself.
+class BackdropDemo extends StatefulWidget {
+  static const String routeName = '/material/backdrop';
+
+  @override
+  _BackdropDemoState createState() => new _BackdropDemoState();
+}
+
+class _BackdropDemoState extends State<BackdropDemo> with SingleTickerProviderStateMixin {
+  final GlobalKey _backdropKey = new GlobalKey(debugLabel: 'Backdrop');
+  AnimationController _controller;
+  Category _category = allCategories[0];
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = new AnimationController(
+      duration: const Duration(milliseconds: 300),
+      value: 1.0,
+      vsync: this,
+    );
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+
+  void _changeCategory(Category category) {
+    setState(() {
+      _category = category;
+      _controller.fling(velocity: 2.0);
+    });
+  }
+
+  bool get _backdropPanelVisible {
+    final AnimationStatus status = _controller.status;
+    return status == AnimationStatus.completed || status == AnimationStatus.forward;
+  }
+
+  void _toggleBackdropPanelVisibility() {
+    _controller.fling(velocity: _backdropPanelVisible ? -2.0 : 2.0);
+  }
+
+  double get _backdropHeight {
+    final RenderBox renderBox = _backdropKey.currentContext.findRenderObject();
+    return renderBox.size.height;
+  }
+
+  // By design: the panel can only be opened with a swipe. To close the panel
+  // the user must either tap its heading or the backdrop's menu icon.
+
+  void _handleDragUpdate(DragUpdateDetails details) {
+    if (_controller.isAnimating || _controller.status == AnimationStatus.completed)
+      return;
+
+    _controller.value -= details.primaryDelta / (_backdropHeight ?? details.primaryDelta);
+  }
+
+  void _handleDragEnd(DragEndDetails details) {
+    if (_controller.isAnimating || _controller.status == AnimationStatus.completed)
+      return;
+
+    final double flingVelocity = details.velocity.pixelsPerSecond.dy / _backdropHeight;
+    if (flingVelocity < 0.0)
+      _controller.fling(velocity: math.max(2.0, -flingVelocity));
+    else if (flingVelocity > 0.0)
+      _controller.fling(velocity: math.min(-2.0, -flingVelocity));
+    else
+      _controller.fling(velocity: _controller.value < 0.5 ? -2.0 : 2.0);
+  }
+
+  // Stacks a BackdropPanel, which displays the selected category, on top
+  // of the backdrop. The categories are displayed with ListTiles. Just one
+  // can be selected at a time. This is a LayoutWidgetBuild function because
+  // we need to know how big the BackdropPanel will be to set up its
+  // animation.
+  Widget _buildStack(BuildContext context, BoxConstraints constraints) {
+    const double panelTitleHeight = 48.0;
+    final Size panelSize = constraints.biggest;
+    final double panelTop = panelSize.height - panelTitleHeight;
+
+    final Animation<RelativeRect> panelAnimation = new RelativeRectTween(
+      begin: new RelativeRect.fromLTRB(0.0, panelTop, 0.0, panelTop - panelSize.height),
+      end: const RelativeRect.fromLTRB(0.0, 0.0, 0.0, 0.0),
+    ).animate(
+      new CurvedAnimation(
+        parent: _controller,
+        curve: Curves.linear,
+      ),
+    );
+
+    final ThemeData theme = Theme.of(context);
+    final List<Widget> backdropItems = allCategories.map<Widget>((Category category) {
+      final bool selected = category == _category;
+      return new Material(
+        shape: const RoundedRectangleBorder(
+          borderRadius: const BorderRadius.all(const Radius.circular(4.0)),
+        ),
+        color: selected
+          ? Colors.white.withOpacity(0.25)
+          : Colors.transparent,
+        child: new ListTile(
+          title: new Text(category.title),
+          selected: selected,
+          onTap: () {
+            _changeCategory(category);
+          },
+        ),
+      );
+    }).toList()
+    ..add(const SizedBox(height: 8.0))
+    ..add(
+      new Align(
+        alignment: AlignmentDirectional.centerStart,
+        child: new BackButton(color: Colors.white.withOpacity(0.5))
+      ),
+    );
+
+    return new Container(
+      key: _backdropKey,
+      color: theme.primaryColor,
+      child: new Stack(
+        children: <Widget>[
+          new ListTileTheme(
+            iconColor: theme.primaryIconTheme.color,
+            textColor: theme.primaryTextTheme.title.color.withOpacity(0.6),
+            selectedColor: theme.primaryTextTheme.title.color,
+            child: new Padding(
+              padding: const EdgeInsets.symmetric(horizontal: 16.0),
+              child: new Column(
+                crossAxisAlignment: CrossAxisAlignment.stretch,
+                children: backdropItems,
+              ),
+            ),
+          ),
+          new PositionedTransition(
+            rect: panelAnimation,
+            child: new BackdropPanel(
+              onTap: _toggleBackdropPanelVisibility,
+              onVerticalDragUpdate: _handleDragUpdate,
+              onVerticalDragEnd: _handleDragEnd,
+              title: new Text(_category.title),
+              child: new CategoryView(category: _category),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return new Scaffold(
+      appBar: new AppBar(
+        elevation: 0.0,
+        leading: new IconButton(
+          onPressed: _toggleBackdropPanelVisibility,
+          icon: new AnimatedIcon(
+            icon: AnimatedIcons.close_menu,
+            progress: _controller.view,
+          ),
+        ),
+        title: new BackdropTitle(
+          listenable: _controller.view,
+        ),
+      ),
+      body: new LayoutBuilder(
+        builder: _buildStack,
+      ),
+    );
+  }
+}
diff --git a/examples/flutter_gallery/lib/demo/material/material.dart b/examples/flutter_gallery/lib/demo/material/material.dart
index ddf46e3..1aa45d9 100644
--- a/examples/flutter_gallery/lib/demo/material/material.dart
+++ b/examples/flutter_gallery/lib/demo/material/material.dart
@@ -2,6 +2,7 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+export 'backdrop_demo.dart';
 export 'bottom_navigation_demo.dart';
 export 'buttons_demo.dart';
 export 'cards_demo.dart';
diff --git a/examples/flutter_gallery/lib/gallery/item.dart b/examples/flutter_gallery/lib/gallery/item.dart
index 5237367..292013e 100644
--- a/examples/flutter_gallery/lib/gallery/item.dart
+++ b/examples/flutter_gallery/lib/gallery/item.dart
@@ -82,6 +82,13 @@
     ),
     // Material Components
     new GalleryItem(
+      title: 'Backdrop',
+      subtitle: 'Select a front layer from back layer',
+      category: 'Material Components',
+      routeName: BackdropDemo.routeName,
+      buildRoute: (BuildContext context) => new BackdropDemo(),
+    ),
+    new GalleryItem(
       title: 'Bottom navigation',
       subtitle: 'Bottom navigation with cross-fading views',
       category: 'Material Components',
diff --git a/examples/flutter_gallery/lib/gallery/theme.dart b/examples/flutter_gallery/lib/gallery/theme.dart
index 04bc7d2..f84b52b 100644
--- a/examples/flutter_gallery/lib/gallery/theme.dart
+++ b/examples/flutter_gallery/lib/gallery/theme.dart
@@ -11,16 +11,16 @@
   final ThemeData theme;
 }
 
+const int _kPurplePrimaryValue = 0xFF6200EE;
 const MaterialColor _kPurpleSwatch = const MaterialColor(
-  500,
+  _kPurplePrimaryValue,
   const <int, Color> {
     50: const Color(0xFFF2E7FE),
     100: const Color(0xFFD7B7FD),
     200: const Color(0xFFBB86FC),
     300: const Color(0xFF9E55FC),
     400: const Color(0xFF7F22FD),
-    500: const Color(0xFF6200EE),
-    600: const Color(0xFF4B00D1),
+    500: const Color(_kPurplePrimaryValue),
     700: const Color(0xFF3700B3),
     800: const Color(0xFF270096),
     900: const Color(0xFF190078),
diff --git a/examples/flutter_gallery/test/example_code_display_test.dart b/examples/flutter_gallery/test/example_code_display_test.dart
index 678dc1a..1e61404 100644
--- a/examples/flutter_gallery/test/example_code_display_test.dart
+++ b/examples/flutter_gallery/test/example_code_display_test.dart
@@ -23,9 +23,8 @@
     final Offset allDemosOrigin = tester.getTopRight(find.text('Demos'));
     final Finder button = find.text('Buttons');
     while (button.evaluate().isEmpty) {
-      await tester.dragFrom(allDemosOrigin, const Offset(0.0, -100.0));
-      await tester.pump(); // start the scroll
-      await tester.pump(const Duration(seconds: 1));
+      await tester.dragFrom(allDemosOrigin, const Offset(0.0, -200.0));
+      await tester.pumpAndSettle();
     }
 
     // Launch the buttons demo and then prove that showing the example
diff --git a/examples/flutter_gallery/test/smoke_test.dart b/examples/flutter_gallery/test/smoke_test.dart
index 7b8d9a3..2a4d206 100644
--- a/examples/flutter_gallery/test/smoke_test.dart
+++ b/examples/flutter_gallery/test/smoke_test.dart
@@ -128,6 +128,8 @@
     final Finder finder = findGalleryItemByRouteName(tester, routeName);
     Scrollable.ensureVisible(tester.element(finder), alignment: 0.5);
     await tester.pumpAndSettle();
+    if (routeName == '/material/backdrop')
+      continue;
     await smokeDemo(tester, routeName);
     tester.binding.debugAssertNoTransientCallbacks('A transient callback was still active after leaving route $routeName');
   }