| // Copyright 2014 The Flutter 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/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, { |
| this.controller, |
| this.child, |
| }); |
| |
| 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) { |
| Widget child = AbsorbPointer( |
| absorbing: !_active!, |
| child: widget.child, |
| ); |
| |
| if (!_active!) { |
| child = FocusScope( |
| canRequestFocus: false, |
| debugLabel: '$_TappableWhileStatusIs', |
| child: child, |
| ); |
| } |
| return child; |
| } |
| } |
| |
| class _CrossFadeTransition extends AnimatedWidget { |
| const _CrossFadeTransition({ |
| this.alignment = Alignment.center, |
| required Animation<double> progress, |
| this.child0, |
| this.child1, |
| }) : super(listenable: progress); |
| |
| final AlignmentGeometry alignment; |
| final Widget? child0; |
| final Widget? child1; |
| |
| @override |
| Widget build(BuildContext context) { |
| final Animation<double> progress = listenable as Animation<double>; |
| |
| 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({ |
| this.leading = const SizedBox(width: 56.0), |
| required this.title, |
| this.trailing, |
| }); |
| |
| final Widget leading; |
| final Widget title; |
| final Widget? trailing; |
| |
| @override |
| Widget build(BuildContext context) { |
| final ThemeData theme = Theme.of(context); |
| return IconTheme.merge( |
| data: theme.primaryIconTheme, |
| child: DefaultTextStyle( |
| style: theme.primaryTextTheme.titleLarge!, |
| child: SizedBox( |
| height: _kBackAppBarHeight, |
| child: Row( |
| children: <Widget>[ |
| Container( |
| alignment: Alignment.center, |
| width: 56.0, |
| child: leading, |
| ), |
| Expanded( |
| child: title, |
| ), |
| if (trailing != null) |
| Container( |
| alignment: Alignment.center, |
| width: 56.0, |
| child: trailing, |
| ), |
| ], |
| ), |
| ), |
| ), |
| ); |
| } |
| } |
| |
| class Backdrop extends StatefulWidget { |
| const Backdrop({ |
| super.key, |
| 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 |
| State<Backdrop> createState() => _BackdropState(); |
| } |
| |
| class _BackdropState extends State<Backdrop> with SingleTickerProviderStateMixin { |
| final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop'); |
| AnimationController? _controller; |
| late 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, |
| ); |
| _controller!.addStatusListener((AnimationStatus status) { |
| setState(() { |
| // This is intentionally left empty. The state change itself takes |
| // place inside the AnimationController, so there's nothing to update. |
| // All we want is for the widget to rebuild and read the new animation |
| // state from the AnimationController. |
| }); |
| }); |
| _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()! as RenderBox; |
| return math.max(0.0, renderBox.size.height - _kBackAppBarHeight - _kFrontClosedHeight); |
| } |
| |
| void _handleDragUpdate(DragUpdateDetails details) { |
| _controller!.value -= details.primaryDelta! / _backdropHeight; |
| } |
| |
| 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), |
| )); |
| return Stack( |
| key: _backdropKey, |
| children: <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: _TappableWhileStatusIs( |
| AnimationStatus.dismissed, |
| controller: _controller, |
| child: Visibility( |
| visible: _controller!.status != AnimationStatus.completed, |
| maintainState: true, |
| child: widget.backLayer!, |
| ), |
| ), |
| ), |
| ], |
| ), |
| // 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) |
| PositionedTransition( |
| rect: frontRelativeRect, |
| child: ExcludeSemantics( |
| child: Container( |
| alignment: Alignment.topLeft, |
| child: GestureDetector( |
| behavior: HitTestBehavior.opaque, |
| onTap: _toggleFrontLayer, |
| onVerticalDragUpdate: _handleDragUpdate, |
| onVerticalDragEnd: _handleDragEnd, |
| child: widget.frontHeading, |
| ), |
| ), |
| ), |
| ), |
| ], |
| ); |
| } |
| |
| @override |
| Widget build(BuildContext context) { |
| return LayoutBuilder(builder: _buildStack); |
| } |
| } |