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 {