| // 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); |
| } |
| } |