backdrop.dart 10.7 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6 7 8 9
// 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
10
const double _kFrontClosedHeight = 92.0; // front layer height when closed
11 12 13
const double _kBackAppBarHeight = 56.0; // back layer (options) appbar height

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

25
class _TappableWhileStatusIs extends StatefulWidget {
26 27
  const _TappableWhileStatusIs(
    this.status, {
28
    Key? key,
29 30 31 32
    this.controller,
    this.child,
  }) : super(key: key);

33
  final AnimationController? controller;
34
  final AnimationStatus status;
35
  final Widget? child;
36 37

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

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

  @override
  void initState() {
    super.initState();
47 48
    widget.controller!.addStatusListener(_handleStatusChange);
    _active = widget.controller!.status == widget.status;
49 50 51 52
  }

  @override
  void dispose() {
53
    widget.controller!.removeStatusListener(_handleStatusChange);
54 55 56
    super.dispose();
  }

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

  @override
  Widget build(BuildContext context) {
68
    Widget child = AbsorbPointer(
69
      absorbing: !_active!,
70
      child: widget.child,
71
    );
72

73
    if (!_active!) {
74 75 76 77 78 79 80
      child = FocusScope(
        canRequestFocus: false,
        debugLabel: '$_TappableWhileStatusIs',
        child: child,
      );
    }
    return child;
81 82 83 84 85
  }
}

class _CrossFadeTransition extends AnimatedWidget {
  const _CrossFadeTransition({
86
    Key? key,
87
    this.alignment = Alignment.center,
88
    required Animation<double> progress,
89 90 91 92 93
    this.child0,
    this.child1,
  }) : super(key: key, listenable: progress);

  final AlignmentGeometry alignment;
94 95
  final Widget? child0;
  final Widget? child1;
96 97 98

  @override
  Widget build(BuildContext context) {
99
    final Animation<double> progress = listenable as Animation<double>;
100

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

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

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

class _BackAppBar extends StatelessWidget {
  const _BackAppBar({
137
    Key? key,
138
    this.leading = const SizedBox(width: 56.0),
139
    required this.title,
140
    this.trailing,
141
  }) : super(key: key);
142 143 144

  final Widget leading;
  final Widget title;
145
  final Widget? trailing;
146 147 148 149 150 151

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    return IconTheme.merge(
      data: theme.primaryIconTheme,
152
      child: DefaultTextStyle(
153
        style: theme.primaryTextTheme.headline6!,
154
        child: SizedBox(
155
          height: _kBackAppBarHeight,
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173
          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,
                ),
            ],
          ),
174 175 176 177 178 179 180 181
        ),
      ),
    );
  }
}

class Backdrop extends StatefulWidget {
  const Backdrop({
182
    Key? key,
183 184 185 186 187 188
    this.frontAction,
    this.frontTitle,
    this.frontHeading,
    this.frontLayer,
    this.backTitle,
    this.backLayer,
189
  }) : super(key: key);
190

191 192 193 194 195 196
  final Widget? frontAction;
  final Widget? frontTitle;
  final Widget? frontLayer;
  final Widget? frontHeading;
  final Widget? backTitle;
  final Widget? backLayer;
197 198

  @override
199
  State<Backdrop> createState() => _BackdropState();
200 201 202
}

class _BackdropState extends State<Backdrop> with SingleTickerProviderStateMixin {
203
  final GlobalKey _backdropKey = GlobalKey(debugLabel: 'Backdrop');
204 205
  AnimationController? _controller;
  late 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
    _controller!.addStatusListener((AnimationStatus status) {
219 220 221 222 223 224 225
      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.
      });
    });
226
    _frontOpacity = _controller!.drive(_frontOpacityTween);
227 228 229 230
  }

  @override
  void dispose() {
231
    _controller!.dispose();
232 233 234 235 236 237
    super.dispose();
  }

  double get _backdropHeight {
    // Warning: this can be safely called from the event handlers but it may
    // not be called at build time.
238
    final RenderBox renderBox = _backdropKey.currentContext!.findRenderObject()! as RenderBox;
239 240 241 242
    return math.max(0.0, renderBox.size.height - _kBackAppBarHeight - _kFrontClosedHeight);
  }

  void _handleDragUpdate(DragUpdateDetails details) {
243
    _controller!.value -= details.primaryDelta! / _backdropHeight;
244 245 246
  }

  void _handleDragEnd(DragEndDetails details) {
247
    if (_controller!.isAnimating || _controller!.status == AnimationStatus.completed)
248 249 250 251
      return;

    final double flingVelocity = details.velocity.pixelsPerSecond.dy / _backdropHeight;
    if (flingVelocity < 0.0)
252
      _controller!.fling(velocity: math.max(2.0, -flingVelocity));
253
    else if (flingVelocity > 0.0)
254
      _controller!.fling(velocity: math.min(-2.0, -flingVelocity));
255
    else
256
      _controller!.fling(velocity: _controller!.value < 0.5 ? -2.0 : 2.0);
257 258 259
  }

  void _toggleFrontLayer() {
260
    final AnimationStatus status = _controller!.status;
261
    final bool isOpen = status == AnimationStatus.completed || status == AnimationStatus.forward;
262
    _controller!.fling(velocity: isOpen ? -2.0 : 2.0);
263 264 265
  }

  Widget _buildStack(BuildContext context, BoxConstraints constraints) {
266
    final Animation<RelativeRect> frontRelativeRect = _controller!.drive(RelativeRectTween(
267
      begin: RelativeRect.fromLTRB(0.0, constraints.biggest.height - _kFrontClosedHeight, 0.0, 0.0),
268
      end: const RelativeRect.fromLTRB(0.0, _kBackAppBarHeight, 0.0, 0.0),
269
    ));
270 271 272 273 274 275 276 277
    return Stack(
      key: _backdropKey,
      children: <Widget>[
        // Back layer
        Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: <Widget>[
            _BackAppBar(
278
              leading: widget.frontAction!,
279
              title: _CrossFadeTransition(
280
                progress: _controller!,
281 282 283
                alignment: AlignmentDirectional.centerStart,
                child0: Semantics(namesRoute: true, child: widget.frontTitle),
                child1: Semantics(namesRoute: true, child: widget.backTitle),
284
              ),
285 286 287 288 289
              trailing: IconButton(
                onPressed: _toggleFrontLayer,
                tooltip: 'Toggle options page',
                icon: AnimatedIcon(
                  icon: AnimatedIcons.close_menu,
290
                  progress: _controller!,
291
                ),
292
              ),
293
            ),
294
            Expanded(
295 296 297 298
              child: _TappableWhileStatusIs(
                AnimationStatus.dismissed,
                controller: _controller,
                child: Visibility(
299
                  visible: _controller!.status != AnimationStatus.completed,
300
                  maintainState: true,
301
                  child: widget.backLayer!,
302
                ),
303 304 305
              ),
            ),
          ],
306
        ),
307
        // Front layer
308
        PositionedTransition(
309
          rect: frontRelativeRect,
310
          child: AnimatedBuilder(
311 312
            animation: _controller!,
            builder: (BuildContext context, Widget? child) {
313 314 315 316 317
              return PhysicalShape(
                elevation: 12.0,
                color: Theme.of(context).canvasColor,
                clipper: ShapeBorderClipper(
                  shape: BeveledRectangleBorder(
318
                    borderRadius: _kFrontHeadingBevelRadius.transform(_controller!.value)!,
319 320 321 322 323 324 325 326 327 328 329 330
                  ),
                ),
                clipBehavior: Clip.antiAlias,
                child: child,
              );
            },
            child: _TappableWhileStatusIs(
              AnimationStatus.completed,
              controller: _controller,
              child: FadeTransition(
                opacity: _frontOpacity,
                child: widget.frontLayer,
331
              ),
332 333 334
            ),
          ),
        ),
335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355
        // 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,
                ),
              ),
            ),
          ),
      ],
356 357 358 359 360
    );
  }

  @override
  Widget build(BuildContext context) {
361
    return LayoutBuilder(builder: _buildStack);
362 363
  }
}