Unverified Commit f40a99ce authored by Mairramer's avatar Mairramer Committed by GitHub

Adds support for StepStyle visual property bundle to the Step widget (#140825)

Fixes  #140770 and #103124

Adds the possibility of passing a height and width to icons. And also a margin for the distance of the lines between the icons.
parent ec97b6d0
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
/// Flutter code sample for [StepStyle].
void main() => runApp(const StepStyleExampleApp());
class StepStyleExampleApp extends StatelessWidget {
const StepStyleExampleApp({ super.key });
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Step Style Example')),
body: const Center(
child: StepStyleExample(),
),
),
);
}
}
class StepStyleExample extends StatefulWidget {
const StepStyleExample({ super.key });
@override
State<StepStyleExample> createState() => _StepStyleExampleState();
}
class _StepStyleExampleState extends State<StepStyleExample> {
final StepStyle _stepStyle = StepStyle(
connectorThickness: 10,
color: Colors.white,
connectorColor: Colors.red,
indexStyle: const TextStyle(
color: Colors.black,
fontSize: 20,
),
border: Border.all(
width: 2,
),
);
@override
Widget build(BuildContext context) {
return Stepper(
type: StepperType.horizontal,
stepIconHeight: 48,
stepIconWidth: 48,
stepIconMargin: EdgeInsets.zero,
steps: <Step>[
Step(
title: const SizedBox.shrink(),
content: const SizedBox.shrink(),
isActive: true,
stepStyle: _stepStyle,
),
Step(
title: const SizedBox.shrink(),
content: const SizedBox.shrink(),
isActive: true,
stepStyle: _stepStyle.copyWith(
connectorColor: Colors.orange,
gradient: const LinearGradient(
colors: <Color>[
Colors.white,
Colors.black,
],
),
),
),
Step(
title: const SizedBox.shrink(),
content: const SizedBox.shrink(),
isActive: true,
stepStyle: _stepStyle.copyWith(
connectorColor: Colors.blue,
),
),
Step(
title: const SizedBox.shrink(),
content: const SizedBox.shrink(),
isActive: true,
stepStyle: _stepStyle.merge(
StepStyle(
color: Colors.white,
indexStyle: const TextStyle(
color: Colors.black,
fontSize: 20,
),
border: Border.all(
width: 2,
),
),
),
),
],
);
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/material/stepper/step_style.0.dart' as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('StepStyle Smoke Test', (WidgetTester tester) async {
await tester.pumpWidget(
const example.StepStyleExampleApp(),
);
expect(find.widgetWithText(AppBar, 'Step Style Example'), findsOneWidget);
final Stepper stepper = tester.widget<Stepper>(find.byType(Stepper));
// Check that the stepper has the correct properties.
expect(stepper.type, StepperType.horizontal);
expect(stepper.stepIconHeight, 48);
expect(stepper.stepIconWidth, 48);
expect(stepper.stepIconMargin, EdgeInsets.zero);
// Check that the first step has the correct properties.
final Step firstStep = stepper.steps[0];
expect(firstStep.title, isA<SizedBox>());
expect(firstStep.content, isA<SizedBox>());
expect(firstStep.isActive, true);
expect(firstStep.stepStyle?.connectorThickness, 10);
expect(firstStep.stepStyle?.color, Colors.white);
expect(firstStep.stepStyle?.connectorColor, Colors.red);
expect(firstStep.stepStyle?.indexStyle?.color, Colors.black);
expect(firstStep.stepStyle?.indexStyle?.fontSize, 20);
expect(firstStep.stepStyle?.border, Border.all(width: 2));
// Check that the second step has the correct properties.
final Step secondStep = stepper.steps[1];
expect(secondStep.title, isA<SizedBox>());
expect(secondStep.content, isA<SizedBox>());
expect(secondStep.isActive, true);
expect(secondStep.stepStyle?.connectorThickness, 10);
expect(secondStep.stepStyle?.connectorColor, Colors.orange);
expect(secondStep.stepStyle?.gradient, const LinearGradient(
colors: <Color>[
Colors.white,
Colors.black,
],
));
// Check that the third step has the correct properties.
final Step thirdStep = stepper.steps[2];
expect(thirdStep.title, isA<SizedBox>());
expect(thirdStep.content, isA<SizedBox>());
expect(thirdStep.isActive, true);
expect(thirdStep.stepStyle?.connectorThickness, 10);
expect(thirdStep.stepStyle?.color, Colors.white);
expect(thirdStep.stepStyle?.connectorColor, Colors.blue);
expect(thirdStep.stepStyle?.indexStyle?.color, Colors.black);
expect(thirdStep.stepStyle?.indexStyle?.fontSize, 20);
expect(thirdStep.stepStyle?.border, Border.all(width: 2));
// Check that the fourth step has the correct properties.
final Step fourthStep = stepper.steps[3];
expect(fourthStep.title, isA<SizedBox>());
expect(fourthStep.content, isA<SizedBox>());
expect(fourthStep.isActive, true);
expect(fourthStep.stepStyle?.color, Colors.white);
expect(fourthStep.stepStyle?.indexStyle?.color, Colors.black);
expect(fourthStep.stepStyle?.indexStyle?.fontSize, 20);
expect(fourthStep.stepStyle?.border, Border.all(width: 2));
});
}
......@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'button_style.dart';
......@@ -68,6 +69,7 @@ class ControlsDetails {
this.onStepCancel,
this.onStepContinue,
});
/// Index that is active for the surrounding [Stepper] widget. This may be
/// different from [stepIndex] if the user has just changed steps and we are
/// currently animating toward that step.
......@@ -121,7 +123,9 @@ const Color _kCircleActiveDark = Colors.black87;
const Color _kDisabledLight = Colors.black38;
const Color _kDisabledDark = Colors.white38;
const double _kStepSize = 24.0;
const double _kTriangleHeight = _kStepSize * 0.866025; // Triangle height. sqrt(3.0) / 2.0
const double _kTriangleSqrt = 0.866025; // sqrt(3.0) / 2.0
const double _kTriangleHeight = _kStepSize * _kTriangleSqrt;
const double _kMaxStepSize = 80.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
......@@ -141,6 +145,7 @@ class Step {
this.state = StepState.indexed,
this.isActive = false,
this.label,
this.stepStyle,
});
/// The title of the step that typically describes it.
......@@ -167,6 +172,9 @@ class Step {
/// Only [StepperType.horizontal], Optional widget that appears under the [title].
/// By default, uses the `bodyLarge` theme.
final Widget? label;
/// Optional overrides for the step's default visual configuration.
final StepStyle? stepStyle;
}
/// A material stepper widget that displays progress through a sequence of
......@@ -211,7 +219,18 @@ class Stepper extends StatefulWidget {
this.connectorColor,
this.connectorThickness,
this.stepIconBuilder,
}) : assert(0 <= currentStep && currentStep < steps.length);
this.stepIconHeight,
this.stepIconWidth,
this.stepIconMargin,
}) : 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'),
assert(stepIconWidth == null || (stepIconWidth >= _kStepSize && stepIconWidth <= _kMaxStepSize),
'stepIconWidth must be greater than $_kStepSize and less or equal to $_kMaxStepSize'),
assert(
stepIconHeight == null || stepIconWidth == null || stepIconHeight == stepIconWidth,
'If either stepIconHeight or stepIconWidth is specified, both must be specified and '
'the values must be equal.');
/// The steps of the stepper whose titles, subtitles, icons always get shown.
///
......@@ -338,6 +357,15 @@ class Stepper extends StatefulWidget {
/// If null, the default icons will be used for respective [StepState].
final StepIconBuilder? stepIconBuilder;
/// Overrides the default step icon size height.
final double? stepIconHeight;
/// Overrides the default step icon size width.
final double? stepIconWidth;
/// Overrides the default step icon margin.
final EdgeInsets? stepIconMargin;
@override
State<Stepper> createState() => _StepperState();
}
......@@ -369,6 +397,16 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
}
}
EdgeInsetsGeometry? get _stepIconMargin => widget.stepIconMargin;
double? get _stepIconHeight => widget.stepIconHeight;
double? get _stepIconWidth => widget.stepIconWidth;
double get _heightFactor {
return (_isLabel() && _stepIconHeight != null) ? 2.5 : 2.0;
}
bool _isFirst(int index) {
return index == 0;
}
......@@ -394,6 +432,10 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
return false;
}
StepStyle? _stepStyle(int index) {
return widget.steps[index].stepStyle;
}
Color _connectorColor(bool isActive) {
final ColorScheme colorScheme = Theme.of(context).colorScheme;
final Set<MaterialState> states = <MaterialState>{
......@@ -421,12 +463,15 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
if (icon != null) {
return icon;
}
TextStyle? textStyle = _stepStyle(index)?.indexStyle;
textStyle ??= isDarkActive ? _kStepStyle.copyWith(color: Colors.black87) : _kStepStyle;
switch (state) {
case StepState.indexed:
case StepState.disabled:
return Text(
'${index + 1}',
style: isDarkActive ? _kStepStyle.copyWith(color: Colors.black87) : _kStepStyle,
style: textStyle,
);
case StepState.editing:
return Icon(
......@@ -441,7 +486,7 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
size: 18.0,
);
case StepState.error:
return const Text('!', style: _kStepStyle);
return const Center(child: Text('!', style: _kStepStyle));
}
}
......@@ -464,15 +509,18 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
Widget _buildCircle(int index, bool oldState) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8.0),
width: _kStepSize,
height: _kStepSize,
margin:_stepIconMargin ?? const EdgeInsets.symmetric(vertical: 8.0),
width: _stepIconWidth ?? _kStepSize,
height: _stepIconHeight ?? _kStepSize,
child: AnimatedContainer(
curve: Curves.fastOutSlowIn,
duration: kThemeAnimationDuration,
decoration: BoxDecoration(
color: _circleColor(index),
color: _stepStyle(index)?.color ?? _circleColor(index),
shape: BoxShape.circle,
border: _stepStyle(index)?.border,
boxShadow: _stepStyle(index)?.boxShadow != null ? <BoxShadow>[_stepStyle(index)!.boxShadow!] : null,
gradient: _stepStyle(index)?.gradient,
),
child: Center(
child: _buildCircleChild(index, oldState && widget.steps[index].state == StepState.error),
......@@ -482,17 +530,20 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
}
Widget _buildTriangle(int index, bool oldState) {
Color? color = _stepStyle(index)?.errorColor;
color ??= _isDark() ? _kErrorDark : _kErrorLight;
return Container(
margin: const EdgeInsets.symmetric(vertical: 8.0),
width: _kStepSize,
height: _kStepSize,
margin: _stepIconMargin ?? const EdgeInsets.symmetric(vertical: 8.0),
width: _stepIconWidth ?? _kStepSize,
height: _stepIconHeight ?? _kStepSize,
child: Center(
child: SizedBox(
width: _kStepSize,
height: _kTriangleHeight, // Height of 24dp-long-sided equilateral triangle.
width: _stepIconWidth ?? _kStepSize,
height: _stepIconHeight != null ? _stepIconHeight! * _kTriangleSqrt : _kTriangleHeight,
child: CustomPaint(
painter: _TrianglePainter(
color: _isDark() ? _kErrorDark : _kErrorLight,
color: color,
),
child: Align(
alignment: const Alignment(0.0, 0.8), // 0.8 looks better than the geometrical 0.33.
......@@ -724,14 +775,25 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
}
Widget _buildVerticalBody(int index) {
final double? marginLeft = _stepIconMargin?.resolve(TextDirection.ltr).left;
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;
return Stack(
children: <Widget>[
PositionedDirectional(
start: 24.0,
// When use margin affects the left or right side of the child, we
// need to add half of the margin to the start or end of the child
// respectively to get the correct positioning.
start: 24.0 + (additionalMarginLeft ?? 0.0) + (additionalMarginRight ?? 0.0),
top: 0.0,
bottom: 0.0,
child: SizedBox(
width: 24.0,
// The line is drawn from the center of the circle vertically until
// it reaches the bottom and then horizontally to the edge of the
// stepper.
width: _stepIconWidth ?? _kStepSize,
child: Center(
child: SizedBox(
width: widget.connectorThickness ?? 1.0,
......@@ -745,8 +807,10 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
AnimatedCrossFade(
firstChild: Container(height: 0.0),
secondChild: Container(
margin: widget.margin ?? const EdgeInsetsDirectional.only(
start: 60.0,
margin: EdgeInsetsDirectional.only(
// Adjust [controlsBuilder] padding so that the content is
// centered vertically.
start: 60.0 + (marginLeft ?? 0.0),
end: 24.0,
bottom: 24.0,
),
......@@ -821,7 +885,7 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
),
),
Container(
margin: const EdgeInsetsDirectional.only(start: 12.0),
margin: _stepIconMargin ?? const EdgeInsetsDirectional.only(start: 12.0),
child: _buildHeaderText(i),
),
],
......@@ -831,9 +895,9 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
Expanded(
child: Container(
key: Key('line$i'),
margin: const EdgeInsets.symmetric(horizontal: 8.0),
height: widget.connectorThickness ?? 1.0,
color: _connectorColor(widget.steps[i+1].isActive),
margin: _stepIconMargin ?? const EdgeInsets.symmetric(horizontal: 8.0),
height: widget.steps[i].stepStyle?.connectorThickness ?? widget.connectorThickness ?? 1.0,
color: widget.steps[i].stepStyle?.connectorColor ?? _connectorColor(widget.steps[i].isActive),
),
),
],
......@@ -856,6 +920,7 @@ class _StepperState extends State<Stepper> with TickerProviderStateMixin {
elevation: widget.elevation ?? 2,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24.0),
height: _stepIconHeight != null ? _stepIconHeight! * _heightFactor : null,
child: Row(
children: children,
),
......@@ -938,3 +1003,170 @@ class _TrianglePainter extends CustomPainter {
);
}
}
/// This class is used to override the default visual properties of [Step] widgets within a [Stepper].
///
/// To customize the appearance of a [Step] create an instance of this class with non-null parameters
/// for the step properties whose default value you want to override.
///
/// Example usage:
/// ```dart
/// Step(
/// title: const Text('Step 1'),
/// content: const Text('Content for Step 1'),
/// stepStyle: StepStyle(
/// color: Colors.blue,
/// errorColor: Colors.red,
/// border: Border.all(color: Colors.grey),
/// boxShadow: const BoxShadow(blurRadius: 3.0, color: Colors.black26),
/// gradient: const LinearGradient(colors: <Color>[Colors.red, Colors.blue]),
/// indexStyle: const TextStyle(color: Colors.white),
/// ),
/// )
/// ```
///
/// {@tool dartpad}
/// An example that uses [StepStyle] to customize the appearance of each [Step] in a [Stepper].
///
/// ** See code in examples/api/lib/material/stepper/step_style.0.dart **
/// {@end-tool}
@immutable
class StepStyle with Diagnosticable {
/// Constructs a [StepStyle].
const StepStyle({
this.color,
this.errorColor,
this.connectorColor,
this.connectorThickness,
this.border,
this.boxShadow,
this.gradient,
this.indexStyle,
});
/// Overrides the default color of the circle in the step.
final Color? color;
/// Overrides the default color of the error indicator in the step.
final Color? errorColor;
/// Overrides the default color of the connector line between two steps.
///
/// This property only applies when [Stepper.type] is [StepperType.horizontal].
final Color? connectorColor;
/// Overrides the default thickness of the connector line between two steps.
///
/// This property only applies when [Stepper.type] is [StepperType.horizontal].
final double? connectorThickness;
/// Add a border around the step.
///
/// Will be applied to the circle in the step.
final BoxBorder? border;
/// Add a shadow around the step.
final BoxShadow? boxShadow;
/// Add a gradient around the step.
///
/// If [gradient] is specified, [color] will be ignored.
final Gradient? gradient;
/// Overrides the default style of the index in the step.
final TextStyle? indexStyle;
/// Returns a copy of this ButtonStyle with the given fields replaced with
/// the new values.
StepStyle copyWith({
Color? color,
Color? errorColor,
Color? connectorColor,
double? connectorThickness,
BoxBorder? border,
BoxShadow? boxShadow,
Gradient? gradient,
TextStyle? indexStyle,
}) {
return StepStyle(
color: color ?? this.color,
errorColor: errorColor ?? this.errorColor,
connectorColor: connectorColor ?? this.connectorColor,
connectorThickness: connectorThickness ?? this.connectorThickness,
border: border ?? this.border,
boxShadow: boxShadow ?? this.boxShadow,
gradient: gradient ?? this.gradient,
indexStyle: indexStyle ?? this.indexStyle,
);
}
/// Returns a copy of this StepStyle where the non-null fields in [stepStyle]
/// have replaced the corresponding null fields in this StepStyle.
///
/// In other words, [stepStyle] is used to fill in unspecified (null) fields
/// this StepStyle.
StepStyle merge(StepStyle? stepStyle) {
if (stepStyle == null) {
return this;
}
return copyWith(
color: stepStyle.color,
errorColor: stepStyle.errorColor,
connectorColor: stepStyle.connectorColor,
connectorThickness: stepStyle.connectorThickness,
border: stepStyle.border,
boxShadow: stepStyle.boxShadow,
gradient: stepStyle.gradient,
indexStyle: stepStyle.indexStyle,
);
}
@override
int get hashCode {
return Object.hash(
color,
errorColor,
connectorColor,
connectorThickness,
border,
boxShadow,
gradient,
indexStyle,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) {
return true;
}
if (other.runtimeType != runtimeType) {
return false;
}
return other is StepStyle &&
other.color == color &&
other.errorColor == errorColor &&
other.connectorColor == connectorColor &&
other.connectorThickness == connectorThickness &&
other.border == border &&
other.boxShadow == boxShadow &&
other.gradient == gradient &&
other.indexStyle == indexStyle;
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
final ThemeData theme = ThemeData.fallback();
final TextTheme defaultTextTheme = theme.textTheme;
properties.add(ColorProperty('color', color, defaultValue: null));
properties.add(ColorProperty('errorColor', errorColor, defaultValue: null));
properties.add(ColorProperty('connectorColor', connectorColor, defaultValue: null));
properties.add(DoubleProperty('connectorThickness', connectorThickness, defaultValue: null));
properties.add(DiagnosticsProperty<BoxBorder>('border', border, defaultValue: null));
properties.add(DiagnosticsProperty<BoxShadow>('boxShadow', boxShadow, defaultValue: null));
properties.add(DiagnosticsProperty<Gradient>('gradient', gradient, defaultValue: null));
properties.add(DiagnosticsProperty<TextStyle>('indexStyle', indexStyle, defaultValue: defaultTextTheme.bodyLarge));
}
}
......@@ -1530,7 +1530,7 @@ testWidgets('Stepper custom indexed controls test', (WidgetTester tester) async
expect(circleColor('1'), selectedColor);
expect(circleColor('2'), disabledColor);
// in two steps case there will be single line
expect(lineColor('line0'), disabledColor);
expect(lineColor('line0'), selectedColor);
// now hitting step two
await tester.tap(find.text('step2'));
......@@ -1587,6 +1587,108 @@ testWidgets('Stepper custom indexed controls test', (WidgetTester tester) async
expect(find.text('!'), findsOneWidget);
});
testWidgets('StepperProperties test', (WidgetTester tester) async {
const Widget widget = SizedBox.shrink();
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Stepper(
stepIconHeight: 24,
stepIconWidth: 24,
stepIconMargin: const EdgeInsets.all(8),
steps: List<Step>.generate(3, (int index) {
return Step(
title: Text('Step $index'),
content: widget,
);
}),
),
),
),
);
final Finder stepperFinder = find.byType(Stepper);
final Stepper stepper = tester.widget<Stepper>(stepperFinder);
expect(stepper.stepIconHeight, 24);
expect(stepper.stepIconWidth, 24);
expect(stepper.stepIconMargin, const EdgeInsets.all(8));
});
testWidgets('StepStyle test', (WidgetTester tester) async {
final StepStyle stepStyle = StepStyle(
color: Colors.white,
errorColor: Colors.orange,
connectorColor: Colors.red,
connectorThickness: 2,
border: Border.all(),
gradient: const LinearGradient(
colors: <Color>[Colors.red, Colors.blue],
),
indexStyle: const TextStyle(color: Colors.black),
);
await tester.pumpWidget(
MaterialApp(
home: Material(
child: Stepper(
steps: <Step>[
Step(
title: const Text('Regular title'),
content: const Text('Text content'),
stepStyle: stepStyle,
),
],
),
),
),
);
final Finder stepperFinder = find.byType(Stepper);
final Stepper stepper = tester.widget<Stepper>(stepperFinder);
final StepStyle? style = stepper.steps.first.stepStyle;
expect(style?.color, stepStyle.color);
expect(style?.errorColor, stepStyle.errorColor);
expect(style?.connectorColor, stepStyle.connectorColor);
expect(style?.connectorThickness, stepStyle.connectorThickness);
expect(style?.border, stepStyle.border);
expect(style?.gradient, stepStyle.gradient);
expect(style?.indexStyle, stepStyle.indexStyle);
//copyWith
final StepStyle newStyle = stepStyle.copyWith(
color: Colors.black,
errorColor: Colors.red,
connectorColor: Colors.blue,
connectorThickness: 3,
border: Border.all(),
gradient: const LinearGradient(
colors: <Color>[Colors.red, Colors.blue],
),
indexStyle: const TextStyle(color: Colors.black),
);
expect(newStyle.color, Colors.black);
expect(newStyle.errorColor, Colors.red);
expect(newStyle.connectorColor, Colors.blue);
expect(newStyle.connectorThickness, 3);
expect(newStyle.border, stepStyle.border);
expect(newStyle.gradient, stepStyle.gradient);
expect(newStyle.indexStyle, stepStyle.indexStyle);
//merge
final StepStyle mergedStyle = stepStyle.merge(newStyle);
expect(mergedStyle.color, Colors.black);
expect(mergedStyle.errorColor, Colors.red);
expect(mergedStyle.connectorColor, Colors.blue);
expect(mergedStyle.connectorThickness, 3);
expect(mergedStyle.border, stepStyle.border);
expect(mergedStyle.gradient, stepStyle.gradient);
expect(mergedStyle.indexStyle, stepStyle.indexStyle);
});
}
class _TappableColorWidget extends StatefulWidget {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment