Enhance the Stepper widget by adding customizable header and content padding (#180257)

Enhance the Stepper widget by adding customizable header and content
padding properties. Default values are set for both paddings to maintain
the existing layout behavior.

<!--
Thanks for filing a pull request!
Reviewers are typically assigned within a week of filing a request.
To learn more about code review, see our documentation on Tree Hygiene:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
-->

*Replace this paragraph with a description of what this PR is changing
or adding, and why. Consider including before/after screenshots.*

*List which issues are fixed by this PR. You must list at least one
issue. An issue is not required if the PR fixes something trivial like a
typo.*

*If you had to change anything in the [flutter/tests] repo, include a
link to the migration guide as per the [breaking change policy].*

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

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](https://developers.google.com/gemini-code-assist/docs/review-github-code).
Comments from the `gemini-code-assist` bot 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.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

---------

Co-authored-by: Tong Mu <dkwingsmt@users.noreply.github.com>
This commit is contained in:
Safwan Mamji 2026-02-12 00:49:55 +05:00 committed by GitHub
parent d0003a62be
commit 656f49ddba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 298 additions and 10 deletions

View File

@ -123,6 +123,13 @@ const double _kStepSize = 24.0;
const double _kTriangleSqrt = 0.866025; // sqrt(3.0) / 2.0
const double _kTriangleHeight = _kStepSize * _kTriangleSqrt;
const double _kMaxStepSize = 80.0;
const EdgeInsetsDirectional _kDefaultVerticalContentPadding = EdgeInsetsDirectional.only(
start: 60.0,
end: 24.0,
bottom: 24.0,
);
const EdgeInsets _kDefaultHorizontalContentPadding = EdgeInsets.all(24.0);
const EdgeInsetsGeometry _kDefaultHeaderPadding = EdgeInsets.symmetric(horizontal: 24.0);
/// A material step used in [Stepper]. The step can have a title and subtitle,
/// an icon within its circle, some content and a state that governs its
@ -220,6 +227,8 @@ class Stepper extends StatefulWidget {
this.stepIconWidth,
this.stepIconMargin,
this.clipBehavior = Clip.none,
this.headerPadding,
this.contentPadding,
}) : assert(0 <= currentStep && currentStep < steps.length),
assert(
stepIconHeight == null ||
@ -379,6 +388,23 @@ class Stepper extends StatefulWidget {
/// * [Clip], which explains how to use this property.
final Clip clipBehavior;
/// The padding around the header row in both [StepperType.vertical] and
/// [StepperType.horizontal] steppers.
///
/// Defaults to to `EdgeInsets.symmetric(horizontal: 24.0)`.
final EdgeInsetsGeometry? headerPadding;
/// The padding around the content area in both [StepperType.vertical] and
/// [StepperType.horizontal] steppers.
///
/// For [StepperType.horizontal], defaults to `EdgeInsets.all(24.0)`.
///
/// For [StepperType.vertical], defaults to
/// `EdgeInsetsDirectional.only(start: 60.0, end: 24.0, bottom: 24.0)`.
/// The `start` padding is also increased by the `left` value of
/// [stepIconMargin] if it is provided.
final EdgeInsetsGeometry? contentPadding;
@override
State<Stepper> createState() => _StepperState();
}
@ -413,6 +439,8 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
double? get _stepIconWidth => widget.stepIconWidth;
EdgeInsetsGeometry get effectiveHeaderPadding => widget.headerPadding ?? _kDefaultHeaderPadding;
double get _heightFactor {
return (_isLabel() && _stepIconHeight != null) ? 2.5 : 2.0;
}
@ -737,7 +765,7 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
final bool isActive = widget.steps[index].isActive;
final bool isPreviousActive = index > 0 && widget.steps[index - 1].isActive;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
padding: effectiveHeaderPadding,
child: Row(
children: <Widget>[
Column(
@ -765,6 +793,11 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
final double? marginRight = _stepIconMargin?.resolve(TextDirection.ltr).right;
final double? additionalMarginLeft = marginLeft != null ? marginLeft / 2.0 : null;
final double? additionalMarginRight = marginRight != null ? marginRight / 2.0 : null;
// Adjust vertical content padding to align with step icon when stepIconMargin is set.
final EdgeInsetsGeometry effectiveVerticalContentPadding =
(widget.contentPadding ?? _kDefaultVerticalContentPadding).add(
EdgeInsetsDirectional.only(start: marginLeft ?? 0.0),
);
return Stack(
children: <Widget>[
@ -790,13 +823,7 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
AnimatedCrossFade(
firstChild: const SizedBox(width: double.infinity, height: 0),
secondChild: Padding(
padding: EdgeInsetsDirectional.only(
// Adjust [controlsBuilder] padding so that the content is
// centered vertically.
start: 60.0 + (marginLeft ?? 0.0),
end: 24.0,
bottom: 24.0,
),
padding: effectiveVerticalContentPadding,
child: Column(
children: <Widget>[
ClipRect(clipBehavior: widget.clipBehavior, child: widget.steps[index].content),
@ -849,6 +876,10 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
}
Widget _buildHorizontal() {
// Effective horizontal content padding (custom or default).
final EdgeInsetsGeometry effectiveHorizontalContentPadding =
widget.contentPadding ?? _kDefaultHorizontalContentPadding;
final children = <Widget>[
for (int i = 0; i < widget.steps.length; i += 1) ...<Widget>[
InkResponse(
@ -915,7 +946,7 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
Material(
elevation: widget.elevation ?? 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
padding: effectiveHeaderPadding,
child: SizedBox(
height: _stepIconHeight != null ? _stepIconHeight! * _heightFactor : null,
child: Row(children: children),
@ -926,7 +957,7 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
child: ListView(
controller: widget.controller,
physics: widget.physics,
padding: const EdgeInsets.all(24.0),
padding: effectiveHorizontalContentPadding,
children: <Widget>[
AnimatedSize(
curve: Curves.fastOutSlowIn,

View File

@ -1706,6 +1706,263 @@ void main() {
expect(tester.getSize(find.byType(Stepper)), Size.zero);
}
});
testWidgets('Stepper custom headerPadding for vertical stepper', (WidgetTester tester) async {
// Default header padding is 24.0 horizontal, 0.0 vertical.
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Stepper(
steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))],
),
),
),
);
final Rect defaultTitleRect = tester.getRect(find.text('Step 1'));
// Custom padding: horizontal 12.0 (reduces left by 12.0), vertical 8.0 (increases top by 8.0).
const EdgeInsetsGeometry customPadding = EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Stepper(
headerPadding: customPadding,
steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))],
),
),
),
);
final Rect customTitleRect = tester.getRect(find.text('Step 1'));
expect(customTitleRect.left, lessThan(defaultTitleRect.left));
expect(defaultTitleRect.left - customTitleRect.left, moreOrLessEquals(12.0));
expect(defaultTitleRect.top, equals(24.0));
expect(customTitleRect.top, greaterThan(defaultTitleRect.top));
expect(customTitleRect.top, equals(32.0));
expect(customTitleRect.top - defaultTitleRect.top, moreOrLessEquals(8.0));
});
testWidgets('Stepper custom headerPadding for horizontal stepper', (WidgetTester tester) async {
// Default header padding is 24.0 horizontal, 0.0 vertical.
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Stepper(
type: StepperType.horizontal,
steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))],
),
),
),
);
final Rect defaultTitleRect = tester.getRect(find.text('Step 1'));
// Custom padding: horizontal 16.0 (reduces left by 8.0 from default 24.0),
// vertical 8.0 (increases top by 8.0).
const EdgeInsetsGeometry customPadding = EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Stepper(
type: StepperType.horizontal,
headerPadding: customPadding,
steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))],
),
),
),
);
final Rect customTitleRect = tester.getRect(find.text('Step 1'));
expect(customTitleRect.left, lessThan(defaultTitleRect.left));
expect(defaultTitleRect.left - customTitleRect.left, moreOrLessEquals(8.0));
expect(defaultTitleRect.top, equals(24.0));
expect(customTitleRect.top, greaterThan(defaultTitleRect.top));
expect(customTitleRect.top, equals(32.0));
expect(customTitleRect.top - defaultTitleRect.top, moreOrLessEquals(8.0));
});
testWidgets('Stepper custom contentPadding for horizontal stepper', (WidgetTester tester) async {
const EdgeInsetsGeometry customPadding = EdgeInsets.all(16.0);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Stepper(
type: StepperType.horizontal,
contentPadding: customPadding,
steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))],
),
),
),
);
final Stepper stepper = tester.widget<Stepper>(find.byType(Stepper));
expect(stepper.contentPadding, customPadding);
// The content is in a ListView, verify its padding
final ListView listView = tester.widget<ListView>(
find.descendant(of: find.byType(Stepper), matching: find.byType(ListView)),
);
expect(listView.padding, customPadding);
});
testWidgets('Stepper custom contentPadding for vertical stepper', (WidgetTester tester) async {
const EdgeInsetsGeometry customPadding = EdgeInsetsDirectional.only(
start: 48.0,
end: 16.0,
bottom: 16.0,
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Stepper(
contentPadding: customPadding,
steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))],
),
),
),
);
// Find the Padding widget that wraps the step content and verify the effective padding.
const expected = EdgeInsets.only(left: 48.0, right: 16.0, bottom: 16.0);
final Iterable<Padding> paddings = tester.widgetList<Padding>(
find.ancestor(of: find.text('Content 1'), matching: find.byType(Padding)),
);
expect(paddings.any((Padding p) => p.padding.resolve(TextDirection.ltr) == expected), isTrue);
});
testWidgets('Stepper default contentPadding for horizontal stepper', (WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Stepper(
type: StepperType.horizontal,
steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))],
),
),
),
);
final Stepper stepper = tester.widget<Stepper>(find.byType(Stepper));
expect(stepper.contentPadding, isNull);
// Verify the default padding is applied to ListView
final ListView listView = tester.widget<ListView>(
find.descendant(of: find.byType(Stepper), matching: find.byType(ListView)),
);
expect(listView.padding, const EdgeInsets.all(24.0));
});
testWidgets('Stepper vertical contentPadding includes stepIconMargin start value', (
WidgetTester tester,
) async {
const customIconMargin = EdgeInsets.only(left: 16.0, right: 8.0);
const customContentPadding = EdgeInsetsDirectional.only(start: 40.0, end: 20.0, bottom: 16.0);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Stepper(
stepIconMargin: customIconMargin,
contentPadding: customContentPadding,
steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))],
),
),
),
);
// Find the Padding widget that wraps the step content
final Finder contentPadding = find.ancestor(
of: find.text('Content 1'),
matching: find.byType(Padding),
);
final Padding paddingWidget = tester.widget<Padding>(contentPadding.first);
final EdgeInsetsGeometry resolvedPadding = paddingWidget.padding;
// The effective padding should be customContentPadding + stepIconMargin.left
// start: 40.0 + 16.0 = 56.0
const expectedPadding = EdgeInsets.only(
left: 56.0, // 40.0 + 16.0 (marginLeft)
right: 20.0,
bottom: 16.0,
);
expect(resolvedPadding.resolve(TextDirection.ltr), expectedPadding);
});
testWidgets('Stepper vertical default contentPadding includes stepIconMargin start value', (
WidgetTester tester,
) async {
const customIconMargin = EdgeInsets.only(left: 10.0);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Stepper(
stepIconMargin: customIconMargin,
steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))],
),
),
),
);
// Find the Padding widget that wraps the step content
final Finder contentPadding = find.ancestor(
of: find.text('Content 1'),
matching: find.byType(Padding),
);
final Padding paddingWidget = tester.widget<Padding>(contentPadding.first);
final EdgeInsetsGeometry resolvedPadding = paddingWidget.padding;
// Default padding is start: 60.0, end: 24.0, bottom: 24.0
// Plus stepIconMargin.left: 10.0
// Expected start: 60.0 + 10.0 = 70.0
const expectedPadding = EdgeInsets.only(
left: 70.0, // 60.0 + 10.0 (marginLeft)
right: 24.0,
bottom: 24.0,
);
expect(resolvedPadding.resolve(TextDirection.ltr), expectedPadding);
});
testWidgets('Stepper vertical contentPadding without stepIconMargin uses default', (
WidgetTester tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Stepper(
steps: const <Step>[Step(title: Text('Step 1'), content: Text('Content 1'))],
),
),
),
);
// Find the Padding widget that wraps the step content
final Finder contentPadding = find.ancestor(
of: find.text('Content 1'),
matching: find.byType(Padding),
);
final Padding paddingWidget = tester.widget<Padding>(contentPadding.first);
final EdgeInsetsGeometry resolvedPadding = paddingWidget.padding;
// Default padding without stepIconMargin
const expectedPadding = EdgeInsets.only(left: 60.0, right: 24.0, bottom: 24.0);
expect(resolvedPadding.resolve(TextDirection.ltr), expectedPadding);
});
}
class _TappableColorWidget extends StatefulWidget {