Unverified Commit ac06523b authored by Taha Tesser's avatar Taha Tesser Committed by GitHub

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
parent 03444076
...@@ -71,6 +71,14 @@ class _${blockName}DefaultsM3 extends SliderThemeData { ...@@ -71,6 +71,14 @@ class _${blockName}DefaultsM3 extends SliderThemeData {
return Colors.transparent; 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();
} }
'''; ''';
......
...@@ -759,7 +759,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin { ...@@ -759,7 +759,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
const SliderTickMarkShape defaultTickMarkShape = RoundSliderTickMarkShape(); const SliderTickMarkShape defaultTickMarkShape = RoundSliderTickMarkShape();
const SliderComponentShape defaultOverlayShape = RoundSliderOverlayShape(); const SliderComponentShape defaultOverlayShape = RoundSliderOverlayShape();
const SliderComponentShape defaultThumbShape = RoundSliderThumbShape(); const SliderComponentShape defaultThumbShape = RoundSliderThumbShape();
const SliderComponentShape defaultValueIndicatorShape = RectangularSliderValueIndicatorShape(); final SliderComponentShape defaultValueIndicatorShape = defaults.valueIndicatorShape!;
const ShowValueIndicator defaultShowValueIndicator = ShowValueIndicator.onlyForDiscrete; const ShowValueIndicator defaultShowValueIndicator = ShowValueIndicator.onlyForDiscrete;
final Set<MaterialState> states = <MaterialState>{ final Set<MaterialState> states = <MaterialState>{
...@@ -810,9 +810,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin { ...@@ -810,9 +810,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
overlayShape: sliderTheme.overlayShape ?? defaultOverlayShape, overlayShape: sliderTheme.overlayShape ?? defaultOverlayShape,
valueIndicatorShape: valueIndicatorShape, valueIndicatorShape: valueIndicatorShape,
showValueIndicator: sliderTheme.showValueIndicator ?? defaultShowValueIndicator, showValueIndicator: sliderTheme.showValueIndicator ?? defaultShowValueIndicator,
valueIndicatorTextStyle: sliderTheme.valueIndicatorTextStyle ?? theme.textTheme.bodyLarge!.copyWith( valueIndicatorTextStyle: sliderTheme.valueIndicatorTextStyle ?? defaults.valueIndicatorTextStyle,
color: theme.colorScheme.onPrimary,
),
); );
final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states) final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs<MouseCursor?>(widget.mouseCursor, states)
?? sliderTheme.mouseCursor?.resolve(states) ?? sliderTheme.mouseCursor?.resolve(states)
...@@ -851,6 +849,14 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin { ...@@ -851,6 +849,14 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
break; 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( return Semantics(
container: true, container: true,
slider: true, slider: true,
...@@ -873,7 +879,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin { ...@@ -873,7 +879,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
divisions: widget.divisions, divisions: widget.divisions,
label: widget.label, label: widget.label,
sliderTheme: sliderTheme, sliderTheme: sliderTheme,
textScaleFactor: MediaQuery.of(context).textScaleFactor, textScaleFactor: textScaleFactor,
screenSize: screenSize(), screenSize: screenSize(),
onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null, onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null,
onChangeStart: _handleDragStart, onChangeStart: _handleDragStart,
...@@ -1858,6 +1864,14 @@ class _SliderDefaultsM2 extends SliderThemeData { ...@@ -1858,6 +1864,14 @@ class _SliderDefaultsM2 extends SliderThemeData {
@override @override
Color? get overlayColor => _colors.primary.withOpacity(0.12); 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 // BEGIN GENERATED TOKEN PROPERTIES - Slider
...@@ -1927,6 +1941,14 @@ class _SliderDefaultsM3 extends SliderThemeData { ...@@ -1927,6 +1941,14 @@ class _SliderDefaultsM3 extends SliderThemeData {
return Colors.transparent; 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 // END GENERATED TOKEN PROPERTIES - Slider
...@@ -3483,3 +3483,185 @@ void _debugDrawShadow(Canvas canvas, Path path, double elevation) { ...@@ -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();
}
}
...@@ -1857,6 +1857,63 @@ void main() { ...@@ -1857,6 +1857,63 @@ void main() {
expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.text); 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', () { group('Material 2', () {
// Tests that are only relevant for Material 2. Once ThemeData.useMaterial3 // Tests that are only relevant for Material 2. Once ThemeData.useMaterial3
...@@ -2010,6 +2067,61 @@ void main() { ...@@ -2010,6 +2067,61 @@ void main() {
debugDisableShadows = true; 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;
}
});
}); });
} }
......
...@@ -10,6 +10,187 @@ import 'package:flutter/material.dart'; ...@@ -10,6 +10,187 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
void main() { void main() {
testWidgets('Slider value indicator', (WidgetTester tester) async {
await _buildValueIndicatorStaticSlider(
tester,
value: 0,
useMaterial3: true,
);
await _pressStartThumb(tester);
await expectLater(
find.byType(MaterialApp),
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_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_m3_end_text_scale_1_width_0.png'),
);
});
testWidgets('Slider value indicator wide text', (WidgetTester tester) async {
await _buildValueIndicatorStaticSlider(
tester,
value: 0,
decimalCount: 5,
useMaterial3: true,
);
await _pressStartThumb(tester);
await expectLater(
find.byType(MaterialApp),
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_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_m3_end_text_scale_1_width_5.png'),
);
});
testWidgets('Slider value indicator large text scale', (WidgetTester tester) async {
await _buildValueIndicatorStaticSlider(
tester,
value: 0,
textScale: 3,
useMaterial3: true,
);
await _pressStartThumb(tester);
await expectLater(
find.byType(MaterialApp),
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_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_m3_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,
useMaterial3: true,
);
await _pressStartThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_m3_start_text_scale_4_width_5.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 0.5,
textScale: 3,
decimalCount: 5,
useMaterial3: true,
);
await _pressMiddleThumb(tester);
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('slider_m3_middle_text_scale_4_width_5.png'),
);
await _buildValueIndicatorStaticSlider(
tester,
value: 1,
textScale: 3,
decimalCount: 5,
useMaterial3: true,
);
await _pressEndThumb(tester);
await expectLater(
find.byType(MaterialApp),
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 { testWidgets('Slider value indicator', (WidgetTester tester) async {
await _buildValueIndicatorStaticSlider( await _buildValueIndicatorStaticSlider(
tester, tester,
...@@ -174,6 +355,7 @@ void main() { ...@@ -174,6 +355,7 @@ void main() {
matchesGoldenFile('slider_end_text_scale_4_width_5.png'), matchesGoldenFile('slider_end_text_scale_4_width_5.png'),
); );
}); });
});
} }
Future<void> _pressStartThumb(WidgetTester tester) async { Future<void> _pressStartThumb(WidgetTester tester) async {
...@@ -204,9 +386,11 @@ Future<void> _buildValueIndicatorStaticSlider( ...@@ -204,9 +386,11 @@ Future<void> _buildValueIndicatorStaticSlider(
required double value, required double value,
double textScale = 1.0, double textScale = 1.0,
int decimalCount = 0, int decimalCount = 0,
bool useMaterial3 = false,
}) async { }) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
theme: ThemeData(useMaterial3: useMaterial3),
home: Scaffold( home: Scaffold(
body: Builder( body: Builder(
builder: (BuildContext context) { builder: (BuildContext context) {
......
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