stepper.dart 30 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 110 111 112
/// A builder that creates the icon widget for the [Step] at [stepIndex], given
/// [stepState].
typedef StepIconBuilder = Widget? Function(int stepIndex, StepState stepState);

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

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

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

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

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

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

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

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

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

225 226 227 228 229 230 231
  /// 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].
232
  final ScrollPhysics? physics;
233

234 235 236 237 238 239 240
  /// An object that can be used to control the position to which this scroll
  /// view is scrolled.
  ///
  /// To control the initial scroll offset of the scroll view, provide a
  /// [controller] with its [ScrollController.initialScrollOffset] property set.
  final ScrollController? controller;

241 242 243 244 245 246 247 248 249 250 251
  /// 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.
252
  final ValueChanged<int>? onStepTapped;
253 254 255 256

  /// The callback called when the 'continue' button is tapped.
  ///
  /// If null, the 'continue' button will be disabled.
257
  final VoidCallback? onStepContinue;
258 259 260 261

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

264 265 266 267
  /// The callback for creating custom controls.
  ///
  /// If null, the default controls from the current theme will be used.
  ///
268 269 270 271 272
  /// 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.
273
  ///
274
  /// {@tool dartpad}
275 276
  /// Creates a stepper control with custom buttons.
  ///
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 304 305 306 307 308 309 310 311 312 313
  /// ```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,
  ///         ),
  ///       ),
  ///     ],
  ///   );
  /// }
  /// ```
314
  /// ** See code in examples/api/lib/material/stepper/stepper.controls_builder.0.dart **
315
  /// {@end-tool}
316
  final ControlsWidgetBuilder? controlsBuilder;
317

318 319 320
  /// The elevation of this stepper's [Material] when [type] is [StepperType.horizontal].
  final double? elevation;

321
  /// Custom margin on vertical stepper.
322 323
  final EdgeInsetsGeometry? margin;

324 325 326 327 328 329 330 331 332 333 334 335 336
  /// Customize connected lines colors.
  ///
  /// Resolves in the following states:
  ///  * [MaterialState.selected].
  ///  * [MaterialState.disabled].
  ///
  /// If not set then the widget will use default colors, primary for selected state
  /// and grey.shade400 for disabled state.
  final MaterialStateProperty<Color>? connectorColor;

  /// The thickness of the connecting lines.
  final double? connectorThickness;

337 338 339 340 341 342 343 344
  /// Callback for creating custom icons for the [steps].
  ///
  /// When overriding icon for [StepState.error], please return
  /// a widget whose width and height are 14 pixels or less to avoid overflow.
  ///
  /// If null, the default icons will be used for respective [StepState].
  final StepIconBuilder? stepIconBuilder;

345
  @override
346
  State<Stepper> createState() => _StepperState();
347 348
}

349
class _StepperState extends State<Stepper> with TickerProviderStateMixin {
350
  late List<GlobalKey> _keys;
351
  final Map<int, StepState> _oldStates = <int, StepState>{};
352 353 354 355

  @override
  void initState() {
    super.initState();
356
    _keys = List<GlobalKey>.generate(
357
      widget.steps.length,
358
      (int i) => GlobalKey(),
359 360
    );

361
    for (int i = 0; i < widget.steps.length; i += 1) {
362
      _oldStates[i] = widget.steps[i].state;
363
    }
364 365 366
  }

  @override
367 368 369
  void didUpdateWidget(Stepper oldWidget) {
    super.didUpdateWidget(oldWidget);
    assert(widget.steps.length == oldWidget.steps.length);
370

371
    for (int i = 0; i < oldWidget.steps.length; i += 1) {
372
      _oldStates[i] = oldWidget.steps[i].state;
373
    }
374 375 376 377 378 379 380
  }

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

  bool _isLast(int index) {
381
    return widget.steps.length - 1 == index;
382 383 384
  }

  bool _isCurrent(int index) {
385
    return widget.currentStep == index;
386 387 388
  }

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

392 393 394 395 396 397 398 399 400
  bool _isLabel() {
    for (final Step step in widget.steps) {
      if (step.label != null) {
        return true;
      }
    }
    return false;
  }

401 402 403 404 405 406 407 408 409 410 411 412 413
  Color _connectorColor(bool isActive) {
    final ColorScheme colorScheme = Theme.of(context).colorScheme;
    final Set<MaterialState> states = <MaterialState>{
      if (isActive) MaterialState.selected else MaterialState.disabled,
    };
    final Color? resolvedConnectorColor = widget.connectorColor?.resolve(states);
    if (resolvedConnectorColor != null) {
      return resolvedConnectorColor;
    }
    return isActive ? colorScheme.primary : Colors.grey.shade400;
  }

  Widget _buildLine(bool visible, bool isActive) {
414
    return Container(
415
      width: visible ? widget.connectorThickness ?? 1.0 : 0.0,
416
      height: 16.0,
417
      color: _connectorColor(isActive),
418 419 420 421
    );
  }

  Widget _buildCircleChild(int index, bool oldState) {
422
    final StepState state = oldState ? _oldStates[index]! : widget.steps[index].state;
423
    final bool isDarkActive = _isDark() && widget.steps[index].isActive;
424 425 426 427
    final Widget? icon = widget.stepIconBuilder?.call(index, state);
    if (icon != null) {
      return icon;
    }
428 429 430
    switch (state) {
      case StepState.indexed:
      case StepState.disabled:
431
        return Text(
432
          '${index + 1}',
433
          style: isDarkActive ? _kStepStyle.copyWith(color: Colors.black87) : _kStepStyle,
434 435
        );
      case StepState.editing:
436
        return Icon(
437
          Icons.edit,
438
          color: isDarkActive ? _kCircleActiveDark : _kCircleActiveLight,
439
          size: 18.0,
440 441
        );
      case StepState.complete:
442
        return Icon(
443
          Icons.check,
444
          color: isDarkActive ? _kCircleActiveDark : _kCircleActiveLight,
445
          size: 18.0,
446 447
        );
      case StepState.error:
448
        return const Text('!', style: _kStepStyle);
449 450 451 452
    }
  }

  Color _circleColor(int index) {
453
    final bool isActive = widget.steps[index].isActive;
454
    final ColorScheme colorScheme = Theme.of(context).colorScheme;
455 456 457 458 459 460 461
    final Set<MaterialState> states = <MaterialState>{
      if (isActive) MaterialState.selected else MaterialState.disabled,
    };
    final Color? resolvedConnectorColor = widget.connectorColor?.resolve(states);
    if (resolvedConnectorColor != null) {
      return resolvedConnectorColor;
    }
462
    if (!_isDark()) {
463
      return isActive ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.38);
464
    } else {
465
      return isActive ? colorScheme.secondary : colorScheme.background;
466 467 468 469
    }
  }

  Widget _buildCircle(int index, bool oldState) {
470
    return Container(
471 472 473
      margin: const EdgeInsets.symmetric(vertical: 8.0),
      width: _kStepSize,
      height: _kStepSize,
474
      child: AnimatedContainer(
475 476
        curve: Curves.fastOutSlowIn,
        duration: kThemeAnimationDuration,
477
        decoration: BoxDecoration(
478
          color: _circleColor(index),
479
          shape: BoxShape.circle,
480
        ),
481
        child: Center(
482
          child: _buildCircleChild(index, oldState && widget.steps[index].state == StepState.error),
483 484
        ),
      ),
485 486 487 488
    );
  }

  Widget _buildTriangle(int index, bool oldState) {
489
    return Container(
490 491 492
      margin: const EdgeInsets.symmetric(vertical: 8.0),
      width: _kStepSize,
      height: _kStepSize,
493 494
      child: Center(
        child: SizedBox(
495 496
          width: _kStepSize,
          height: _kTriangleHeight, // Height of 24dp-long-sided equilateral triangle.
497 498
          child: CustomPaint(
            painter: _TrianglePainter(
499
              color: _isDark() ? _kErrorDark : _kErrorLight,
500
            ),
501
            child: Align(
502
              alignment: const Alignment(0.0, 0.8), // 0.8 looks better than the geometrical 0.33.
503
              child: _buildCircleChild(index, oldState && widget.steps[index].state != StepState.error),
504 505 506 507
            ),
          ),
        ),
      ),
508 509 510 511
    );
  }

  Widget _buildIcon(int index) {
512
    if (widget.steps[index].state != _oldStates[index]) {
513
      return AnimatedCrossFade(
514 515
        firstChild: _buildCircle(index, true),
        secondChild: _buildTriangle(index, true),
516 517
        firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
        secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
518
        sizeCurve: Curves.fastOutSlowIn,
519
        crossFadeState: widget.steps[index].state == StepState.error ? CrossFadeState.showSecond : CrossFadeState.showFirst,
520 521 522
        duration: kThemeAnimationDuration,
      );
    } else {
523
      if (widget.steps[index].state != StepState.error) {
524
        return _buildCircle(index, false);
525
      } else {
526
        return _buildTriangle(index, false);
527
      }
528 529 530
    }
  }

531
  Widget _buildVerticalControls(int stepIndex) {
532
    if (widget.controlsBuilder != null) {
533 534 535 536 537 538 539 540 541
      return widget.controlsBuilder!(
        context,
        ControlsDetails(
          currentStep: widget.currentStep,
          onStepContinue: widget.onStepContinue,
          onStepCancel: widget.onStepCancel,
          stepIndex: stepIndex,
        ),
      );
542
    }
543

544
    final Color cancelColor;
545
    switch (Theme.of(context).brightness) {
546 547 548 549 550 551
      case Brightness.light:
        cancelColor = Colors.black54;
      case Brightness.dark:
        cancelColor = Colors.white70;
    }

552
    final ThemeData themeData = Theme.of(context);
553
    final ColorScheme colorScheme = themeData.colorScheme;
554
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
555

556 557 558
    const OutlinedBorder buttonShape = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2)));
    const EdgeInsets buttonPadding = EdgeInsets.symmetric(horizontal: 16.0);

559
    return Container(
560
      margin: const EdgeInsets.only(top: 16.0),
561
      child: ConstrainedBox(
562
        constraints: const BoxConstraints.tightFor(height: 48.0),
563
        child: Row(
564 565 566
          // 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.
567
          children: <Widget>[
568
            TextButton(
569
              onPressed: widget.onStepContinue,
570
              style: ButtonStyle(
571
                foregroundColor: MaterialStateProperty.resolveWith<Color?>((Set<MaterialState> states) {
572 573
                  return states.contains(MaterialState.disabled) ? null : (_isDark() ? colorScheme.onSurface : colorScheme.onPrimary);
                }),
574
                backgroundColor: MaterialStateProperty.resolveWith<Color?>((Set<MaterialState> states) {
575 576
                  return _isDark() || states.contains(MaterialState.disabled) ? null : colorScheme.primary;
                }),
577 578
                padding: const MaterialStatePropertyAll<EdgeInsetsGeometry>(buttonPadding),
                shape: const MaterialStatePropertyAll<OutlinedBorder>(buttonShape),
579
              ),
580 581 582 583 584
              child: Text(
                themeData.useMaterial3
                  ? localizations.continueButtonLabel
                  : localizations.continueButtonLabel.toUpperCase()
              ),
585
            ),
586
            Container(
587
              margin: const EdgeInsetsDirectional.only(start: 8.0),
588
              child: TextButton(
589
                onPressed: widget.onStepCancel,
590
                style: TextButton.styleFrom(
591
                  foregroundColor: cancelColor,
592 593 594
                  padding: buttonPadding,
                  shape: buttonShape,
                ),
595 596 597 598 599
                child: Text(
                  themeData.useMaterial3
                    ? localizations.cancelButtonLabel
                    : localizations.cancelButtonLabel.toUpperCase()
                ),
600 601 602 603 604
              ),
            ),
          ],
        ),
      ),
605 606 607 608
    );
  }

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

612
    switch (widget.steps[index].state) {
613 614 615
      case StepState.indexed:
      case StepState.editing:
      case StepState.complete:
616
        return textTheme.bodyLarge!;
617
      case StepState.disabled:
618
        return textTheme.bodyLarge!.copyWith(
619
          color: _isDark() ? _kDisabledDark : _kDisabledLight,
620 621
        );
      case StepState.error:
622
        return textTheme.bodyLarge!.copyWith(
623
          color: _isDark() ? _kErrorDark : _kErrorLight,
624 625 626 627 628
        );
    }
  }

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

632
    switch (widget.steps[index].state) {
633 634 635
      case StepState.indexed:
      case StepState.editing:
      case StepState.complete:
636
        return textTheme.bodySmall!;
637
      case StepState.disabled:
638
        return textTheme.bodySmall!.copyWith(
639
          color: _isDark() ? _kDisabledDark : _kDisabledLight,
640 641
        );
      case StepState.error:
642
        return textTheme.bodySmall!.copyWith(
643
          color: _isDark() ? _kErrorDark : _kErrorLight,
644 645 646 647
        );
    }
  }

648 649 650 651 652 653 654 655
  TextStyle _labelStyle(int index) {
    final ThemeData themeData = Theme.of(context);
    final TextTheme textTheme = themeData.textTheme;

    switch (widget.steps[index].state) {
      case StepState.indexed:
      case StepState.editing:
      case StepState.complete:
656
        return textTheme.bodyLarge!;
657
      case StepState.disabled:
658
        return textTheme.bodyLarge!.copyWith(
659 660 661
          color: _isDark() ? _kDisabledDark : _kDisabledLight,
        );
      case StepState.error:
662
        return textTheme.bodyLarge!.copyWith(
663 664 665 666 667
          color: _isDark() ? _kErrorDark : _kErrorLight,
        );
    }
  }

668
  Widget _buildHeaderText(int index) {
669
    return Column(
670 671
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.min,
672 673 674 675 676 677 678 679 680 681 682 683 684 685
      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,
686
              child: widget.steps[index].subtitle!,
687 688 689
            ),
          ),
      ],
690 691 692
    );
  }

693 694 695 696 697 698 699 700
  Widget _buildLabelText(int index) {
    if (widget.steps[index].label != null) {
      return AnimatedDefaultTextStyle(
        style: _labelStyle(index),
        duration: kThemeAnimationDuration,
        child: widget.steps[index].label!,
      );
    }
701
    return const SizedBox.shrink();
702 703
  }

704
  Widget _buildVerticalHeader(int index) {
705
    final bool isActive = widget.steps[index].isActive;
706
    return Container(
707
      margin: const EdgeInsets.symmetric(horizontal: 24.0),
708
      child: Row(
709
        children: <Widget>[
710
          Column(
711 712 713
            children: <Widget>[
              // Line parts are always added in order for the ink splash to
              // flood the tips of the connector lines.
714
              _buildLine(!_isFirst(index), isActive),
715
              _buildIcon(index),
716
              _buildLine(!_isLast(index), isActive),
717
            ],
718
          ),
719 720 721 722
          Expanded(
            child: Container(
              margin: const EdgeInsetsDirectional.only(start: 12.0),
              child: _buildHeaderText(index),
723
            ),
724 725 726
          ),
        ],
      ),
727 728 729 730
    );
  }

  Widget _buildVerticalBody(int index) {
731
    return Stack(
732
      children: <Widget>[
733
        PositionedDirectional(
734
          start: 24.0,
735 736
          top: 0.0,
          bottom: 0.0,
737
          child: SizedBox(
738
            width: 24.0,
739 740
            child: Center(
              child: SizedBox(
741
                width: widget.connectorThickness ?? 1.0,
742
                child: Container(
743
                  color: _connectorColor(widget.steps[index].isActive),
744 745 746 747
                ),
              ),
            ),
          ),
748
        ),
749 750 751
        AnimatedCrossFade(
          firstChild: Container(height: 0.0),
          secondChild: Container(
752
            margin: widget.margin ?? const EdgeInsetsDirectional.only(
753 754
              start: 60.0,
              end: 24.0,
755
              bottom: 24.0,
756
            ),
757
            child: Column(
758
              children: <Widget>[
759
                widget.steps[index].content,
760
                _buildVerticalControls(index),
761 762
              ],
            ),
763
          ),
764 765
          firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
          secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
766 767 768
          sizeCurve: Curves.fastOutSlowIn,
          crossFadeState: _isCurrent(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
          duration: kThemeAnimationDuration,
769 770
        ),
      ],
771 772 773 774
    );
  }

  Widget _buildVertical() {
775
    return ListView(
776
      controller: widget.controller,
777
      shrinkWrap: true,
778
      physics: widget.physics,
779 780 781 782 783 784 785 786 787 788
      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(
789
                    _keys[i].currentContext!,
790 791 792 793
                    curve: Curves.fastOutSlowIn,
                    duration: kThemeAnimationDuration,
                  );

794
                  widget.onStepTapped?.call(i);
795
                } : null,
796
                canRequestFocus: widget.steps[i].state != StepState.disabled,
797 798 799 800 801 802
                child: _buildVerticalHeader(i),
              ),
              _buildVerticalBody(i),
            ],
          ),
      ],
803 804 805 806
    );
  }

  Widget _buildHorizontal() {
807 808
    final List<Widget> children = <Widget>[
      for (int i = 0; i < widget.steps.length; i += 1) ...<Widget>[
809
        InkResponse(
810
          onTap: widget.steps[i].state != StepState.disabled ? () {
811
            widget.onStepTapped?.call(i);
812
          } : null,
813
          canRequestFocus: widget.steps[i].state != StepState.disabled,
814
          child: Row(
815
            children: <Widget>[
816
              SizedBox(
817 818 819 820 821 822 823 824
                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),),
                  ],
825
                ),
826
              ),
827
              Container(
828
                margin: const EdgeInsetsDirectional.only(start: 12.0),
829 830 831 832 833
                child: _buildHeaderText(i),
              ),
            ],
          ),
        ),
834
        if (!_isLast(i))
835 836
          Expanded(
            child: Container(
837
              key: Key('line$i'),
838
              margin: const EdgeInsets.symmetric(horizontal: 8.0),
839 840
              height: widget.connectorThickness ?? 1.0,
              color: _connectorColor(widget.steps[i+1].isActive),
841 842
            ),
          ),
843 844
      ],
    ];
845

846 847 848 849 850 851 852 853 854 855 856
    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,
        ),
      );
    }

857
    return Column(
858
      children: <Widget>[
859
        Material(
860
          elevation: widget.elevation ?? 2,
861
          child: Container(
862
            margin: const EdgeInsets.symmetric(horizontal: 24.0),
863
            child: Row(
864 865 866
              children: children,
            ),
          ),
867
        ),
868 869
        Expanded(
          child: ListView(
870
            controller: widget.controller,
TheBirb's avatar
TheBirb committed
871
            physics: widget.physics,
872 873
            padding: const EdgeInsets.all(24.0),
            children: <Widget>[
874
              AnimatedSize(
875 876
                curve: Curves.fastOutSlowIn,
                duration: kThemeAnimationDuration,
877
                child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: stepPanels),
878
              ),
879
              _buildVerticalControls(widget.currentStep),
880 881 882 883
            ],
          ),
        ),
      ],
884 885 886 887 888 889
    );
  }

  @override
  Widget build(BuildContext context) {
    assert(debugCheckHasMaterial(context));
890
    assert(debugCheckHasMaterialLocalizations(context));
891
    assert(() {
892
      if (context.findAncestorWidgetOfExactType<Stepper>() != null) {
893
        throw FlutterError(
894 895 896
          'Steppers must not be nested.\n'
          'The material specification advises that one should avoid embedding '
          'steppers within steppers. '
897
          'https://material.io/archive/guidelines/components/steppers.html#steppers-usage',
898
        );
899
      }
900
      return true;
901
    }());
902
    switch (widget.type) {
903 904 905 906 907 908 909 910 911 912 913 914
      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({
915
    required this.color,
916 917 918 919 920
  });

  final Color color;

  @override
921
  bool hitTest(Offset point) => true; // Hitting the rectangle is fine enough.
922 923 924 925 926 927 928 929 930 931 932

  @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;
933
    final List<Offset> points = <Offset>[
934 935 936
      Offset(0.0, height),
      Offset(base, height),
      Offset(halfBase, 0.0),
937 938 939
    ];

    canvas.drawPath(
940 941
      Path()..addPolygon(points, true),
      Paint()..color = color,
942 943 944
    );
  }
}