[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,
- );
- },
);
}
}