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