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:
515 _stepStyle(index)?.boxShadow != null
516 ? <BoxShadow>[_stepStyle(index)!.boxShadow!]
517 : null,
518 gradient: _stepStyle(index)?.gradient,
519 ),
520 child: Center(
521 child: _buildCircleChild(
522 index,
523 oldState && widget.steps[index].state == StepState.error,
524 ),
525 ),
526 ),
527 ),
528 );
529 }
530
531 Widget _buildTriangle(int index, bool oldState) {
532 Color? color = _stepStyle(index)?.errorColor;
533 color ??= _isDark() ? _kErrorDark : _kErrorLight;
534
535 return Padding(
536 padding: _stepIconMargin ?? const EdgeInsets.symmetric(vertical: 8.0),
537 child: SizedBox(
538 width: _stepIconWidth ?? _kStepSize,
539 height: _stepIconHeight ?? _kStepSize,
540 child: Center(
541 child: SizedBox(
542 width: _stepIconWidth ?? _kStepSize,
543 height: _stepIconHeight != null ? _stepIconHeight! * _kTriangleSqrt : _kTriangleHeight,
544 child: CustomPaint(
545 painter: _TrianglePainter(color: color),
546 child: Align(
547 alignment: const Alignment(0.0, 0.8), // 0.8 looks better than the geometrical 0.33.
548 child: _buildCircleChild(
549 index,
550 oldState && widget.steps[index].state != StepState.error,
551 ),
552 ),
553 ),
554 ),
555 ),
556 ),
557 );
558 }
559
560 Widget _buildIcon(int index) {
561 if (widget.steps[index].state != _oldStates[index]) {
562 return AnimatedCrossFade(
563 firstChild: _buildCircle(index, true),
564 secondChild: _buildTriangle(index, true),
565 firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
566 secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
567 sizeCurve: Curves.fastOutSlowIn,
568 crossFadeState:
569 widget.steps[index].state == StepState.error
570 ? CrossFadeState.showSecond
571 : CrossFadeState.showFirst,
572 duration: kThemeAnimationDuration,
573 );
574 } else {
575 if (widget.steps[index].state != StepState.error) {
576 return _buildCircle(index, false);
577 } else {
578 return _buildTriangle(index, false);
579 }
580 }
581 }
582
583 Widget _buildVerticalControls(int stepIndex) {
584 if (widget.controlsBuilder != null) {
585 return widget.controlsBuilder!(
586 context,
587 ControlsDetails(
588 currentStep: widget.currentStep,
589 onStepContinue: widget.onStepContinue,
590 onStepCancel: widget.onStepCancel,
591 stepIndex: stepIndex,
592 ),
593 );
594 }
595
596 final Color cancelColor = switch (Theme.brightnessOf(context)) {
597 Brightness.light => Colors.black54,
598 Brightness.dark => Colors.white70,
599 };
600
601 final ThemeData themeData = Theme.of(context);
602 final ColorScheme colorScheme = themeData.colorScheme;
603 final MaterialLocalizations localizations = MaterialLocalizations.of(context);
604
605 const OutlinedBorder buttonShape = RoundedRectangleBorder(
606 borderRadius: BorderRadius.all(Radius.circular(2)),
607 );
608 const EdgeInsets buttonPadding = EdgeInsets.symmetric(horizontal: 16.0);
609
610 return Padding(
611 padding: const EdgeInsets.only(top: 16.0),
612 child: SizedBox(
613 height: 48.0,
614 child: Row(
615 // The Material spec no longer includes a Stepper widget. The continue
616 // and cancel button styles have been configured to match the original
617 // version of this widget.
618 children: <Widget>[
619 TextButton(
620 onPressed: widget.onStepContinue,
621 style: ButtonStyle(
622 foregroundColor: MaterialStateProperty.resolveWith<Color?>((
623 Set<MaterialState> states,
624 ) {
625 return states.contains(MaterialState.disabled)
626 ? null
627 : (_isDark() ? colorScheme.onSurface : colorScheme.onPrimary);
628 }),
629 backgroundColor: MaterialStateProperty.resolveWith<Color?>((
630 Set<MaterialState> states,
631 ) {
632 return _isDark() || states.contains(MaterialState.disabled)
633 ? null
634 : colorScheme.primary;
635 }),
636 padding: const MaterialStatePropertyAll<EdgeInsetsGeometry>(buttonPadding),
637 shape: const MaterialStatePropertyAll<OutlinedBorder>(buttonShape),
638 ),
639 child: Text(
640 themeData.useMaterial3
641 ? localizations.continueButtonLabel
642 : localizations.continueButtonLabel.toUpperCase(),
643 ),
644 ),
645 Padding(
646 padding: const EdgeInsetsDirectional.only(start: 8.0),
647 child: TextButton(
648 onPressed: widget.onStepCancel,
649 style: TextButton.styleFrom(
650 foregroundColor: cancelColor,
651 padding: buttonPadding,
652 shape: buttonShape,
653 ),
654 child: Text(
655 themeData.useMaterial3
656 ? localizations.cancelButtonLabel
657 : localizations.cancelButtonLabel.toUpperCase(),
658 ),
659 ),
660 ),
661 ],
662 ),
663 ),
664 );
665 }
666
667 TextStyle _titleStyle(int index) {
668 final ThemeData themeData = Theme.of(context);
669 final TextTheme textTheme = themeData.textTheme;
670
671 switch (widget.steps[index].state) {
672 case StepState.indexed:
673 case StepState.editing:
674 case StepState.complete:
675 return textTheme.bodyLarge!;
676 case StepState.disabled:
677 return textTheme.bodyLarge!.copyWith(color: _isDark() ? _kDisabledDark : _kDisabledLight);
678 case StepState.error:
679 return textTheme.bodyLarge!.copyWith(color: _isDark() ? _kErrorDark : _kErrorLight);
680 }
681 }
682
683 TextStyle _subtitleStyle(int index) {
684 final ThemeData themeData = Theme.of(context);
685 final TextTheme textTheme = themeData.textTheme;
686
687 switch (widget.steps[index].state) {
688 case StepState.indexed:
689 case StepState.editing:
690 case StepState.complete:
691 return textTheme.bodySmall!;
692 case StepState.disabled:
693 return textTheme.bodySmall!.copyWith(color: _isDark() ? _kDisabledDark : _kDisabledLight);
694 case StepState.error:
695 return textTheme.bodySmall!.copyWith(color: _isDark() ? _kErrorDark : _kErrorLight);
696 }
697 }
698
699 TextStyle _labelStyle(int index) {
700 final ThemeData themeData = Theme.of(context);
701 final TextTheme textTheme = themeData.textTheme;
702
703 switch (widget.steps[index].state) {
704 case StepState.indexed:
705 case StepState.editing:
706 case StepState.complete:
707 return textTheme.bodyLarge!;
708 case StepState.disabled:
709 return textTheme.bodyLarge!.copyWith(color: _isDark() ? _kDisabledDark : _kDisabledLight);
710 case StepState.error:
711 return textTheme.bodyLarge!.copyWith(color: _isDark() ? _kErrorDark : _kErrorLight);
712 }
713 }
714
715 Widget _buildHeaderText(int index) {
716 return Column(
717 crossAxisAlignment: CrossAxisAlignment.start,
718 mainAxisSize: MainAxisSize.min,
719 children: <Widget>[
720 AnimatedDefaultTextStyle(
721 style: _titleStyle(index),
722 duration: kThemeAnimationDuration,
723 curve: Curves.fastOutSlowIn,
724 child: widget.steps[index].title,
725 ),
726 if (widget.steps[index].subtitle != null)
727 Padding(
728 padding: const EdgeInsets.only(top: 2.0),
729 child: AnimatedDefaultTextStyle(
730 style: _subtitleStyle(index),
731 duration: kThemeAnimationDuration,
732 curve: Curves.fastOutSlowIn,
733 child: widget.steps[index].subtitle!,
734 ),
735 ),
736 ],
737 );
738 }
739
740 Widget _buildLabelText(int index) {
741 if (widget.steps[index].label != null) {
742 return AnimatedDefaultTextStyle(
743 style: _labelStyle(index),
744 duration: kThemeAnimationDuration,
745 child: widget.steps[index].label!,
746 );
747 }
748 return const SizedBox.shrink();
749 }
750
751 Widget _buildVerticalHeader(int index) {
752 final bool isActive = widget.steps[index].isActive;
753 return Padding(
754 padding: const EdgeInsets.symmetric(horizontal: 24.0),
755 child: Row(
756 children: <Widget>[
757 Column(
758 children: <Widget>[
759 // Line parts are always added in order for the ink splash to
760 // flood the tips of the connector lines.
761 _buildLine(!_isFirst(index), isActive),
762 _buildIcon(index),
763 _buildLine(!_isLast(index), isActive),
764 ],
765 ),
766 Expanded(
767 child: Padding(
768 padding: const EdgeInsetsDirectional.only(start: 12.0),
769 child: _buildHeaderText(index),
770 ),
771 ),
772 ],
773 ),
774 );
775 }
776
777 Widget _buildVerticalBody(int index) {
778 final double? marginLeft = _stepIconMargin?.resolve(TextDirection.ltr).left;
779 final double? marginRight = _stepIconMargin?.resolve(TextDirection.ltr).right;
780 final double? additionalMarginLeft = marginLeft != null ? marginLeft / 2.0 : null;
781 final double? additionalMarginRight = marginRight != null ? marginRight / 2.0 : null;
782
783 return Stack(
784 children: <Widget>[
785 PositionedDirectional(
786 // When use margin affects the left or right side of the child, we
787 // need to add half of the margin to the start or end of the child
788 // respectively to get the correct positioning.
789 start: 24.0 + (additionalMarginLeft ?? 0.0) + (additionalMarginRight ?? 0.0),
790 top: 0.0,
791 bottom: 0.0,
792 width: _stepIconWidth ?? _kStepSize,
793 child: Center(
794 // The line is drawn from the center of the circle vertically until
795 // it reaches the bottom and then horizontally to the edge of the
796 // stepper.
797 child: SizedBox(
798 width: !_isLast(index) ? (widget.connectorThickness ?? 1.0) : 0.0,
799 height: double.infinity,
800 child: ColoredBox(color: _connectorColor(widget.steps[index].isActive)),
801 ),
802 ),
803 ),
804 AnimatedCrossFade(
805 firstChild: const SizedBox(width: double.infinity, height: 0),
806 secondChild: Padding(
807 padding: EdgeInsetsDirectional.only(
808 // Adjust [controlsBuilder] padding so that the content is
809 // centered vertically.
810 start: 60.0 + (marginLeft ?? 0.0),
811 end: 24.0,
812 bottom: 24.0,
813 ),
814 child: Column(
815 children: <Widget>[
816 ClipRect(clipBehavior: widget.clipBehavior, child: widget.steps[index].content),
817 _buildVerticalControls(index),
818 ],
819 ),
820 ),
821 firstCurve: const Interval(0.0, 0.6, curve: Curves.fastOutSlowIn),
822 secondCurve: const Interval(0.4, 1.0, curve: Curves.fastOutSlowIn),
823 sizeCurve: Curves.fastOutSlowIn,
824 crossFadeState: _isCurrent(index) ? CrossFadeState.showSecond : CrossFadeState.showFirst,
825 duration: kThemeAnimationDuration,
826 ),
827 ],
828 );
829 }
830
831 Widget _buildVertical() {
832 return ListView(
833 controller: widget.controller,
834 shrinkWrap: true,
835 physics: widget.physics,
836 children: <Widget>[
837 for (int i = 0; i < widget.steps.length; i += 1)
838 Column(
839 key: _keys[i],
840 children: <Widget>[
841 InkWell(
842 onTap:
843 widget.steps[i].state != StepState.disabled
844 ? () {
845 // In the vertical case we need to scroll to the newly tapped
846 // step.
847 Scrollable.ensureVisible(
848 _keys[i].currentContext!,
849 curve: Curves.fastOutSlowIn,
850 duration: kThemeAnimationDuration,
851 );
852
853 widget.onStepTapped?.call(i);
854 }
855 : null,
856 canRequestFocus: widget.steps[i].state != StepState.disabled,
857 child: _buildVerticalHeader(i),
858 ),
859 _buildVerticalBody(i),
860 ],
861 ),
862 ],
863 );
864 }
865
866 Widget _buildHorizontal() {
867 final List<Widget> children = <Widget>[
868 for (int i = 0; i < widget.steps.length; i += 1) ...<Widget>[
869 InkResponse(
870 onTap:
871 widget.steps[i].state != StepState.disabled
872 ? () {
873 widget.onStepTapped?.call(i);
874 }
875 : null,
876 canRequestFocus: widget.steps[i].state != StepState.disabled,
877 child: Row(
878 children: <Widget>[
879 SizedBox(
880 height: _isLabel() ? 104.0 : 72.0,
881 child: Column(
882 mainAxisAlignment: MainAxisAlignment.center,
883 children: <Widget>[
884 if (widget.steps[i].label != null) const SizedBox(height: 24.0),
885 Center(child: _buildIcon(i)),
886 if (widget.steps[i].label != null)
887 SizedBox(height: 24.0, child: _buildLabelText(i)),
888 ],
889 ),
890 ),
891 Padding(
892 padding: _stepIconMargin ?? const EdgeInsetsDirectional.only(start: 12.0),
893 child: _buildHeaderText(i),
894 ),
895 ],
896 ),
897 ),
898 if (!_isLast(i))
899 Expanded(
900 child: Padding(
901 key: Key('line$i'),
902 padding: _stepIconMargin ?? const EdgeInsets.symmetric(horizontal: 8.0),
903 child: SizedBox(
904 height:
905 widget.steps[i].stepStyle?.connectorThickness ??
906 widget.connectorThickness ??
907 1.0,
908 child: ColoredBox(
909 color:
910 widget.steps[i].stepStyle?.connectorColor ??
911 _connectorColor(widget.steps[i].isActive),
912 ),
913 ),
914 ),
915 ),
916 ],
917 ];
918
919 final List<Widget> stepPanels = <Widget>[];
920 for (int i = 0; i < widget.steps.length; i += 1) {
921 stepPanels.add(
922 Visibility(
923 maintainState: true,
924 visible: i == widget.currentStep,
925 child: ClipRect(clipBehavior: widget.clipBehavior, child: widget.steps[i].content),
926 ),
927 );
928 }
929
930 return Column(
931 children: <Widget>[
932 Material(
933 elevation: widget.elevation ?? 2,
934 child: Padding(
935 padding: const EdgeInsets.symmetric(horizontal: 24.0),
936 child: SizedBox(
937 height: _stepIconHeight != null ? _stepIconHeight! * _heightFactor : null,
938 child: Row(children: children),
939 ),
940 ),
941 ),
942 Expanded(
943 child: ListView(
944 controller: widget.controller,
945 physics: widget.physics,
946 padding: const EdgeInsets.all(24.0),
947 children: <Widget>[
948 AnimatedSize(
949 curve: Curves.fastOutSlowIn,
950 duration: kThemeAnimationDuration,
951 child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: stepPanels),
952 ),
953 _buildVerticalControls(widget.currentStep),
954 ],
955 ),
956 ),
957 ],
958 );
959 }
960
961 @override
962 Widget build(BuildContext context) {
963 assert(debugCheckHasMaterial(context));
964 assert(debugCheckHasMaterialLocalizations(context));
965 assert(() {
966 if (context.findAncestorWidgetOfExactType<Stepper>() != null) {
967 throw FlutterError(
968 'Steppers must not be nested.\n'
969 'The material specification advises that one should avoid embedding '
970 'steppers within steppers. '
971 'https://material.io/archive/guidelines/components/steppers.html#steppers-usage',
972 );
973 }
974 return true;
975 }());
976 return switch (widget.type) {
977 StepperType.vertical => _buildVertical(),
978 StepperType.horizontal => _buildHorizontal(),
979 };
980 }
981}
982
983// Paints a triangle whose base is the bottom of the bounding rectangle and its
984// top vertex the middle of its top.
985class _TrianglePainter extends CustomPainter {
986 _TrianglePainter({required this.color});
987
988 final Color color;
989
990 @override
991 bool hitTest(Offset point) => true; // Hitting the rectangle is fine enough.
992
993 @override
994 bool shouldRepaint(_TrianglePainter oldPainter) {
995 return oldPainter.color != color;
996 }
997
998 @override
999 void paint(Canvas canvas, Size size) {
1000 final double base = size.width;
1001 final double halfBase = size.width / 2.0;
1002 final double height = size.height;
1003 final List<Offset> points = <Offset>[
1004 Offset(0.0, height),
1005 Offset(base, height),
1006 Offset(halfBase, 0.0),
1007 ];
1008
1009 canvas.drawPath(Path()..addPolygon(points, true), Paint()..color = color);
1010 }
1011}
1012
1013/// This class is used to override the default visual properties of [Step] widgets within a [Stepper].
1014///
1015/// To customize the appearance of a [Step] create an instance of this class with non-null parameters
1016/// for the step properties whose default value you want to override.
1017///
1018/// Example usage:
1019/// ```dart
1020/// Step(
1021/// title: const Text('Step 1'),
1022/// content: const Text('Content for Step 1'),
1023/// stepStyle: StepStyle(
1024/// color: Colors.blue,
1025/// errorColor: Colors.red,
1026/// border: Border.all(color: Colors.grey),
1027/// boxShadow: const BoxShadow(blurRadius: 3.0, color: Colors.black26),
1028/// gradient: const LinearGradient(colors: <Color>[Colors.red, Colors.blue]),
1029/// indexStyle: const TextStyle(color: Colors.white),
1030/// ),
1031/// )
1032/// ```
1033///
1034/// {@tool dartpad}
1035/// An example that uses [StepStyle] to customize the appearance of each [Step] in a [Stepper].
1036///
1037/// ** See code in examples/api/lib/material/stepper/step_style.0.dart **
1038/// {@end-tool}
1039
1040@immutable
1041class StepStyle with Diagnosticable {
1042 /// Constructs a [StepStyle].
1043 const StepStyle({
1044 this.color,
1045 this.errorColor,
1046 this.connectorColor,
1047 this.connectorThickness,
1048 this.border,
1049 this.boxShadow,
1050 this.gradient,
1051 this.indexStyle,
1052 });
1053
1054 /// Overrides the default color of the circle in the step.
1055 final Color? color;
1056
1057 /// Overrides the default color of the error indicator in the step.
1058 final Color? errorColor;
1059
1060 /// Overrides the default color of the connector line between two steps.
1061 ///
1062 /// This property only applies when [Stepper.type] is [StepperType.horizontal].
1063 final Color? connectorColor;
1064
1065 /// Overrides the default thickness of the connector line between two steps.
1066 ///
1067 /// This property only applies when [Stepper.type] is [StepperType.horizontal].
1068 final double? connectorThickness;
1069
1070 /// Add a border around the step.
1071 ///
1072 /// Will be applied to the circle in the step.
1073 final BoxBorder? border;
1074
1075 /// Add a shadow around the step.
1076 final BoxShadow? boxShadow;
1077
1078 /// Add a gradient around the step.
1079 ///
1080 /// If [gradient] is specified, [color] will be ignored.
1081 final Gradient? gradient;
1082
1083 /// Overrides the default style of the index in the step.
1084 final TextStyle? indexStyle;
1085
1086 /// Returns a copy of this ButtonStyle with the given fields replaced with
1087 /// the new values.
1088 StepStyle copyWith({
1089 Color? color,
1090 Color? errorColor,
1091 Color? connectorColor,
1092 double? connectorThickness,
1093 BoxBorder? border,
1094 BoxShadow? boxShadow,
1095 Gradient? gradient,
1096 TextStyle? indexStyle,
1097 }) {
1098 return StepStyle(
1099 color: color ?? this.color,
1100 errorColor: errorColor ?? this.errorColor,
1101 connectorColor: connectorColor ?? this.connectorColor,
1102 connectorThickness: connectorThickness ?? this.connectorThickness,
1103 border: border ?? this.border,
1104 boxShadow: boxShadow ?? this.boxShadow,
1105 gradient: gradient ?? this.gradient,
1106 indexStyle: indexStyle ?? this.indexStyle,
1107 );
1108 }
1109
1110 /// Returns a copy of this StepStyle where the non-null fields in [stepStyle]
1111 /// have replaced the corresponding null fields in this StepStyle.
1112 ///
1113 /// In other words, [stepStyle] is used to fill in unspecified (null) fields
1114 /// this StepStyle.
1115 StepStyle merge(StepStyle? stepStyle) {
1116 if (stepStyle == null) {
1117 return this;
1118 }
1119 return copyWith(
1120 color: stepStyle.color,
1121 errorColor: stepStyle.errorColor,
1122 connectorColor: stepStyle.connectorColor,
1123 connectorThickness: stepStyle.connectorThickness,
1124 border: stepStyle.border,
1125 boxShadow: stepStyle.boxShadow,
1126 gradient: stepStyle.gradient,
1127 indexStyle: stepStyle.indexStyle,
1128 );
1129 }
1130
1131 @override
1132 int get hashCode {
1133 return Object.hash(
1134 color,
1135 errorColor,
1136 connectorColor,
1137 connectorThickness,
1138 border,
1139 boxShadow,
1140 gradient,
1141 indexStyle,
1142 );
1143 }
1144
1145 @override
1146 bool operator ==(Object other) {
1147 if (identical(this, other)) {
1148 return true;
1149 }
1150 if (other.runtimeType != runtimeType) {
1151 return false;
1152 }
1153 return other is StepStyle &&
1154 other.color == color &&
1155 other.errorColor == errorColor &&
1156 other.connectorColor == connectorColor &&
1157 other.connectorThickness == connectorThickness &&
1158 other.border == border &&
1159 other.boxShadow == boxShadow &&
1160 other.gradient == gradient &&
1161 other.indexStyle == indexStyle;
1162 }
1163
1164 @override
1165 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
1166 super.debugFillProperties(properties);
1167 final ThemeData theme = ThemeData.fallback();
1168 final TextTheme defaultTextTheme = theme.textTheme;
1169 properties.add(ColorProperty('color', color, defaultValue: null));
1170 properties.add(ColorProperty('errorColor', errorColor, defaultValue: null));
1171 properties.add(ColorProperty('connectorColor', connectorColor, defaultValue: null));
1172 properties.add(DoubleProperty('connectorThickness', connectorThickness, defaultValue: null));
1173 properties.add(DiagnosticsProperty<BoxBorder>('border', border, defaultValue: null));
1174 properties.add(DiagnosticsProperty<BoxShadow>('boxShadow', boxShadow, defaultValue: null));
1175 properties.add(DiagnosticsProperty<Gradient>('gradient', gradient, defaultValue: null));
1176 properties.add(
1177 DiagnosticsProperty<TextStyle>(
1178 'indexStyle',
1179 indexStyle,
1180 defaultValue: defaultTextTheme.bodyLarge,
1181 ),
1182 );
1183 }
1184}
1185

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com