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 | |
5 | import 'package:flutter/foundation.dart'; |
6 | import 'package:flutter/widgets.dart'; |
7 | |
8 | import 'button_style.dart'; |
9 | import 'color_scheme.dart'; |
10 | import 'colors.dart'; |
11 | import 'debug.dart'; |
12 | import 'icons.dart'; |
13 | import 'ink_well.dart'; |
14 | import 'material.dart'; |
15 | import 'material_localizations.dart'; |
16 | import 'material_state.dart'; |
17 | import 'text_button.dart'; |
18 | import 'text_theme.dart'; |
19 | import '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] |
32 | enum 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. |
51 | enum 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 |
64 | class 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]. |
109 | typedef ControlsWidgetBuilder = Widget Function(BuildContext context, ControlsDetails details); |
110 | |
111 | /// A builder that creates the icon widget for the [Step] at [stepIndex], given |
112 | /// [stepState]. |
113 | typedef StepIconBuilder = Widget? Function(int stepIndex, StepState stepState); |
114 | |
115 | const TextStyle _kStepStyle = TextStyle(fontSize: 12.0, color: Colors.white); |
116 | const Color _kErrorLight = Colors.red; |
117 | final Color _kErrorDark = Colors.red.shade400; |
118 | const Color _kCircleActiveLight = Colors.white; |
119 | const Color _kCircleActiveDark = Colors.black87; |
120 | const Color _kDisabledLight = Colors.black38; |
121 | const Color _kDisabledDark = Colors.white38; |
122 | const double _kStepSize = 24.0; |
123 | const double _kTriangleSqrt = 0.866025; // sqrt(3.0) / 2.0 |
124 | const double _kTriangleHeight = _kStepSize * _kTriangleSqrt; |
125 | const 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 |
136 | class 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> |
197 | class 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 | |
386 | class _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. |
985 | class _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 |
1041 | class 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 |
Definitions
- StepState
- StepperType
- ControlsDetails
- ControlsDetails
- isActive
- _kStepStyle
- _kErrorLight
- _kErrorDark
- _kCircleActiveLight
- _kCircleActiveDark
- _kDisabledLight
- _kDisabledDark
- _kStepSize
- _kTriangleSqrt
- _kTriangleHeight
- _kMaxStepSize
- Step
- Step
- Stepper
- Stepper
- createState
- _StepperState
- initState
- didUpdateWidget
- _stepIconMargin
- _stepIconHeight
- _stepIconWidth
- _heightFactor
- _isFirst
- _isLast
- _isCurrent
- _isDark
- _isLabel
- _stepStyle
- _connectorColor
- _buildLine
- _buildCircleChild
- _circleColor
- _buildCircle
- _buildTriangle
- _buildIcon
- _buildVerticalControls
- _titleStyle
- _subtitleStyle
- _labelStyle
- _buildHeaderText
- _buildLabelText
- _buildVerticalHeader
- _buildVerticalBody
- _buildVertical
- _buildHorizontal
- build
- _TrianglePainter
- _TrianglePainter
- hitTest
- shouldRepaint
- paint
- StepStyle
- StepStyle
- copyWith
- merge
- hashCode
- ==
Learn more about Flutter for embedded and desktop on industrialflutter.com