stepper.dart 27.9 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6
// 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 8
import 'button_style.dart';
import 'color_scheme.dart';
9 10 11 12 13
import 'colors.dart';
import 'debug.dart';
import 'icons.dart';
import 'ink_well.dart';
import 'material.dart';
14
import 'material_localizations.dart';
15 16
import 'material_state.dart';
import 'text_button.dart';
17
import 'text_theme.dart';
18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
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,
34

35 36
  /// A step that displays a pencil icon in its circle.
  editing,
37

38 39
  /// A step that displays a tick icon in its circle.
  complete,
40

41 42
  /// A step that is disabled and does not to react to taps.
  disabled,
43

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

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

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

58
/// Container for all the information necessary to build a Stepper widget's
59
/// forward and backward controls for any given step.
60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108
///
/// Used by [Stepper.controlsBuilder].
@immutable
class ControlsDetails {
  /// Creates a set of details describing the Stepper.
  const ControlsDetails({
    required this.currentStep,
    required this.stepIndex,
    this.onStepCancel,
    this.onStepContinue,
  });
  /// Index that is active for the surrounding [Stepper] widget. This may be
  /// different from [stepIndex] if the user has just changed steps and we are
  /// currently animating toward that step.
  final int currentStep;

  /// Index of the step for which these controls are being built. This is
  /// not necessarily the active index, if the user has just changed steps and
  /// this step is animating away. To determine whether a given builder is building
  /// the active step or the step being navigated away from, see [isActive].
  final int stepIndex;

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

  /// True if the indicated step is also the current active step. If the user has
  /// just activated the transition to a new step, some [Stepper.type] values will
  /// lead to both steps being rendered for the duration of the animation shifting
  /// between steps.
  bool get isActive => currentStep == stepIndex;
}

/// A builder that creates a widget given the two callbacks `onStepContinue` and
/// `onStepCancel`.
///
/// Used by [Stepper.controlsBuilder].
///
/// See also:
///
///  * [WidgetBuilder], which is similar but only takes a [BuildContext].
typedef ControlsWidgetBuilder = Widget Function(BuildContext context, ControlsDetails details);

109
const TextStyle _kStepStyle = TextStyle(
110
  fontSize: 12.0,
111
  color: Colors.white,
112
);
113
const Color _kErrorLight = Colors.red;
114
final Color _kErrorDark = Colors.red.shade400;
115 116 117
const Color _kCircleActiveLight = Colors.white;
const Color _kCircleActiveDark = Colors.black87;
const Color _kDisabledLight = Colors.black38;
118
const Color _kDisabledDark = Colors.white38;
119
const double _kStepSize = 24.0;
Josh Soref's avatar
Josh Soref committed
120
const double _kTriangleHeight = _kStepSize * 0.866025; // Triangle height. sqrt(3.0) / 2.0
121 122 123 124 125 126 127 128

/// 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]
129
///  * <https://material.io/archive/guidelines/components/steppers.html>
130
@immutable
131 132 133 134
class Step {
  /// Creates a step for a [Stepper].
  ///
  /// The [title], [content], and [state] arguments must not be null.
135
  const Step({
136
    required this.title,
137
    this.subtitle,
138
    required this.content,
139 140
    this.state = StepState.indexed,
    this.isActive = false,
141
    this.label,
142 143 144
  }) : assert(title != null),
       assert(content != null),
       assert(state != null);
145 146 147 148 149 150 151 152

  /// 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.
153
  final Widget? subtitle;
154 155 156 157 158 159

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

160
  /// The state of the step which determines the styling of its components
161 162 163 164 165
  /// and whether steps are interactive.
  final StepState state;

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

  /// Only [StepperType.horizontal], Optional widget that appears under the [title].
168
  /// By default, uses the `bodyLarge` theme.
169
  final Widget? label;
170 171 172 173 174 175 176 177 178 179 180
}

/// 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.
///
181 182 183
/// {@tool dartpad}
/// An example the shows how to use the [Stepper], and the [Stepper] UI
/// appearance.
184
///
185
/// ** See code in examples/api/lib/material/stepper/stepper.0.dart **
186 187
/// {@end-tool}
///
188 189 190
/// See also:
///
///  * [Step]
191
///  * <https://material.io/archive/guidelines/components/steppers.html>
192 193 194 195 196 197 198 199
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.
200
  const Stepper({
201
    super.key,
202
    required this.steps,
203
    this.physics,
204 205
    this.type = StepperType.vertical,
    this.currentStep = 0,
206 207
    this.onStepTapped,
    this.onStepContinue,
208
    this.onStepCancel,
209
    this.controlsBuilder,
210
    this.elevation,
211
    this.margin,
212 213 214
  }) : assert(steps != null),
       assert(type != null),
       assert(currentStep != null),
215
       assert(0 <= currentStep && currentStep < steps.length);
216 217 218 219 220 221

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

222 223 224 225 226 227 228
  /// 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].
229
  final ScrollPhysics? physics;
230

231 232 233 234 235 236 237 238 239 240 241
  /// 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.
242
  final ValueChanged<int>? onStepTapped;
243 244 245 246

  /// The callback called when the 'continue' button is tapped.
  ///
  /// If null, the 'continue' button will be disabled.
247
  final VoidCallback? onStepContinue;
248 249 250 251

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

254 255 256 257
  /// The callback for creating custom controls.
  ///
  /// If null, the default controls from the current theme will be used.
  ///
258 259 260 261 262
  /// This callback which takes in a context and a [ControlsDetails] object, which
  /// contains step information and two functions: [onStepContinue] and [onStepCancel].
  /// These can be used to control the stepper. For example, reading the
  /// [ControlsDetails.currentStep] value within the callback can change the text
  /// of the continue or cancel button depending on which step users are at.
263
  ///
264
  /// {@tool dartpad}
265 266
  /// Creates a stepper control with custom buttons.
  ///
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303
  /// ```dart
  /// Widget build(BuildContext context) {
  ///   return Stepper(
  ///     controlsBuilder:
  ///       (BuildContext context, ControlsDetails details) {
  ///          return Row(
  ///            children: <Widget>[
  ///              TextButton(
  ///                onPressed: details.onStepContinue,
  ///                child: Text('Continue to Step ${details.stepIndex + 1}'),
  ///              ),
  ///              TextButton(
  ///                onPressed: details.onStepCancel,
  ///                child: Text('Back to Step ${details.stepIndex - 1}'),
  ///              ),
  ///            ],
  ///          );
  ///       },
  ///     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,
  ///         ),
  ///       ),
  ///     ],
  ///   );
  /// }
  /// ```
304
  /// ** See code in examples/api/lib/material/stepper/stepper.controls_builder.0.dart **
305
  /// {@end-tool}
306
  final ControlsWidgetBuilder? controlsBuilder;
307

308 309 310
  /// The elevation of this stepper's [Material] when [type] is [StepperType.horizontal].
  final double? elevation;

311 312 313
  /// custom margin on vertical stepper.
  final EdgeInsetsGeometry? margin;

314
  @override
315
  State<Stepper> createState() => _StepperState();
316 317
}

318
class _StepperState extends State<Stepper> with TickerProviderStateMixin {
319
  late List<GlobalKey> _keys;
320
  final Map<int, StepState> _oldStates = <int, StepState>{};
321 322 323 324

  @override
  void initState() {
    super.initState();
325
    _keys = List<GlobalKey>.generate(
326
      widget.steps.length,
327
      (int i) => GlobalKey(),
328 329
    );

330
    for (int i = 0; i < widget.steps.length; i += 1) {
331
      _oldStates[i] = widget.steps[i].state;
332
    }
333 334 335
  }

  @override
336 337 338
  void didUpdateWidget(Stepper oldWidget) {
    super.didUpdateWidget(oldWidget);
    assert(widget.steps.length == oldWidget.steps.length);
339

340
    for (int i = 0; i < oldWidget.steps.length; i += 1) {
341
      _oldStates[i] = oldWidget.steps[i].state;
342
    }
343 344 345 346 347 348 349
  }

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

  bool _isLast(int index) {
350
    return widget.steps.length - 1 == index;
351 352 353
  }

  bool _isCurrent(int index) {
354
    return widget.currentStep == index;
355 356 357
  }

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

361 362 363 364 365 366 367 368 369
  bool _isLabel() {
    for (final Step step in widget.steps) {
      if (step.label != null) {
        return true;
      }
    }
    return false;
  }

370
  Widget _buildLine(bool visible) {
371
    return Container(
372 373
      width: visible ? 1.0 : 0.0,
      height: 16.0,
374
      color: Colors.grey.shade400,
375 376 377 378
    );
  }

  Widget _buildCircleChild(int index, bool oldState) {
379
    final StepState state = oldState ? _oldStates[index]! : widget.steps[index].state;
380
    final bool isDarkActive = _isDark() && widget.steps[index].isActive;
381 382 383 384
    assert(state != null);
    switch (state) {
      case StepState.indexed:
      case StepState.disabled:
385
        return Text(
386
          '${index + 1}',
387
          style: isDarkActive ? _kStepStyle.copyWith(color: Colors.black87) : _kStepStyle,
388 389
        );
      case StepState.editing:
390
        return Icon(
391
          Icons.edit,
392
          color: isDarkActive ? _kCircleActiveDark : _kCircleActiveLight,
393
          size: 18.0,
394 395
        );
      case StepState.complete:
396
        return Icon(
397
          Icons.check,
398
          color: isDarkActive ? _kCircleActiveDark : _kCircleActiveLight,
399
          size: 18.0,
400 401
        );
      case StepState.error:
402
        return const Text('!', style: _kStepStyle);
403 404 405 406
    }
  }

  Color _circleColor(int index) {
407
    final ColorScheme colorScheme = Theme.of(context).colorScheme;
408
    if (!_isDark()) {
409
      return widget.steps[index].isActive ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.38);
410
    } else {
411
      return widget.steps[index].isActive ? colorScheme.secondary : colorScheme.background;
412 413 414 415
    }
  }

  Widget _buildCircle(int index, bool oldState) {
416
    return Container(
417 418 419
      margin: const EdgeInsets.symmetric(vertical: 8.0),
      width: _kStepSize,
      height: _kStepSize,
420
      child: AnimatedContainer(
421 422
        curve: Curves.fastOutSlowIn,
        duration: kThemeAnimationDuration,
423
        decoration: BoxDecoration(
424
          color: _circleColor(index),
425
          shape: BoxShape.circle,
426
        ),
427
        child: Center(
428
          child: _buildCircleChild(index, oldState && widget.steps[index].state == StepState.error),
429 430
        ),
      ),
431 432 433 434
    );
  }

  Widget _buildTriangle(int index, bool oldState) {
435
    return Container(
436 437 438
      margin: const EdgeInsets.symmetric(vertical: 8.0),
      width: _kStepSize,
      height: _kStepSize,
439 440
      child: Center(
        child: SizedBox(
441 442
          width: _kStepSize,
          height: _kTriangleHeight, // Height of 24dp-long-sided equilateral triangle.
443 444
          child: CustomPaint(
            painter: _TrianglePainter(
445
              color: _isDark() ? _kErrorDark : _kErrorLight,
446
            ),
447
            child: Align(
448
              alignment: const Alignment(0.0, 0.8), // 0.8 looks better than the geometrical 0.33.
449
              child: _buildCircleChild(index, oldState && widget.steps[index].state != StepState.error),
450 451 452 453
            ),
          ),
        ),
      ),
454 455 456 457
    );
  }

  Widget _buildIcon(int index) {
458
    if (widget.steps[index].state != _oldStates[index]) {
459
      return AnimatedCrossFade(
460 461
        firstChild: _buildCircle(index, true),
        secondChild: _buildTriangle(index, true),
462 463
        firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
        secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
464
        sizeCurve: Curves.fastOutSlowIn,
465
        crossFadeState: widget.steps[index].state == StepState.error ? CrossFadeState.showSecond : CrossFadeState.showFirst,
466 467 468
        duration: kThemeAnimationDuration,
      );
    } else {
469
      if (widget.steps[index].state != StepState.error) {
470
        return _buildCircle(index, false);
471
      } else {
472
        return _buildTriangle(index, false);
473
      }
474 475 476
    }
  }

477
  Widget _buildVerticalControls(int stepIndex) {
478
    if (widget.controlsBuilder != null) {
479 480 481 482 483 484 485 486 487
      return widget.controlsBuilder!(
        context,
        ControlsDetails(
          currentStep: widget.currentStep,
          onStepContinue: widget.onStepContinue,
          onStepCancel: widget.onStepCancel,
          stepIndex: stepIndex,
        ),
      );
488
    }
489

490
    final Color cancelColor;
491
    switch (Theme.of(context).brightness) {
492 493 494 495 496 497 498 499
      case Brightness.light:
        cancelColor = Colors.black54;
        break;
      case Brightness.dark:
        cancelColor = Colors.white70;
        break;
    }

500
    final ThemeData themeData = Theme.of(context);
501
    final ColorScheme colorScheme = themeData.colorScheme;
502
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
503

504 505 506
    const OutlinedBorder buttonShape = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2)));
    const EdgeInsets buttonPadding = EdgeInsets.symmetric(horizontal: 16.0);

507
    return Container(
508
      margin: const EdgeInsets.only(top: 16.0),
509
      child: ConstrainedBox(
510
        constraints: const BoxConstraints.tightFor(height: 48.0),
511
        child: Row(
512 513 514
          // The Material spec no longer includes a Stepper widget. The continue
          // and cancel button styles have been configured to match the original
          // version of this widget.
515
          children: <Widget>[
516
            TextButton(
517
              onPressed: widget.onStepContinue,
518
              style: ButtonStyle(
519
                foregroundColor: MaterialStateProperty.resolveWith<Color?>((Set<MaterialState> states) {
520 521
                  return states.contains(MaterialState.disabled) ? null : (_isDark() ? colorScheme.onSurface : colorScheme.onPrimary);
                }),
522
                backgroundColor: MaterialStateProperty.resolveWith<Color?>((Set<MaterialState> states) {
523 524
                  return _isDark() || states.contains(MaterialState.disabled) ? null : colorScheme.primary;
                }),
525 526
                padding: const MaterialStatePropertyAll<EdgeInsetsGeometry>(buttonPadding),
                shape: const MaterialStatePropertyAll<OutlinedBorder>(buttonShape),
527
              ),
528 529 530 531 532
              child: Text(
                themeData.useMaterial3
                  ? localizations.continueButtonLabel
                  : localizations.continueButtonLabel.toUpperCase()
              ),
533
            ),
534
            Container(
535
              margin: const EdgeInsetsDirectional.only(start: 8.0),
536
              child: TextButton(
537
                onPressed: widget.onStepCancel,
538
                style: TextButton.styleFrom(
539
                  foregroundColor: cancelColor,
540 541 542
                  padding: buttonPadding,
                  shape: buttonShape,
                ),
543 544 545 546 547
                child: Text(
                  themeData.useMaterial3
                    ? localizations.cancelButtonLabel
                    : localizations.cancelButtonLabel.toUpperCase()
                ),
548 549 550 551 552
              ),
            ),
          ],
        ),
      ),
553 554 555 556
    );
  }

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

560 561
    assert(widget.steps[index].state != null);
    switch (widget.steps[index].state) {
562 563 564
      case StepState.indexed:
      case StepState.editing:
      case StepState.complete:
565
        return textTheme.bodyLarge!;
566
      case StepState.disabled:
567
        return textTheme.bodyLarge!.copyWith(
568
          color: _isDark() ? _kDisabledDark : _kDisabledLight,
569 570
        );
      case StepState.error:
571
        return textTheme.bodyLarge!.copyWith(
572
          color: _isDark() ? _kErrorDark : _kErrorLight,
573 574 575 576 577
        );
    }
  }

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

581 582
    assert(widget.steps[index].state != null);
    switch (widget.steps[index].state) {
583 584 585
      case StepState.indexed:
      case StepState.editing:
      case StepState.complete:
586
        return textTheme.bodySmall!;
587
      case StepState.disabled:
588
        return textTheme.bodySmall!.copyWith(
589
          color: _isDark() ? _kDisabledDark : _kDisabledLight,
590 591
        );
      case StepState.error:
592
        return textTheme.bodySmall!.copyWith(
593
          color: _isDark() ? _kErrorDark : _kErrorLight,
594 595 596 597
        );
    }
  }

598 599 600 601 602 603 604 605 606
  TextStyle _labelStyle(int index) {
    final ThemeData themeData = Theme.of(context);
    final TextTheme textTheme = themeData.textTheme;

    assert(widget.steps[index].state != null);
    switch (widget.steps[index].state) {
      case StepState.indexed:
      case StepState.editing:
      case StepState.complete:
607
        return textTheme.bodyLarge!;
608
      case StepState.disabled:
609
        return textTheme.bodyLarge!.copyWith(
610 611 612
          color: _isDark() ? _kDisabledDark : _kDisabledLight,
        );
      case StepState.error:
613
        return textTheme.bodyLarge!.copyWith(
614 615 616 617 618
          color: _isDark() ? _kErrorDark : _kErrorLight,
        );
    }
  }

619
  Widget _buildHeaderText(int index) {
620
    return Column(
621 622
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
623 624 625 626 627 628 629 630 631 632 633 634 635 636
      children: <Widget>[
        AnimatedDefaultTextStyle(
          style: _titleStyle(index),
          duration: kThemeAnimationDuration,
          curve: Curves.fastOutSlowIn,
          child: widget.steps[index].title,
        ),
        if (widget.steps[index].subtitle != null)
          Container(
            margin: const EdgeInsets.only(top: 2.0),
            child: AnimatedDefaultTextStyle(
              style: _subtitleStyle(index),
              duration: kThemeAnimationDuration,
              curve: Curves.fastOutSlowIn,
637
              child: widget.steps[index].subtitle!,
638 639 640
            ),
          ),
      ],
641 642 643
    );
  }

644 645 646 647 648 649 650 651
  Widget _buildLabelText(int index) {
    if (widget.steps[index].label != null) {
      return AnimatedDefaultTextStyle(
        style: _labelStyle(index),
        duration: kThemeAnimationDuration,
        child: widget.steps[index].label!,
      );
    }
652
    return const SizedBox.shrink();
653 654
  }

655
  Widget _buildVerticalHeader(int index) {
656
    return Container(
657
      margin: const EdgeInsets.symmetric(horizontal: 24.0),
658
      child: Row(
659
        children: <Widget>[
660
          Column(
661 662 663 664 665 666
            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)),
667
            ],
668
          ),
669 670 671 672
          Expanded(
            child: Container(
              margin: const EdgeInsetsDirectional.only(start: 12.0),
              child: _buildHeaderText(index),
673
            ),
674 675 676
          ),
        ],
      ),
677 678 679 680
    );
  }

  Widget _buildVerticalBody(int index) {
681
    return Stack(
682
      children: <Widget>[
683
        PositionedDirectional(
684
          start: 24.0,
685 686
          top: 0.0,
          bottom: 0.0,
687
          child: SizedBox(
688
            width: 24.0,
689 690
            child: Center(
              child: SizedBox(
691
                width: _isLast(index) ? 0.0 : 1.0,
692
                child: Container(
693
                  color: Colors.grey.shade400,
694 695 696 697
                ),
              ),
            ),
          ),
698
        ),
699 700 701
        AnimatedCrossFade(
          firstChild: Container(height: 0.0),
          secondChild: Container(
702
            margin: widget.margin ?? const EdgeInsetsDirectional.only(
703 704
              start: 60.0,
              end: 24.0,
705
              bottom: 24.0,
706
            ),
707
            child: Column(
708
              children: <Widget>[
709
                widget.steps[index].content,
710
                _buildVerticalControls(index),
711 712
              ],
            ),
713
          ),
714 715
          firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
          secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
716 717 718
          sizeCurve: Curves.fastOutSlowIn,
          crossFadeState: _isCurrent(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
          duration: kThemeAnimationDuration,
719 720
        ),
      ],
721 722 723 724
    );
  }

  Widget _buildVertical() {
725
    return ListView(
726
      shrinkWrap: true,
727
      physics: widget.physics,
728 729 730 731 732 733 734 735 736 737
      children: <Widget>[
        for (int i = 0; i < widget.steps.length; i += 1)
          Column(
            key: _keys[i],
            children: <Widget>[
              InkWell(
                onTap: widget.steps[i].state != StepState.disabled ? () {
                  // In the vertical case we need to scroll to the newly tapped
                  // step.
                  Scrollable.ensureVisible(
738
                    _keys[i].currentContext!,
739 740 741 742
                    curve: Curves.fastOutSlowIn,
                    duration: kThemeAnimationDuration,
                  );

743
                  widget.onStepTapped?.call(i);
744
                } : null,
745
                canRequestFocus: widget.steps[i].state != StepState.disabled,
746 747 748 749 750 751
                child: _buildVerticalHeader(i),
              ),
              _buildVerticalBody(i),
            ],
          ),
      ],
752 753 754 755
    );
  }

  Widget _buildHorizontal() {
756 757
    final List<Widget> children = <Widget>[
      for (int i = 0; i < widget.steps.length; i += 1) ...<Widget>[
758
        InkResponse(
759
          onTap: widget.steps[i].state != StepState.disabled ? () {
760
            widget.onStepTapped?.call(i);
761
          } : null,
762
          canRequestFocus: widget.steps[i].state != StepState.disabled,
763
          child: Row(
764
            children: <Widget>[
765
              SizedBox(
766 767 768 769 770 771 772 773
                height: _isLabel() ? 104.0 : 72.0,
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    if (widget.steps[i].label != null) const SizedBox(height: 24.0,),
                    Center(child: _buildIcon(i)),
                    if (widget.steps[i].label != null) SizedBox(height : 24.0, child: _buildLabelText(i),),
                  ],
774
                ),
775
              ),
776
              Container(
777
                margin: const EdgeInsetsDirectional.only(start: 12.0),
778 779 780 781 782
                child: _buildHeaderText(i),
              ),
            ],
          ),
        ),
783
        if (!_isLast(i))
784 785
          Expanded(
            child: Container(
786 787
              margin: const EdgeInsets.symmetric(horizontal: 8.0),
              height: 1.0,
788
              color: Colors.grey.shade400,
789 790
            ),
          ),
791 792
      ],
    ];
793

794 795 796 797 798 799 800 801 802 803 804
    final List<Widget> stepPanels = <Widget>[];
    for (int i = 0; i < widget.steps.length; i += 1) {
      stepPanels.add(
        Visibility(
          maintainState: true,
          visible: i == widget.currentStep,
          child: widget.steps[i].content,
        ),
      );
    }

805
    return Column(
806
      children: <Widget>[
807
        Material(
808
          elevation: widget.elevation ?? 2,
809
          child: Container(
810
            margin: const EdgeInsets.symmetric(horizontal: 24.0),
811
            child: Row(
812 813 814
              children: children,
            ),
          ),
815
        ),
816 817
        Expanded(
          child: ListView(
TheBirb's avatar
TheBirb committed
818
            physics: widget.physics,
819 820
            padding: const EdgeInsets.all(24.0),
            children: <Widget>[
821
              AnimatedSize(
822 823
                curve: Curves.fastOutSlowIn,
                duration: kThemeAnimationDuration,
824
                child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: stepPanels),
825
              ),
826
              _buildVerticalControls(widget.currentStep),
827 828 829 830
            ],
          ),
        ),
      ],
831 832 833 834 835 836
    );
  }

  @override
  Widget build(BuildContext context) {
    assert(debugCheckHasMaterial(context));
837
    assert(debugCheckHasMaterialLocalizations(context));
838
    assert(() {
839
      if (context.findAncestorWidgetOfExactType<Stepper>() != null) {
840
        throw FlutterError(
841 842 843
          'Steppers must not be nested.\n'
          'The material specification advises that one should avoid embedding '
          'steppers within steppers. '
844
          'https://material.io/archive/guidelines/components/steppers.html#steppers-usage',
845
        );
846
      }
847
      return true;
848
    }());
849 850
    assert(widget.type != null);
    switch (widget.type) {
851 852 853 854 855 856 857 858 859 860 861 862
      case StepperType.vertical:
        return _buildVertical();
      case StepperType.horizontal:
        return _buildHorizontal();
    }
  }
}

// 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({
863
    required this.color,
864 865 866 867 868
  });

  final Color color;

  @override
869
  bool hitTest(Offset point) => true; // Hitting the rectangle is fine enough.
870 871 872 873 874 875 876 877 878 879 880

  @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;
881
    final List<Offset> points = <Offset>[
882 883 884
      Offset(0.0, height),
      Offset(base, height),
      Offset(halfBase, 0.0),
885 886 887
    ];

    canvas.drawPath(
888 889
      Path()..addPolygon(points, true),
      Paint()..color = color,
890 891 892
    );
  }
}