1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'package:flutter/foundation.dart';
6import 'package:flutter/widgets.dart';
7
8import 'button_style.dart';
9import 'color_scheme.dart';
10import 'colors.dart';
11import 'debug.dart';
12import 'icons.dart';
13import 'ink_well.dart';
14import 'material.dart';
15import 'material_localizations.dart';
16import 'material_state.dart';
17import 'text_button.dart';
18import 'text_theme.dart';
19import 'theme.dart';
20
21// TODO(dragostis): Missing functionality:
22// * mobile horizontal mode with adding/removing steps
23// * alternative labeling
24// * stepper feedback in the case of high-latency interactions
25
26/// The state of a [Step] which is used to control the style of the circle and
27/// text.
28///
29/// See also:
30///
31/// * [Step]
32enum StepState {
33 /// A step that displays its index in its circle.
34 indexed,
35
36 /// A step that displays a pencil icon in its circle.
37 editing,
38
39 /// A step that displays a tick icon in its circle.
40 complete,
41
42 /// A step that is disabled and does not to react to taps.
43 disabled,
44
45 /// A step that is currently having an error. e.g. the user has submitted wrong
46 /// input.
47 error,
48}
49
50/// Defines the [Stepper]'s main axis.
51enum StepperType {
52 /// A vertical layout of the steps with their content in-between the titles.
53 vertical,
54
55 /// A horizontal layout of the steps with their content below the titles.
56 horizontal,
57}
58
59/// Container for all the information necessary to build a Stepper widget's
60/// forward and backward controls for any given step.
61///
62/// Used by [Stepper.controlsBuilder].
63@immutable
64class ControlsDetails {
65 /// Creates a set of details describing the Stepper.
66 const ControlsDetails({
67 required this.currentStep,
68 required this.stepIndex,
69 this.onStepCancel,
70 this.onStepContinue,
71 });
72
73 /// Index that is active for the surrounding [Stepper] widget. This may be
74 /// different from [stepIndex] if the user has just changed steps and we are
75 /// currently animating toward that step.
76 final int currentStep;
77
78 /// Index of the step for which these controls are being built. This is
79 /// not necessarily the active index, if the user has just changed steps and
80 /// this step is animating away. To determine whether a given builder is building
81 /// the active step or the step being navigated away from, see [isActive].
82 final int stepIndex;
83
84 /// The callback called when the 'continue' button is tapped.
85 ///
86 /// If null, the 'continue' button will be disabled.
87 final VoidCallback? onStepContinue;
88
89 /// The callback called when the 'cancel' button is tapped.
90 ///
91 /// If null, the 'cancel' button will be disabled.
92 final VoidCallback? onStepCancel;
93
94 /// True if the indicated step is also the current active step. If the user has
95 /// just activated the transition to a new step, some [Stepper.type] values will
96 /// lead to both steps being rendered for the duration of the animation shifting
97 /// between steps.
98 bool get isActive => currentStep == stepIndex;
99}
100
101/// A builder that creates a widget given the two callbacks `onStepContinue` and
102/// `onStepCancel`.
103///
104/// Used by [Stepper.controlsBuilder].
105///
106/// See also:
107///
108/// * [WidgetBuilder], which is similar but only takes a [BuildContext].
109typedef ControlsWidgetBuilder = Widget Function(BuildContext context, ControlsDetails details);
110
111/// A builder that creates the icon widget for the [Step] at [stepIndex], given
112/// [stepState].
113typedef StepIconBuilder = Widget? Function(int stepIndex, StepState stepState);
114
115const TextStyle _kStepStyle = TextStyle(fontSize: 12.0, color: Colors.white);
116const Color _kErrorLight = Colors.red;
117final Color _kErrorDark = Colors.red.shade400;
118const Color _kCircleActiveLight = Colors.white;
119const Color _kCircleActiveDark = Colors.black87;
120const Color _kDisabledLight = Colors.black38;
121const Color _kDisabledDark = Colors.white38;
122const double _kStepSize = 24.0;
123const double _kTriangleSqrt = 0.866025; // sqrt(3.0) / 2.0
124const double _kTriangleHeight = _kStepSize * _kTriangleSqrt;
125const double _kMaxStepSize = 80.0;
126
127/// A material step used in [Stepper]. The step can have a title and subtitle,
128/// an icon within its circle, some content and a state that governs its
129/// styling.
130///
131/// See also:
132///
133/// * [Stepper]
134/// * <https://material.io/archive/guidelines/components/steppers.html>
135@immutable
136class Step {
137 /// Creates a step for a [Stepper].
138 const Step({
139 required this.title,
140 this.subtitle,
141 required this.content,
142 this.state = StepState.indexed,
143 this.isActive = false,
144 this.label,
145 this.stepStyle,
146 });
147
148 /// The title of the step that typically describes it.
149 final Widget title;
150
151 /// The subtitle of the step that appears below the title and has a smaller
152 /// font size. It typically gives more details that complement the title.
153 ///
154 /// If null, the subtitle is not shown.
155 final Widget? subtitle;
156
157 /// The content of the step that appears below the [title] and [subtitle].
158 ///
159 /// Below the content, every step has a 'continue' and 'cancel' button.
160 final Widget content;
161
162 /// The state of the step which determines the styling of its components
163 /// and whether steps are interactive.
164 final StepState state;
165
166 /// Whether or not the step is active. The flag only influences styling.
167 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 /// Optional overrides for the step's default visual configuration.
174 final StepStyle? stepStyle;
175}
176
177/// A material stepper widget that displays progress through a sequence of
178/// steps. Steppers are particularly useful in the case of forms where one step
179/// requires the completion of another one, or where multiple steps need to be
180/// completed in order to submit the whole form.
181///
182/// The widget is a flexible wrapper. A parent class should pass [currentStep]
183/// to this widget based on some logic triggered by the three callbacks that it
184/// provides.
185///
186/// {@tool dartpad}
187/// An example the shows how to use the [Stepper], and the [Stepper] UI
188/// appearance.
189///
190/// ** See code in examples/api/lib/material/stepper/stepper.0.dart **
191/// {@end-tool}
192///
193/// See also:
194///
195/// * [Step]
196/// * <https://material.io/archive/guidelines/components/steppers.html>
197class Stepper extends StatefulWidget {
198 /// Creates a stepper from a list of steps.
199 ///
200 /// This widget is not meant to be rebuilt with a different list of steps
201 /// unless a key is provided in order to distinguish the old stepper from the
202 /// new one.
203 const Stepper({
204 super.key,
205 required this.steps,
206 this.controller,
207 this.physics,
208 this.type = StepperType.vertical,
209 this.currentStep = 0,
210 this.onStepTapped,
211 this.onStepContinue,
212 this.onStepCancel,
213 this.controlsBuilder,
214 this.elevation,
215 this.margin,
216 this.connectorColor,
217 this.connectorThickness,
218 this.stepIconBuilder,
219 this.stepIconHeight,
220 this.stepIconWidth,
221 this.stepIconMargin,
222 this.clipBehavior = Clip.none,
223 }) : assert(0 <= currentStep && currentStep < steps.length),
224 assert(
225 stepIconHeight == null ||
226 (stepIconHeight >= _kStepSize && stepIconHeight <= _kMaxStepSize),
227 'stepIconHeight must be greater than $_kStepSize and less or equal to $_kMaxStepSize',
228 ),
229 assert(
230 stepIconWidth == null || (stepIconWidth >= _kStepSize && stepIconWidth <= _kMaxStepSize),
231 'stepIconWidth must be greater than $_kStepSize and less or equal to $_kMaxStepSize',
232 ),
233 assert(
234 stepIconHeight == null || stepIconWidth == null || stepIconHeight == stepIconWidth,
235 'If either stepIconHeight or stepIconWidth is specified, both must be specified and '
236 'the values must be equal.',
237 );
238
239 /// The steps of the stepper whose titles, subtitles, icons always get shown.
240 ///
241 /// The length of [steps] must not change.
242 final List<Step> steps;
243
244 /// How the stepper's scroll view should respond to user input.
245 ///
246 /// For example, determines how the scroll view continues to
247 /// animate after the user stops dragging the scroll view.
248 ///
249 /// If the stepper is contained within another scrollable it
250 /// can be helpful to set this property to [ClampingScrollPhysics].
251 final ScrollPhysics? physics;
252
253 /// An object that can be used to control the position to which this scroll
254 /// view is scrolled.
255 ///
256 /// To control the initial scroll offset of the scroll view, provide a
257 /// [controller] with its [ScrollController.initialScrollOffset] property set.
258 final ScrollController? controller;
259
260 /// The type of stepper that determines the layout. In the case of
261 /// [StepperType.horizontal], the content of the current step is displayed
262 /// underneath as opposed to the [StepperType.vertical] case where it is
263 /// displayed in-between.
264 final StepperType type;
265
266 /// The index into [steps] of the current step whose content is displayed.
267 final int currentStep;
268
269 /// The callback called when a step is tapped, with its index passed as
270 /// an argument.
271 final ValueChanged<int>? onStepTapped;
272
273 /// The callback called when the 'continue' button is tapped.
274 ///
275 /// If null, the 'continue' button will be disabled.
276 final VoidCallback? onStepContinue;
277
278 /// The callback called when the 'cancel' button is tapped.
279 ///
280 /// If null, the 'cancel' button will be disabled.
281 final VoidCallback? onStepCancel;
282
283 /// The callback for creating custom controls.
284 ///
285 /// If null, the default controls from the current theme will be used.
286 ///
287 /// This callback which takes in a context and a [ControlsDetails] object, which
288 /// contains step information and two functions: [onStepContinue] and [onStepCancel].
289 /// These can be used to control the stepper. For example, reading the
290 /// [ControlsDetails.currentStep] value within the callback can change the text
291 /// of the continue or cancel button depending on which step users are at.
292 ///
293 /// {@tool dartpad}
294 /// Creates a stepper control with custom buttons.
295 ///
296 /// ```dart
297 /// Widget build(BuildContext context) {
298 /// return Stepper(
299 /// controlsBuilder:
300 /// (BuildContext context, ControlsDetails details) {
301 /// return Row(
302 /// children: <Widget>[
303 /// TextButton(
304 /// onPressed: details.onStepContinue,
305 /// child: Text('Continue to Step ${details.stepIndex + 1}'),
306 /// ),
307 /// TextButton(
308 /// onPressed: details.onStepCancel,
309 /// child: Text('Back to Step ${details.stepIndex - 1}'),
310 /// ),
311 /// ],
312 /// );
313 /// },
314 /// steps: const <Step>[
315 /// Step(
316 /// title: Text('A'),
317 /// content: SizedBox(
318 /// width: 100.0,
319 /// height: 100.0,
320 /// ),
321 /// ),
322 /// Step(
323 /// title: Text('B'),
324 /// content: SizedBox(
325 /// width: 100.0,
326 /// height: 100.0,
327 /// ),
328 /// ),
329 /// ],
330 /// );
331 /// }
332 /// ```
333 /// ** See code in examples/api/lib/material/stepper/stepper.controls_builder.0.dart **
334 /// {@end-tool}
335 final ControlsWidgetBuilder? controlsBuilder;
336
337 /// The elevation of this stepper's [Material] when [type] is [StepperType.horizontal].
338 final double? elevation;
339
340 /// Custom margin on vertical stepper.
341 final EdgeInsetsGeometry? margin;
342
343 /// Customize connected lines colors.
344 ///
345 /// Resolves in the following states:
346 /// * [WidgetState.selected].
347 /// * [WidgetState.disabled].
348 ///
349 /// If not set then the widget will use default colors, primary for selected state
350 /// and grey.shade400 for disabled state.
351 final MaterialStateProperty<Color>? connectorColor;
352
353 /// The thickness of the connecting lines.
354 final double? connectorThickness;
355
356 /// Callback for creating custom icons for the [steps].
357 ///
358 /// When overriding icon for [StepState.error], please return
359 /// a widget whose width and height are 14 pixels or less to avoid overflow.
360 ///
361 /// If null, the default icons will be used for respective [StepState].
362 final StepIconBuilder? stepIconBuilder;
363
364 /// Overrides the default step icon size height.
365 final double? stepIconHeight;
366
367 /// Overrides the default step icon size width.
368 final double? stepIconWidth;
369
370 /// Overrides the default step icon margin.
371 final EdgeInsets? stepIconMargin;
372
373 /// The [Step.content] will be clipped to this Clip type.
374 ///
375 /// Defaults to [Clip.none].
376 ///
377 /// See also:
378 ///
379 /// * [Clip], which explains how to use this property.
380 final Clip clipBehavior;
381
382 @override
383 State<Stepper> createState() => _StepperState();
384}
385
386class _StepperState extends State<Stepper> with TickerProviderStateMixin {
387 late List<GlobalKey> _keys;
388 final Map<int, StepState> _oldStates = <int, StepState>{};
389
390 @override
391 void initState() {
392 super.initState();
393 _keys = List<GlobalKey>.generate(widget.steps.length, (int i) => GlobalKey());
394
395 for (int i = 0; i < widget.steps.length; i += 1) {
396 _oldStates[i] = widget.steps[i].state;
397 }
398 }
399
400 @override
401 void didUpdateWidget(Stepper oldWidget) {
402 super.didUpdateWidget(oldWidget);
403 assert(widget.steps.length == oldWidget.steps.length);
404
405 for (int i = 0; i < oldWidget.steps.length; i += 1) {
406 _oldStates[i] = oldWidget.steps[i].state;
407 }
408 }
409
410 EdgeInsetsGeometry? get _stepIconMargin => widget.stepIconMargin;
411
412 double? get _stepIconHeight => widget.stepIconHeight;
413
414 double? get _stepIconWidth => widget.stepIconWidth;
415
416 double get _heightFactor {
417 return (_isLabel() && _stepIconHeight != null) ? 2.5 : 2.0;
418 }
419
420 bool _isFirst(int index) {
421 return index == 0;
422 }
423
424 bool _isLast(int index) {
425 return widget.steps.length - 1 == index;
426 }
427
428 bool _isCurrent(int index) {
429 return widget.currentStep == index;
430 }
431
432 bool _isDark() {
433 return Theme.brightnessOf(context) == Brightness.dark;
434 }
435
436 bool _isLabel() {
437 for (final Step step in widget.steps) {
438 if (step.label != null) {
439 return true;
440 }
441 }
442 return false;
443 }
444
445 StepStyle? _stepStyle(int index) {
446 return widget.steps[index].stepStyle;
447 }
448
449 Color _connectorColor(bool isActive) {
450 final ColorScheme colorScheme = Theme.of(context).colorScheme;
451 final Set<MaterialState> states = <MaterialState>{
452 if (isActive) MaterialState.selected else MaterialState.disabled,
453 };
454 final Color? resolvedConnectorColor = widget.connectorColor?.resolve(states);
455
456 return resolvedConnectorColor ?? (isActive ? colorScheme.primary : Colors.grey.shade400);
457 }
458
459 Widget _buildLine(bool visible, bool isActive) {
460 return ColoredBox(
461 color: _connectorColor(isActive),
462 child: SizedBox(width: visible ? widget.connectorThickness ?? 1.0 : 0.0, height: 16.0),
463 );
464 }
465
466 Widget _buildCircleChild(int index, bool oldState) {
467 final StepState state = oldState ? _oldStates[index]! : widget.steps[index].state;
468 if (widget.stepIconBuilder?.call(index, state) case final Widget icon) {
469 return icon;
470 }
471 TextStyle? textStyle = _stepStyle(index)?.indexStyle;
472 final bool isDarkActive = _isDark() && widget.steps[index].isActive;
473 final Color iconColor = isDarkActive ? _kCircleActiveDark : _kCircleActiveLight;
474 textStyle ??= isDarkActive ? _kStepStyle.copyWith(color: Colors.black87) : _kStepStyle;
475
476 return switch (state) {
477 StepState.indexed || StepState.disabled => Text('${index + 1}', style: textStyle),
478 StepState.editing => Icon(Icons.edit, color: iconColor, size: 18.0),
479 StepState.complete => Icon(Icons.check, color: iconColor, size: 18.0),
480 StepState.error => const Center(child: Text('!', style: _kStepStyle)),
481 };
482 }
483
484 Color _circleColor(int index) {
485 final bool isActive = widget.steps[index].isActive;
486 final ColorScheme colorScheme = Theme.of(context).colorScheme;
487 final Set<MaterialState> states = <MaterialState>{
488 if (isActive) MaterialState.selected else MaterialState.disabled,
489 };
490 final Color? resolvedConnectorColor = widget.connectorColor?.resolve(states);
491 if (resolvedConnectorColor != null) {
492 return resolvedConnectorColor;
493 }
494 if (!_isDark()) {
495 return isActive ? colorScheme.primary : colorScheme.onSurface.withOpacity(0.38);
496 } else {
497 return isActive ? colorScheme.secondary : colorScheme.background;
498 }
499 }
500
501 Widget _buildCircle(int index, bool oldState) {
502 return Padding(
503 padding: _stepIconMargin ?? const EdgeInsets.symmetric(vertical: 8.0),
504 child: SizedBox(
505 width: _stepIconWidth ?? _kStepSize,
506 height: _stepIconHeight ?? _kStepSize,
507 child: AnimatedContainer(
508 curve: Curves.fastOutSlowIn,
509 duration: kThemeAnimationDuration,
510 decoration: BoxDecoration(
511 color: _stepStyle(index)?.color ?? _circleColor(index),
512 shape: BoxShape.circle,
513 border: _stepStyle(index)?.border,
514 boxShadow: _stepStyle(index)?.boxShadow != null
515 ? <BoxShadow>[_stepStyle(index)!.boxShadow!]
516 : null,
517 gradient: _stepStyle(index)?.gradient,
518 ),
519 child: Center(
520 child: _buildCircleChild(
521 index,
522 oldState && widget.steps[index].state == StepState.error,
523 ),
524 ),
525 ),
526 ),
527 );
528 }
529
530 Widget _buildTriangle(int index, bool oldState) {
531 Color? color = _stepStyle(index)?.errorColor;
532 color ??= _isDark() ? _kErrorDark : _kErrorLight;
533
534 return Padding(
535 padding: _stepIconMargin ?? const EdgeInsets.symmetric(vertical: 8.0),
536 child: SizedBox(
537 width: _stepIconWidth ?? _kStepSize,
538 height: _stepIconHeight ?? _kStepSize,
539 child: Center(
540 child: SizedBox(
541 width: _stepIconWidth ?? _kStepSize,
542 height: _stepIconHeight != null ? _stepIconHeight! * _kTriangleSqrt : _kTriangleHeight,
543 child: CustomPaint(
544 painter: _TrianglePainter(color: color),
545 child: Align(
546 alignment: const Alignment(0.0, 0.8), // 0.8 looks better than the geometrical 0.33.
547 child: _buildCircleChild(
548 index,
549 oldState && widget.steps[index].state != StepState.error,
550 ),
551 ),
552 ),
553 ),
554 ),
555 ),
556 );
557 }
558
559 Widget _buildIcon(int index) {
560 if (widget.steps[index].state != _oldStates[index]) {
561 return AnimatedCrossFade(
562 firstChild: _buildCircle(index, true),
563 secondChild: _buildTriangle(index, true),
564 firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
565 secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
566 sizeCurve: Curves.fastOutSlowIn,
567 crossFadeState: widget.steps[index].state == StepState.error
568 ? CrossFadeState.showSecond
569 : CrossFadeState.showFirst,
570 duration: kThemeAnimationDuration,
571 );
572 } else {
573 if (widget.steps[index].state != StepState.error) {
574 return _buildCircle(index, false);
575 } else {
576 return _buildTriangle(index, false);
577 }
578 }
579 }
580
581 Widget _buildVerticalControls(int stepIndex) {
582 if (widget.controlsBuilder != null) {
583 return widget.controlsBuilder!(
584 context,
585 ControlsDetails(
586 currentStep: widget.currentStep,
587 onStepContinue: widget.onStepContinue,
588 onStepCancel: widget.onStepCancel,
589 stepIndex: stepIndex,
590 ),
591 );
592 }
593
594 final Color cancelColor = switch (Theme.brightnessOf(context)) {
595 Brightness.light => Colors.black54,
596 Brightness.dark => Colors.white70,
597 };
598
599 final ThemeData themeData = Theme.of(context);
600 final ColorScheme colorScheme = themeData.colorScheme;
601 final MaterialLocalizations localizations = MaterialLocalizations.of(context);
602
603 const OutlinedBorder buttonShape = RoundedRectangleBorder(
604 borderRadius: BorderRadius.all(Radius.circular(2)),
605 );
606 const EdgeInsets buttonPadding = EdgeInsets.symmetric(horizontal: 16.0);
607
608 return Padding(
609 padding: const EdgeInsets.only(top: 16.0),
610 child: SizedBox(
611 height: 48.0,
612 child: Row(
613 // The Material spec no longer includes a Stepper widget. The continue
614 // and cancel button styles have been configured to match the original
615 // version of this widget.
616 children: <Widget>[
617 TextButton(
618 onPressed: widget.onStepContinue,
619 style: ButtonStyle(
620 foregroundColor: MaterialStateProperty.resolveWith<Color?>((
621 Set<MaterialState> states,
622 ) {
623 return states.contains(MaterialState.disabled)
624 ? null
625 : (_isDark() ? colorScheme.onSurface : colorScheme.onPrimary);
626 }),
627 backgroundColor: MaterialStateProperty.resolveWith<Color?>((
628 Set<MaterialState> states,
629 ) {
630 return _isDark() || states.contains(MaterialState.disabled)
631 ? null
632 : colorScheme.primary;
633 }),
634 padding: const MaterialStatePropertyAll<EdgeInsetsGeometry>(buttonPadding),
635 shape: const MaterialStatePropertyAll<OutlinedBorder>(buttonShape),
636 ),
637 child: Text(
638 themeData.useMaterial3
639 ? localizations.continueButtonLabel
640 : localizations.continueButtonLabel.toUpperCase(),
641 ),
642 ),
643 Padding(
644 padding: const EdgeInsetsDirectional.only(start: 8.0),
645 child: TextButton(
646 onPressed: widget.onStepCancel,
647 style: TextButton.styleFrom(
648 foregroundColor: cancelColor,
649 padding: buttonPadding,
650 shape: buttonShape,
651 ),
652 child: Text(
653 themeData.useMaterial3
654 ? localizations.cancelButtonLabel
655 : localizations.cancelButtonLabel.toUpperCase(),
656 ),
657 ),
658 ),
659 ],
660 ),
661 ),
662 );
663 }
664
665 TextStyle _titleStyle(int index) {
666 final ThemeData themeData = Theme.of(context);
667 final TextTheme textTheme = themeData.textTheme;
668
669 switch (widget.steps[index].state) {
670 case StepState.indexed:
671 case StepState.editing:
672 case StepState.complete:
673 return textTheme.bodyLarge!;
674 case StepState.disabled:
675 return textTheme.bodyLarge!.copyWith(color: _isDark() ? _kDisabledDark : _kDisabledLight);
676 case StepState.error:
677 return textTheme.bodyLarge!.copyWith(color: _isDark() ? _kErrorDark : _kErrorLight);
678 }
679 }
680
681 TextStyle _subtitleStyle(int index) {
682 final ThemeData themeData = Theme.of(context);
683 final TextTheme textTheme = themeData.textTheme;
684
685 switch (widget.steps[index].state) {
686 case StepState.indexed:
687 case StepState.editing:
688 case StepState.complete:
689 return textTheme.bodySmall!;
690 case StepState.disabled:
691 return textTheme.bodySmall!.copyWith(color: _isDark() ? _kDisabledDark : _kDisabledLight);
692 case StepState.error:
693 return textTheme.bodySmall!.copyWith(color: _isDark() ? _kErrorDark : _kErrorLight);
694 }
695 }
696
697 TextStyle _labelStyle(int index) {
698 final ThemeData themeData = Theme.of(context);
699 final TextTheme textTheme = themeData.textTheme;
700
701 switch (widget.steps[index].state) {
702 case StepState.indexed:
703 case StepState.editing:
704 case StepState.complete:
705 return textTheme.bodyLarge!;
706 case StepState.disabled:
707 return textTheme.bodyLarge!.copyWith(color: _isDark() ? _kDisabledDark : _kDisabledLight);
708 case StepState.error:
709 return textTheme.bodyLarge!.copyWith(color: _isDark() ? _kErrorDark : _kErrorLight);
710 }
711 }
712
713 Widget _buildHeaderText(int index) {
714 return Column(
715 crossAxisAlignment: CrossAxisAlignment.start,
716 mainAxisSize: MainAxisSize.min,
717 children: <Widget>[
718 AnimatedDefaultTextStyle(
719 style: _titleStyle(index),
720 duration: kThemeAnimationDuration,
721 curve: Curves.fastOutSlowIn,
722 child: widget.steps[index].title,
723 ),
724 if (widget.steps[index].subtitle != null)
725 Padding(
726 padding: const EdgeInsets.only(top: 2.0),
727 child: AnimatedDefaultTextStyle(
728 style: _subtitleStyle(index),
729 duration: kThemeAnimationDuration,
730 curve: Curves.fastOutSlowIn,
731 child: widget.steps[index].subtitle!,
732 ),
733 ),
734 ],
735 );
736 }
737
738 Widget _buildLabelText(int index) {
739 if (widget.steps[index].label != null) {
740 return AnimatedDefaultTextStyle(
741 style: _labelStyle(index),
742 duration: kThemeAnimationDuration,
743 child: widget.steps[index].label!,
744 );
745 }
746 return const SizedBox.shrink();
747 }
748
749 Widget _buildVerticalHeader(int index) {
750 final bool isActive = widget.steps[index].isActive;
751 return Padding(
752 padding: const EdgeInsets.symmetric(horizontal: 24.0),
753 child: Row(
754 children: <Widget>[
755 Column(
756 children: <Widget>[
757 // Line parts are always added in order for the ink splash to
758 // flood the tips of the connector lines.
759 _buildLine(!_isFirst(index), isActive),
760 _buildIcon(index),
761 _buildLine(!_isLast(index), isActive),
762 ],
763 ),
764 Expanded(
765 child: Padding(
766 padding: const EdgeInsetsDirectional.only(start: 12.0),
767 child: _buildHeaderText(index),
768 ),
769 ),
770 ],
771 ),
772 );
773 }
774
775 Widget _buildVerticalBody(int index) {
776 final double? marginLeft = _stepIconMargin?.resolve(TextDirection.ltr).left;
777 final double? marginRight = _stepIconMargin?.resolve(TextDirection.ltr).right;
778 final double? additionalMarginLeft = marginLeft != null ? marginLeft / 2.0 : null;
779 final double? additionalMarginRight = marginRight != null ? marginRight / 2.0 : null;
780
781 return Stack(
782 children: <Widget>[
783 PositionedDirectional(
784 // When use margin affects the left or right side of the child, we
785 // need to add half of the margin to the start or end of the child
786 // respectively to get the correct positioning.
787 start: 24.0 + (additionalMarginLeft ?? 0.0) + (additionalMarginRight ?? 0.0),
788 top: 0.0,
789 bottom: 0.0,
790 width: _stepIconWidth ?? _kStepSize,
791 child: Center(
792 // The line is drawn from the center of the circle vertically until
793 // it reaches the bottom and then horizontally to the edge of the
794 // stepper.
795 child: SizedBox(
796 width: !_isLast(index) ? (widget.connectorThickness ?? 1.0) : 0.0,
797 height: double.infinity,
798 child: ColoredBox(color: _connectorColor(widget.steps[index].isActive)),
799 ),
800 ),
801 ),
802 AnimatedCrossFade(
803 firstChild: const SizedBox(width: double.infinity, height: 0),
804 secondChild: Padding(
805 padding: EdgeInsetsDirectional.only(
806 // Adjust [controlsBuilder] padding so that the content is
807 // centered vertically.
808 start: 60.0 + (marginLeft ?? 0.0),
809 end: 24.0,
810 bottom: 24.0,
811 ),
812 child: Column(
813 children: <Widget>[
814 ClipRect(clipBehavior: widget.clipBehavior, child: widget.steps[index].content),
815 _buildVerticalControls(index),
816 ],
817 ),
818 ),
819 firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
820 secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
821 sizeCurve: Curves.fastOutSlowIn,
822 crossFadeState: _isCurrent(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
823 duration: kThemeAnimationDuration,
824 ),
825 ],
826 );
827 }
828
829 Widget _buildVertical() {
830 return ListView(
831 controller: widget.controller,
832 shrinkWrap: true,
833 physics: widget.physics,
834 children: <Widget>[
835 for (int i = 0; i < widget.steps.length; i += 1)
836 Column(
837 key: _keys[i],
838 children: <Widget>[
839 InkWell(
840 onTap: widget.steps[i].state != StepState.disabled
841 ? () {
842 // In the vertical case we need to scroll to the newly tapped
843 // step.
844 Scrollable.ensureVisible(
845 _keys[i].currentContext!,
846 curve: Curves.fastOutSlowIn,
847 duration: kThemeAnimationDuration,
848 );
849
850 widget.onStepTapped?.call(i);
851 }
852 : null,
853 canRequestFocus: widget.steps[i].state != StepState.disabled,
854 child: _buildVerticalHeader(i),
855 ),
856 _buildVerticalBody(i),
857 ],
858 ),
859 ],
860 );
861 }
862
863 Widget _buildHorizontal() {
864 final List<Widget> children = <Widget>[
865 for (int i = 0; i < widget.steps.length; i += 1) ...<Widget>[
866 InkResponse(
867 onTap: widget.steps[i].state != StepState.disabled
868 ? () {
869 widget.onStepTapped?.call(i);
870 }
871 : null,
872 canRequestFocus: widget.steps[i].state != StepState.disabled,
873 child: Row(
874 children: <Widget>[
875 SizedBox(
876 height: _isLabel() ? 104.0 : 72.0,
877 child: Column(
878 mainAxisAlignment: MainAxisAlignment.center,
879 children: <Widget>[
880 if (widget.steps[i].label != null) const SizedBox(height: 24.0),
881 Center(child: _buildIcon(i)),
882 if (widget.steps[i].label != null)
883 SizedBox(height: 24.0, child: _buildLabelText(i)),
884 ],
885 ),
886 ),
887 Padding(
888 padding: _stepIconMargin ?? const EdgeInsetsDirectional.only(start: 12.0),
889 child: _buildHeaderText(i),
890 ),
891 ],
892 ),
893 ),
894 if (!_isLast(i))
895 Expanded(
896 child: Padding(
897 padding: _stepIconMargin ?? const EdgeInsets.symmetric(horizontal: 8.0),
898 child: SizedBox(
899 height:
900 widget.steps[i].stepStyle?.connectorThickness ??
901 widget.connectorThickness ??
902 1.0,
903 child: ColoredBox(
904 color:
905 widget.steps[i].stepStyle?.connectorColor ??
906 _connectorColor(widget.steps[i].isActive),
907 ),
908 ),
909 ),
910 ),
911 ],
912 ];
913
914 final List<Widget> stepPanels = <Widget>[];
915 for (int i = 0; i < widget.steps.length; i += 1) {
916 stepPanels.add(
917 Visibility(
918 maintainState: true,
919 visible: i == widget.currentStep,
920 child: ClipRect(clipBehavior: widget.clipBehavior, child: widget.steps[i].content),
921 ),
922 );
923 }
924
925 return Column(
926 children: <Widget>[
927 Material(
928 elevation: widget.elevation ?? 2,
929 child: Padding(
930 padding: const EdgeInsets.symmetric(horizontal: 24.0),
931 child: SizedBox(
932 height: _stepIconHeight != null ? _stepIconHeight! * _heightFactor : null,
933 child: Row(children: children),
934 ),
935 ),
936 ),
937 Expanded(
938 child: ListView(
939 controller: widget.controller,
940 physics: widget.physics,
941 padding: const EdgeInsets.all(24.0),
942 children: <Widget>[
943 AnimatedSize(
944 curve: Curves.fastOutSlowIn,
945 duration: kThemeAnimationDuration,
946 child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: stepPanels),
947 ),
948 _buildVerticalControls(widget.currentStep),
949 ],
950 ),
951 ),
952 ],
953 );
954 }
955
956 @override
957 Widget build(BuildContext context) {
958 assert(debugCheckHasMaterial(context));
959 assert(debugCheckHasMaterialLocalizations(context));
960 assert(() {
961 if (context.findAncestorWidgetOfExactType<Stepper>() != null) {
962 throw FlutterError(
963 'Steppers must not be nested.\n'
964 'The material specification advises that one should avoid embedding '
965 'steppers within steppers. '
966 'https://material.io/archive/guidelines/components/steppers.html#steppers-usage',
967 );
968 }
969 return true;
970 }());
971 return switch (widget.type) {
972 StepperType.vertical => _buildVertical(),
973 StepperType.horizontal => _buildHorizontal(),
974 };
975 }
976}
977
978// Paints a triangle whose base is the bottom of the bounding rectangle and its
979// top vertex the middle of its top.
980class _TrianglePainter extends CustomPainter {
981 _TrianglePainter({required this.color});
982
983 final Color color;
984
985 @override
986 bool hitTest(Offset point) => true; // Hitting the rectangle is fine enough.
987
988 @override
989 bool shouldRepaint(_TrianglePainter oldPainter) {
990 return oldPainter.color != color;
991 }
992
993 @override
994 void paint(Canvas canvas, Size size) {
995 final double base = size.width;
996 final double halfBase = size.width / 2.0;
997 final double height = size.height;
998 final List<Offset> points = <Offset>[
999 Offset(0.0, height),
1000 Offset(base, height),
1001 Offset(halfBase, 0.0),
1002 ];
1003
1004 canvas.drawPath(Path()..addPolygon(points, true), Paint()..color = color);
1005 }
1006}
1007
1008/// This class is used to override the default visual properties of [Step] widgets within a [Stepper].
1009///
1010/// To customize the appearance of a [Step] create an instance of this class with non-null parameters
1011/// for the step properties whose default value you want to override.
1012///
1013/// Example usage:
1014/// ```dart
1015/// Step(
1016/// title: const Text('Step 1'),
1017/// content: const Text('Content for Step 1'),
1018/// stepStyle: StepStyle(
1019/// color: Colors.blue,
1020/// errorColor: Colors.red,
1021/// border: Border.all(color: Colors.grey),
1022/// boxShadow: const BoxShadow(blurRadius: 3.0, color: Colors.black26),
1023/// gradient: const LinearGradient(colors: <Color>[Colors.red, Colors.blue]),
1024/// indexStyle: const TextStyle(color: Colors.white),
1025/// ),
1026/// )
1027/// ```
1028///
1029/// {@tool dartpad}
1030/// An example that uses [StepStyle] to customize the appearance of each [Step] in a [Stepper].
1031///
1032/// ** See code in examples/api/lib/material/stepper/step_style.0.dart **
1033/// {@end-tool}
1034
1035@immutable
1036class StepStyle with Diagnosticable {
1037 /// Constructs a [StepStyle].
1038 const StepStyle({
1039 this.color,
1040 this.errorColor,
1041 this.connectorColor,
1042 this.connectorThickness,
1043 this.border,
1044 this.boxShadow,
1045 this.gradient,
1046 this.indexStyle,
1047 });
1048
1049 /// Overrides the default color of the circle in the step.
1050 final Color? color;
1051
1052 /// Overrides the default color of the error indicator in the step.
1053 final Color? errorColor;
1054
1055 /// Overrides the default color of the connector line between two steps.
1056 ///
1057 /// This property only applies when [Stepper.type] is [StepperType.horizontal].
1058 final Color? connectorColor;
1059
1060 /// Overrides the default thickness of the connector line between two steps.
1061 ///
1062 /// This property only applies when [Stepper.type] is [StepperType.horizontal].
1063 final double? connectorThickness;
1064
1065 /// Add a border around the step.
1066 ///
1067 /// Will be applied to the circle in the step.
1068 final BoxBorder? border;
1069
1070 /// Add a shadow around the step.
1071 final BoxShadow? boxShadow;
1072
1073 /// Add a gradient around the step.
1074 ///
1075 /// If [gradient] is specified, [color] will be ignored.
1076 final Gradient? gradient;
1077
1078 /// Overrides the default style of the index in the step.
1079 final TextStyle? indexStyle;
1080
1081 /// Returns a copy of this ButtonStyle with the given fields replaced with
1082 /// the new values.
1083 StepStyle copyWith({
1084 Color? color,
1085 Color? errorColor,
1086 Color? connectorColor,
1087 double? connectorThickness,
1088 BoxBorder? border,
1089 BoxShadow? boxShadow,
1090 Gradient? gradient,
1091 TextStyle? indexStyle,
1092 }) {
1093 return StepStyle(
1094 color: color ?? this.color,
1095 errorColor: errorColor ?? this.errorColor,
1096 connectorColor: connectorColor ?? this.connectorColor,
1097 connectorThickness: connectorThickness ?? this.connectorThickness,
1098 border: border ?? this.border,
1099 boxShadow: boxShadow ?? this.boxShadow,
1100 gradient: gradient ?? this.gradient,
1101 indexStyle: indexStyle ?? this.indexStyle,
1102 );
1103 }
1104
1105 /// Returns a copy of this StepStyle where the non-null fields in [stepStyle]
1106 /// have replaced the corresponding null fields in this StepStyle.
1107 ///
1108 /// In other words, [stepStyle] is used to fill in unspecified (null) fields
1109 /// this StepStyle.
1110 StepStyle merge(StepStyle? stepStyle) {
1111 if (stepStyle == null) {
1112 return this;
1113 }
1114 return copyWith(
1115 color: stepStyle.color,
1116 errorColor: stepStyle.errorColor,
1117 connectorColor: stepStyle.connectorColor,
1118 connectorThickness: stepStyle.connectorThickness,
1119 border: stepStyle.border,
1120 boxShadow: stepStyle.boxShadow,
1121 gradient: stepStyle.gradient,
1122 indexStyle: stepStyle.indexStyle,
1123 );
1124 }
1125
1126 @override
1127 int get hashCode {
1128 return Object.hash(
1129 color,
1130 errorColor,
1131 connectorColor,
1132 connectorThickness,
1133 border,
1134 boxShadow,
1135 gradient,
1136 indexStyle,
1137 );
1138 }
1139
1140 @override
1141 bool operator ==(Object other) {
1142 if (identical(this, other)) {
1143 return true;
1144 }
1145 if (other.runtimeType != runtimeType) {
1146 return false;
1147 }
1148 return other is StepStyle &&
1149 other.color == color &&
1150 other.errorColor == errorColor &&
1151 other.connectorColor == connectorColor &&
1152 other.connectorThickness == connectorThickness &&
1153 other.border == border &&
1154 other.boxShadow == boxShadow &&
1155 other.gradient == gradient &&
1156 other.indexStyle == indexStyle;
1157 }
1158
1159 @override
1160 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
1161 super.debugFillProperties(properties);
1162 final ThemeData theme = ThemeData.fallback();
1163 final TextTheme defaultTextTheme = theme.textTheme;
1164 properties.add(ColorProperty('color', color, defaultValue: null));
1165 properties.add(ColorProperty('errorColor', errorColor, defaultValue: null));
1166 properties.add(ColorProperty('connectorColor', connectorColor, defaultValue: null));
1167 properties.add(DoubleProperty('connectorThickness', connectorThickness, defaultValue: null));
1168 properties.add(DiagnosticsProperty<BoxBorder>('border', border, defaultValue: null));
1169 properties.add(DiagnosticsProperty<BoxShadow>('boxShadow', boxShadow, defaultValue: null));
1170 properties.add(DiagnosticsProperty<Gradient>('gradient', gradient, defaultValue: null));
1171 properties.add(
1172 DiagnosticsProperty<TextStyle>(
1173 'indexStyle',
1174 indexStyle,
1175 defaultValue: defaultTextTheme.bodyLarge,
1176 ),
1177 );
1178 }
1179}
1180