blob: 7f6a56303dc47c25f2c6b2fdf9e0d464ec4c73b6 [file] [log] [blame]
// 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);
}
}