blob: 974e6afee1d1a1689a85af457fa2ca9224b485cf [file] [log] [blame]
// 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/rendering.dart';
import 'package:flutter/material.dart';
const double _kFrontHeadingHeight = 32.0; // front layer beveled rectangle
const double _kFrontClosedHeight = 92.0; // front layer height when closed
const double _kBackAppBarHeight = 56.0; // back layer (options) appbar height
// The size of the front layer heading's left and right beveled corners.
final Animatable<BorderRadius> _kFrontHeadingBevelRadius = BorderRadiusTween(
begin: const BorderRadius.only(
topLeft: Radius.circular(12.0),
topRight: Radius.circular(12.0),
),
end: const BorderRadius.only(
topLeft: Radius.circular(_kFrontHeadingHeight),
topRight: Radius.circular(_kFrontHeadingHeight),
),
);
class _TappableWhileStatusIs extends StatefulWidget {
const _TappableWhileStatusIs(
this.status, {
Key key,
this.controller,
this.child,
}) : super(key: key);
final AnimationController controller;
final AnimationStatus status;
final Widget child;
@override
_TappableWhileStatusIsState createState() => _TappableWhileStatusIsState();
}
class _TappableWhileStatusIsState extends State<_TappableWhileStatusIs> {
bool _active;
@override
void initState() {
super.initState();
widget.controller.addStatusListener(_handleStatusChange);
_active = widget.controller.status == widget.status;
}
@override
void dispose() {
widget.controller.removeStatusListener(_handleStatusChange);
super.dispose();
}
void _handleStatusChange(AnimationStatus status) {
final bool value = widget.controller.status == widget.status;
if (_active != value) {
setState(() {
_active = value;
});
}
}
@override
Widget build(BuildContext context) {
return AbsorbPointer(
absorbing: !_active,
child: widget.child,
);
}
}
class _CrossFadeTransition extends AnimatedWidget {
const _CrossFadeTransition({
Key key,
this.alignment = Alignment.center,
Animation<double> progress,
this.child0,
this.child1,
}) : super(key: key, listenable: progress);
final AlignmentGeometry alignment;
final Widget child0;
final Widget child1;
@override
Widget build(BuildContext context) {
final Animation<double> progress = listenable;
final double opacity1 = CurvedAnimation(
parent: ReverseAnimation(progress),
curve: const Interval(0.5, 1.0),
).value;
final double opacity2 = CurvedAnimation(
parent: progress,
curve: const Interval(0.5, 1.0),
).value;
return Stack(
alignment: alignment,
children: <Widget>[
Opacity(
opacity: opacity1,
child: Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: child1,
),
),
Opacity(
opacity: opacity2,
child: Semantics(
scopesRoute: true,
explicitChildNodes: true,
child: child0,
),
),
],
);
}
}
class _BackAppBar extends StatelessWidget {
const _BackAppBar({
Key key,
this.leading = const SizedBox(width: 56.0),
@required this.title,
this.trailing,
}) : assert(leading != null), assert(title != null), super(key: key);
final Widget leading;
final Widget title;
final Widget trailing;
@override
Widget build(BuildContext context) {
final List<Widget> children = <Widget>[
Container(
alignment: Alignment.center,
width: 56.0,
child: leading,
),
Expanded(
child: title,
),
];
if (trailing != null) {
children.add(
Container(
alignment: Alignment.center,
width: 56.0,
child: trailing,
),
);
}
final ThemeData theme = Theme.of(context);
return IconTheme.merge(
data: theme.primaryIconTheme,
child: DefaultTextStyle(
style: theme.primaryTextTheme.title,
child: SizedBox(
height: _kBackAppBarHeight,
child: Row(children: children),
),
),
);
}
}
class Backdrop extends StatefulWidget {
const Backdrop({
this.frontAction,
this.frontTitle,
this.frontHeading,
this.frontLayer,
this.backTitle,
this.backLayer,
});
final Widget frontAction;
final Widget frontTitle;
final Widget frontLayer;
final Widget frontHeading;
final Widget backTitle;
final Widget backLayer;
@override
_BackdropState createState() => _BackdropState();
}
class _BackdropState extends State<Backdrop> with SingleTickerProviderStateMixin {
final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop');
AnimationController _controller;
Animation<double> _frontOpacity;
static final Animatable<double> _frontOpacityTween = Tween<double>(begin: 0.2, end: 1.0)
.chain(CurveTween(curve: const Interval(0.0, 0.4, curve: Curves.easeInOut)));
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
value: 1.0,
vsync: this,
);
_frontOpacity = _controller.drive(_frontOpacityTween);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
double get _backdropHeight {
// Warning: this can be safely called from the event handlers but it may
// not be called at build time.
final RenderBox renderBox = _backdropKey.currentContext.findRenderObject();
return math.max(0.0, renderBox.size.height - _kBackAppBarHeight - _kFrontClosedHeight);
}
void _handleDragUpdate(DragUpdateDetails details) {
_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);
}
void _toggleFrontLayer() {
final AnimationStatus status = _controller.status;
final bool isOpen = status == AnimationStatus.completed || status == AnimationStatus.forward;
_controller.fling(velocity: isOpen ? -2.0 : 2.0);
}
Widget _buildStack(BuildContext context, BoxConstraints constraints) {
final Animation<RelativeRect> frontRelativeRect = _controller.drive(RelativeRectTween(
begin: RelativeRect.fromLTRB(0.0, constraints.biggest.height - _kFrontClosedHeight, 0.0, 0.0),
end: const RelativeRect.fromLTRB(0.0, _kBackAppBarHeight, 0.0, 0.0),
));
final List<Widget> layers = <Widget>[
// Back layer
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_BackAppBar(
leading: widget.frontAction,
title: _CrossFadeTransition(
progress: _controller,
alignment: AlignmentDirectional.centerStart,
child0: Semantics(namesRoute: true, child: widget.frontTitle),
child1: Semantics(namesRoute: true, child: widget.backTitle),
),
trailing: IconButton(
onPressed: _toggleFrontLayer,
tooltip: 'Toggle options page',
icon: AnimatedIcon(
icon: AnimatedIcons.close_menu,
progress: _controller,
),
),
),
Expanded(
child: Visibility(
child: widget.backLayer,
visible: _controller.status != AnimationStatus.completed,
maintainState: true,
),
),
],
),
// Front layer
PositionedTransition(
rect: frontRelativeRect,
child: AnimatedBuilder(
animation: _controller,
builder: (BuildContext context, Widget child) {
return PhysicalShape(
elevation: 12.0,
color: Theme.of(context).canvasColor,
clipper: ShapeBorderClipper(
shape: BeveledRectangleBorder(
borderRadius: _kFrontHeadingBevelRadius.transform(_controller.value),
),
),
clipBehavior: Clip.antiAlias,
child: child,
);
},
child: _TappableWhileStatusIs(
AnimationStatus.completed,
controller: _controller,
child: FadeTransition(
opacity: _frontOpacity,
child: widget.frontLayer,
),
),
),
),
];
// The front "heading" is a (typically transparent) widget that's stacked on
// top of, and at the top of, the front layer. It adds support for dragging
// the front layer up and down and for opening and closing the front layer
// with a tap. It may obscure part of the front layer's topmost child.
if (widget.frontHeading != null) {
layers.add(
PositionedTransition(
rect: frontRelativeRect,
child: ExcludeSemantics(
child: Container(
alignment: Alignment.topLeft,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggleFrontLayer,
onVerticalDragUpdate: _handleDragUpdate,
onVerticalDragEnd: _handleDragEnd,
child: widget.frontHeading,
),
),
),
),
);
}
return Stack(
key: _backdropKey,
children: layers,
);
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: _buildStack);
}
}