stepper.dart 29.8 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
class Step {
  /// Creates a step for a [Stepper].
137
  const Step({
138
    required this.title,
139
    this.subtitle,
140
    required this.content,
141 142
    this.state = StepState.indexed,
    this.isActive = false,
143
    this.label,
144
  });
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
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.
198
  const Stepper({
199
    super.key,
200
    required this.steps,
201
    this.controller,
202
    this.physics,
203 204
    this.type = StepperType.vertical,
    this.currentStep = 0,
205 206
    this.onStepTapped,
    this.onStepContinue,
207
    this.onStepCancel,
208
    this.controlsBuilder,
209
    this.elevation,
210
    this.margin,
211 212
    this.connectorColor,
    this.connectorThickness,
213
    this.stepIconBuilder,
214
  }) : assert(0 <= currentStep && currentStep < steps.length);
215 216 217 218 219 220

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

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

230 231 232 233 234 235 236
  /// 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;

237 238 239 240 241 242 243 244 245 246 247
  /// 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.
248
  final ValueChanged<int>? onStepTapped;
249 250 251 252

  /// The callback called when the 'continue' button is tapped.
  ///
  /// If null, the 'continue' button will be disabled.
253
  final VoidCallback? onStepContinue;
254 255 256 257

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

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

314 315 316
  /// The elevation of this stepper's [Material] when [type] is [StepperType.horizontal].
  final double? elevation;

317
  /// Custom margin on vertical stepper.
318 319
  final EdgeInsetsGeometry? margin;

320 321 322 323 324 325 326 327 328 329 330 331 332
  /// 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;

333 334 335 336 337 338 339 340
  /// 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;

341
  @override
342
  State<Stepper> createState() => _StepperState();
343 344
}

345
class _StepperState extends State<Stepper> with TickerProviderStateMixin {
346
  late List<GlobalKey> _keys;
347
  final Map<int, StepState> _oldStates = <int, StepState>{};
348 349 350 351

  @override
  void initState() {
    super.initState();
352
    _keys = List<GlobalKey>.generate(
353
      widget.steps.length,
354
      (int i) => GlobalKey(),
355 356
    );

357
    for (int i = 0; i < widget.steps.length; i += 1) {
358
      _oldStates[i] = widget.steps[i].state;
359
    }
360 361 362
  }

  @override
363 364 365
  void didUpdateWidget(Stepper oldWidget) {
    super.didUpdateWidget(oldWidget);
    assert(widget.steps.length == oldWidget.steps.length);
366

367
    for (int i = 0; i < oldWidget.steps.length; i += 1) {
368
      _oldStates[i] = oldWidget.steps[i].state;
369
    }
370 371 372 373 374 375 376
  }

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

  bool _isLast(int index) {
377
    return widget.steps.length - 1 == index;
378 379 380
  }

  bool _isCurrent(int index) {
381
    return widget.currentStep == index;
382 383 384
  }

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

388 389 390 391 392 393 394 395 396
  bool _isLabel() {
    for (final Step step in widget.steps) {
      if (step.label != null) {
        return true;
      }
    }
    return false;
  }

397 398 399 400 401 402 403 404 405 406 407 408 409
  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) {
410
    return Container(
411
      width: visible ? widget.connectorThickness ?? 1.0 : 0.0,
412
      height: 16.0,
413
      color: _connectorColor(isActive),
414 415 416 417
    );
  }

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

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

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

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

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

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

540
    final Color cancelColor;
541
    switch (Theme.of(context).brightness) {
542 543 544 545 546 547
      case Brightness.light:
        cancelColor = Colors.black54;
      case Brightness.dark:
        cancelColor = Colors.white70;
    }

548
    final ThemeData themeData = Theme.of(context);
549
    final ColorScheme colorScheme = themeData.colorScheme;
550
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
551

552 553 554
    const OutlinedBorder buttonShape = RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(2)));
    const EdgeInsets buttonPadding = EdgeInsets.symmetric(horizontal: 16.0);

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

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

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

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

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

644 645 646 647 648 649 650 651
  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:
652
        return textTheme.bodyLarge!;
653
      case StepState.disabled:
654
        return textTheme.bodyLarge!.copyWith(
655 656 657
          color: _isDark() ? _kDisabledDark : _kDisabledLight,
        );
      case StepState.error:
658
        return textTheme.bodyLarge!.copyWith(
659 660 661 662 663
          color: _isDark() ? _kErrorDark : _kErrorLight,
        );
    }
  }

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

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

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

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

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

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

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

842 843 844 845 846 847 848 849 850 851 852
    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,
        ),
      );
    }

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

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

  final Color color;

  @override
917
  bool hitTest(Offset point) => true; // Hitting the rectangle is fine enough.
918 919 920 921 922 923 924 925 926 927 928

  @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;
929
    final List<Offset> points = <Offset>[
930 931 932
      Offset(0.0, height),
      Offset(base, height),
      Offset(halfBase, 0.0),
933 934 935
    ];

    canvas.drawPath(
936 937
      Path()..addPolygon(points, true),
      Paint()..color = color,
938 939 940
    );
  }
}