stepper.dart 21.6 KB
Newer Older
1 2 3 4 5 6
// Copyright 2016 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 'package:flutter/widgets.dart';

7
import 'button_theme.dart';
8 9 10 11 12 13
import 'colors.dart';
import 'debug.dart';
import 'flat_button.dart';
import 'icons.dart';
import 'ink_well.dart';
import 'material.dart';
14
import 'material_localizations.dart';
15
import 'text_theme.dart';
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
import 'theme.dart';

// TODO(dragostis): Missing functionality:
//   * mobile horizontal mode with adding/removing steps
//   * alternative labeling
//   * stepper feedback in the case of high-latency interactions

/// The state of a [Step] which is used to control the style of the circle and
/// text.
///
/// See also:
///
///  * [Step]
enum StepState {
  /// A step that displays its index in its circle.
  indexed,
32

33 34
  /// A step that displays a pencil icon in its circle.
  editing,
35

36 37
  /// A step that displays a tick icon in its circle.
  complete,
38

39 40
  /// A step that is disabled and does not to react to taps.
  disabled,
41

42
  /// A step that is currently having an error. e.g. the user has submitted wrong
43
  /// input.
44
  error,
45 46 47 48 49 50
}

/// Defines the [Stepper]'s main axis.
enum StepperType {
  /// A vertical layout of the steps with their content in-between the titles.
  vertical,
51

52
  /// A horizontal layout of the steps with their content below the titles.
53
  horizontal,
54 55
}

56
const TextStyle _kStepStyle = TextStyle(
57
  fontSize: 12.0,
58
  color: Colors.white,
59
);
60
const Color _kErrorLight = Colors.red;
61
final Color _kErrorDark = Colors.red.shade400;
62 63 64 65 66
const Color _kCircleActiveLight = Colors.white;
const Color _kCircleActiveDark = Colors.black87;
const Color _kDisabledLight = Colors.black38;
const Color _kDisabledDark = Colors.white30;
const double _kStepSize = 24.0;
Josh Soref's avatar
Josh Soref committed
67
const double _kTriangleHeight = _kStepSize * 0.866025; // Triangle height. sqrt(3.0) / 2.0
68 69 70 71 72 73 74 75

/// A material step used in [Stepper]. The step can have a title and subtitle,
/// an icon within its circle, some content and a state that governs its
/// styling.
///
/// See also:
///
///  * [Stepper]
76
///  * <https://material.io/archive/guidelines/components/steppers.html>
77
@immutable
78 79 80 81
class Step {
  /// Creates a step for a [Stepper].
  ///
  /// The [title], [content], and [state] arguments must not be null.
82
  const Step({
83 84 85
    @required this.title,
    this.subtitle,
    @required this.content,
86 87
    this.state = StepState.indexed,
    this.isActive = false,
88 89 90
  }) : assert(title != null),
       assert(content != null),
       assert(state != null);
91 92 93 94 95 96 97 98 99 100 101 102 103 104 105

  /// The title of the step that typically describes it.
  final Widget title;

  /// The subtitle of the step that appears below the title and has a smaller
  /// font size. It typically gives more details that complement the title.
  ///
  /// If null, the subtitle is not shown.
  final Widget subtitle;

  /// The content of the step that appears below the [title] and [subtitle].
  ///
  /// Below the content, every step has a 'continue' and 'cancel' button.
  final Widget content;

106
  /// The state of the step which determines the styling of its components
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125
  /// and whether steps are interactive.
  final StepState state;

  /// Whether or not the step is active. The flag only influences styling.
  final bool isActive;
}

/// A material stepper widget that displays progress through a sequence of
/// steps. Steppers are particularly useful in the case of forms where one step
/// requires the completion of another one, or where multiple steps need to be
/// completed in order to submit the whole form.
///
/// The widget is a flexible wrapper. A parent class should pass [currentStep]
/// to this widget based on some logic triggered by the three callbacks that it
/// provides.
///
/// See also:
///
///  * [Step]
126
///  * <https://material.io/archive/guidelines/components/steppers.html>
127 128 129 130 131 132 133 134
class Stepper extends StatefulWidget {
  /// Creates a stepper from a list of steps.
  ///
  /// This widget is not meant to be rebuilt with a different list of steps
  /// unless a key is provided in order to distinguish the old stepper from the
  /// new one.
  ///
  /// The [steps], [type], and [currentStep] arguments must not be null.
135
  const Stepper({
136
    Key key,
137
    @required this.steps,
138
    this.physics,
139 140
    this.type = StepperType.vertical,
    this.currentStep = 0,
141 142
    this.onStepTapped,
    this.onStepContinue,
143
    this.onStepCancel,
144
    this.controlsBuilder,
145 146 147 148 149
  }) : assert(steps != null),
       assert(type != null),
       assert(currentStep != null),
       assert(0 <= currentStep && currentStep < steps.length),
       super(key: key);
150 151 152 153 154 155

  /// The steps of the stepper whose titles, subtitles, icons always get shown.
  ///
  /// The length of [steps] must not change.
  final List<Step> steps;

156 157 158 159 160 161 162 163 164
  /// How the stepper's scroll view should respond to user input.
  ///
  /// For example, determines how the scroll view continues to
  /// animate after the user stops dragging the scroll view.
  ///
  /// If the stepper is contained within another scrollable it
  /// can be helpful to set this property to [ClampingScrollPhysics].
  final ScrollPhysics physics;

165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187
  /// The type of stepper that determines the layout. In the case of
  /// [StepperType.horizontal], the content of the current step is displayed
  /// underneath as opposed to the [StepperType.vertical] case where it is
  /// displayed in-between.
  final StepperType type;

  /// The index into [steps] of the current step whose content is displayed.
  final int currentStep;

  /// The callback called when a step is tapped, with its index passed as
  /// an argument.
  final ValueChanged<int> onStepTapped;

  /// The callback called when the 'continue' button is tapped.
  ///
  /// If null, the 'continue' button will be disabled.
  final VoidCallback onStepContinue;

  /// The callback called when the 'cancel' button is tapped.
  ///
  /// If null, the 'cancel' button will be disabled.
  final VoidCallback onStepCancel;

188 189 190 191 192 193 194
  /// The callback for creating custom controls.
  ///
  /// If null, the default controls from the current theme will be used.
  ///
  /// This callback which takes in a context and two functions,[onStepContinue]
  /// and [onStepCancel]. These can be used to control the stepper.
  ///
195
  /// {@tool snippet --template=stateless_widget_scaffold}
196 197 198
  /// Creates a stepper control with custom buttons.
  ///
  /// ```dart
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222
  /// Widget build(BuildContext context) {
  ///   return Stepper(
  ///     controlsBuilder:
  ///       (BuildContext context, {VoidCallback onStepContinue, VoidCallback onStepCancel}) {
  ///          return Row(
  ///            children: <Widget>[
  ///              FlatButton(
  ///                onPressed: onStepContinue,
  ///                child: const Text('CONTINUE'),
  ///              ),
  ///              FlatButton(
  ///                onPressed: onStepCancel,
  ///                child: const Text('CANCEL'),
  ///              ),
  ///            ],
  ///          );
  ///       },
  ///     steps: const <Step>[
  ///       Step(
  ///         title: Text('A'),
  ///         content: SizedBox(
  ///           width: 100.0,
  ///           height: 100.0,
  ///         ),
223
  ///       ),
224 225 226 227 228 229
  ///       Step(
  ///         title: Text('B'),
  ///         content: SizedBox(
  ///           width: 100.0,
  ///           height: 100.0,
  ///         ),
230
  ///       ),
231 232 233
  ///     ],
  ///   );
  /// }
234
  /// ```
235
  /// {@end-tool}
236 237
  final ControlsWidgetBuilder controlsBuilder;

238
  @override
239
  _StepperState createState() => _StepperState();
240 241
}

242
class _StepperState extends State<Stepper> with TickerProviderStateMixin {
243
  List<GlobalKey> _keys;
244
  final Map<int, StepState> _oldStates = <int, StepState>{};
245 246 247 248

  @override
  void initState() {
    super.initState();
249
    _keys = List<GlobalKey>.generate(
250
      widget.steps.length,
251
      (int i) => GlobalKey(),
252 253
    );

254 255
    for (int i = 0; i < widget.steps.length; i += 1)
      _oldStates[i] = widget.steps[i].state;
256 257 258
  }

  @override
259 260 261
  void didUpdateWidget(Stepper oldWidget) {
    super.didUpdateWidget(oldWidget);
    assert(widget.steps.length == oldWidget.steps.length);
262

263 264
    for (int i = 0; i < oldWidget.steps.length; i += 1)
      _oldStates[i] = oldWidget.steps[i].state;
265 266 267 268 269 270 271
  }

  bool _isFirst(int index) {
    return index == 0;
  }

  bool _isLast(int index) {
272
    return widget.steps.length - 1 == index;
273 274 275
  }

  bool _isCurrent(int index) {
276
    return widget.currentStep == index;
277 278 279 280 281 282 283
  }

  bool _isDark() {
    return Theme.of(context).brightness == Brightness.dark;
  }

  Widget _buildLine(bool visible) {
284
    return Container(
285 286
      width: visible ? 1.0 : 0.0,
      height: 16.0,
287
      color: Colors.grey.shade400,
288 289 290 291
    );
  }

  Widget _buildCircleChild(int index, bool oldState) {
292 293
    final StepState state = oldState ? _oldStates[index] : widget.steps[index].state;
    final bool isDarkActive = _isDark() && widget.steps[index].isActive;
294 295 296 297
    assert(state != null);
    switch (state) {
      case StepState.indexed:
      case StepState.disabled:
298
        return Text(
299
          '${index + 1}',
300
          style: isDarkActive ? _kStepStyle.copyWith(color: Colors.black87) : _kStepStyle,
301 302
        );
      case StepState.editing:
303
        return Icon(
304
          Icons.edit,
305
          color: isDarkActive ? _kCircleActiveDark : _kCircleActiveLight,
306
          size: 18.0,
307 308
        );
      case StepState.complete:
309
        return Icon(
310
          Icons.check,
311
          color: isDarkActive ? _kCircleActiveDark : _kCircleActiveLight,
312
          size: 18.0,
313 314
        );
      case StepState.error:
315
        return const Text('!', style: _kStepStyle);
316 317 318 319 320 321 322
    }
    return null;
  }

  Color _circleColor(int index) {
    final ThemeData themeData = Theme.of(context);
    if (!_isDark()) {
323
      return widget.steps[index].isActive ? themeData.primaryColor : Colors.black38;
324
    } else {
325
      return widget.steps[index].isActive ? themeData.accentColor : themeData.backgroundColor;
326 327 328 329
    }
  }

  Widget _buildCircle(int index, bool oldState) {
330
    return Container(
331 332 333
      margin: const EdgeInsets.symmetric(vertical: 8.0),
      width: _kStepSize,
      height: _kStepSize,
334
      child: AnimatedContainer(
335 336
        curve: Curves.fastOutSlowIn,
        duration: kThemeAnimationDuration,
337
        decoration: BoxDecoration(
338
          color: _circleColor(index),
339
          shape: BoxShape.circle,
340
        ),
341
        child: Center(
342
          child: _buildCircleChild(index, oldState && widget.steps[index].state == StepState.error),
343 344
        ),
      ),
345 346 347 348
    );
  }

  Widget _buildTriangle(int index, bool oldState) {
349
    return Container(
350 351 352
      margin: const EdgeInsets.symmetric(vertical: 8.0),
      width: _kStepSize,
      height: _kStepSize,
353 354
      child: Center(
        child: SizedBox(
355 356
          width: _kStepSize,
          height: _kTriangleHeight, // Height of 24dp-long-sided equilateral triangle.
357 358
          child: CustomPaint(
            painter: _TrianglePainter(
359
              color: _isDark() ? _kErrorDark : _kErrorLight,
360
            ),
361
            child: Align(
362
              alignment: const Alignment(0.0, 0.8), // 0.8 looks better than the geometrical 0.33.
363
              child: _buildCircleChild(index, oldState && widget.steps[index].state != StepState.error),
364 365 366 367
            ),
          ),
        ),
      ),
368 369 370 371
    );
  }

  Widget _buildIcon(int index) {
372
    if (widget.steps[index].state != _oldStates[index]) {
373
      return AnimatedCrossFade(
374 375
        firstChild: _buildCircle(index, true),
        secondChild: _buildTriangle(index, true),
376 377
        firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
        secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
378
        sizeCurve: Curves.fastOutSlowIn,
379
        crossFadeState: widget.steps[index].state == StepState.error ? CrossFadeState.showSecond : CrossFadeState.showFirst,
380 381 382
        duration: kThemeAnimationDuration,
      );
    } else {
383
      if (widget.steps[index].state != StepState.error)
384 385 386 387 388 389 390
        return _buildCircle(index, false);
      else
        return _buildTriangle(index, false);
    }
  }

  Widget _buildVerticalControls() {
391 392 393
    if (widget.controlsBuilder != null)
      return widget.controlsBuilder(context, onStepContinue: widget.onStepContinue, onStepCancel: widget.onStepCancel);

394 395 396 397 398 399 400 401 402 403 404 405 406 407
    Color cancelColor;

    switch (Theme.of(context).brightness) {
      case Brightness.light:
        cancelColor = Colors.black54;
        break;
      case Brightness.dark:
        cancelColor = Colors.white70;
        break;
    }

    assert(cancelColor != null);

    final ThemeData themeData = Theme.of(context);
408
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
409

410
    return Container(
411
      margin: const EdgeInsets.only(top: 16.0),
412
      child: ConstrainedBox(
413
        constraints: const BoxConstraints.tightFor(height: 48.0),
414
        child: Row(
415
          children: <Widget>[
416
            FlatButton(
417
              onPressed: widget.onStepContinue,
418 419 420
              color: _isDark() ? themeData.backgroundColor : themeData.primaryColor,
              textColor: Colors.white,
              textTheme: ButtonTextTheme.normal,
421
              child: Text(localizations.continueButtonLabel),
422
            ),
423
            Container(
424
              margin: const EdgeInsetsDirectional.only(start: 8.0),
425
              child: FlatButton(
426
                onPressed: widget.onStepCancel,
427 428
                textColor: cancelColor,
                textTheme: ButtonTextTheme.normal,
429
                child: Text(localizations.cancelButtonLabel),
430 431 432 433 434
              ),
            ),
          ],
        ),
      ),
435 436 437 438 439 440 441
    );
  }

  TextStyle _titleStyle(int index) {
    final ThemeData themeData = Theme.of(context);
    final TextTheme textTheme = themeData.textTheme;

442 443
    assert(widget.steps[index].state != null);
    switch (widget.steps[index].state) {
444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463
      case StepState.indexed:
      case StepState.editing:
      case StepState.complete:
        return textTheme.body2;
      case StepState.disabled:
        return textTheme.body2.copyWith(
          color: _isDark() ? _kDisabledDark : _kDisabledLight
        );
      case StepState.error:
        return textTheme.body2.copyWith(
          color: _isDark() ? _kErrorDark : _kErrorLight
        );
    }
    return null;
  }

  TextStyle _subtitleStyle(int index) {
    final ThemeData themeData = Theme.of(context);
    final TextTheme textTheme = themeData.textTheme;

464 465
    assert(widget.steps[index].state != null);
    switch (widget.steps[index].state) {
466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483
      case StepState.indexed:
      case StepState.editing:
      case StepState.complete:
        return textTheme.caption;
      case StepState.disabled:
        return textTheme.caption.copyWith(
          color: _isDark() ? _kDisabledDark : _kDisabledLight
        );
      case StepState.error:
        return textTheme.caption.copyWith(
          color: _isDark() ? _kErrorDark : _kErrorLight
        );
    }
    return null;
  }

  Widget _buildHeaderText(int index) {
    final List<Widget> children = <Widget>[
484
      AnimatedDefaultTextStyle(
485 486 487
        style: _titleStyle(index),
        duration: kThemeAnimationDuration,
        curve: Curves.fastOutSlowIn,
488
        child: widget.steps[index].title,
489
      ),
490 491
    ];

492
    if (widget.steps[index].subtitle != null)
493
      children.add(
494
        Container(
495
          margin: const EdgeInsets.only(top: 2.0),
496
          child: AnimatedDefaultTextStyle(
497 498 499
            style: _subtitleStyle(index),
            duration: kThemeAnimationDuration,
            curve: Curves.fastOutSlowIn,
500
            child: widget.steps[index].subtitle,
501 502
          ),
        ),
503 504
      );

505
    return Column(
506 507
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
508
      children: children,
509 510 511 512
    );
  }

  Widget _buildVerticalHeader(int index) {
513
    return Container(
514
      margin: const EdgeInsets.symmetric(horizontal: 24.0),
515
      child: Row(
516
        children: <Widget>[
517
          Column(
518 519 520 521 522 523
            children: <Widget>[
              // Line parts are always added in order for the ink splash to
              // flood the tips of the connector lines.
              _buildLine(!_isFirst(index)),
              _buildIcon(index),
              _buildLine(!_isLast(index)),
524
            ],
525
          ),
526
          Container(
527
            margin: const EdgeInsetsDirectional.only(start: 12.0),
528 529 530 531
            child: _buildHeaderText(index),
          ),
        ],
      ),
532 533 534 535
    );
  }

  Widget _buildVerticalBody(int index) {
536
    return Stack(
537
      children: <Widget>[
538
        PositionedDirectional(
539
          start: 24.0,
540 541
          top: 0.0,
          bottom: 0.0,
542
          child: SizedBox(
543
            width: 24.0,
544 545
            child: Center(
              child: SizedBox(
546
                width: _isLast(index) ? 0.0 : 1.0,
547
                child: Container(
548
                  color: Colors.grey.shade400,
549 550 551 552
                ),
              ),
            ),
          ),
553
        ),
554 555 556
        AnimatedCrossFade(
          firstChild: Container(height: 0.0),
          secondChild: Container(
557 558 559
            margin: const EdgeInsetsDirectional.only(
              start: 60.0,
              end: 24.0,
560
              bottom: 24.0,
561
            ),
562
            child: Column(
563
              children: <Widget>[
564
                widget.steps[index].content,
565 566 567
                _buildVerticalControls(),
              ],
            ),
568
          ),
569 570
          firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
          secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
571 572 573
          sizeCurve: Curves.fastOutSlowIn,
          crossFadeState: _isCurrent(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
          duration: kThemeAnimationDuration,
574 575
        ),
      ],
576 577 578 579
    );
  }

  Widget _buildVertical() {
580
    final List<Widget> children = <Widget>[];
581

582
    for (int i = 0; i < widget.steps.length; i += 1) {
583
      children.add(
584
        Column(
585 586
          key: _keys[i],
          children: <Widget>[
587
            InkWell(
588
              onTap: widget.steps[i].state != StepState.disabled ? () {
589 590
                // In the vertical case we need to scroll to the newly tapped
                // step.
Adam Barth's avatar
Adam Barth committed
591
                Scrollable.ensureVisible(
592 593
                  _keys[i].currentContext,
                  curve: Curves.fastOutSlowIn,
594
                  duration: kThemeAnimationDuration,
595 596
                );

597 598
                if (widget.onStepTapped != null)
                  widget.onStepTapped(i);
599
              } : null,
600
              child: _buildVerticalHeader(i),
601
            ),
602 603
            _buildVerticalBody(i),
          ],
604 605 606 607
        )
      );
    }

608
    return ListView(
609
      shrinkWrap: true,
610
      physics: widget.physics,
611
      children: children,
612 613 614 615 616 617
    );
  }

  Widget _buildHorizontal() {
    final List<Widget> children = <Widget>[];

618
    for (int i = 0; i < widget.steps.length; i += 1) {
619
      children.add(
620
        InkResponse(
621 622 623
          onTap: widget.steps[i].state != StepState.disabled ? () {
            if (widget.onStepTapped != null)
              widget.onStepTapped(i);
624
          } : null,
625
          child: Row(
626
            children: <Widget>[
627
              Container(
628
                height: 72.0,
629
                child: Center(
630 631
                  child: _buildIcon(i),
                ),
632
              ),
633
              Container(
634
                margin: const EdgeInsetsDirectional.only(start: 12.0),
635 636 637 638 639
                child: _buildHeaderText(i),
              ),
            ],
          ),
        ),
640 641
      );

642
      if (!_isLast(i)) {
643
        children.add(
644 645
          Expanded(
            child: Container(
646 647
              margin: const EdgeInsets.symmetric(horizontal: 8.0),
              height: 1.0,
648
              color: Colors.grey.shade400,
649 650
            ),
          ),
651
        );
652
      }
653 654
    }

655
    return Column(
656
      children: <Widget>[
657
        Material(
658
          elevation: 2.0,
659
          child: Container(
660
            margin: const EdgeInsets.symmetric(horizontal: 24.0),
661
            child: Row(
662 663 664
              children: children,
            ),
          ),
665
        ),
666 667
        Expanded(
          child: ListView(
668 669
            padding: const EdgeInsets.all(24.0),
            children: <Widget>[
670
              AnimatedSize(
671 672 673
                curve: Curves.fastOutSlowIn,
                duration: kThemeAnimationDuration,
                vsync: this,
674
                child: widget.steps[widget.currentStep].content,
675 676 677 678 679 680
              ),
              _buildVerticalControls(),
            ],
          ),
        ),
      ],
681 682 683 684 685 686
    );
  }

  @override
  Widget build(BuildContext context) {
    assert(debugCheckHasMaterial(context));
687
    assert(debugCheckHasMaterialLocalizations(context));
688 689
    assert(() {
      if (context.ancestorWidgetOfExactType(Stepper) != null)
690
        throw FlutterError(
691 692
          'Steppers must not be nested. The material specification advises '
          'that one should avoid embedding steppers within steppers. '
693
          'https://material.io/archive/guidelines/components/steppers.html#steppers-usage\n'
694 695
        );
      return true;
696
    }());
697 698
    assert(widget.type != null);
    switch (widget.type) {
699 700 701 702 703 704 705 706 707 708 709 710 711
      case StepperType.vertical:
        return _buildVertical();
      case StepperType.horizontal:
        return _buildHorizontal();
    }
    return null;
  }
}

// Paints a triangle whose base is the bottom of the bounding rectangle and its
// top vertex the middle of its top.
class _TrianglePainter extends CustomPainter {
  _TrianglePainter({
712
    this.color,
713 714 715 716 717
  });

  final Color color;

  @override
718
  bool hitTest(Offset point) => true; // Hitting the rectangle is fine enough.
719 720 721 722 723 724 725 726 727 728 729

  @override
  bool shouldRepaint(_TrianglePainter oldPainter) {
    return oldPainter.color != color;
  }

  @override
  void paint(Canvas canvas, Size size) {
    final double base = size.width;
    final double halfBase = size.width / 2.0;
    final double height = size.height;
730
    final List<Offset> points = <Offset>[
731 732 733
      Offset(0.0, height),
      Offset(base, height),
      Offset(halfBase, 0.0),
734 735 736
    ];

    canvas.drawPath(
737 738
      Path()..addPolygon(points, true),
      Paint()..color = color,
739 740 741
    );
  }
}