Add Material 3 support for Slider - Part 2 (#114624)

* Add Material 3 support for Slider - Part 2

* Kick tests

* Update drawing order to fix html renderer bug

* Update test
This commit is contained in:
Taha Tesser 2022-11-17 20:33:16 +02:00 committed by GitHub
parent 0344407614
commit ac06523b74
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 525 additions and 17 deletions

View file

@ -71,6 +71,14 @@ class _${blockName}DefaultsM3 extends SliderThemeData {
return Colors.transparent;
});
@override
TextStyle? get valueIndicatorTextStyle => ${textStyle('$tokenGroup.label.label-text')}!.copyWith(
color: ${componentColor('$tokenGroup.label.label-text')},
);
@override
SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape();
}
''';

View file

@ -759,7 +759,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
const SliderTickMarkShape defaultTickMarkShape = RoundSliderTickMarkShape();
const SliderComponentShape defaultOverlayShape = RoundSliderOverlayShape();
const SliderComponentShape defaultThumbShape = RoundSliderThumbShape();
const SliderComponentShape defaultValueIndicatorShape = RectangularSliderValueIndicatorShape();
final SliderComponentShape defaultValueIndicatorShape = defaults.valueIndicatorShape!;
const ShowValueIndicator defaultShowValueIndicator = ShowValueIndicator.onlyForDiscrete;
final Set<MaterialState> states = <MaterialState>{
@ -810,9 +810,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
overlayShape: sliderTheme.overlayShape ?? defaultOverlayShape,
valueIndicatorShape: valueIndicatorShape,
showValueIndicator: sliderTheme.showValueIndicator ?? defaultShowValueIndicator,
valueIndicatorTextStyle: sliderTheme.valueIndicatorTextStyle ?? theme.textTheme.bodyLarge!.copyWith(
color: theme.colorScheme.onPrimary,
),
valueIndicatorTextStyle: sliderTheme.valueIndicatorTextStyle ?? defaults.valueIndicatorTextStyle,
);
final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states)
?? sliderTheme.mouseCursor?.resolve(states)
@ -851,6 +849,14 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
break;
}
final double textScaleFactor = theme.useMaterial3
// TODO(tahatesser): This is an eye-balled value.
// This needs to be updated when accessibility
// guidelines are available on the material specs page
// https://m3.material.io/components/sliders/accessibility.
? math.min(MediaQuery.of(context).textScaleFactor, 1.3)
: MediaQuery.of(context).textScaleFactor;
return Semantics(
container: true,
slider: true,
@ -873,7 +879,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
divisions: widget.divisions,
label: widget.label,
sliderTheme: sliderTheme,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
textScaleFactor: textScaleFactor,
screenSize: screenSize(),
onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null,
onChangeStart: _handleDragStart,
@ -1858,6 +1864,14 @@ class _SliderDefaultsM2 extends SliderThemeData {
@override
Color? get overlayColor => _colors.primary.withOpacity(0.12);
@override
TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.bodyLarge!.copyWith(
color: _colors.onPrimary,
);
@override
SliderComponentShape? get valueIndicatorShape => const RectangularSliderValueIndicatorShape();
}
// BEGIN GENERATED TOKEN PROPERTIES - Slider
@ -1927,6 +1941,14 @@ class _SliderDefaultsM3 extends SliderThemeData {
return Colors.transparent;
});
@override
TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelMedium!.copyWith(
color: _colors.onPrimary,
);
@override
SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape();
}
// END GENERATED TOKEN PROPERTIES - Slider

View file

@ -3483,3 +3483,185 @@ void _debugDrawShadow(Canvas canvas, Path path, double elevation) {
);
}
}
/// The default shape of a Material 3 [Slider]'s value indicator.
///
/// See also:
///
/// * [Slider], which includes a value indicator defined by this shape.
/// * [SliderTheme], which can be used to configure the slider value indicator
/// of all sliders in a widget subtree.
class DropSliderValueIndicatorShape extends SliderComponentShape {
/// Create a slider value indicator that resembles a drop shape.
const DropSliderValueIndicatorShape();
static const _DropSliderValueIndicatorPathPainter _pathPainter = _DropSliderValueIndicatorPathPainter();
@override
Size getPreferredSize(
bool isEnabled,
bool isDiscrete, {
TextPainter? labelPainter,
double? textScaleFactor,
}) {
assert(labelPainter != null);
assert(textScaleFactor != null && textScaleFactor >= 0);
return _pathPainter.getPreferredSize(labelPainter!, textScaleFactor!);
}
@override
void paint(
PaintingContext context,
Offset center, {
required Animation<double> activationAnimation,
required Animation<double> enableAnimation,
required bool isDiscrete,
required TextPainter labelPainter,
required RenderBox parentBox,
required SliderThemeData sliderTheme,
required TextDirection textDirection,
required double value,
required double textScaleFactor,
required Size sizeWithOverflow,
}) {
final Canvas canvas = context.canvas;
final double scale = activationAnimation.value;
_pathPainter.paint(
parentBox: parentBox,
canvas: canvas,
center: center,
scale: scale,
labelPainter: labelPainter,
textScaleFactor: textScaleFactor,
sizeWithOverflow: sizeWithOverflow,
backgroundPaintColor: sliderTheme.valueIndicatorColor!,
);
}
}
class _DropSliderValueIndicatorPathPainter {
const _DropSliderValueIndicatorPathPainter();
static const double _triangleHeight = 10.0;
static const double _labelPadding = 8.0;
static const double _preferredHeight = 32.0;
static const double _minLabelWidth = 20.0;
static const double _minRectHeight = 28.0;
static const double _rectYOffset = 6.0;
static const double _bottomTipYOffset = 16.0;
static const double _preferredHalfHeight = _preferredHeight / 2;
static const double _upperRectRadius = 4;
Size getPreferredSize(
TextPainter labelPainter,
double textScaleFactor,
) {
assert(labelPainter != null);
final double width = math.max(_minLabelWidth, labelPainter.width) + _labelPadding * 2 * textScaleFactor;
return Size(width, _preferredHeight * textScaleFactor);
}
double getHorizontalShift({
required RenderBox parentBox,
required Offset center,
required TextPainter labelPainter,
required double textScaleFactor,
required Size sizeWithOverflow,
required double scale,
}) {
assert(!sizeWithOverflow.isEmpty);
const double edgePadding = 8.0;
final double rectangleWidth = _upperRectangleWidth(labelPainter, scale);
/// Value indicator draws on the Overlay and by using the global Offset
/// we are making sure we use the bounds of the Overlay instead of the Slider.
final Offset globalCenter = parentBox.localToGlobal(center);
// The rectangle must be shifted towards the center so that it minimizes the
// chance of it rendering outside the bounds of the render box. If the shift
// is negative, then the lobe is shifted from right to left, and if it is
// positive, then the lobe is shifted from left to right.
final double overflowLeft = math.max(0, rectangleWidth / 2 - globalCenter.dx + edgePadding);
final double overflowRight = math.max(0, rectangleWidth / 2 - (sizeWithOverflow.width - globalCenter.dx - edgePadding));
if (rectangleWidth < sizeWithOverflow.width) {
return overflowLeft - overflowRight;
} else if (overflowLeft - overflowRight > 0) {
return overflowLeft - (edgePadding * textScaleFactor);
} else {
return -overflowRight + (edgePadding * textScaleFactor);
}
}
double _upperRectangleWidth(TextPainter labelPainter, double scale) {
final double unscaledWidth = math.max(_minLabelWidth, labelPainter.width) + _labelPadding;
return unscaledWidth * scale;
}
BorderRadius _adjustBorderRadius(Rect rect) {
const double rectness = 0.0;
return BorderRadius.lerp(
BorderRadius.circular(_upperRectRadius),
BorderRadius.all(Radius.circular(rect.shortestSide / 2.0)),
1.0 - rectness,
)!;
}
void paint({
required RenderBox parentBox,
required Canvas canvas,
required Offset center,
required double scale,
required TextPainter labelPainter,
required double textScaleFactor,
required Size sizeWithOverflow,
required Color backgroundPaintColor,
Color? strokePaintColor,
}) {
if (scale == 0.0) {
// Zero scale essentially means "do not draw anything", so it's safe to just return.
return;
}
assert(!sizeWithOverflow.isEmpty);
final double rectangleWidth = _upperRectangleWidth(labelPainter, scale);
final double horizontalShift = getHorizontalShift(
parentBox: parentBox,
center: center,
labelPainter: labelPainter,
textScaleFactor: textScaleFactor,
sizeWithOverflow: sizeWithOverflow,
scale: scale,
);
final Rect upperRect = Rect.fromLTWH(
-rectangleWidth / 2 + horizontalShift,
-_rectYOffset - _minRectHeight,
rectangleWidth,
_minRectHeight,
);
final Paint fillPaint = Paint()..color = backgroundPaintColor;
canvas.save();
canvas.translate(center.dx, center.dy - _bottomTipYOffset);
canvas.scale(scale, scale);
final BorderRadius adjustedBorderRadius = _adjustBorderRadius(upperRect);
final RRect borderRect = adjustedBorderRadius.resolve(labelPainter.textDirection).toRRect(upperRect);
final Path trianglePath = Path()
..lineTo(-_triangleHeight, -_triangleHeight)
..lineTo(_triangleHeight, -_triangleHeight)
..close();
canvas.drawPath(trianglePath, fillPaint);
canvas.drawRRect(borderRect, fillPaint);
// The label text is centered within the value indicator.
final double bottomTipToUpperRectTranslateY = -_preferredHalfHeight / 2 - upperRect.height;
canvas.translate(0, bottomTipToUpperRectTranslateY);
final Offset boxCenter = Offset(horizontalShift, upperRect.height / 1.75);
final Offset halfLabelPainterOffset = Offset(labelPainter.width / 2, labelPainter.height / 2);
final Offset labelOffset = boxCenter - halfLabelPainterOffset;
labelPainter.paint(canvas, labelOffset);
canvas.restore();
}
}

View file

@ -1857,6 +1857,63 @@ void main() {
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.text);
});
testWidgets('Default value indicator color', (WidgetTester tester) async {
debugDisableShadows = false;
try {
final ThemeData theme = ThemeData(
useMaterial3: true,
platform: TargetPlatform.android,
);
Widget buildApp(String value, { double sliderValue = 0.5, double textScale = 1.0 }) {
return MaterialApp(
theme: theme,
home: Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData.fromWindow(WidgetsBinding.instance.window).copyWith(textScaleFactor: textScale),
child: Material(
child: Row(
children: <Widget>[
Expanded(
child: Slider(
value: sliderValue,
label: value,
divisions: 3,
onChanged: (double d) { },
),
),
],
),
),
),
),
);
}
await tester.pumpWidget(buildApp('1'));
final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay));
final Offset center = tester.getCenter(find.byType(Slider));
await tester.startGesture(center);
// Wait for value indicator animation to finish.
await tester.pumpAndSettle();
expect(
valueIndicatorBox,
paints
..rrect(color: const Color(0xfffffbfe))
..rrect(color: const Color(0xff6750a4))
..rrect(color: const Color(0xffe7e0ec))
..path(color: Color(theme.colorScheme.primary.value))
..rrect(
color: Color(theme.colorScheme.primary.value),
)
);
} finally {
debugDisableShadows = true;
}
});
group('Material 2', () {
// Tests that are only relevant for Material 2. Once ThemeData.useMaterial3
@ -2010,6 +2067,61 @@ void main() {
debugDisableShadows = true;
}
});
testWidgets('Default value indicator color', (WidgetTester tester) async {
debugDisableShadows = false;
try {
final ThemeData theme = ThemeData(
platform: TargetPlatform.android,
);
Widget buildApp(String value, { double sliderValue = 0.5, double textScale = 1.0 }) {
return MaterialApp(
theme: theme,
home: Directionality(
textDirection: TextDirection.ltr,
child: MediaQuery(
data: MediaQueryData.fromWindow(WidgetsBinding.instance.window).copyWith(textScaleFactor: textScale),
child: Material(
child: Row(
children: <Widget>[
Expanded(
child: Slider(
value: sliderValue,
label: value,
divisions: 3,
onChanged: (double d) { },
),
),
],
),
),
),
),
);
}
await tester.pumpWidget(buildApp('1'));
final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay));
final Offset center = tester.getCenter(find.byType(Slider));
await tester.startGesture(center);
// Wait for value indicator animation to finish.
await tester.pumpAndSettle();
expect(
valueIndicatorBox,
paints
..rrect(color: const Color(0xfffafafa))
..rrect(color: const Color(0xff2196f3))
..rrect(color: const Color(0x3d2196f3))
// Test that the value indicator text is painted with the correct color.
..path(color: const Color(0xf55f5f5f))
);
} finally {
debugDisableShadows = true;
}
});
});
}

View file

@ -14,37 +14,40 @@ void main() {
await _buildValueIndicatorStaticSlider(
tester,
value: 0,
useMaterial3: true,
);
await _pressStartThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_start_text_scale_1_width_0.png'),
matchesGoldenFile('slider_m3_start_text_scale_1_width_0.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 0.5,
useMaterial3: true,
);
await _pressMiddleThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_middle_text_scale_1_width_0.png'),
matchesGoldenFile('slider_m3_middle_text_scale_1_width_0.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 1,
useMaterial3: true,
);
await _pressEndThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_end_text_scale_1_width_0.png'),
matchesGoldenFile('slider_m3_end_text_scale_1_width_0.png'),
);
});
@ -53,39 +56,42 @@ void main() {
tester,
value: 0,
decimalCount: 5,
useMaterial3: true,
);
await _pressStartThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_start_text_scale_1_width_5.png'),
matchesGoldenFile('slider_m3_start_text_scale_1_width_5.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 0.5,
decimalCount: 5,
useMaterial3: true,
);
await _pressMiddleThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_middle_text_scale_1_width_5.png'),
matchesGoldenFile('slider_m3_middle_text_scale_1_width_5.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 1,
decimalCount: 5,
useMaterial3: true,
);
await _pressEndThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_end_text_scale_1_width_5.png'),
matchesGoldenFile('slider_m3_end_text_scale_1_width_5.png'),
);
});
@ -94,39 +100,42 @@ void main() {
tester,
value: 0,
textScale: 3,
useMaterial3: true,
);
await _pressStartThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_start_text_scale_4_width_0.png'),
matchesGoldenFile('slider_m3_start_text_scale_4_width_0.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 0.5,
textScale: 3,
useMaterial3: true,
);
await _pressMiddleThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_middle_text_scale_4_width_0.png'),
matchesGoldenFile('slider_m3_middle_text_scale_4_width_0.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 1,
textScale: 3,
useMaterial3: true,
);
await _pressEndThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_end_text_scale_4_width_0.png'),
matchesGoldenFile('slider_m3_end_text_scale_4_width_0.png'),
);
});
@ -137,13 +146,14 @@ void main() {
value: 0,
textScale: 3,
decimalCount: 5,
useMaterial3: true,
);
await _pressStartThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_start_text_scale_4_width_5.png'),
matchesGoldenFile('slider_m3_start_text_scale_4_width_5.png'),
);
await _buildValueIndicatorStaticSlider(
@ -151,13 +161,14 @@ void main() {
value: 0.5,
textScale: 3,
decimalCount: 5,
useMaterial3: true,
);
await _pressMiddleThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_middle_text_scale_4_width_5.png'),
matchesGoldenFile('slider_m3_middle_text_scale_4_width_5.png'),
);
await _buildValueIndicatorStaticSlider(
@ -165,15 +176,186 @@ void main() {
value: 1,
textScale: 3,
decimalCount: 5,
useMaterial3: true,
);
await _pressEndThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_end_text_scale_4_width_5.png'),
matchesGoldenFile('slider_m3_end_text_scale_4_width_5.png'),
);
});
group('Material 2', () {
// Tests that are only relevant for Material 2. Once ThemeData.useMaterial3
// is turned on by default, these tests can be removed.
testWidgets('Slider value indicator', (WidgetTester tester) async {
await _buildValueIndicatorStaticSlider(
tester,
value: 0,
);
await _pressStartThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_start_text_scale_1_width_0.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 0.5,
);
await _pressMiddleThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_middle_text_scale_1_width_0.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 1,
);
await _pressEndThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_end_text_scale_1_width_0.png'),
);
});
testWidgets('Slider value indicator wide text', (WidgetTester tester) async {
await _buildValueIndicatorStaticSlider(
tester,
value: 0,
decimalCount: 5,
);
await _pressStartThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_start_text_scale_1_width_5.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 0.5,
decimalCount: 5,
);
await _pressMiddleThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_middle_text_scale_1_width_5.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 1,
decimalCount: 5,
);
await _pressEndThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_end_text_scale_1_width_5.png'),
);
});
testWidgets('Slider value indicator large text scale', (WidgetTester tester) async {
await _buildValueIndicatorStaticSlider(
tester,
value: 0,
textScale: 3,
);
await _pressStartThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_start_text_scale_4_width_0.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 0.5,
textScale: 3,
);
await _pressMiddleThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_middle_text_scale_4_width_0.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 1,
textScale: 3,
);
await _pressEndThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_end_text_scale_4_width_0.png'),
);
});
testWidgets('Slider value indicator large text scale and wide text',
(WidgetTester tester) async {
await _buildValueIndicatorStaticSlider(
tester,
value: 0,
textScale: 3,
decimalCount: 5,
);
await _pressStartThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_start_text_scale_4_width_5.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 0.5,
textScale: 3,
decimalCount: 5,
);
await _pressMiddleThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_middle_text_scale_4_width_5.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 1,
textScale: 3,
decimalCount: 5,
);
await _pressEndThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_end_text_scale_4_width_5.png'),
);
});
});
}
Future<void> _pressStartThumb(WidgetTester tester) async {
@ -204,9 +386,11 @@ Future<void> _buildValueIndicatorStaticSlider(
required double value,
double textScale = 1.0,
int decimalCount = 0,
bool useMaterial3 = false,
}) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(useMaterial3: useMaterial3),
home: Scaffold(
body: Builder(
builder: (BuildContext context) {