[material_ui] expand Stepper documentation to explain why step.state is final and add example of usage - #13113
AbdeMohlbi wants to merge 1 commit into
Conversation
…nd add example of usage
Stepper documentation to explain why step.state is final and add example of usage[material_ui] expand Stepper documentation to explain why step.state is final and add example of usage
There was a problem hiding this comment.
Code Review
This pull request adds a new example application and widget test demonstrating how to manage and rebuild steps in a Stepper widget when state changes, along with updating the Stepper documentation and changelog. The review feedback suggests defining the steps list locally within the build method to avoid redundant allocations from a getter, and refactoring the widget test to simulate user interactions with tester.tap rather than invoking callbacks directly on the widget.
| List<Step> get _steps => [ | ||
| Step( | ||
| title: const Text('Create account title'), | ||
| content: ElevatedButton( | ||
| onPressed: () { | ||
| setState(() { | ||
| _accountCreated = true; | ||
| }); | ||
| }, | ||
| child: const Text('Create account button'), | ||
| ), | ||
| state: _accountCreated ? StepState.complete : StepState.indexed, | ||
| isActive: true, | ||
| ), | ||
| Step( | ||
| title: const Text('Complete profile'), | ||
| content: ElevatedButton( | ||
| onPressed: _accountCreated | ||
| ? () { | ||
| setState(() { | ||
| _profileCompleted = true; | ||
| }); | ||
| } | ||
| : null, | ||
| child: const Text('Complete profile button'), | ||
| ), | ||
| state: _profileCompleted ? StepState.complete : StepState.indexed, | ||
| isActive: _accountCreated, | ||
| ), | ||
| Step( | ||
| title: const Text('Finish setup'), | ||
| content: ElevatedButton( | ||
| onPressed: _profileCompleted | ||
| ? () { | ||
| setState(() { | ||
| _setupCompleted = true; | ||
| }); | ||
| } | ||
| : null, | ||
| child: const Text('Finish setup button'), | ||
| ), | ||
| state: _setupCompleted ? StepState.complete : StepState.indexed, | ||
| isActive: _profileCompleted, | ||
| ), | ||
| ]; | ||
|
|
||
| @override | ||
| Widget build(BuildContext context) { | ||
| return Stepper( | ||
| currentStep: _currentStep, | ||
| steps: _steps, | ||
| onStepTapped: (step) { | ||
| setState(() { | ||
| _currentStep = step; | ||
| }); | ||
| }, | ||
| onStepContinue: () { | ||
| if (_currentStep < _steps.length - 1) { | ||
| setState(() { | ||
| _currentStep++; | ||
| }); | ||
| } | ||
| }, | ||
| onStepCancel: () { | ||
| if (_currentStep > 0) { | ||
| setState(() { | ||
| _currentStep--; | ||
| }); | ||
| } | ||
| }, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Instead of using a getter _steps that allocates a new list and new Step instances every time it is accessed (which happens multiple times during build and callback execution), it is more idiomatic and efficient to define the steps list locally inside the build method.
@override
Widget build(BuildContext context) {
final List<Step> steps = [
Step(
title: const Text('Create account title'),
content: ElevatedButton(
onPressed: () {
setState(() {
_accountCreated = true;
});
},
child: const Text('Create account button'),
),
state: _accountCreated ? StepState.complete : StepState.indexed,
isActive: true,
),
Step(
title: const Text('Complete profile'),
content: ElevatedButton(
onPressed: _accountCreated
? () {
setState(() {
_profileCompleted = true;
});
}
: null,
child: const Text('Complete profile button'),
),
state: _profileCompleted ? StepState.complete : StepState.indexed,
isActive: _accountCreated,
),
Step(
title: const Text('Finish setup'),
content: ElevatedButton(
onPressed: _profileCompleted
? () {
setState(() {
_setupCompleted = true;
});
}
: null,
child: const Text('Finish setup button'),
),
state: _setupCompleted ? StepState.complete : StepState.indexed,
isActive: _profileCompleted,
),
];
return Stepper(
currentStep: _currentStep,
steps: steps,
onStepTapped: (step) {
setState(() {
_currentStep = step;
});
},
onStepContinue: () {
if (_currentStep < steps.length - 1) {
setState(() {
_currentStep++;
});
}
},
onStepCancel: () {
if (_currentStep > 0) {
setState(() {
_currentStep--;
});
}
},
);
}There was a problem hiding this comment.
so you are saying that doing final List<Step> steps each time inside build is faster ? that's wrong (no way did gemini try to gaslight me ?)
| final Stepper stepper = tester.widget<Stepper>(find.byType(Stepper)); | ||
| await tester.pumpAndSettle(); | ||
| expect(find.text('Create account title').hitTestable(), findsOneWidget); | ||
|
|
||
| // current: 0 and taps the 0th step | ||
| stepper.onStepTapped?.call(0); | ||
| await tester.pumpAndSettle(); | ||
| expect(find.text('Create account title').hitTestable(), findsOneWidget); | ||
|
|
||
| // Create the account so the next step becomes active. | ||
| await tester.tap( | ||
| find.widgetWithText(ElevatedButton, 'Create account button'), | ||
| ); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| // current: 0 and clicks continue | ||
| stepper.onStepContinue?.call(); | ||
| await tester.pumpAndSettle(); | ||
| expect(find.text('Complete profile').hitTestable(), findsOneWidget); | ||
| expect(find.text('Complete profile button').hitTestable(), findsOneWidget); | ||
|
|
||
| // current: 1 and taps the 1st step | ||
| stepper.onStepTapped?.call(1); | ||
| await tester.pumpAndSettle(); | ||
| expect(find.text('Complete profile').hitTestable(), findsOneWidget); | ||
|
|
||
| // Complete the profile so the next step becomes active. | ||
| await tester.tap( | ||
| find.widgetWithText(ElevatedButton, 'Complete profile button'), | ||
| ); | ||
| await tester.pumpAndSettle(); | ||
|
|
||
| // current: 1 and clicks continue | ||
| stepper.onStepContinue?.call(); | ||
| await tester.pumpAndSettle(); | ||
| expect(find.text('Finish setup').hitTestable(), findsOneWidget); | ||
| expect(find.text('Finish setup button').hitTestable(), findsOneWidget); | ||
|
|
||
| // current: 2 and clicks cancel | ||
| stepper.onStepCancel?.call(); | ||
| await tester.pumpAndSettle(); | ||
| expect(find.text('Complete profile').hitTestable(), findsOneWidget); | ||
|
|
||
| // current: 1 and taps the 0th step | ||
| stepper.onStepTapped?.call(0); | ||
| await tester.pumpAndSettle(); | ||
| expect(find.text('Create account title').hitTestable(), findsOneWidget); | ||
|
|
||
| // current: 0 and taps the 2nd step | ||
| stepper.onStepTapped?.call(2); | ||
| await tester.pumpAndSettle(); | ||
| expect(find.text('Finish setup').hitTestable(), findsOneWidget); |
There was a problem hiding this comment.
Invoking callbacks directly on the widget retrieved via tester.widget is an anti-pattern in Flutter widget tests. It bypasses hit-testing, gesture detection, and disabled state checks (e.g., a disabled step header should not trigger onStepTapped when tapped, but calling the callback directly will still execute it). Instead, simulate actual user interactions by using tester.tap on the corresponding UI elements (such as the step titles, 'Continue', and 'Cancel' buttons). This also allows you to completely remove the stepper widget reference.
await tester.pumpAndSettle();
expect(find.text('Create account title').hitTestable(), findsOneWidget);
// current: 0 and taps the 0th step
await tester.tap(find.text('Create account title'));
await tester.pumpAndSettle();
expect(find.text('Create account title').hitTestable(), findsOneWidget);
// Create the account so the next step becomes active.
await tester.tap(
find.widgetWithText(ElevatedButton, 'Create account button'),
);
await tester.pumpAndSettle();
// current: 0 and clicks continue
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(find.text('Complete profile').hitTestable(), findsOneWidget);
expect(find.text('Complete profile button').hitTestable(), findsOneWidget);
// current: 1 and taps the 1st step
await tester.tap(find.text('Complete profile'));
await tester.pumpAndSettle();
expect(find.text('Complete profile').hitTestable(), findsOneWidget);
// Complete the profile so the next step becomes active.
await tester.tap(
find.widgetWithText(ElevatedButton, 'Complete profile button'),
);
await tester.pumpAndSettle();
// current: 1 and clicks continue
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(find.text('Finish setup').hitTestable(), findsOneWidget);
expect(find.text('Finish setup button').hitTestable(), findsOneWidget);
// current: 2 and clicks cancel
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('Complete profile').hitTestable(), findsOneWidget);
// current: 1 and taps the 0th step
await tester.tap(find.text('Create account title'));
await tester.pumpAndSettle();
expect(find.text('Create account title').hitTestable(), findsOneWidget);
// current: 0 and taps the 2nd step
await tester.tap(find.text('Finish setup'));
await tester.pumpAndSettle();
expect(find.text('Finish setup').hitTestable(), findsOneWidget);There was a problem hiding this comment.
that's a good point i will update it accordingly
Fixes #18303
Pre-Review Checklist
[shared_preferences]///).If you need help, consider asking for advice on the #hackers-new channel on Discord.
Note: The Flutter team is currently trialing the use of Gemini Code Assist for GitHub. Comments from the
gemini-code-assistbot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed.Footnotes
Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. ↩ ↩2