[ermine][ask] Ask module uses sheet layout

- Added AskSheet widget to manage scrolling
- Animation controller removed from model, now managed by AskSheet
- AskModule is now hidden when animation is completed
- Removed redundant GestureDetector from AskModule

MI4-2366 #start-progress
MI4-2385 #done

Change-Id: I4a19314184f9be75424882d9ec7cf2dac8b21c07
diff --git a/shell/ermine/BUILD.gn b/shell/ermine/BUILD.gn
index bbdef58..6748570 100644
--- a/shell/ermine/BUILD.gn
+++ b/shell/ermine/BUILD.gn
@@ -41,6 +41,7 @@
     "src/models/web_proposer.dart",
     "src/modules/ask_model.dart",
     "src/modules/ask_module.dart",
+    "src/modules/ask_sheet.dart",
     "src/modules/ask_suggestion_list.dart",
     "src/modules/ask_text_field.dart",
     "src/utils/elevations.dart",
diff --git a/shell/ermine/lib/src/modules/ask_model.dart b/shell/ermine/lib/src/modules/ask_model.dart
index 775108d..5f39cd8 100644
--- a/shell/ermine/lib/src/modules/ask_model.dart
+++ b/shell/ermine/lib/src/modules/ask_model.dart
@@ -22,7 +22,6 @@
 import 'package:fidl_fuchsia_shell_ermine/fidl_async.dart'
     show AskBar, AskBarBinding;
 import 'package:fuchsia_services/services.dart' show StartupContext;
-import 'package:lib.widgets/model.dart' show SpringModel;
 import 'package:zircon/zircon.dart' show Vmo;
 
 const int _kMaxSuggestions = 20;
@@ -43,7 +42,6 @@
   final ValueNotifier<List<Suggestion>> suggestions =
       ValueNotifier(<Suggestion>[]);
   final ValueNotifier<int> selection = ValueNotifier(-1);
-  final SpringModel animation = SpringModel();
 
   double autoCompleteTop = 0;
   double elevation = 200.0;
@@ -96,9 +94,6 @@
   void show() {
     visibility.value = true;
     _ask.fireVisible();
-    animation
-      ..jump(0.8)
-      ..target = 1.0;
     controller.clear();
     onQuery('');
   }
@@ -107,12 +102,15 @@
     _suggestions = <Suggestion>[];
     suggestions.value = _suggestions;
     selection.value = -1;
-    _ask.fireHidden();
     visibility.value = false;
-    animation.jump(0.8);
     controller.clear();
   }
 
+  /// Called from ui when hiding animation has completed.
+  void hideAnimationCompleted() {
+    _ask.fireHidden();
+  }
+
   void onAsk(String query) {
     // If there are no suggestions, do nothing.
     if (suggestions.value.isEmpty) {
diff --git a/shell/ermine/lib/src/modules/ask_module.dart b/shell/ermine/lib/src/modules/ask_module.dart
index 3ab996b..942dc2f 100644
--- a/shell/ermine/lib/src/modules/ask_module.dart
+++ b/shell/ermine/lib/src/modules/ask_module.dart
@@ -7,8 +7,7 @@
 import 'package:fuchsia_logger/logger.dart';
 
 import 'ask_model.dart';
-import 'ask_suggestion_list.dart';
-import 'ask_text_field.dart';
+import 'ask_sheet.dart';
 
 void main() {
   setupLogger(name: 'ermine_ask_module');
@@ -35,42 +34,12 @@
               fontSize: 24.0,
               color: Colors.white,
             ),
-        child: Builder(
-          builder: (context) => GestureDetector(
-                behavior: HitTestBehavior.translucent,
-                onLongPress: model.show,
-                onTap: model.hide,
-                child: AnimatedBuilder(
-                  animation: model.visibility,
-                  child: Column(
-                    children: <Widget>[
-                      Expanded(
-                        child: Offstage(),
-                      ),
-                      AskTextField(
-                        model: model,
-                      ),
-                      Padding(
-                        padding: EdgeInsets.only(top: 5),
-                      ),
-                      Expanded(
-                        child: AskSuggestionList(
-                          model: model,
-                        ),
-                      ),
-                    ],
-                  ),
-                  builder: (context, child) {
-                    if (model.isVisible) {
-                      model.focus(context);
-                      return child;
-                    } else {
-                      model.unfocus();
-                      return Offstage(child: child);
-                    }
-                  },
-                ),
-              ),
+        child: Align(
+          alignment: Alignment.bottomRight,
+          child: SizedBox(
+            width: 500.0,
+            child: AskSheet(model: model),
+          ),
         ),
       ),
     );
diff --git a/shell/ermine/lib/src/modules/ask_sheet.dart b/shell/ermine/lib/src/modules/ask_sheet.dart
new file mode 100644
index 0000000..292aba5
--- /dev/null
+++ b/shell/ermine/lib/src/modules/ask_sheet.dart
@@ -0,0 +1,248 @@
+// Copyright 2019 The Fuchsia 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' show max, min;
+
+import 'package:flutter/material.dart';
+
+import 'ask_model.dart';
+import 'ask_suggestion_list.dart';
+import 'ask_text_field.dart';
+
+const _kDefaultHeight = 320.0;
+
+class AskSheet extends StatefulWidget {
+  final AskModel model;
+
+  const AskSheet({@required this.model});
+
+  @override
+  _AskSheetState createState() => _AskSheetState();
+}
+
+enum _ExpandStatus { expanded, expanding, collapsed, collapsing }
+_ExpandStatus _expandStatusForAnimationStatus(AnimationStatus status) {
+  switch (status) {
+    case AnimationStatus.completed:
+      return _ExpandStatus.expanded;
+    case AnimationStatus.dismissed:
+      return _ExpandStatus.collapsed;
+    case AnimationStatus.forward:
+      return _ExpandStatus.expanding;
+    case AnimationStatus.reverse:
+      return _ExpandStatus.collapsing;
+    default:
+      return null;
+  }
+}
+
+class _AskSheetState extends State<AskSheet> with TickerProviderStateMixin {
+  ScrollController _scrollController;
+  _ExpandStatus _expandedState;
+
+  bool get _expanded =>
+      _expandedState == _ExpandStatus.expanded ||
+      _expandedState == _ExpandStatus.expanding;
+
+  /// The initial velocity for the next fling animation.
+  /// This is changed by [_DismissablePhysics] when starting the ballistics simulation,
+  /// and return to default when the animation starts.
+  double _dismissVelocity = 1.0;
+
+  AnimationController _expandedAnimationController;
+  Animation<double> _translateAnimation;
+  final _translateTween = Tween<double>(
+    // begin value will be updated as the user scrolls
+    // will be inaccurate when under default height but works well enough for our purposes
+    begin: _kDefaultHeight,
+    end: 0,
+  );
+
+  @override
+  void initState() {
+    _expandedState = widget.model.isVisible
+        ? _ExpandStatus.expanded
+        : _ExpandStatus.collapsed;
+
+    _scrollController = ScrollController();
+
+    _expandedAnimationController = AnimationController(
+      vsync: this,
+      duration: Duration(milliseconds: 300),
+      lowerBound: 0,
+      upperBound: 1,
+      value: _expanded ? 1 : 0,
+    )..addStatusListener((status) {
+        setState(() {
+          _expandedState = _expandStatusForAnimationStatus(status);
+        });
+        _updateFocus();
+        if (_expandedState == _ExpandStatus.collapsed) {
+          // currently invisible, reset ui and update model that hide animation is complete
+          _reset();
+          widget.model.hideAnimationCompleted();
+        }
+      });
+
+    _translateAnimation = _translateTween.animate(_expandedAnimationController);
+
+    widget.model.visibility.addListener(_visibilityListener);
+
+    _scrollController.addListener(() {
+      _translateTween.begin = _kDefaultHeight + _scrollOffset;
+    });
+
+    super.initState();
+  }
+
+  void _updateFocus() {
+    if (_expanded) {
+      widget.model.focus(context);
+    } else {
+      widget.model.unfocus();
+    }
+  }
+
+  void _reset() {
+    _scrollController.jumpTo(0);
+  }
+
+  void _runAnimationController(bool expand) {
+    double dismissVelocity = _dismissVelocity;
+    _dismissVelocity = 1;
+    _expandedAnimationController.fling(
+      // Set a minimum velocity above zero so the animation will not go in the
+      // wrong direction when collpasing but being flung slightly towards the expand direction.
+      // This is needed due to the implementation of fling that doesn't offer a distinction
+      // between initial velocity and animation direction
+      velocity: max(dismissVelocity, 0.01) * (expand ? 1 : -1),
+    );
+  }
+
+  double get _scrollOffset =>
+      (_scrollController.hasClients ? _scrollController.offset : 0.0);
+
+  /// Sets dismiss velocity before beginning collapse.
+  /// velocity is in aboslute pixels and is converted here.
+  void _close({double velocity}) {
+    _dismissVelocity = velocity == null
+        ? 1
+        : velocity / (_translateTween.begin - _translateTween.end);
+    widget.model.hide();
+  }
+
+  void _visibilityListener() {
+    _runAnimationController(widget.model.isVisible);
+  }
+
+  @override
+  void dispose() {
+    _expandedAnimationController.dispose();
+    _scrollController.dispose();
+    widget.model.visibility.removeListener(_visibilityListener);
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Offstage(
+      offstage: _expandedState == _ExpandStatus.collapsed,
+      child: IgnorePointer(
+        ignoring: !_expanded,
+        child: LayoutBuilder(
+          builder: (context, constraints) {
+            final topMargin = constraints.maxHeight - _kDefaultHeight;
+            return _buildExpandAnimationWidget(
+              child: Align(
+                alignment: Alignment.bottomRight,
+                child: CustomScrollView(
+                  shrinkWrap: true,
+                  controller: _scrollController,
+                  physics: _DismissablePhysics(onDismiss: _close),
+                  slivers: <Widget>[
+                    SliverToBoxAdapter(child: SizedBox(height: topMargin)),
+                    _buildHeader(),
+                    _buildBody(),
+                    SliverToBoxAdapter(child: SizedBox(height: 8.0)),
+                  ],
+                ),
+              ),
+            );
+          },
+        ),
+      ),
+    );
+  }
+
+  Widget _buildHeader() => SliverToBoxAdapter(
+        child: Column(
+          children: <Widget>[
+            AskTextField(model: widget.model),
+            SizedBox(height: 8.0),
+          ],
+        ),
+      );
+
+  Widget _buildBody() => AskSuggestionList(model: widget.model);
+
+  Widget _buildExpandAnimationWidget({Widget child}) => AnimatedBuilder(
+        animation: Listenable.merge([
+          _translateAnimation,
+          _scrollController,
+        ]),
+        builder: (_, child) {
+          // any negative scroll offset is added here to make up for shrinkWrap
+          // absorbing negative bounce in the scrollview.
+          final dy = _translateAnimation.value - min(_scrollOffset, 0.0);
+          return Transform.translate(
+            offset: Offset(0, dy),
+            child: child,
+          );
+        },
+        child: child,
+      );
+}
+
+/// [_DismissablePhysics] is an extension of [ScrollPhysics] that is parented with
+/// both an [AlwaysScrollableScrollPhysics], [BouncingScrollPhysics].
+///
+/// [onDismiss] is called when the user flings the scrollview out of bounds, downwards
+/// but only if the gesture was released while beyond those bounds.
+///
+/// This behavior means that a when a previously scrolled scrollview is flung towards it's
+/// starting offset, it will first come to a stop at that position, and a second swipe will
+/// be needed before it is dismissed.
+class _DismissablePhysics extends ScrollPhysics {
+  final void Function({double velocity}) onDismiss;
+
+  _DismissablePhysics({
+    @required this.onDismiss,
+  })  : assert(onDismiss != null),
+        super(
+          parent: BouncingScrollPhysics(
+            parent: AlwaysScrollableScrollPhysics(),
+          ),
+        );
+
+  @override
+  _DismissablePhysics applyTo(ScrollPhysics ancestor) {
+    return _DismissablePhysics(onDismiss: onDismiss);
+  }
+
+  @override
+  Simulation createBallisticSimulation(
+    ScrollMetrics position,
+    double velocity,
+  ) {
+    final estimatedTarget = position.pixels + velocity * 2.0;
+
+    if (position.pixels < position.minScrollExtent &&
+        estimatedTarget < position.minScrollExtent) {
+      onDismiss(velocity: velocity);
+      return null;
+    } else {
+      return parent.createBallisticSimulation(position, velocity);
+    }
+  }
+}
diff --git a/shell/ermine/lib/src/modules/ask_suggestion_list.dart b/shell/ermine/lib/src/modules/ask_suggestion_list.dart
index 17f249a..39c9676 100644
--- a/shell/ermine/lib/src/modules/ask_suggestion_list.dart
+++ b/shell/ermine/lib/src/modules/ask_suggestion_list.dart
@@ -9,130 +9,85 @@
 class AskSuggestionList extends StatelessWidget {
   final AskModel model;
   final _kListItemHeight = 50.0;
-  final _kListVerticalPadding = 8.0;
 
   const AskSuggestionList({this.model});
 
   @override
   Widget build(BuildContext context) {
-    final controller = ScrollController();
-    model.selection.addListener(() {
-      if (controller.hasClients && model.selection.value >= 0) {
-        double itemOffset = model.selection.value * _kListItemHeight;
-        if (itemOffset < controller.offset) {
-          double scrollOffset = itemOffset;
-          controller.animateTo(
-            scrollOffset,
-            duration: Duration(milliseconds: 200),
-            curve: Curves.fastOutSlowIn,
-          );
-        } else if (itemOffset >=
-            controller.offset +
-                controller.position.viewportDimension -
-                _kListItemHeight -
-                _kListVerticalPadding) {
-          double scrollOffset = (model.selection.value + 1) * _kListItemHeight -
-              controller.position.viewportDimension;
-          controller.animateTo(
-            scrollOffset + _kListVerticalPadding,
-            duration: Duration(milliseconds: 200),
-            curve: Curves.fastOutSlowIn,
-          );
-        }
-      }
-    });
     return AnimatedBuilder(
       animation: model.suggestions,
-      builder: (context, child) => Offstage(
-            offstage: model.suggestions.value.isEmpty,
-            child: Align(
-              alignment: Alignment.topCenter,
-              child: Material(
-                elevation: model.elevation,
-                color: Colors.white,
-                child: FractionallySizedBox(
-                  widthFactor: 0.5,
-                  child: RawKeyboardListener(
-                    onKey: model.onKey,
-                    focusNode: model.focusNode,
-                    child: ListView.builder(
-                      controller: controller,
-                      padding: EdgeInsets.symmetric(
-                        horizontal: 8,
-                        vertical: _kListVerticalPadding / 2,
-                      ),
-                      physics: BouncingScrollPhysics(),
-                      shrinkWrap: true,
-                      itemCount: model.suggestions.value.length,
-                      itemBuilder: (context, index) {
-                        final suggestion = model.suggestions.value[index];
-                        final iconImageNotifier =
-                            model.imageFromSuggestion(suggestion);
-                        return GestureDetector(
-                          onTap: () => model.onSelect(suggestion),
-                          child: AnimatedBuilder(
-                            animation: model.selection,
-                            builder: (context, child) {
-                              return Container(
-                                alignment: Alignment.centerLeft,
-                                height: _kListItemHeight,
-                                color: model.selection.value == index
-                                    ? Colors.lightBlue
-                                    : Colors.white,
-                                padding: EdgeInsets.symmetric(vertical: 8),
-                                child: Row(
-                                  crossAxisAlignment:
-                                      CrossAxisAlignment.stretch,
-                                  children: <Widget>[
-                                    AnimatedBuilder(
-                                      animation: iconImageNotifier,
-                                      builder: (context, child) => Offstage(
-                                            offstage:
-                                                iconImageNotifier.value == null,
-                                            child: RawImage(
-                                              color:
-                                                  model.selection.value == index
-                                                      ? Colors.white
-                                                      : Colors.grey[900],
-                                              image: iconImageNotifier.value,
-                                              width: 24,
-                                              height: 24,
-                                              filterQuality:
-                                                  FilterQuality.medium,
-                                            ),
-                                          ),
-                                    ),
-                                    Padding(
-                                      padding: EdgeInsets.only(left: 8),
-                                    ),
-                                    Text(
-                                      suggestion.display.headline,
-                                      maxLines: 1,
-                                      softWrap: false,
-                                      overflow: TextOverflow.fade,
-                                      textAlign: TextAlign.start,
-                                      style: TextStyle(
-                                        color: model.selection.value == index
-                                            ? Colors.white
-                                            : Colors.grey[900],
-                                        fontFamily: 'RobotoMono',
-                                        fontWeight:
-                                            model.selection.value == index
-                                                ? FontWeight.bold
-                                                : FontWeight.normal,
-                                        fontSize: 22.0,
+      builder: (context, child) => RawKeyboardListener(
+            onKey: model.onKey,
+            focusNode: model.focusNode,
+            child: SliverList(
+              delegate: SliverChildBuilderDelegate(
+                (context, index) {
+                  final suggestion = model.suggestions.value[index];
+                  final iconImageNotifier =
+                      model.imageFromSuggestion(suggestion);
+                  return GestureDetector(
+                    onTap: () => model.onSelect(suggestion),
+                    child: AnimatedBuilder(
+                      animation: model.selection,
+                      builder: (context, child) {
+                        return Material(
+                          color: Colors.white,
+                          elevation: model.elevation,
+                          child: Container(
+                            alignment: Alignment.centerLeft,
+                            height: _kListItemHeight,
+                            color: model.selection.value == index
+                                ? Colors.lightBlue
+                                : Colors.white,
+                            padding: EdgeInsets.symmetric(vertical: 8),
+                            child: Row(
+                              crossAxisAlignment: CrossAxisAlignment.stretch,
+                              children: <Widget>[
+                                AnimatedBuilder(
+                                  animation: iconImageNotifier,
+                                  builder: (context, child) => Offstage(
+                                        offstage:
+                                            iconImageNotifier.value == null,
+                                        child: RawImage(
+                                          color: model.selection.value == index
+                                              ? Colors.white
+                                              : Colors.grey[900],
+                                          image: iconImageNotifier.value,
+                                          width: 24,
+                                          height: 24,
+                                          filterQuality: FilterQuality.medium,
+                                        ),
                                       ),
-                                    ),
-                                  ],
                                 ),
-                              );
-                            },
+                                Padding(
+                                  padding: EdgeInsets.only(left: 8),
+                                ),
+                                Text(
+                                  suggestion.display.headline,
+                                  maxLines: 1,
+                                  softWrap: false,
+                                  overflow: TextOverflow.fade,
+                                  textAlign: TextAlign.start,
+                                  style: TextStyle(
+                                    color: model.selection.value == index
+                                        ? Colors.white
+                                        : Colors.grey[900],
+                                    fontFamily: 'RobotoMono',
+                                    fontWeight: model.selection.value == index
+                                        ? FontWeight.bold
+                                        : FontWeight.normal,
+                                    fontSize: 22.0,
+                                  ),
+                                ),
+                              ],
+                            ),
                           ),
                         );
                       },
                     ),
-                  ),
-                ),
+                  );
+                },
+                childCount: model.suggestions.value.length,
               ),
             ),
           ),
diff --git a/shell/ermine/lib/src/modules/ask_text_field.dart b/shell/ermine/lib/src/modules/ask_text_field.dart
index 6c2f327..22cce61 100644
--- a/shell/ermine/lib/src/modules/ask_text_field.dart
+++ b/shell/ermine/lib/src/modules/ask_text_field.dart
@@ -13,52 +13,40 @@
 
   @override
   Widget build(BuildContext context) {
-    return AnimatedBuilder(
-      animation: model.animation,
-      child: Material(
-        color: Colors.black,
-        elevation: model.elevation,
-        child: FractionallySizedBox(
-          widthFactor: 0.5,
-          child: TextField(
-            controller: model.controller,
-            decoration: InputDecoration(
-              prefixIcon: Icon(
-                Icons.search,
-                color: Colors.white54,
-                size: 24.0,
-              ),
-              border: InputBorder.none,
-              focusedBorder: OutlineInputBorder(
-                borderRadius: BorderRadius.zero,
-                borderSide: BorderSide(color: Colors.white),
-              ),
-              hintText: '#Ask for anything',
-              hintStyle: Theme.of(context).textTheme.subhead.merge(
-                    TextStyle(
-                      color: Colors.white30,
-                      fontFamily: 'RobotoMono',
-                    ),
-                  ),
-            ),
-            style: Theme.of(context).textTheme.subhead.merge(
-                  TextStyle(
-                    color: Colors.white,
-                    fontFamily: 'RobotoMono',
-                  ),
-                ),
-            focusNode: model.focusNode,
-            onChanged: model.onQuery,
-            onSubmitted: model.onAsk,
+    return Material(
+      color: Colors.black,
+      elevation: model.elevation,
+      child: TextField(
+        controller: model.controller,
+        decoration: InputDecoration(
+          prefixIcon: Icon(
+            Icons.search,
+            color: Colors.white54,
+            size: 24.0,
           ),
+          border: InputBorder.none,
+          focusedBorder: OutlineInputBorder(
+            borderRadius: BorderRadius.zero,
+            borderSide: BorderSide(color: Colors.white),
+          ),
+          hintText: '#Ask for anything',
+          hintStyle: Theme.of(context).textTheme.subhead.merge(
+                TextStyle(
+                  color: Colors.white30,
+                  fontFamily: 'RobotoMono',
+                ),
+              ),
         ),
+        style: Theme.of(context).textTheme.subhead.merge(
+              TextStyle(
+                color: Colors.white,
+                fontFamily: 'RobotoMono',
+              ),
+            ),
+        focusNode: model.focusNode,
+        onChanged: model.onQuery,
+        onSubmitted: model.onAsk,
       ),
-      builder: (context, child) {
-        return Transform.scale(
-          scale: model.animation.value,
-          child: child,
-        );
-      },
     );
   }
 }