From 656f49ddbaf61dc0f6abc4e25be82304216aa595 Mon Sep 17 00:00:00 2001 From: Safwan Mamji Date: Thu, 12 Feb 2026 00:49:55 +0500 Subject: [PATCH] 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. *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. [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 --- .../flutter/lib/src/material/stepper.dart | 51 +++- .../flutter/test/material/stepper_test.dart | 257 ++++++++++++++++++ 2 files changed, 298 insertions(+), 10 deletions(-) diff --git a/packages/flutter/lib/src/material/stepper.dart b/packages/flutter/lib/src/material/stepper.dart index ff09c13f820..39af17ae7c2 100644 --- a/packages/flutter/lib/src/material/stepper.dart +++ b/packages/flutter/lib/src/material/stepper.dart @@ -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 createState() => _StepperState(); } @@ -413,6 +439,8 @@ class _StepperState extends State 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 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: [ Column( @@ -765,6 +793,11 @@ class _StepperState extends State 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: [ @@ -790,13 +823,7 @@ class _StepperState extends State 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: [ ClipRect(clipBehavior: widget.clipBehavior, child: widget.steps[index].content), @@ -849,6 +876,10 @@ class _StepperState extends State with TickerProviderStateMixin { } Widget _buildHorizontal() { + // Effective horizontal content padding (custom or default). + final EdgeInsetsGeometry effectiveHorizontalContentPadding = + widget.contentPadding ?? _kDefaultHorizontalContentPadding; + final children = [ for (int i = 0; i < widget.steps.length; i += 1) ...[ InkResponse( @@ -915,7 +946,7 @@ class _StepperState extends State 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 with TickerProviderStateMixin { child: ListView( controller: widget.controller, physics: widget.physics, - padding: const EdgeInsets.all(24.0), + padding: effectiveHorizontalContentPadding, children: [ AnimatedSize( curve: Curves.fastOutSlowIn, diff --git a/packages/flutter/test/material/stepper_test.dart b/packages/flutter/test/material/stepper_test.dart index f25b67f1d59..30321e99074 100644 --- a/packages/flutter/test/material/stepper_test.dart +++ b/packages/flutter/test/material/stepper_test.dart @@ -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(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(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(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(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(title: Text('Step 1'), content: Text('Content 1'))], + ), + ), + ), + ); + + final Stepper stepper = tester.widget(find.byType(Stepper)); + expect(stepper.contentPadding, customPadding); + + // The content is in a ListView, verify its padding + final ListView listView = tester.widget( + 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(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 paddings = tester.widgetList( + 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(title: Text('Step 1'), content: Text('Content 1'))], + ), + ), + ), + ); + + final Stepper stepper = tester.widget(find.byType(Stepper)); + expect(stepper.contentPadding, isNull); + + // Verify the default padding is applied to ListView + final ListView listView = tester.widget( + 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(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(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(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(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(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(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 {