From 0d154e55d1e82d4e823dda0561e37655723bc70b Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Wed, 31 Jul 2024 22:58:04 +0300 Subject: [PATCH] Add ability to clip `Stepper` step content (#152370) fixes [Dismissible content overlays Stepper interface while dismissing it](https://github.com/flutter/flutter/issues/66007) ### Code sample
expand to view the code sample ```dart import 'package:flutter/material.dart'; void main() => runApp(const MyApp()); class MyApp extends StatefulWidget { const MyApp({super.key}); @override State createState() => _MyAppState(); } class _MyAppState extends State { final List items = List.generate(20, (int i) => 'Item ${i + 1}'); @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: Padding( padding: const EdgeInsets.all(20), child: DecoratedBox( decoration: BoxDecoration( border: Border.all(color: Colors.amber, width: 2), ), child: Padding( padding: const EdgeInsets.all(2.0), child: Column( children: [ const SizedBox(height: 8.0), Text( 'Dismissible Widget - Vertical Stepper Widget', style: Theme.of(context).textTheme.titleLarge, ), Expanded( child: Stepper( clipBehavior: Clip.hardEdge, steps: [ Step( isActive: true, title: const Text('Step 1'), content: ColoredBox( color: Colors.black12, child: ListView.builder( itemCount: items.length, shrinkWrap: true, itemBuilder: (BuildContext context, int index) { final String item = items[index]; return Dismissible( key: Key(item), onDismissed: (DismissDirection direction) { setState(() { items.removeAt(index); }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('$item dismissed'))); }, background: Container(color: Colors.red), child: ListTile(title: Text(item)), ); }, ), ), ), const Step( title: Text('Step 2'), content: Text('content'), ), ], ), ), const Divider(height: 1), const SizedBox(height: 8.0), Text( 'Dismissible Widget - Horizontal Stepper Widget', style: Theme.of(context).textTheme.titleLarge, ), Expanded( child: Stepper( clipBehavior: Clip.hardEdge, type: StepperType.horizontal, elevation: 0.0, steps: [ Step( isActive: true, title: const Text('Step 1'), content: ColoredBox( color: Colors.black12, child: ListView.builder( itemCount: items.length, shrinkWrap: true, itemBuilder: (BuildContext context, int index) { final String item = items[index]; return Dismissible( key: Key(item), onDismissed: (DismissDirection direction) { setState(() { items.removeAt(index); }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('$item dismissed'))); }, background: Container(color: Colors.red), child: ListTile(title: Text(item)), ); }, ), ), ), const Step( title: Text('Step 2'), content: Text('content'), ), ], ), ), ], ), ), ), ), ), ); } } ```
### Without `Stepper` step content clipping ![Group 1](https://github.com/user-attachments/assets/1814ad90-8d43-4e03-9f68-7da47e08c718) ### With `Stepper` step content clipping ![Group 2](https://github.com/user-attachments/assets/652ff597-7e9a-4d35-abc2-80d60cee03f4) --- .../flutter/lib/src/material/stepper.dart | 20 ++++- .../flutter/test/material/stepper_test.dart | 85 +++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) diff --git a/packages/flutter/lib/src/material/stepper.dart b/packages/flutter/lib/src/material/stepper.dart index 32982fa2326..17eec2e0181 100644 --- a/packages/flutter/lib/src/material/stepper.dart +++ b/packages/flutter/lib/src/material/stepper.dart @@ -222,6 +222,7 @@ class Stepper extends StatefulWidget { this.stepIconHeight, this.stepIconWidth, this.stepIconMargin, + this.clipBehavior = Clip.none, }) : assert(0 <= currentStep && currentStep < steps.length), assert(stepIconHeight == null || (stepIconHeight >= _kStepSize && stepIconHeight <= _kMaxStepSize), 'stepIconHeight must be greater than $_kStepSize and less or equal to $_kMaxStepSize'), @@ -366,6 +367,15 @@ class Stepper extends StatefulWidget { /// Overrides the default step icon margin. final EdgeInsets? stepIconMargin; + /// The [Step.content] will be clipped to this Clip type. + /// + /// Defaults to [Clip.none]. + /// + /// See also: + /// + /// * [Clip], which explains how to use this property. + final Clip clipBehavior; + @override State createState() => _StepperState(); } @@ -795,7 +805,10 @@ class _StepperState extends State with TickerProviderStateMixin { ), child: Column( children: [ - widget.steps[index].content, + ClipRect( + clipBehavior: widget.clipBehavior, + child: widget.steps[index].content, + ), _buildVerticalControls(index), ], ), @@ -888,7 +901,10 @@ class _StepperState extends State with TickerProviderStateMixin { Visibility( maintainState: true, visible: i == widget.currentStep, - child: widget.steps[i].content, + child: ClipRect( + clipBehavior: widget.clipBehavior, + child: widget.steps[i].content, + ), ), ); } diff --git a/packages/flutter/test/material/stepper_test.dart b/packages/flutter/test/material/stepper_test.dart index 9c57d8e7998..2ce124ff5fa 100644 --- a/packages/flutter/test/material/stepper_test.dart +++ b/packages/flutter/test/material/stepper_test.dart @@ -1720,6 +1720,91 @@ testWidgets('Stepper custom indexed controls test', (WidgetTester tester) async )); expect(lastConnector.width, equals(0.0)); }); + + // This is a regression test for https://github.com/flutter/flutter/issues/66007. + testWidgets('Default Stepper clipBehavior', (WidgetTester tester) async { + Widget buildStepper({ required StepperType type }) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Stepper( + type: type, + steps: const [ + Step( + title: Text('step1'), + content: Text('step1 content'), + ), + Step( + title: Text('step2'), + content: Text('step2 content'), + ), + ], + ), + ), + ), + ); + } + + ClipRect getContentClipRect() { + return tester.widget(find.ancestor( + of: find.text('step1 content'), + matching: find.byType(ClipRect), + ).first); + } + + // Test vertical stepper with default clipBehavior. + await tester.pumpWidget(buildStepper(type: StepperType.vertical)); + + expect(getContentClipRect().clipBehavior, equals(Clip.none)); + + // Test horizontal stepper with default clipBehavior. + await tester.pumpWidget(buildStepper(type: StepperType.horizontal)); + + expect(getContentClipRect().clipBehavior, equals(Clip.none)); + }); + + // This is a regression test for https://github.com/flutter/flutter/issues/66007. + testWidgets('Stepper steps can be clipped', (WidgetTester tester) async { + Widget buildStepper({ required StepperType type, required Clip clipBehavior }) { + return MaterialApp( + home: Scaffold( + body: Center( + child: Stepper( + clipBehavior: clipBehavior, + type: type, + steps: const [ + Step( + title: Text('step1'), + content: Text('step1 content'), + ), + Step( + title: Text('step2'), + content: Text('step2 content'), + ), + ], + ), + ), + ), + ); + } + + ClipRect getContentClipRect() { + return tester.widget(find.ancestor( + of: find.text('step1 content'), + matching: find.byType(ClipRect), + ).first); + } + + // Test vertical stepper with clipBehavior set to Clip.hardEdge. + await tester.pumpWidget(buildStepper(type: StepperType.vertical, clipBehavior: Clip.hardEdge)); + + expect(getContentClipRect().clipBehavior, equals(Clip.hardEdge)); + + // Test horizontal stepper with clipBehavior set to Clip.hardEdge. + await tester.pumpWidget(buildStepper(type: StepperType.horizontal, clipBehavior: Clip.hardEdge)); + + expect(getContentClipRect().clipBehavior, equals(Clip.hardEdge)); + }); } class _TappableColorWidget extends StatefulWidget {