backdrop.dart 10.4 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
class _TappableWhileStatusIs extends StatefulWidget {
27 28
  const _TappableWhileStatusIs(
    this.status, {
29 30 31 32 33 34 35 36 37 38
    Key key,
    this.controller,
    this.child,
  }) : super(key: key);

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

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

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

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

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

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

  @override
  Widget build(BuildContext context) {
69
    Widget child = AbsorbPointer(
70
      absorbing: !_active,
71
      child: widget.child,
72
    );
73 74 75 76 77 78 79 80 81

    if (!_active) {
      child = FocusScope(
        canRequestFocus: false,
        debugLabel: '$_TappableWhileStatusIs',
        child: child,
      );
    }
    return child;
82 83 84 85 86 87
  }
}

class _CrossFadeTransition extends AnimatedWidget {
  const _CrossFadeTransition({
    Key key,
88
    this.alignment = Alignment.center,
89 90 91 92 93 94 95 96 97 98 99 100 101
    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;

102 103
    final double opacity1 = CurvedAnimation(
      parent: ReverseAnimation(progress),
104 105 106
      curve: const Interval(0.5, 1.0),
    ).value;

107
    final double opacity2 = CurvedAnimation(
108 109 110 111
      parent: progress,
      curve: const Interval(0.5, 1.0),
    ).value;

112
    return Stack(
113 114
      alignment: alignment,
      children: <Widget>[
115
        Opacity(
116
          opacity: opacity1,
117
          child: Semantics(
118 119 120
            scopesRoute: true,
            explicitChildNodes: true,
            child: child1,
121 122
          ),
        ),
123
        Opacity(
124
          opacity: opacity2,
125
          child: Semantics(
126 127 128
            scopesRoute: true,
            explicitChildNodes: true,
            child: child0,
129 130 131 132 133 134 135 136 137 138
          ),
        ),
      ],
    );
  }
}

class _BackAppBar extends StatelessWidget {
  const _BackAppBar({
    Key key,
139
    this.leading = const SizedBox(width: 56.0),
140 141 142 143 144 145 146 147 148 149 150 151 152
    @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 ThemeData theme = Theme.of(context);
    return IconTheme.merge(
      data: theme.primaryIconTheme,
153
      child: DefaultTextStyle(
154
        style: theme.primaryTextTheme.title,
155
        child: SizedBox(
156
          height: _kBackAppBarHeight,
157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174
          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,
                ),
            ],
          ),
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198
        ),
      ),
    );
  }
}

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
199
  _BackdropState createState() => _BackdropState();
200 201 202
}

class _BackdropState extends State<Backdrop> with SingleTickerProviderStateMixin {
203
  final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop');
204
  AnimationController _controller;
205
  Animation<double> _frontOpacity;
206

207 208 209
  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)));

210 211 212
  @override
  void initState() {
    super.initState();
213
    _controller = AnimationController(
214 215 216 217
      duration: const Duration(milliseconds: 300),
      value: 1.0,
      vsync: this,
    );
218
    _frontOpacity = _controller.drive(_frontOpacityTween);
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 253 254 255 256 257
  }

  @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) {
258
    final Animation<RelativeRect> frontRelativeRect = _controller.drive(RelativeRectTween(
259
      begin: RelativeRect.fromLTRB(0.0, constraints.biggest.height - _kFrontClosedHeight, 0.0, 0.0),
260
      end: const RelativeRect.fromLTRB(0.0, _kBackAppBarHeight, 0.0, 0.0),
261
    ));
262 263 264 265 266 267 268 269 270 271
    return Stack(
      key: _backdropKey,
      children: <Widget>[
        // Back layer
        Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            _BackAppBar(
              leading: widget.frontAction,
              title: _CrossFadeTransition(
272
                progress: _controller,
273 274 275
                alignment: AlignmentDirectional.centerStart,
                child0: Semantics(namesRoute: true, child: widget.frontTitle),
                child1: Semantics(namesRoute: true, child: widget.backTitle),
276
              ),
277 278 279 280 281 282
              trailing: IconButton(
                onPressed: _toggleFrontLayer,
                tooltip: 'Toggle options page',
                icon: AnimatedIcon(
                  icon: AnimatedIcons.close_menu,
                  progress: _controller,
283
                ),
284
              ),
285
            ),
286
            Expanded(
287 288 289 290 291 292 293 294
              child: _TappableWhileStatusIs(
                AnimationStatus.dismissed,
                controller: _controller,
                child: Visibility(
                  child: widget.backLayer,
                  visible: _controller.status != AnimationStatus.completed,
                  maintainState: true,
                ),
295 296 297
              ),
            ),
          ],
298
        ),
299
        // Front layer
300
        PositionedTransition(
301
          rect: frontRelativeRect,
302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322
          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,
323
              ),
324 325 326
            ),
          ),
        ),
327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347
        // 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,
                ),
              ),
            ),
          ),
      ],
348 349 350 351 352
    );
  }

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