stepper.dart 21.1 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 43
  /// A step that is currently having an error. e.g. the use has submitted wrong
  /// 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 135 136
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.
  Stepper({
    Key key,
137
    @required this.steps,
138 139
    this.type = StepperType.vertical,
    this.currentStep = 0,
140 141
    this.onStepTapped,
    this.onStepContinue,
142
    this.onStepCancel,
143
    this.controlsBuilder,
144 145 146 147 148
  }) : assert(steps != null),
       assert(type != null),
       assert(currentStep != null),
       assert(0 <= currentStep && currentStep < steps.length),
       super(key: key);
149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177

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

  /// 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;

178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224
  /// 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.
  ///
  /// ## Sample Code:
  /// Creates a stepper control with custom buttons.
  ///
  /// ```dart
  /// Stepper(
  ///   controlsBuilder:
  ///     (BuildContext context, {VoidCallback onStepContinue, VoidCallback onStepCancel}) {
  ///        return Row(
  ///          children: <Widget>[
  ///            FlatButton(
  ///              onPressed: onStepContinue,
  ///              child: const Text('My Awesome Continue Message!'),
  ///            ),
  ///            FlatButton(
  ///              onPressed: onStepCancel,
  ///              child: const Text('My Awesome Cancel Message!'),
  ///            ),
  ///          ],
  ///        ),
  ///     },
  ///   steps: const <Step>[
  ///     Step(
  ///       title: Text('A'),
  ///       content: SizedBox(
  ///         width: 100.0,
  ///         height: 100.0,
  ///       ),
  ///     ),
  ///     Step(
  ///       title: Text('B'),
  ///       content: SizedBox(
  ///         width: 100.0,
  ///         height: 100.0,
  ///       ),
  ///     ),
  ///   ],
  /// )
  /// ```
  final ControlsWidgetBuilder controlsBuilder;

225
  @override
226
  _StepperState createState() => _StepperState();
227 228
}

229
class _StepperState extends State<Stepper> with TickerProviderStateMixin {
230
  List<GlobalKey> _keys;
231
  final Map<int, StepState> _oldStates = <int, StepState>{};
232 233 234 235

  @override
  void initState() {
    super.initState();
236
    _keys = List<GlobalKey>.generate(
237
      widget.steps.length,
238
      (int i) => GlobalKey(),
239 240
    );

241 242
    for (int i = 0; i < widget.steps.length; i += 1)
      _oldStates[i] = widget.steps[i].state;
243 244 245
  }

  @override
246 247 248
  void didUpdateWidget(Stepper oldWidget) {
    super.didUpdateWidget(oldWidget);
    assert(widget.steps.length == oldWidget.steps.length);
249

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

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

  bool _isLast(int index) {
259
    return widget.steps.length - 1 == index;
260 261 262
  }

  bool _isCurrent(int index) {
263
    return widget.currentStep == index;
264 265 266 267 268 269 270
  }

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

  Widget _buildLine(bool visible) {
271
    return Container(
272 273
      width: visible ? 1.0 : 0.0,
      height: 16.0,
274
      color: Colors.grey.shade400,
275 276 277 278
    );
  }

  Widget _buildCircleChild(int index, bool oldState) {
279 280
    final StepState state = oldState ? _oldStates[index] : widget.steps[index].state;
    final bool isDarkActive = _isDark() && widget.steps[index].isActive;
281 282 283 284
    assert(state != null);
    switch (state) {
      case StepState.indexed:
      case StepState.disabled:
285
        return Text(
286
          '${index + 1}',
287
          style: isDarkActive ? _kStepStyle.copyWith(color: Colors.black87) : _kStepStyle,
288 289
        );
      case StepState.editing:
290
        return Icon(
291
          Icons.edit,
292
          color: isDarkActive ? _kCircleActiveDark : _kCircleActiveLight,
293
          size: 18.0,
294 295
        );
      case StepState.complete:
296
        return Icon(
297
          Icons.check,
298
          color: isDarkActive ? _kCircleActiveDark : _kCircleActiveLight,
299
          size: 18.0,
300 301
        );
      case StepState.error:
302
        return const Text('!', style: _kStepStyle);
303 304 305 306 307 308 309
    }
    return null;
  }

  Color _circleColor(int index) {
    final ThemeData themeData = Theme.of(context);
    if (!_isDark()) {
310
      return widget.steps[index].isActive ? themeData.primaryColor : Colors.black38;
311
    } else {
312
      return widget.steps[index].isActive ? themeData.accentColor : themeData.backgroundColor;
313 314 315 316
    }
  }

  Widget _buildCircle(int index, bool oldState) {
317
    return Container(
318 319 320
      margin: const EdgeInsets.symmetric(vertical: 8.0),
      width: _kStepSize,
      height: _kStepSize,
321
      child: AnimatedContainer(
322 323
        curve: Curves.fastOutSlowIn,
        duration: kThemeAnimationDuration,
324
        decoration: BoxDecoration(
325
          color: _circleColor(index),
326
          shape: BoxShape.circle,
327
        ),
328
        child: Center(
329
          child: _buildCircleChild(index, oldState && widget.steps[index].state == StepState.error),
330 331
        ),
      ),
332 333 334 335
    );
  }

  Widget _buildTriangle(int index, bool oldState) {
336
    return Container(
337 338 339
      margin: const EdgeInsets.symmetric(vertical: 8.0),
      width: _kStepSize,
      height: _kStepSize,
340 341
      child: Center(
        child: SizedBox(
342 343
          width: _kStepSize,
          height: _kTriangleHeight, // Height of 24dp-long-sided equilateral triangle.
344 345
          child: CustomPaint(
            painter: _TrianglePainter(
346
              color: _isDark() ? _kErrorDark : _kErrorLight,
347
            ),
348
            child: Align(
349
              alignment: const Alignment(0.0, 0.8), // 0.8 looks better than the geometrical 0.33.
350
              child: _buildCircleChild(index, oldState && widget.steps[index].state != StepState.error),
351 352 353 354
            ),
          ),
        ),
      ),
355 356 357 358
    );
  }

  Widget _buildIcon(int index) {
359
    if (widget.steps[index].state != _oldStates[index]) {
360
      return AnimatedCrossFade(
361 362
        firstChild: _buildCircle(index, true),
        secondChild: _buildTriangle(index, true),
363 364
        firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
        secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
365
        sizeCurve: Curves.fastOutSlowIn,
366
        crossFadeState: widget.steps[index].state == StepState.error ? CrossFadeState.showSecond : CrossFadeState.showFirst,
367 368 369
        duration: kThemeAnimationDuration,
      );
    } else {
370
      if (widget.steps[index].state != StepState.error)
371 372 373 374 375 376 377
        return _buildCircle(index, false);
      else
        return _buildTriangle(index, false);
    }
  }

  Widget _buildVerticalControls() {
378 379 380
    if (widget.controlsBuilder != null)
      return widget.controlsBuilder(context, onStepContinue: widget.onStepContinue, onStepCancel: widget.onStepCancel);

381 382 383 384 385 386 387 388 389 390 391 392 393 394
    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);
395
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
396

397
    return Container(
398
      margin: const EdgeInsets.only(top: 16.0),
399
      child: ConstrainedBox(
400
        constraints: const BoxConstraints.tightFor(height: 48.0),
401
        child: Row(
402
          children: <Widget>[
403
            FlatButton(
404
              onPressed: widget.onStepContinue,
405 406 407
              color: _isDark() ? themeData.backgroundColor : themeData.primaryColor,
              textColor: Colors.white,
              textTheme: ButtonTextTheme.normal,
408
              child: Text(localizations.continueButtonLabel),
409
            ),
410
            Container(
411
              margin: const EdgeInsetsDirectional.only(start: 8.0),
412
              child: FlatButton(
413
                onPressed: widget.onStepCancel,
414 415
                textColor: cancelColor,
                textTheme: ButtonTextTheme.normal,
416
                child: Text(localizations.cancelButtonLabel),
417 418 419 420 421
              ),
            ),
          ],
        ),
      ),
422 423 424 425 426 427 428
    );
  }

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

429 430
    assert(widget.steps[index].state != null);
    switch (widget.steps[index].state) {
431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450
      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;

451 452
    assert(widget.steps[index].state != null);
    switch (widget.steps[index].state) {
453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470
      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>[
471
      AnimatedDefaultTextStyle(
472 473 474
        style: _titleStyle(index),
        duration: kThemeAnimationDuration,
        curve: Curves.fastOutSlowIn,
475
        child: widget.steps[index].title,
476
      ),
477 478
    ];

479
    if (widget.steps[index].subtitle != null)
480
      children.add(
481
        Container(
482
          margin: const EdgeInsets.only(top: 2.0),
483
          child: AnimatedDefaultTextStyle(
484 485 486
            style: _subtitleStyle(index),
            duration: kThemeAnimationDuration,
            curve: Curves.fastOutSlowIn,
487
            child: widget.steps[index].subtitle,
488 489
          ),
        ),
490 491
      );

492
    return Column(
493 494 495 496 497 498 499
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
      children: children
    );
  }

  Widget _buildVerticalHeader(int index) {
500
    return Container(
501
      margin: const EdgeInsets.symmetric(horizontal: 24.0),
502
      child: Row(
503
        children: <Widget>[
504
          Column(
505 506 507 508 509 510 511 512
            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)),
            ]
          ),
513
          Container(
514
            margin: const EdgeInsetsDirectional.only(start: 12.0),
515 516 517 518 519 520 521 522
            child: _buildHeaderText(index)
          )
        ]
      )
    );
  }

  Widget _buildVerticalBody(int index) {
523
    return Stack(
524
      children: <Widget>[
525
        PositionedDirectional(
526
          start: 24.0,
527 528
          top: 0.0,
          bottom: 0.0,
529
          child: SizedBox(
530
            width: 24.0,
531 532
            child: Center(
              child: SizedBox(
533
                width: _isLast(index) ? 0.0 : 1.0,
534
                child: Container(
535
                  color: Colors.grey.shade400,
536 537 538 539
                ),
              ),
            ),
          ),
540
        ),
541 542 543
        AnimatedCrossFade(
          firstChild: Container(height: 0.0),
          secondChild: Container(
544 545 546
            margin: const EdgeInsetsDirectional.only(
              start: 60.0,
              end: 24.0,
547
              bottom: 24.0,
548
            ),
549
            child: Column(
550
              children: <Widget>[
551
                widget.steps[index].content,
552 553 554
                _buildVerticalControls(),
              ],
            ),
555
          ),
556 557
          firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
          secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
558 559 560
          sizeCurve: Curves.fastOutSlowIn,
          crossFadeState: _isCurrent(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
          duration: kThemeAnimationDuration,
561 562
        ),
      ],
563 564 565 566
    );
  }

  Widget _buildVertical() {
567
    final List<Widget> children = <Widget>[];
568

569
    for (int i = 0; i < widget.steps.length; i += 1) {
570
      children.add(
571
        Column(
572 573
          key: _keys[i],
          children: <Widget>[
574
            InkWell(
575
              onTap: widget.steps[i].state != StepState.disabled ? () {
576 577
                // In the vertical case we need to scroll to the newly tapped
                // step.
Adam Barth's avatar
Adam Barth committed
578
                Scrollable.ensureVisible(
579 580
                  _keys[i].currentContext,
                  curve: Curves.fastOutSlowIn,
581
                  duration: kThemeAnimationDuration,
582 583
                );

584 585
                if (widget.onStepTapped != null)
                  widget.onStepTapped(i);
586 587 588 589 590 591 592 593 594
              } : null,
              child: _buildVerticalHeader(i)
            ),
            _buildVerticalBody(i)
          ]
        )
      );
    }

595
    return ListView(
596 597
      shrinkWrap: true,
      children: children,
598 599 600 601 602 603
    );
  }

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

604
    for (int i = 0; i < widget.steps.length; i += 1) {
605
      children.add(
606
        InkResponse(
607 608 609
          onTap: widget.steps[i].state != StepState.disabled ? () {
            if (widget.onStepTapped != null)
              widget.onStepTapped(i);
610
          } : null,
611
          child: Row(
612
            children: <Widget>[
613
              Container(
614
                height: 72.0,
615
                child: Center(
616 617
                  child: _buildIcon(i),
                ),
618
              ),
619
              Container(
620
                margin: const EdgeInsetsDirectional.only(start: 12.0),
621 622 623 624 625
                child: _buildHeaderText(i),
              ),
            ],
          ),
        ),
626 627
      );

628
      if (!_isLast(i)) {
629
        children.add(
630 631
          Expanded(
            child: Container(
632 633
              margin: const EdgeInsets.symmetric(horizontal: 8.0),
              height: 1.0,
634
              color: Colors.grey.shade400,
635 636
            ),
          ),
637
        );
638
      }
639 640
    }

641
    return Column(
642
      children: <Widget>[
643
        Material(
644
          elevation: 2.0,
645
          child: Container(
646
            margin: const EdgeInsets.symmetric(horizontal: 24.0),
647
            child: Row(
648 649 650
              children: children,
            ),
          ),
651
        ),
652 653
        Expanded(
          child: ListView(
654 655
            padding: const EdgeInsets.all(24.0),
            children: <Widget>[
656
              AnimatedSize(
657 658 659
                curve: Curves.fastOutSlowIn,
                duration: kThemeAnimationDuration,
                vsync: this,
660
                child: widget.steps[widget.currentStep].content,
661 662 663 664 665 666
              ),
              _buildVerticalControls(),
            ],
          ),
        ),
      ],
667 668 669 670 671 672
    );
  }

  @override
  Widget build(BuildContext context) {
    assert(debugCheckHasMaterial(context));
673
    assert(debugCheckHasMaterialLocalizations(context));
674 675
    assert(() {
      if (context.ancestorWidgetOfExactType(Stepper) != null)
676
        throw FlutterError(
677 678
          'Steppers must not be nested. The material specification advises '
          'that one should avoid embedding steppers within steppers. '
679
          'https://material.io/archive/guidelines/components/steppers.html#steppers-usage\n'
680 681
        );
      return true;
682
    }());
683 684
    assert(widget.type != null);
    switch (widget.type) {
685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703
      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({
    this.color
  });

  final Color color;

  @override
704
  bool hitTest(Offset point) => true; // Hitting the rectangle is fine enough.
705 706 707 708 709 710 711 712 713 714 715

  @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;
716
    final List<Offset> points = <Offset>[
717 718 719
      Offset(0.0, height),
      Offset(base, height),
      Offset(halfBase, 0.0),
720 721 722
    ];

    canvas.drawPath(
723 724
      Path()..addPolygon(points, true),
      Paint()..color = color,
725 726 727
    );
  }
}