user_accounts_drawer_header.dart 12.8 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
import 'dart:math' as math;

7
import 'package:flutter/widgets.dart';
8

9
import 'colors.dart';
10
import 'debug.dart';
11 12 13
import 'drawer_header.dart';
import 'icons.dart';
import 'ink_well.dart';
14
import 'material_localizations.dart';
15
import 'theme.dart';
16

17
class _AccountPictures extends StatelessWidget {
18
  const _AccountPictures({
19
    Key? key,
20 21
    this.currentAccountPicture,
    this.otherAccountsPictures,
22 23
    this.currentAccountPictureSize,
    this.otherAccountsPicturesSize,
24 25
  }) : super(key: key);

26 27
  final Widget? currentAccountPicture;
  final List<Widget>? otherAccountsPictures;
28 29
  final Size? currentAccountPictureSize;
  final Size? otherAccountsPicturesSize;
30 31 32

  @override
  Widget build(BuildContext context) {
33
    return Stack(
34
      children: <Widget>[
35
        PositionedDirectional(
36
          top: 0.0,
37
          end: 0.0,
38
          child: Row(
39
            children: (otherAccountsPictures ?? <Widget>[]).take(3).map<Widget>((Widget picture) {
40
              return Padding(
41
                padding: const EdgeInsetsDirectional.only(start: 8.0),
42
                child: Semantics(
43
                  container: true,
44 45 46 47 48 49 50
                  child: Padding(
                    padding: const EdgeInsets.only(left: 8.0, bottom: 8.0),
                    child: SizedBox.fromSize(
                      size: otherAccountsPicturesSize,
                      child: picture,
                    ),
                  ),
51
                ),
52 53 54 55
              );
            }).toList(),
          ),
        ),
56
        Positioned(
57
          top: 0.0,
58
          child: Semantics(
59
            explicitChildNodes: true,
60 61
            child: SizedBox.fromSize(
              size: currentAccountPictureSize,
62
              child: currentAccountPicture,
63
            ),
64 65 66 67 68 69 70
          ),
        ),
      ],
    );
  }
}

71
class _AccountDetails extends StatefulWidget {
72
  const _AccountDetails({
73 74 75
    Key? key,
    required this.accountName,
    required this.accountEmail,
76
    this.onTap,
77
    required this.isOpen,
78
    this.arrowColor,
79 80
  }) : super(key: key);

81 82 83
  final Widget? accountName;
  final Widget? accountEmail;
  final VoidCallback? onTap;
84
  final bool isOpen;
85
  final Color? arrowColor;
86

87 88 89 90 91
  @override
  _AccountDetailsState createState() => _AccountDetailsState();
}

class _AccountDetailsState extends State<_AccountDetails> with SingleTickerProviderStateMixin {
92 93
  late Animation<double> _animation;
  late AnimationController _controller;
94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
  @override
  void initState () {
    super.initState();
    _controller = AnimationController(
      value: widget.isOpen ? 1.0 : 0.0,
      duration: const Duration(milliseconds: 200),
      vsync: this,
    );
    _animation = CurvedAnimation(
      parent: _controller,
      curve: Curves.fastOutSlowIn,
      reverseCurve: Curves.fastOutSlowIn.flipped,
    )
      ..addListener(() => setState(() {
        // [animation]'s value has changed here.
      }));
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  void didUpdateWidget (_AccountDetails oldWidget) {
    super.didUpdateWidget(oldWidget);
121 122 123 124 125 126
    // If the state of the arrow did not change, there is no need to trigger the animation
    if (oldWidget.isOpen == widget.isOpen) {
      return;
    }

    if (widget.isOpen) {
127 128 129 130 131 132
      _controller.forward();
    } else {
      _controller.reverse();
    }
  }

133 134
  @override
  Widget build(BuildContext context) {
135
    assert(debugCheckHasDirectionality(context));
136
    assert(debugCheckHasMaterialLocalizations(context));
137
    assert(debugCheckHasMaterialLocalizations(context));
138

139
    final ThemeData theme = Theme.of(context);
140
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
141

142 143
    Widget accountDetails = CustomMultiChildLayout(
      delegate: _AccountDetailsLayout(
144
        textDirection: Directionality.of(context),
145 146 147 148 149 150 151 152
      ),
      children: <Widget>[
        if (widget.accountName != null)
          LayoutId(
            id: _AccountDetailsLayout.accountName,
            child: Padding(
              padding: const EdgeInsets.symmetric(vertical: 2.0),
              child: DefaultTextStyle(
153
                style: theme.primaryTextTheme.bodyText1!,
154
                overflow: TextOverflow.ellipsis,
155
                child: widget.accountName!,
156 157
              ),
            ),
158
          ),
159 160 161 162 163 164
        if (widget.accountEmail != null)
          LayoutId(
            id: _AccountDetailsLayout.accountEmail,
            child: Padding(
              padding: const EdgeInsets.symmetric(vertical: 2.0),
              child: DefaultTextStyle(
165
                style: theme.primaryTextTheme.bodyText2!,
166
                overflow: TextOverflow.ellipsis,
167
                child: widget.accountEmail!,
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187
              ),
            ),
          ),
        if (widget.onTap != null)
          LayoutId(
            id: _AccountDetailsLayout.dropdownIcon,
            child: Semantics(
              container: true,
              button: true,
              onTap: widget.onTap,
              child: SizedBox(
                height: _kAccountDetailsHeight,
                width: _kAccountDetailsHeight,
                child: Center(
                  child: Transform.rotate(
                    angle: _animation.value * math.pi,
                    child: Icon(
                      Icons.arrow_drop_down,
                      color: widget.arrowColor,
                      semanticLabel: widget.isOpen
188 189
                          ? localizations.hideAccountsLabel
                          : localizations.showAccountsLabel,
190 191
                    ),
                  ),
192
                ),
193 194 195
              ),
            ),
          ),
196
      ],
197 198
    );

199
    if (widget.onTap != null) {
200
      accountDetails = InkWell(
201
        onTap: widget.onTap,
202
        excludeFromSemantics: true,
203
        child: accountDetails,
204 205
      );
    }
206

207
    return SizedBox(
208
      height: _kAccountDetailsHeight,
209
      child: accountDetails,
210 211 212 213
    );
  }
}

214 215 216 217
const double _kAccountDetailsHeight = 56.0;

class _AccountDetailsLayout extends MultiChildLayoutDelegate {

218
  _AccountDetailsLayout({ required this.textDirection });
219

220 221 222
  static const String accountName = 'accountName';
  static const String accountEmail = 'accountEmail';
  static const String dropdownIcon = 'dropdownIcon';
223 224 225 226 227

  final TextDirection textDirection;

  @override
  void performLayout(Size size) {
228
    Size? iconSize;
229 230
    if (hasChild(dropdownIcon)) {
      // place the dropdown icon in bottom right (LTR) or bottom left (RTL)
231
      iconSize = layoutChild(dropdownIcon, BoxConstraints.loose(size));
232 233 234
      positionChild(dropdownIcon, _offsetForIcon(size, iconSize));
    }

235
    final String? bottomLine = hasChild(accountEmail) ? accountEmail : (hasChild(accountName) ? accountName : null);
236 237

    if (bottomLine != null) {
238
      final Size constraintSize = iconSize == null ? size : Size(size.width - iconSize.width, size.height);
239 240 241
      iconSize ??= const Size(_kAccountDetailsHeight, _kAccountDetailsHeight);

      // place bottom line center at same height as icon center
242
      final Size bottomLineSize = layoutChild(bottomLine, BoxConstraints.loose(constraintSize));
243 244 245 246 247
      final Offset bottomLineOffset = _offsetForBottomLine(size, iconSize, bottomLineSize);
      positionChild(bottomLine, bottomLineOffset);

      // place account name above account email
      if (bottomLine == accountEmail && hasChild(accountName)) {
248
        final Size nameSize = layoutChild(accountName, BoxConstraints.loose(constraintSize));
249 250 251 252 253 254 255 256 257 258 259
        positionChild(accountName, _offsetForName(size, nameSize, bottomLineOffset));
      }
    }
  }

  @override
  bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) => true;

  Offset _offsetForIcon(Size size, Size iconSize) {
    switch (textDirection) {
      case TextDirection.ltr:
260
        return Offset(size.width - iconSize.width, size.height - iconSize.height);
261
      case TextDirection.rtl:
262
        return Offset(0.0, size.height - iconSize.height);
263 264 265 266 267 268 269
    }
  }

  Offset _offsetForBottomLine(Size size, Size iconSize, Size bottomLineSize) {
    final double y = size.height - 0.5 * iconSize.height - 0.5 * bottomLineSize.height;
    switch (textDirection) {
      case TextDirection.ltr:
270
        return Offset(0.0, y);
271
      case TextDirection.rtl:
272
        return Offset(size.width - bottomLineSize.width, y);
273 274 275 276 277 278 279
    }
  }

  Offset _offsetForName(Size size, Size nameSize, Offset bottomLineOffset) {
    final double y = bottomLineOffset.dy - nameSize.height;
    switch (textDirection) {
      case TextDirection.ltr:
280
        return Offset(0.0, y);
281
      case TextDirection.rtl:
282
        return Offset(size.width - nameSize.width, y);
283 284 285 286
    }
  }
}

287 288 289 290 291 292
/// A material design [Drawer] header that identifies the app's user.
///
/// Requires one of its ancestors to be a [Material] widget.
///
/// See also:
///
293
///  * [DrawerHeader], for a drawer header that doesn't show user accounts.
294
///  * <https://material.io/design/components/navigation-drawer.html#anatomy>
295 296 297 298
class UserAccountsDrawerHeader extends StatefulWidget {
  /// Creates a material design drawer header.
  ///
  /// Requires one of its ancestors to be a [Material] widget.
299
  const UserAccountsDrawerHeader({
300
    Key? key,
301
    this.decoration,
302
    this.margin = const EdgeInsets.only(bottom: 8.0),
303 304
    this.currentAccountPicture,
    this.otherAccountsPictures,
305 306
    this.currentAccountPictureSize = const Size.square(72.0),
    this.otherAccountsPicturesSize = const Size.square(40.0),
307 308
    required this.accountName,
    required this.accountEmail,
309
    this.onDetailsPressed,
310
    this.arrowColor = Colors.white,
311 312
  }) : super(key: key);

313 314
  /// The header's background. If decoration is null then a [BoxDecoration]
  /// with its background color set to the current theme's primaryColor is used.
315
  final Decoration? decoration;
316

317
  /// The margin around the drawer header.
318
  final EdgeInsetsGeometry? margin;
319

320 321
  /// A widget placed in the upper-left corner that represents the current
  /// user's account. Normally a [CircleAvatar].
322
  final Widget? currentAccountPicture;
323

324 325 326
  /// A list of widgets that represent the current user's other accounts.
  /// Up to three of these widgets will be arranged in a row in the header's
  /// upper-right corner. Normally a list of [CircleAvatar] widgets.
327
  final List<Widget>? otherAccountsPictures;
328

329 330 331 332 333 334
  /// The size of the [currentAccountPicture].
  final Size currentAccountPictureSize;

  /// The size of each widget in [otherAccountsPicturesSize].
  final Size otherAccountsPicturesSize;

335 336
  /// A widget that represents the user's current account name. It is
  /// displayed on the left, below the [currentAccountPicture].
337
  final Widget? accountName;
338

339 340
  /// A widget that represents the email address of the user's current account.
  /// It is displayed on the left, below the [accountName].
341
  final Widget? accountEmail;
342

343 344
  /// A callback that is called when the horizontal area which contains the
  /// [accountName] and [accountEmail] is tapped.
345
  final VoidCallback? onDetailsPressed;
346

347 348 349
  /// The [Color] of the arrow icon.
  final Color arrowColor;

350
  @override
351
  State<UserAccountsDrawerHeader> createState() => _UserAccountsDrawerHeaderState();
352 353 354
}

class _UserAccountsDrawerHeaderState extends State<UserAccountsDrawerHeader> {
355
  bool _isOpen = false;
356

357 358 359 360
  void _handleDetailsPressed() {
    setState(() {
      _isOpen = !_isOpen;
    });
361
    widget.onDetailsPressed!();
362 363
  }

364 365 366
  @override
  Widget build(BuildContext context) {
    assert(debugCheckHasMaterial(context));
367
    assert(debugCheckHasMaterialLocalizations(context));
368
    return Semantics(
369
      container: true,
370
      label: MaterialLocalizations.of(context).signedInLabel,
371 372
      child: DrawerHeader(
        decoration: widget.decoration ?? BoxDecoration(
373
          color: Theme.of(context).primaryColor,
374 375 376
        ),
        margin: widget.margin,
        padding: const EdgeInsetsDirectional.only(top: 16.0, start: 16.0),
377
        child: SafeArea(
378
          bottom: false,
379
          child: Column(
380 381
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
382 383
              Expanded(
                child: Padding(
384
                  padding: const EdgeInsetsDirectional.only(end: 16.0),
385
                  child: _AccountPictures(
386 387
                    currentAccountPicture: widget.currentAccountPicture,
                    otherAccountsPictures: widget.otherAccountsPictures,
388 389
                    currentAccountPictureSize: widget.currentAccountPictureSize,
                    otherAccountsPicturesSize: widget.otherAccountsPicturesSize,
390
                  ),
391
                ),
392
              ),
393
              _AccountDetails(
394 395 396 397
                accountName: widget.accountName,
                accountEmail: widget.accountEmail,
                isOpen: _isOpen,
                onTap: widget.onDetailsPressed == null ? null : _handleDetailsPressed,
398
                arrowColor: widget.arrowColor,
399 400 401
              ),
            ],
          ),
402
        ),
403
      ),
404 405 406
    );
  }
}