blob: 893e0e08289461e19b67461fd12152b2709072f7 [file] [log] [blame]
// 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 _kDefaultListHeight = 320.0;
const _kAskFieldHeight = 88.0;
class AskSheet extends StatefulWidget {
final AskModel model;
const AskSheet({@required this.model});
@override
_AskSheetState createState() => _AskSheetState();
}
enum _VisibilityStatus { visible, appearing, hidden, disappearing }
_VisibilityStatus _visibilityStatusForAnimationStatus(AnimationStatus status) {
switch (status) {
case AnimationStatus.completed:
return _VisibilityStatus.visible;
case AnimationStatus.dismissed:
return _VisibilityStatus.hidden;
case AnimationStatus.forward:
return _VisibilityStatus.appearing;
case AnimationStatus.reverse:
return _VisibilityStatus.disappearing;
default:
return null;
}
}
class _AskSheetState extends State<AskSheet> with TickerProviderStateMixin {
ScrollController _scrollController;
_VisibilityStatus _visibilityState;
bool get _visible =>
_visibilityState == _VisibilityStatus.visible ||
_visibilityState == _VisibilityStatus.appearing;
/// 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 _appearAnimationController;
Animation<double> _translateAnimation;
final _translateTween = Tween<double>(
// TODO(ahetzroni): update begin value based on number of items
begin: -(_kDefaultListHeight + _kAskFieldHeight),
end: 0,
);
@override
void initState() {
_visibilityState = widget.model.isVisible
? _VisibilityStatus.visible
: _VisibilityStatus.hidden;
_scrollController = ScrollController();
_appearAnimationController = AnimationController(
vsync: this,
duration: Duration(milliseconds: 300),
lowerBound: 0,
upperBound: 1,
value: _visible ? 1 : 0,
)..addStatusListener((status) {
setState(() {
_visibilityState = _visibilityStatusForAnimationStatus(status);
});
_updateFocus();
if (_visibilityState == _VisibilityStatus.hidden) {
// currently invisible, reset ui and update model that hide animation is complete
_reset();
widget.model.hideAnimationCompleted();
}
});
_translateAnimation = _translateTween.animate(_appearAnimationController);
widget.model.visibility.addListener(_visibilityListener);
super.initState();
}
void _updateFocus() {
if (_visible) {
widget.model.focus(context);
} else {
widget.model.unfocus();
}
}
void _reset() {
_scrollController.jumpTo(0);
}
void _runAnimationController(bool visible) {
double dismissVelocity = _dismissVelocity;
_dismissVelocity = 1;
_appearAnimationController.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 appear 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) * (visible ? 1 : -1),
);
}
double get _scrollOffset =>
_scrollController.hasClients ? _scrollController.offset : 0.0;
double get _maxScrollExtent => _scrollController.hasClients
? _scrollController.position.maxScrollExtent
: 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() {
_appearAnimationController.dispose();
_scrollController.dispose();
widget.model.visibility.removeListener(_visibilityListener);
super.dispose();
}
@override
Widget build(BuildContext context) => Visibility(
maintainState: true,
maintainSize: true,
maintainAnimation: true,
maintainInteractivity: true,
visible: _visibilityState != _VisibilityStatus.hidden,
child: IgnorePointer(
ignoring: !_visible,
child: LayoutBuilder(
builder: (context, constraints) {
return _buildAppearAnimationWidget(
child: Align(
alignment: Alignment.topRight,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
AskTextField(model: widget.model),
_buildBody(),
],
),
),
);
},
),
),
);
Widget _buildBody() => Material(
color: Colors.white,
elevation: widget.model.elevation,
child: Container(
color: Colors.black,
margin: const EdgeInsets.all(2.0),
child: AnimatedSize(
vsync: this,
curve: Curves.easeOutExpo,
alignment: Alignment.topRight,
duration: Duration(milliseconds: 300),
child: AnimatedBuilder(
animation: widget.model.suggestions,
builder: (_, child) => SizedBox(
height: min(
_kDefaultListHeight,
AskSuggestionList.kListItemHeight *
widget.model.suggestions.value.length,
),
child: child,
),
child: CustomScrollView(
controller: _scrollController,
physics: _DismissablePhysics(onDismiss: _close),
slivers: <Widget>[AskSuggestionList(model: widget.model)],
),
),
),
),
);
Widget _buildAppearAnimationWidget({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) +
-max(
_scrollOffset - _maxScrollExtent,
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.maxScrollExtent &&
estimatedTarget > position.maxScrollExtent) {
onDismiss(velocity: velocity);
return null;
} else {
return parent.createBallisticSimulation(position, velocity);
}
}
}