backdrop.dart 9.86 KB
Newer Older
1 2 3 4 5 6 7 8 9 10
// 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
11
const double _kFrontClosedHeight = 92.0; // front layer height when closed
12 13 14
const double _kBackAppBarHeight = 56.0; // back layer (options) appbar height

// The size of the front layer heading's left and right beveled corners.
15
final Animatable<BorderRadius> _kFrontHeadingBevelRadius = BorderRadiusTween(
16
  begin: const BorderRadius.only(
17 18
    topLeft: Radius.circular(12.0),
    topRight: Radius.circular(12.0),
19 20
  ),
  end: const BorderRadius.only(
21 22
    topLeft: Radius.circular(_kFrontHeadingHeight),
    topRight: Radius.circular(_kFrontHeadingHeight),
23 24 25
  ),
);

26 27
class _TappableWhileStatusIs extends StatefulWidget {
  const _TappableWhileStatusIs(this.status, {
28 29 30 31 32 33 34 35 36 37
    Key key,
    this.controller,
    this.child,
  }) : super(key: key);

  final AnimationController controller;
  final AnimationStatus status;
  final Widget child;

  @override
38
  _TappableWhileStatusIsState createState() => _TappableWhileStatusIsState();
39 40
}

41 42
class _TappableWhileStatusIsState extends State<_TappableWhileStatusIs> {
  bool _active;
43 44 45 46 47

  @override
  void initState() {
    super.initState();
    widget.controller.addStatusListener(_handleStatusChange);
48
    _active = widget.controller.status == widget.status;
49 50 51 52 53 54 55 56
  }

  @override
  void dispose() {
    widget.controller.removeStatusListener(_handleStatusChange);
    super.dispose();
  }

57 58 59
  void _handleStatusChange(AnimationStatus status) {
    final bool value = widget.controller.status == widget.status;
    if (_active != value) {
60
      setState(() {
61
        _active = value;
62 63 64 65 66 67
      });
    }
  }

  @override
  Widget build(BuildContext context) {
68
    return AbsorbPointer(
69
      absorbing: !_active,
70
      child: widget.child,
71 72 73 74 75 76 77
    );
  }
}

class _CrossFadeTransition extends AnimatedWidget {
  const _CrossFadeTransition({
    Key key,
78
    this.alignment = Alignment.center,
79 80 81 82 83 84 85 86 87 88 89 90 91
    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;

92 93
    final double opacity1 = CurvedAnimation(
      parent: ReverseAnimation(progress),
94 95 96
      curve: const Interval(0.5, 1.0),
    ).value;

97
    final double opacity2 = CurvedAnimation(
98 99 100 101
      parent: progress,
      curve: const Interval(0.5, 1.0),
    ).value;

102
    return Stack(
103 104
      alignment: alignment,
      children: <Widget>[
105
        Opacity(
106
          opacity: opacity1,
107
          child: Semantics(
108 109 110
            scopesRoute: true,
            explicitChildNodes: true,
            child: child1,
111 112
          ),
        ),
113
        Opacity(
114
          opacity: opacity2,
115
          child: Semantics(
116 117 118
            scopesRoute: true,
            explicitChildNodes: true,
            child: child0,
119 120 121 122 123 124 125 126 127 128
          ),
        ),
      ],
    );
  }
}

class _BackAppBar extends StatelessWidget {
  const _BackAppBar({
    Key key,
129
    this.leading = const SizedBox(width: 56.0),
130 131 132 133 134 135 136 137 138 139 140
    @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>[
141
      Container(
142 143 144 145
        alignment: Alignment.center,
        width: 56.0,
        child: leading,
      ),
146
      Expanded(
147 148 149 150 151 152
        child: title,
      ),
    ];

    if (trailing != null) {
      children.add(
153
        Container(
154 155 156 157 158 159 160 161 162 163 164
          alignment: Alignment.center,
          width: 56.0,
          child: trailing,
        ),
      );
    }

    final ThemeData theme = Theme.of(context);

    return IconTheme.merge(
      data: theme.primaryIconTheme,
165
      child: DefaultTextStyle(
166
        style: theme.primaryTextTheme.title,
167
        child: SizedBox(
168
          height: _kBackAppBarHeight,
169
          child: Row(children: children),
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193
        ),
      ),
    );
  }
}

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
194
  _BackdropState createState() => _BackdropState();
195 196 197
}

class _BackdropState extends State<Backdrop> with SingleTickerProviderStateMixin {
198
  final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop');
199
  AnimationController _controller;
200
  Animation<double> _frontOpacity;
201

202 203 204
  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)));

205 206 207
  @override
  void initState() {
    super.initState();
208
    _controller = AnimationController(
209 210 211 212
      duration: const Duration(milliseconds: 300),
      value: 1.0,
      vsync: this,
    );
213
    _frontOpacity = _controller.drive(_frontOpacityTween);
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252
  }

  @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) {
253
    final Animation<RelativeRect> frontRelativeRect = _controller.drive(RelativeRectTween(
254
      begin: RelativeRect.fromLTRB(0.0, constraints.biggest.height - _kFrontClosedHeight, 0.0, 0.0),
255
      end: const RelativeRect.fromLTRB(0.0, _kBackAppBarHeight, 0.0, 0.0),
256
    ));
257

258 259
    final List<Widget> layers = <Widget>[
      // Back layer
260
      Column(
261 262
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: <Widget>[
263
          _BackAppBar(
264
            leading: widget.frontAction,
265
            title: _CrossFadeTransition(
266 267
              progress: _controller,
              alignment: AlignmentDirectional.centerStart,
268 269
              child0: Semantics(namesRoute: true, child: widget.frontTitle),
              child1: Semantics(namesRoute: true, child: widget.backTitle),
270
            ),
271
            trailing: IconButton(
272 273
              onPressed: _toggleFrontLayer,
              tooltip: 'Toggle options page',
274
              icon: AnimatedIcon(
275 276
                icon: AnimatedIcons.close_menu,
                progress: _controller,
277 278
              ),
            ),
279
          ),
280 281
          Expanded(
            child: Visibility(
282
              child: widget.backLayer,
283 284 285
              visible: _controller.status != AnimationStatus.completed,
              maintainState: true,
            )
286 287 288 289
          ),
        ],
      ),
      // Front layer
290
      PositionedTransition(
291
        rect: frontRelativeRect,
292
        child: AnimatedBuilder(
293 294
          animation: _controller,
          builder: (BuildContext context, Widget child) {
295
            return PhysicalShape(
296 297
              elevation: 12.0,
              color: Theme.of(context).canvasColor,
298 299
              clipper: ShapeBorderClipper(
                shape: BeveledRectangleBorder(
300
                  borderRadius: _kFrontHeadingBevelRadius.transform(_controller.value),
301
                ),
302
              ),
303
              clipBehavior: Clip.antiAlias,
304 305 306
              child: child,
            );
          },
307
          child: _TappableWhileStatusIs(
308 309
            AnimationStatus.completed,
            controller: _controller,
310
            child: FadeTransition(
311 312
              opacity: _frontOpacity,
              child: widget.frontLayer,
313 314 315
            ),
          ),
        ),
316 317 318 319 320 321 322 323 324
      ),
    ];

    // 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(
325
        PositionedTransition(
326
          rect: frontRelativeRect,
327 328
          child: ExcludeSemantics(
            child: Container(
329
              alignment: Alignment.topLeft,
330
              child: GestureDetector(
331 332 333 334 335 336
                behavior: HitTestBehavior.opaque,
                onTap: _toggleFrontLayer,
                onVerticalDragUpdate: _handleDragUpdate,
                onVerticalDragEnd: _handleDragEnd,
                child: widget.frontHeading,
              ),
337 338 339
            ),
          ),
        ),
340 341 342
      );
    }

343
    return Stack(
344 345
      key: _backdropKey,
      children: layers,
346 347 348 349 350
    );
  }

  @override
  Widget build(BuildContext context) {
351
    return LayoutBuilder(builder: _buildStack);
352 353
  }
}