drawer.dart 13.1 KB
Newer Older
1 2 3 4
// Copyright 2015 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.

5
import 'package:flutter/foundation.dart';
6
import 'package:flutter/widgets.dart';
7

8
import 'colors.dart';
9
import 'debug.dart';
10
import 'list_tile.dart';
11
import 'material.dart';
12
import 'material_localizations.dart';
13

14
/// The possible alignments of a [Drawer].
15
enum DrawerAlignment {
16 17 18 19
  /// Denotes that the [Drawer] is at the start side of the [Scaffold].
  ///
  /// This corresponds to the left side when the text direction is left-to-right
  /// and the right side when the text direction is right-to-left.
20 21
  start,

22 23 24 25
  /// Denotes that the [Drawer] is at the end side of the [Scaffold].
  ///
  /// This corresponds to the right side when the text direction is left-to-right
  /// and the left side when the text direction is right-to-left.
26 27 28
  end,
}

29
// TODO(eseidel): Draw width should vary based on device size:
30
// http://material.google.com/layout/structure.html#structure-side-nav
31 32 33 34 35 36 37 38 39 40 41 42

// Mobile:
// Width = Screen width − 56 dp
// Maximum width: 320dp
// Maximum width applies only when using a left nav. When using a right nav,
// the panel can cover the full width of the screen.

// Desktop/Tablet:
// Maximum width for a left nav is 400dp.
// The right nav can vary depending on content.

const double _kWidth = 304.0;
43
const double _kEdgeDragWidth = 20.0;
Matt Perry's avatar
Matt Perry committed
44
const double _kMinFlingVelocity = 365.0;
45
const Duration _kBaseSettleDuration = Duration(milliseconds: 246);
46

47 48
/// A material design panel that slides in horizontally from the edge of a
/// [Scaffold] to show navigation links in an application.
49
///
50 51
/// Drawers are typically used with the [Scaffold.drawer] property. The child of
/// the drawer is usually a [ListView] whose first child is a [DrawerHeader]
52 53 54 55 56 57 58 59
/// that displays status information about the current user. The remaining
/// drawer children are often constructed with [ListTile]s, often concluding
/// with an [AboutListTile].
///
/// An open drawer can be closed by calling [Navigator.pop]. For example
/// a drawer item might close the drawer when tapped:
///
/// ```dart
60 61 62
/// ListTile(
///   leading: Icon(Icons.change_history),
///   title: Text('Change history'),
63 64 65 66 67 68
///   onTap: () {
///     // change app state...
///     Navigator.pop(context); // close the drawer
///   },
/// );
/// ```
69
///
70 71 72
/// The [AppBar] automatically displays an appropriate [IconButton] to show the
/// [Drawer] when a [Drawer] is available in the [Scaffold]. The [Scaffold]
/// automatically handles the edge-swipe gesture to show the drawer.
73
///
74
/// See also:
75
///
76 77 78 79 80
///  * [Scaffold.drawer], where one specifies a [Drawer] so that it can be
///    shown.
///  * [Scaffold.of], to obtain the current [ScaffoldState], which manages the
///    display and animation of the drawer.
///  * [ScaffoldState.openDrawer], which displays its [Drawer], if any.
81
///  * <https://material.google.com/patterns/navigation-drawer.html>
82
class Drawer extends StatelessWidget {
83 84 85
  /// Creates a material design drawer.
  ///
  /// Typically used in the [Scaffold.drawer] property.
86
  const Drawer({
87
    Key key,
88
    this.elevation = 16.0,
89
    this.child,
90
    this.semanticLabel,
91
  }) : super(key: key);
92

93 94
  /// The z-coordinate at which to place this drawer. This controls the size of
  /// the shadow below the drawer.
95
  ///
96
  /// Defaults to 16, the appropriate elevation for drawers.
97
  final double elevation;
98 99

  /// The widget below this widget in the tree.
100
  ///
101
  /// Typically a [SliverList].
102 103
  ///
  /// {@macro flutter.widgets.child}
104
  final Widget child;
105

106
  /// The semantic label of the dialog used by accessibility frameworks to
107
  /// announce screen transitions when the drawer is opened and closed.
108
  ///
109 110
  /// If this label is not provided, it will default to
  /// [MaterialLocalizations.drawerLabel].
111
  ///
112
  /// See also:
113
  ///
114 115 116 117
  ///  * [SemanticsConfiguration.namesRoute], for a description of how this
  ///    value is used.
  final String semanticLabel;

118
  @override
119
  Widget build(BuildContext context) {
120
    assert(debugCheckHasMaterialLocalizations(context));
121 122 123 124 125 126 127 128 129
    String label = semanticLabel;
    switch (defaultTargetPlatform) {
      case TargetPlatform.iOS:
        label = semanticLabel;
        break;
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
        label = semanticLabel ?? MaterialLocalizations.of(context)?.drawerLabel;
    }
130
    return Semantics(
131 132 133 134
      scopesRoute: true,
      namesRoute: true,
      explicitChildNodes: true,
      label: label,
135
      child: ConstrainedBox(
136
        constraints: const BoxConstraints.expand(width: _kWidth),
137
        child: Material(
138 139 140
          elevation: elevation,
          child: child,
        ),
141
      ),
142 143 144 145
    );
  }
}

jslavitz's avatar
jslavitz committed
146 147
/// Signature for the callback that's called when a [DrawerController] is
/// opened or closed.
148
typedef DrawerCallback = void Function(bool isOpened);
jslavitz's avatar
jslavitz committed
149

150 151
/// Provides interactive behavior for [Drawer] widgets.
///
152 153 154 155 156 157 158
/// Rarely used directly. Drawer controllers are typically created automatically
/// by [Scaffold] widgets.
///
/// The draw controller provides the ability to open and close a drawer, either
/// via an animation or via user interaction. When closed, the drawer collapses
/// to a translucent gesture detector that can be used to listen for edge
/// swipes.
159 160
///
/// See also:
161
///
162 163
///  * [Drawer], a container with the default width of a drawer.
///  * [Scaffold.drawer], the [Scaffold] slot for showing a drawer.
164
class DrawerController extends StatefulWidget {
165 166 167 168 169
  /// Creates a controller for a [Drawer].
  ///
  /// Rarely used directly.
  ///
  /// The [child] argument must not be null and is typically a [Drawer].
170
  const DrawerController({
171
    GlobalKey key,
172
    @required this.child,
173
    @required this.alignment,
jslavitz's avatar
jslavitz committed
174
    this.drawerCallback,
175
  }) : assert(child != null),
176
       assert(alignment != null),
177
       super(key: key);
178

179
  /// The widget below this widget in the tree.
180 181
  ///
  /// Typically a [Drawer].
182 183
  final Widget child;

184
  /// The alignment of the [Drawer].
185
  ///
186 187
  /// This controls the direction in which the user should swipe to open and
  /// close the drawer.
188 189
  final DrawerAlignment alignment;

jslavitz's avatar
jslavitz committed
190 191 192
  /// Optional callback that is called when a [Drawer] is opened or closed.
  final DrawerCallback drawerCallback;

193
  @override
194
  DrawerControllerState createState() => DrawerControllerState();
195
}
196

197 198 199
/// State for a [DrawerController].
///
/// Typically used by a [Scaffold] to [open] and [close] the drawer.
200
class DrawerControllerState extends State<DrawerController> with SingleTickerProviderStateMixin {
201
  @override
202 203
  void initState() {
    super.initState();
204
    _controller = AnimationController(duration: _kBaseSettleDuration, vsync: this)
205 206
      ..addListener(_animationChanged)
      ..addStatusListener(_animationStatusChanged);
207 208
  }

209
  @override
210
  void dispose() {
211
    _historyEntry?.remove();
212
    _controller.dispose();
213 214 215
    super.dispose();
  }

216
  void _animationChanged() {
217
    setState(() {
218
      // The animation controller's state is our build state, and it changed already.
219 220 221
    });
  }

222
  LocalHistoryEntry _historyEntry;
223
  final FocusScopeNode _focusScopeNode = FocusScopeNode();
224 225 226

  void _ensureHistoryEntry() {
    if (_historyEntry == null) {
227
      final ModalRoute<dynamic> route = ModalRoute.of(context);
228
      if (route != null) {
229
        _historyEntry = LocalHistoryEntry(onRemove: _handleHistoryEntryRemoved);
230
        route.addLocalHistoryEntry(_historyEntry);
231
        FocusScope.of(context).setFirstFocus(_focusScopeNode);
232 233 234 235
      }
    }
  }

236
  void _animationStatusChanged(AnimationStatus status) {
237
    switch (status) {
238
      case AnimationStatus.forward:
239 240
        _ensureHistoryEntry();
        break;
241
      case AnimationStatus.reverse:
242 243 244
        _historyEntry?.remove();
        _historyEntry = null;
        break;
245
      case AnimationStatus.dismissed:
246
        break;
247
      case AnimationStatus.completed:
248 249
        break;
    }
250 251
  }

252 253 254 255
  void _handleHistoryEntryRemoved() {
    _historyEntry = null;
    close();
  }
256

257
  AnimationController _controller;
Hixie's avatar
Hixie committed
258

259
  void _handleDragDown(DragDownDetails details) {
260
    _controller.stop();
261
    _ensureHistoryEntry();
Hixie's avatar
Hixie committed
262 263
  }

264 265 266 267 268 269 270 271 272 273
  void _handleDragCancel() {
    if (_controller.isDismissed || _controller.isAnimating)
      return;
    if (_controller.value < 0.5) {
      close();
    } else {
      open();
    }
  }

274
  final GlobalKey _drawerKey = GlobalKey();
275

Hixie's avatar
Hixie committed
276
  double get _width {
277 278 279
    final RenderBox box = _drawerKey.currentContext?.findRenderObject();
    if (box != null)
      return box.size.width;
Hixie's avatar
Hixie committed
280 281 282
    return _kWidth; // drawer not being shown currently
  }

jslavitz's avatar
jslavitz committed
283 284
  bool _previouslyOpened = false;

285
  void _move(DragUpdateDetails details) {
286 287 288 289 290 291 292 293
    double delta = details.primaryDelta / _width;
    switch (widget.alignment) {
      case DrawerAlignment.start:
        break;
      case DrawerAlignment.end:
        delta = -delta;
        break;
    }
294 295 296 297 298 299 300 301
    switch (Directionality.of(context)) {
      case TextDirection.rtl:
        _controller.value -= delta;
        break;
      case TextDirection.ltr:
        _controller.value += delta;
        break;
    }
jslavitz's avatar
jslavitz committed
302 303 304 305 306

    final bool opened = _controller.value > 0.5 ? true : false;
    if (opened != _previouslyOpened && widget.drawerCallback != null)
      widget.drawerCallback(opened);
    _previouslyOpened = opened;
307 308
  }

309
  void _settle(DragEndDetails details) {
310
    if (_controller.isDismissed)
311
      return;
312
    if (details.velocity.pixelsPerSecond.dx.abs() >= _kMinFlingVelocity) {
313 314 315 316 317 318 319 320
      double visualVelocity = details.velocity.pixelsPerSecond.dx / _width;
      switch (widget.alignment) {
        case DrawerAlignment.start:
          break;
        case DrawerAlignment.end:
          visualVelocity = -visualVelocity;
          break;
      }
321 322 323 324 325 326 327
      switch (Directionality.of(context)) {
      case TextDirection.rtl:
        _controller.fling(velocity: -visualVelocity);
        break;
      case TextDirection.ltr:
        _controller.fling(velocity: visualVelocity);
        break;
328
      }
329
    } else if (_controller.value < 0.5) {
330
      close();
331
    } else {
332
      open();
333 334 335
    }
  }

336 337
  /// Starts an animation to open the drawer.
  ///
338
  /// Typically called by [ScaffoldState.openDrawer].
339
  void open() {
340
    _controller.fling(velocity: 1.0);
jslavitz's avatar
jslavitz committed
341 342
    if (widget.drawerCallback != null)
      widget.drawerCallback(true);
343 344
  }

345
  /// Starts an animation to close the drawer.
346
  void close() {
347
    _controller.fling(velocity: -1.0);
jslavitz's avatar
jslavitz committed
348 349
    if (widget.drawerCallback != null)
      widget.drawerCallback(false);
350
  }
351

352 353
  final ColorTween _color = ColorTween(begin: Colors.transparent, end: Colors.black54);
  final GlobalKey _gestureDetectorKey = GlobalKey();
354

355
  AlignmentDirectional get _drawerOuterAlignment {
356
    assert(widget.alignment != null);
357 358 359 360 361 362
    switch (widget.alignment) {
      case DrawerAlignment.start:
        return AlignmentDirectional.centerStart;
      case DrawerAlignment.end:
        return AlignmentDirectional.centerEnd;
    }
363
    return null;
364 365 366
  }

  AlignmentDirectional get _drawerInnerAlignment {
367
    assert(widget.alignment != null);
368 369 370 371 372 373
    switch (widget.alignment) {
      case DrawerAlignment.start:
        return AlignmentDirectional.centerEnd;
      case DrawerAlignment.end:
        return AlignmentDirectional.centerStart;
    }
374
    return null;
375 376
  }

377
  Widget _buildDrawer(BuildContext context) {
378
    if (_controller.status == AnimationStatus.dismissed) {
379
      return Align(
380
        alignment: _drawerOuterAlignment,
381
        child: GestureDetector(
382 383 384 385
          key: _gestureDetectorKey,
          onHorizontalDragUpdate: _move,
          onHorizontalDragEnd: _settle,
          behavior: HitTestBehavior.translucent,
Hixie's avatar
Hixie committed
386
          excludeFromSemantics: true,
387
          child: Container(width: _kEdgeDragWidth)
388
        ),
389 390
      );
    } else {
391
      return GestureDetector(
392
        key: _gestureDetectorKey,
393
        onHorizontalDragDown: _handleDragDown,
394 395
        onHorizontalDragUpdate: _move,
        onHorizontalDragEnd: _settle,
396
        onHorizontalDragCancel: _handleDragCancel,
397
        excludeFromSemantics: true,
398 399
        child: RepaintBoundary(
          child: Stack(
400
            children: <Widget>[
401 402
              BlockSemantics(
                child: GestureDetector(
403 404
                  // On Android, the back button is used to dismiss a modal.
                  excludeFromSemantics: defaultTargetPlatform == TargetPlatform.android,
405
                  onTap: close,
406
                  child: Semantics(
407
                    label: MaterialLocalizations.of(context)?.modalBarrierDismissLabel,
408
                    child: Container(
409 410
                      color: _color.evaluate(_controller),
                    ),
411
                  ),
412
                ),
413
              ),
414
              Align(
415
                alignment: _drawerOuterAlignment,
416
                child: Align(
417
                  alignment: _drawerInnerAlignment,
418
                  widthFactor: _controller.value,
419 420
                  child: RepaintBoundary(
                    child: FocusScope(
421
                      key: _drawerKey,
422
                      node: _focusScopeNode,
423
                      child: widget.child
424 425 426 427 428 429 430
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
431 432
      );
    }
433
  }
434 435
  @override
  Widget build(BuildContext context) {
436
    assert(debugCheckHasMaterialLocalizations(context));
437
    return ListTileTheme(
438 439 440 441
      style: ListTileStyle.drawer,
      child: _buildDrawer(context),
    );
  }
442
}