From 56cfef73fcde34c3ada950f1c53c640add23079b Mon Sep 17 00:00:00 2001 From: Taha Tesser Date: Tue, 19 Nov 2024 21:10:44 +0200 Subject: [PATCH] Introduce new Material 3 `Slider` shapes (#152237) fixes [Update `Slider` for Material 3 redesign](https://github.com/flutter/flutter/issues/141842) previous implementation https://github.com/flutter/flutter/pull/147783 ### Description This PR introduces new Material 3 Slider design. ### Slider Preview Screenshot 2024-07-24 at 16 38 11 Screenshot 2024-07-24 at 16 38 24 ### Value indicator Preview https://github.com/user-attachments/assets/45fa001c-de81-433a-a8e9-6c0d6a2335c0 ### New stop indicator https://github.com/user-attachments/assets/ad05621d-042d-4b17-9dbb-7f7b802a2593 ### Customized Screenshot 2024-07-24 at 16 41 49 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- dev/tools/gen_defaults/bin/gen_defaults.dart | 4 +- .../gen_defaults/generated/used_tokens.csv | 27 + .../gen_defaults/lib/slider_template.dart | 64 ++- packages/flutter/lib/src/material/slider.dart | 259 +++++++-- .../lib/src/material/slider_theme.dart | 494 +++++++++++++++++- .../flutter/test/material/slider_test.dart | 206 ++++++++ .../test/material/slider_theme_test.dart | 169 ++++++ 7 files changed, 1171 insertions(+), 52 deletions(-) diff --git a/dev/tools/gen_defaults/bin/gen_defaults.dart b/dev/tools/gen_defaults/bin/gen_defaults.dart index 78918ca431..5431e708f5 100644 --- a/dev/tools/gen_defaults/bin/gen_defaults.dart +++ b/dev/tools/gen_defaults/bin/gen_defaults.dart @@ -47,6 +47,7 @@ import 'package:gen_defaults/radio_template.dart'; import 'package:gen_defaults/search_bar_template.dart'; import 'package:gen_defaults/search_view_template.dart'; import 'package:gen_defaults/segmented_button_template.dart'; +import 'package:gen_defaults/slider_template.dart'; import 'package:gen_defaults/snackbar_template.dart'; import 'package:gen_defaults/surface_tint.dart'; import 'package:gen_defaults/switch_template.dart'; @@ -142,8 +143,7 @@ Future main(List args) async { SearchViewTemplate('SearchView', '$materialLib/search_anchor.dart', tokens).updateFile(); SegmentedButtonTemplate('md.comp.outlined-segmented-button', 'SegmentedButton', '$materialLib/segmented_button.dart', tokens).updateFile(); SnackbarTemplate('md.comp.snackbar', 'Snackbar', '$materialLib/snack_bar.dart', tokens).updateFile(); - // TODO(QuncCccccc): uncomment `SliderTemplate` once `Slider` widget is updated to match the latest M3 specs. - // SliderTemplate('md.comp.slider', 'Slider', '$materialLib/slider.dart', tokens).updateFile(); + SliderTemplate('md.comp.slider', 'Slider', '$materialLib/slider.dart', tokens).updateFile(); SurfaceTintTemplate('SurfaceTint', '$materialLib/elevation_overlay.dart', tokens).updateFile(); SwitchTemplate('Switch', '$materialLib/switch.dart', tokens).updateFile(); TimePickerTemplate('TimePicker', '$materialLib/time_picker.dart', tokens).updateFile(); diff --git a/dev/tools/gen_defaults/generated/used_tokens.csv b/dev/tools/gen_defaults/generated/used_tokens.csv index c5ecfb6f58..139edf98e8 100644 --- a/dev/tools/gen_defaults/generated/used_tokens.csv +++ b/dev/tools/gen_defaults/generated/used_tokens.csv @@ -655,6 +655,33 @@ md.comp.sheet.bottom.docked.drag-handle.height, md.comp.sheet.bottom.docked.drag-handle.width, md.comp.sheet.bottom.docked.modal.container.elevation, md.comp.sheet.bottom.docked.standard.container.elevation, +md.comp.slider.active.handle.padding, +md.comp.slider.active.stop-indicator.container.color, +md.comp.slider.active.stop-indicator.container.opacity, +md.comp.slider.active.track.color, +md.comp.slider.active.track.height, +md.comp.slider.disabled.active.stop-indicator.container.color, +md.comp.slider.disabled.active.track.color, +md.comp.slider.disabled.active.track.opacity, +md.comp.slider.disabled.handle.color, +md.comp.slider.disabled.handle.opacity, +md.comp.slider.disabled.handle.width, +md.comp.slider.disabled.inactive.stop-indicator.container.color, +md.comp.slider.disabled.inactive.track.color, +md.comp.slider.disabled.inactive.track.opacity, +md.comp.slider.focus.handle.width, +md.comp.slider.handle.color, +md.comp.slider.handle.height, +md.comp.slider.handle.width, +md.comp.slider.hover.handle.width, +md.comp.slider.inactive.stop-indicator.container.color, +md.comp.slider.inactive.stop-indicator.container.opacity, +md.comp.slider.inactive.track.color, +md.comp.slider.pressed.handle.width, +md.comp.slider.stop-indicator.size, +md.comp.slider.value-indicator.container.color, +md.comp.slider.value-indicator.label.label-text.color, +md.comp.slider.value-indicator.label.label-text.text-style, md.comp.snackbar.action.focus.label-text.color, md.comp.snackbar.action.hover.label-text.color, md.comp.snackbar.action.label-text.color, diff --git a/dev/tools/gen_defaults/lib/slider_template.dart b/dev/tools/gen_defaults/lib/slider_template.dart index 8ced06757f..ff9fb64fa0 100644 --- a/dev/tools/gen_defaults/lib/slider_template.dart +++ b/dev/tools/gen_defaults/lib/slider_template.dart @@ -27,7 +27,7 @@ class _${blockName}DefaultsM3 extends SliderThemeData { Color? get inactiveTrackColor => ${componentColor('$tokenGroup.inactive.track')}; @override - Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54); + Color? get secondaryActiveTrackColor => ${componentColor('$tokenGroup.active.track')}.withOpacity(0.54); @override Color? get disabledActiveTrackColor => ${componentColor('$tokenGroup.disabled.active.track')}; @@ -36,49 +36,85 @@ class _${blockName}DefaultsM3 extends SliderThemeData { Color? get disabledInactiveTrackColor => ${componentColor('$tokenGroup.disabled.inactive.track')}; @override - Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.12); + Color? get disabledSecondaryActiveTrackColor => ${componentColor('$tokenGroup.disabled.active.track')}; @override - Color? get activeTickMarkColor => ${componentColor('$tokenGroup.with-tick-marks.active.container')}; + Color? get activeTickMarkColor => ${componentColor('$tokenGroup.active.stop-indicator.container')}; @override - Color? get inactiveTickMarkColor => ${componentColor('$tokenGroup.with-tick-marks.inactive.container')}; + Color? get inactiveTickMarkColor => ${componentColor('$tokenGroup.inactive.stop-indicator.container')}; @override - Color? get disabledActiveTickMarkColor => ${componentColor('$tokenGroup.with-tick-marks.disabled.container')}; + Color? get disabledActiveTickMarkColor => ${componentColor('$tokenGroup.disabled.active.stop-indicator.container')}; @override - Color? get disabledInactiveTickMarkColor => ${componentColor('$tokenGroup.with-tick-marks.disabled.container')}; + Color? get disabledInactiveTickMarkColor => ${componentColor('$tokenGroup.disabled.inactive.stop-indicator.container')}; @override Color? get thumbColor => ${componentColor('$tokenGroup.handle')}; @override - Color? get disabledThumbColor => Color.alphaBlend(${componentColor('$tokenGroup.disabled.handle')}, _colors.surface); + Color? get disabledThumbColor => ${componentColor('$tokenGroup.disabled.handle')}; @override Color? get overlayColor => MaterialStateColor.resolveWith((Set states) { if (states.contains(MaterialState.dragged)) { - return ${componentColor('$tokenGroup.pressed.state-layer')}; + return _colors.primary.withOpacity(0.1); } if (states.contains(MaterialState.hovered)) { - return ${componentColor('$tokenGroup.hover.state-layer')}; + return _colors.primary.withOpacity(0.08); } if (states.contains(MaterialState.focused)) { - return ${componentColor('$tokenGroup.focus.state-layer')}; + return _colors.primary.withOpacity(0.1); } return Colors.transparent; }); @override - TextStyle? get valueIndicatorTextStyle => ${textStyle('$tokenGroup.label.label-text')}!.copyWith( - color: ${componentColor('$tokenGroup.label.label-text')}, + TextStyle? get valueIndicatorTextStyle => ${textStyle('$tokenGroup.value-indicator.label.label-text')}!.copyWith( + color: ${componentColor('$tokenGroup.value-indicator.label.label-text')}, ); @override - SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape(); + Color? get valueIndicatorColor => ${componentColor('$tokenGroup.value-indicator.container')}; + + @override + SliderComponentShape? get valueIndicatorShape => const RoundedRectSliderValueIndicatorShape(); + + @override + SliderComponentShape? get thumbShape => const HandleThumbShape(); + + @override + SliderTrackShape? get trackShape => const GappedSliderTrackShape(); + + @override + SliderComponentShape? get overlayShape => const RoundSliderOverlayShape(); + + @override + SliderTickMarkShape? get tickMarkShape => const RoundSliderTickMarkShape(tickMarkRadius: ${getToken("$tokenGroup.stop-indicator.size")} / 2); + + @override + MaterialStateProperty? get thumbSize { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return const Size(${getToken("$tokenGroup.disabled.handle.width")}, ${getToken("$tokenGroup.handle.height")}); + } + if (states.contains(MaterialState.hovered)) { + return const Size(${getToken("$tokenGroup.hover.handle.width")}, ${getToken("$tokenGroup.handle.height")}); + } + if (states.contains(MaterialState.focused)) { + return const Size(${getToken("$tokenGroup.focus.handle.width")}, ${getToken("$tokenGroup.handle.height")}); + } + if (states.contains(MaterialState.pressed)) { + return const Size(${getToken("$tokenGroup.pressed.handle.width")}, ${getToken("$tokenGroup.handle.height")}); + } + return const Size(${getToken("$tokenGroup.handle.width")}, ${getToken("$tokenGroup.handle.height")}); + }); + } + + @override + double? get trackGap => ${getToken("$tokenGroup.active.handle.padding")}; } '''; - } diff --git a/packages/flutter/lib/src/material/slider.dart b/packages/flutter/lib/src/material/slider.dart index c666e1d540..a90119040c 100644 --- a/packages/flutter/lib/src/material/slider.dart +++ b/packages/flutter/lib/src/material/slider.dart @@ -197,6 +197,11 @@ class Slider extends StatefulWidget { this.autofocus = false, this.allowedInteraction, this.padding, + @Deprecated( + 'Use SliderTheme to customize the Slider appearance. ' + 'This feature was deprecated after v3.27.0-0.1.pre.' + ) + this.year2023 = true, }) : _sliderType = _SliderType.material, assert(min <= max), assert(value >= min && value <= max, @@ -238,6 +243,11 @@ class Slider extends StatefulWidget { this.focusNode, this.autofocus = false, this.allowedInteraction, + @Deprecated( + 'Use SliderTheme to customize the Slider appearance. ' + 'This feature was deprecated after v3.27.0-0.1.pre.' + ) + this.year2023 = true, }) : _sliderType = _SliderType.adaptive, padding = null, assert(min <= max), @@ -432,9 +442,10 @@ class Slider extends StatefulWidget { /// maximum value. /// /// If null, [SliderThemeData.inactiveTrackColor] of the ambient [SliderTheme] - /// is used. If that is null and [ThemeData.useMaterial3] is true, - /// [ColorScheme.surfaceContainerHighest] will be used, otherwise [ColorScheme.primary] - /// with an opacity of 0.24 will be used. + /// is used. If [Slider.year2023] is false and [ThemeData.useMaterial3] is true, + /// then [ColorScheme.secondaryContainer] is used and if [ThemeData.useMaterial3] + /// is false, [ColorScheme.primary] with an opacity of 0.24 is used. Otherwise, + /// [ColorScheme.surfaceContainerHighest] is used. /// /// Using a [SliderTheme] gives much more fine-grained control over the /// appearance of various components of the slider. @@ -555,6 +566,18 @@ class Slider extends StatefulWidget { /// overlay shape, whichever is larger. final EdgeInsetsGeometry? padding; + /// When true, the [Slider] will use the 2023 Material 3 esign appearance. + /// + /// Defaults to true. If false, the [Slider] will use the latest Material 3 + /// Design appearance, which was introduced in December 2023. + /// + /// If [ThemeData.useMaterial3] is false, then this property is ignored. + @Deprecated( + 'Use SliderTheme to customize the Slider appearance. ' + 'This feature was deprecated after v3.27.0-0.1.pre.' + ) + final bool year2023; + final _SliderType _sliderType ; @override @@ -787,7 +810,12 @@ class _SliderState extends State with TickerProviderStateMixin { Widget _buildMaterialSlider(BuildContext context) { final ThemeData theme = Theme.of(context); SliderThemeData sliderTheme = SliderTheme.of(context); - final SliderThemeData defaults = theme.useMaterial3 ? _SliderDefaultsM3(context) : _SliderDefaultsM2(context); + final SliderThemeData defaults = switch (theme.useMaterial3) { + true => widget.year2023 + ? _SliderDefaultsM3Year2023(context) + : _SliderDefaultsM3(context), + false => _SliderDefaultsM2(context), + }; // If the widget has active or inactive colors specified, then we plug them // in to the slider theme as best we can. If the developer wants more @@ -796,11 +824,6 @@ class _SliderState extends State with TickerProviderStateMixin { // the default shapes and text styles are aligned to the Material // Guidelines. - const SliderTrackShape defaultTrackShape = RoundedRectSliderTrackShape(); - const SliderTickMarkShape defaultTickMarkShape = RoundSliderTickMarkShape(); - const SliderComponentShape defaultOverlayShape = RoundSliderOverlayShape(); - const SliderComponentShape defaultThumbShape = RoundSliderThumbShape(); - final SliderComponentShape defaultValueIndicatorShape = defaults.valueIndicatorShape!; const ShowValueIndicator defaultShowValueIndicator = ShowValueIndicator.onlyForDiscrete; const SliderInteraction defaultAllowedInteraction = SliderInteraction.tapAndSlide; @@ -815,12 +838,18 @@ class _SliderState extends State with TickerProviderStateMixin { // (which can be defined by activeColor) if the // RectangularSliderValueIndicatorShape is used. In all other cases, the // value indicator is assumed to be the same as the active color. - final SliderComponentShape valueIndicatorShape = sliderTheme.valueIndicatorShape ?? defaultValueIndicatorShape; + final SliderComponentShape valueIndicatorShape = sliderTheme.valueIndicatorShape ?? defaults.valueIndicatorShape!; final Color valueIndicatorColor; if (valueIndicatorShape is RectangularSliderValueIndicatorShape) { - valueIndicatorColor = sliderTheme.valueIndicatorColor ?? Color.alphaBlend(theme.colorScheme.onSurface.withOpacity(0.60), theme.colorScheme.surface.withOpacity(0.90)); + valueIndicatorColor = sliderTheme.valueIndicatorColor + ?? Color.alphaBlend( + theme.colorScheme.onSurface.withOpacity(0.60), + theme.colorScheme.surface.withOpacity(0.90), + ); } else { - valueIndicatorColor = widget.activeColor ?? sliderTheme.valueIndicatorColor ?? theme.colorScheme.primary; + valueIndicatorColor = widget.activeColor + ?? sliderTheme.valueIndicatorColor + ?? defaults.valueIndicatorColor!; } Color? effectiveOverlayColor() { @@ -851,14 +880,16 @@ class _SliderState extends State with TickerProviderStateMixin { disabledThumbColor: sliderTheme.disabledThumbColor ?? defaults.disabledThumbColor, overlayColor: effectiveOverlayColor(), valueIndicatorColor: valueIndicatorColor, - trackShape: sliderTheme.trackShape ?? defaultTrackShape, - tickMarkShape: sliderTheme.tickMarkShape ?? defaultTickMarkShape, - thumbShape: sliderTheme.thumbShape ?? defaultThumbShape, - overlayShape: sliderTheme.overlayShape ?? defaultOverlayShape, + trackShape: sliderTheme.trackShape ?? defaults.trackShape, + tickMarkShape: sliderTheme.tickMarkShape ?? defaults.tickMarkShape, + thumbShape: sliderTheme.thumbShape ?? defaults.thumbShape, + overlayShape: sliderTheme.overlayShape ?? defaults.overlayShape, valueIndicatorShape: valueIndicatorShape, showValueIndicator: sliderTheme.showValueIndicator ?? defaultShowValueIndicator, valueIndicatorTextStyle: valueIndicatorTextStyle, padding: widget.padding ?? sliderTheme.padding, + thumbSize: sliderTheme.thumbSize ?? defaults.thumbSize, + trackGap: sliderTheme.trackGap ?? defaults.trackGap, ); final MouseCursor effectiveMouseCursor = MaterialStateProperty.resolveAs(widget.mouseCursor, states) ?? sliderTheme.mouseCursor?.resolve(states) @@ -1683,8 +1714,8 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { : trackRect.left + visualPosition * trackRect.width; // Apply padding to trackRect.left and trackRect.right if the track height is // greater than the thumb radius to ensure the thumb is drawn within the track. - final Size thumbSize = _sliderTheme.thumbShape!.getPreferredSize(isInteractive, isDiscrete); - final double thumbPadding = (padding > thumbSize.width / 2 ? padding / 2 : 0); + final Size thumbPreferredSize = _sliderTheme.thumbShape!.getPreferredSize(isInteractive, isDiscrete); + final double thumbPadding = (padding > thumbPreferredSize.width / 2 ? padding / 2 : 0); final Offset thumbCenter = Offset( clampDouble( thumbPosition, @@ -1697,13 +1728,32 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { final Size overlaySize = sliderTheme.overlayShape!.getPreferredSize(isInteractive, false); overlayRect = Rect.fromCircle(center: thumbCenter, radius: overlaySize.width / 2.0); } - final Offset? secondaryOffset = (secondaryVisualPosition != null) ? Offset(trackRect.left + secondaryVisualPosition * trackRect.width, trackRect.center.dy) : null; + final Offset? secondaryOffset = (secondaryVisualPosition != null) + ? Offset(trackRect.left + secondaryVisualPosition * trackRect.width, trackRect.center.dy) + : null; + + // If [Slider.year2023] is false, the thumb uses handle thumb shape and gapped track shape. + // The handle width and track gap are adjusted when the thumb is pressed. + double? thumbWidth = _sliderTheme.thumbSize?.resolve({})?.width; + final double? thumbHeight = _sliderTheme.thumbSize?.resolve({})?.height; + double? trackGap = _sliderTheme.trackGap; + final double? pressedThumbWidth = _sliderTheme.thumbSize?.resolve({ MaterialState.pressed })?.width; + final double delta; + if (_active && thumbWidth != null && pressedThumbWidth != null && trackGap != null) { + delta = thumbWidth - pressedThumbWidth; + if (thumbWidth > 0.0) { + thumbWidth = pressedThumbWidth; + } + if (trackGap > 0.0) { + trackGap = trackGap - delta / 2; + } + } _sliderTheme.trackShape!.paint( context, offset, parentBox: this, - sliderTheme: _sliderTheme, + sliderTheme: _sliderTheme.copyWith(trackGap: trackGap), enableAnimation: _enableAnimation, textDirection: _textDirection, thumbCenter: thumbCenter, @@ -1789,7 +1839,9 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { isDiscrete: isDiscrete, labelPainter: _labelPainter, parentBox: this, - sliderTheme: _sliderTheme, + sliderTheme: thumbWidth != null && thumbHeight != null + ? _sliderTheme.copyWith(thumbSize: MaterialStatePropertyAll(Size(thumbWidth, thumbHeight))) + : _sliderTheme, textDirection: _textDirection, value: _value, textScaleFactor: textScaleFactor, @@ -1960,12 +2012,11 @@ class _RenderValueIndicator extends RenderBox with RelayoutWhenSystemFontsChange } class _SliderDefaultsM2 extends SliderThemeData { - _SliderDefaultsM2(this.context) - : _colors = Theme.of(context).colorScheme, - super(trackHeight: 4.0); + _SliderDefaultsM2(this.context) : super(trackHeight: 4.0); final BuildContext context; - final ColorScheme _colors; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final SliderThemeData sliderTheme = SliderTheme.of(context); @override Color? get activeTrackColor => _colors.primary; @@ -2011,20 +2062,32 @@ class _SliderDefaultsM2 extends SliderThemeData { color: _colors.onPrimary, ); + @override + Color? get valueIndicatorColor { + if (sliderTheme.valueIndicatorShape is RoundedRectSliderValueIndicatorShape) { + return _colors.inverseSurface; + } + return _colors.primary; + } + @override SliderComponentShape? get valueIndicatorShape => const RectangularSliderValueIndicatorShape(); + + @override + SliderComponentShape? get thumbShape => const RoundSliderThumbShape(); + + @override + SliderTrackShape? get trackShape => const RoundedRectSliderTrackShape(); + + @override + SliderComponentShape? get overlayShape => const RoundSliderOverlayShape(); + + @override + SliderTickMarkShape? get tickMarkShape => const RoundSliderTickMarkShape(); } -// TODO(quncheng): Update M3 defaults to match the latest specs. -// BEGIN GENERATED TOKEN PROPERTIES - Slider - -// Do not edit by hand. The code between the "BEGIN GENERATED" and -// "END GENERATED" comments are generated from data in the Material -// Design token database by the script: -// dev/tools/gen_defaults/bin/gen_defaults.dart. - -class _SliderDefaultsM3 extends SliderThemeData { - _SliderDefaultsM3(this.context) +class _SliderDefaultsM3Year2023 extends SliderThemeData { + _SliderDefaultsM3Year2023(this.context) : super(trackHeight: 4.0); final BuildContext context; @@ -2086,8 +2149,134 @@ class _SliderDefaultsM3 extends SliderThemeData { color: _colors.onPrimary, ); + @override + Color? get valueIndicatorColor => _colors.primary; + @override SliderComponentShape? get valueIndicatorShape => const DropSliderValueIndicatorShape(); + + @override + SliderComponentShape? get thumbShape => const RoundSliderThumbShape(); + + @override + SliderTrackShape? get trackShape => const RoundedRectSliderTrackShape(); + + @override + SliderComponentShape? get overlayShape => const RoundSliderOverlayShape(); + + @override + SliderTickMarkShape? get tickMarkShape => const RoundSliderTickMarkShape(); +} + +// BEGIN GENERATED TOKEN PROPERTIES - Slider + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +class _SliderDefaultsM3 extends SliderThemeData { + _SliderDefaultsM3(this.context) + : super(trackHeight: 16.0); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + + @override + Color? get activeTrackColor => _colors.primary; + + @override + Color? get inactiveTrackColor => _colors.secondaryContainer; + + @override + Color? get secondaryActiveTrackColor => _colors.primary.withOpacity(0.54); + + @override + Color? get disabledActiveTrackColor => _colors.onSurface.withOpacity(0.38); + + @override + Color? get disabledInactiveTrackColor => _colors.onSurface.withOpacity(0.12); + + @override + Color? get disabledSecondaryActiveTrackColor => _colors.onSurface.withOpacity(0.38); + + @override + Color? get activeTickMarkColor => _colors.onPrimary.withOpacity(1.0); + + @override + Color? get inactiveTickMarkColor => _colors.onSecondaryContainer.withOpacity(1.0); + + @override + Color? get disabledActiveTickMarkColor => _colors.onInverseSurface; + + @override + Color? get disabledInactiveTickMarkColor => _colors.onSurface; + + @override + Color? get thumbColor => _colors.primary; + + @override + Color? get disabledThumbColor => _colors.onSurface.withOpacity(0.38); + + @override + Color? get overlayColor => MaterialStateColor.resolveWith((Set states) { + if (states.contains(MaterialState.dragged)) { + return _colors.primary.withOpacity(0.1); + } + if (states.contains(MaterialState.hovered)) { + return _colors.primary.withOpacity(0.08); + } + if (states.contains(MaterialState.focused)) { + return _colors.primary.withOpacity(0.1); + } + + return Colors.transparent; + }); + + @override + TextStyle? get valueIndicatorTextStyle => Theme.of(context).textTheme.labelLarge!.copyWith( + color: _colors.onInverseSurface, + ); + + @override + Color? get valueIndicatorColor => _colors.inverseSurface; + + @override + SliderComponentShape? get valueIndicatorShape => const RoundedRectSliderValueIndicatorShape(); + + @override + SliderComponentShape? get thumbShape => const HandleThumbShape(); + + @override + SliderTrackShape? get trackShape => const GappedSliderTrackShape(); + + @override + SliderComponentShape? get overlayShape => const RoundSliderOverlayShape(); + + @override + SliderTickMarkShape? get tickMarkShape => const RoundSliderTickMarkShape(tickMarkRadius: 4.0 / 2); + + @override + MaterialStateProperty? get thumbSize { + return MaterialStateProperty.resolveWith((Set states) { + if (states.contains(MaterialState.disabled)) { + return const Size(4.0, 44.0); + } + if (states.contains(MaterialState.hovered)) { + return const Size(4.0, 44.0); + } + if (states.contains(MaterialState.focused)) { + return const Size(2.0, 44.0); + } + if (states.contains(MaterialState.pressed)) { + return const Size(2.0, 44.0); + } + return const Size(4.0, 44.0); + }); + } + + @override + double? get trackGap => 6.0; } // END GENERATED TOKEN PROPERTIES - Slider diff --git a/packages/flutter/lib/src/material/slider_theme.dart b/packages/flutter/lib/src/material/slider_theme.dart index db1004076b..500fb5fc97 100644 --- a/packages/flutter/lib/src/material/slider_theme.dart +++ b/packages/flutter/lib/src/material/slider_theme.dart @@ -297,6 +297,8 @@ class SliderThemeData with Diagnosticable { this.mouseCursor, this.allowedInteraction, this.padding, + this.thumbSize, + this.trackGap, }); /// Generates a SliderThemeData from three main colors. @@ -597,6 +599,30 @@ class SliderThemeData with Diagnosticable { /// overlay shape, whichever is larger. final EdgeInsetsGeometry? padding; + /// The size of the [HandleThumbShape] thumb. + /// + /// If [SliderThemeData.thumbShape] is [HandleThumbShape], this property is used to + /// set the size of the thumb. Otherwise, the default thumb size is 4 pixels for the + /// width and 44 pixels for the height. + final MaterialStateProperty? thumbSize; + + /// The size of the gap between the active and inactive tracks of the [GappedSliderTrackShape]. + /// + /// If [SliderThemeData.trackShape] is [GappedSliderTrackShape], this property + /// is used to set the gap between the active and inactive tracks. Otherwise, + /// the default gap size is 6.0 pixels. + /// + /// The Slider defaults to [GappedSliderTrackShape] when the track shape is + /// not specified, and the [trackGap] can be used to adjust the gap size. + /// + /// If [Slider.year2023] is false or [ThemeData.useMaterial3] is false, then + /// the Slider track shape defaults to [RoundedRectSliderTrackShape] and the + /// [trackGap] is ignored. In this case, set the track shape to + /// [GappedSliderTrackShape] to use the [trackGap]. + /// + /// Defaults to 6.0 pixels of gap between the active and inactive tracks. + final double? trackGap; + /// Creates a copy of this object but with the given fields replaced with the /// new values. SliderThemeData copyWith({ @@ -633,6 +659,8 @@ class SliderThemeData with Diagnosticable { MaterialStateProperty? mouseCursor, SliderInteraction? allowedInteraction, EdgeInsetsGeometry? padding, + MaterialStateProperty? thumbSize, + double? trackGap, }) { return SliderThemeData( trackHeight: trackHeight ?? this.trackHeight, @@ -668,6 +696,8 @@ class SliderThemeData with Diagnosticable { mouseCursor: mouseCursor ?? this.mouseCursor, allowedInteraction: allowedInteraction ?? this.allowedInteraction, padding: padding ?? this.padding, + thumbSize: thumbSize ?? this.thumbSize, + trackGap: trackGap ?? this.trackGap, ); } @@ -712,6 +742,8 @@ class SliderThemeData with Diagnosticable { mouseCursor: t < 0.5 ? a.mouseCursor : b.mouseCursor, allowedInteraction: t < 0.5 ? a.allowedInteraction : b.allowedInteraction, padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + thumbSize: MaterialStateProperty.lerp(a.thumbSize, b.thumbSize, t, Size.lerp), + trackGap: lerpDouble(a.trackGap, b.trackGap, t), ); } @@ -750,6 +782,8 @@ class SliderThemeData with Diagnosticable { mouseCursor, allowedInteraction, padding, + thumbSize, + trackGap, ), ); @@ -794,7 +828,9 @@ class SliderThemeData with Diagnosticable { && other.thumbSelector == thumbSelector && other.mouseCursor == mouseCursor && other.allowedInteraction == allowedInteraction - && other.padding == padding; + && other.padding == padding + && other.thumbSize == thumbSize + && other.trackGap == trackGap; } @override @@ -834,6 +870,8 @@ class SliderThemeData with Diagnosticable { properties.add(DiagnosticsProperty>('mouseCursor', mouseCursor, defaultValue: defaultData.mouseCursor)); properties.add(EnumProperty('allowedInteraction', allowedInteraction, defaultValue: defaultData.allowedInteraction)); properties.add(DiagnosticsProperty('padding', padding, defaultValue: defaultData.padding)); + properties.add(DiagnosticsProperty>('thumbSize', thumbSize, defaultValue: defaultData.thumbSize)); + properties.add(DoubleProperty('trackGap', trackGap, defaultValue: defaultData.trackGap)); } } @@ -3559,3 +3597,457 @@ class _DropSliderValueIndicatorPathPainter { canvas.restore(); } } + +/// The bar shape of a [Slider]'s thumb. +/// +/// When the slider is enabled, the [ColorScheme.primary] color is used for the +/// thumb. When the slider is disabled, the [ColorScheme.onSurface] color with an +/// opacity of 0.38 is used for the thumb. +/// +/// The thumb bar shape width is reduced when the thumb is pressed. +/// +/// If [SliderThemeData.thumbSize] is null, then the thumb size is 4 pixels for the width +/// and 44 pixels for the height. +/// +/// This is the default thumb shape for [Slider]. If [ThemeData.useMaterial3] is false, +/// then the default thumb shape is [RoundSliderThumbShape]. +/// +/// See also: +/// +/// * [Slider], which includes an overlay defined by this shape. +/// * [SliderTheme], which can be used to configure the overlay shape of all +/// sliders in a widget subtree. +class HandleThumbShape extends SliderComponentShape { + /// Create a slider thumb that draws a bar. + const HandleThumbShape(); + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete) { + return const Size(4.0, 44.0); + } + + @override + void paint( + PaintingContext context, + Offset center, { + required Animation activationAnimation, + required Animation 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, + }) { + assert(sliderTheme.disabledThumbColor != null); + assert(sliderTheme.thumbColor != null); + assert(sliderTheme.thumbSize != null); + + final ColorTween colorTween = ColorTween( + begin: sliderTheme.disabledThumbColor, + end: sliderTheme.thumbColor, + ); + final Color color = colorTween.evaluate(enableAnimation)!; + + final Canvas canvas = context.canvas; + final Size thumbSize = sliderTheme.thumbSize!.resolve({})!; // This is resolved in the paint method. + final RRect rrect = RRect.fromRectAndRadius( + Rect.fromCenter( + center: center, + width: thumbSize.width, + height: thumbSize.height, + ), + Radius.circular(thumbSize.shortestSide / 2), + ); + canvas.drawRRect(rrect, Paint()..color = color); + } +} + +/// The gapped shape of a [Slider]'s track. +/// +/// The [GappedSliderTrackShape] consists of active and inactive +/// tracks. The active track uses the [SliderThemeData.activeTrackColor] and the +/// inactive track uses the [SliderThemeData.inactiveTrackColor]. +/// +/// The track shape uses circular corner radius for the edge corners and a corner radius +/// of 2 pixels for the inside corners. +/// +/// Between the active and inactive tracks there is a gap of size [SliderThemeData.trackGap]. +/// If the [SliderThemeData.thumbShape] is [HandleThumbShape] and the thumb is pressed, the thumb's +/// width is reduced; as a result, the track gap size in [GappedSliderTrackShape] +/// is also reduced. +/// +/// If [SliderThemeData.trackGap] is null, then the track gap size defaults to 6 pixels. +/// +/// This is the default track shape for [Slider]. If [ThemeData.useMaterial3] is false, +/// then the default track shape is [RoundedRectSliderTrackShape]. +/// +/// See also: +/// +/// * [Slider], which includes an overlay defined by this shape. +/// * [SliderTheme], which can be used to configure the overlay shape of all +/// sliders in a widget subtree. +class GappedSliderTrackShape extends SliderTrackShape with BaseSliderTrackShape { + /// Create a slider track that draws two rectangles with rounded outer edges. + const GappedSliderTrackShape(); + + @override + void paint( + PaintingContext context, + Offset offset, { + required RenderBox parentBox, + required SliderThemeData sliderTheme, + required Animation enableAnimation, + required TextDirection textDirection, + required Offset thumbCenter, + Offset? secondaryOffset, + bool isDiscrete = false, + bool isEnabled = false, + double additionalActiveTrackHeight = 2, + }) { + assert(sliderTheme.disabledActiveTrackColor != null); + assert(sliderTheme.disabledInactiveTrackColor != null); + assert(sliderTheme.activeTrackColor != null); + assert(sliderTheme.inactiveTrackColor != null); + assert(sliderTheme.thumbShape != null); + assert(sliderTheme.trackGap != null); + assert(!sliderTheme.trackGap!.isNegative); + // If the slider [SliderThemeData.trackHeight] is less than or equal to 0, + // then it makes no difference whether the track is painted or not, + // therefore the painting can be a no-op. + if (sliderTheme.trackHeight == null || sliderTheme.trackHeight! <= 0) { + return; + } + + // Assign the track segment paints, which are left: active, right: inactive, + // but reversed for right to left text. + final ColorTween activeTrackColorTween = ColorTween( + begin: sliderTheme.disabledActiveTrackColor, + end: sliderTheme.activeTrackColor, + ); + final ColorTween inactiveTrackColorTween = ColorTween( + begin: sliderTheme.disabledInactiveTrackColor, + end: sliderTheme.inactiveTrackColor, + ); + final Paint activePaint = Paint() + ..color = activeTrackColorTween.evaluate(enableAnimation)!; + final Paint inactivePaint = Paint() + ..color = inactiveTrackColorTween.evaluate(enableAnimation)!; + final Paint leftTrackPaint; + final Paint rightTrackPaint; + switch (textDirection) { + case TextDirection.ltr: + leftTrackPaint = activePaint; + rightTrackPaint = inactivePaint; + case TextDirection.rtl: + leftTrackPaint = inactivePaint; + rightTrackPaint = activePaint; + } + + // Gap, starting from the middle of the thumb. + final double trackGap = sliderTheme.trackGap!; + + final Rect trackRect = getPreferredRect( + parentBox: parentBox, + offset: offset, + sliderTheme: sliderTheme, + isEnabled: isEnabled, + isDiscrete: isDiscrete, + ); + + final Radius trackCornerRadius = Radius.circular(trackRect.shortestSide / 2); + const Radius trackInsideCornerRadius = Radius.circular(2.0); + + final RRect trackRRect = RRect.fromRectAndCorners( + trackRect, + topLeft: trackCornerRadius, + bottomLeft: trackCornerRadius, + topRight: trackCornerRadius, + bottomRight: trackCornerRadius, + ); + + final RRect leftRRect = RRect.fromLTRBAndCorners( + trackRect.left, + trackRect.top, + math.max(trackRect.left, thumbCenter.dx - trackGap), + trackRect.bottom, + topLeft: trackCornerRadius, + bottomLeft: trackCornerRadius, + topRight: trackInsideCornerRadius, + bottomRight: trackInsideCornerRadius, + ); + + final RRect rightRRect = RRect.fromLTRBAndCorners( + thumbCenter.dx + trackGap, + trackRect.top, + trackRect.right, + trackRect.bottom, + topRight: trackCornerRadius, + bottomRight: trackCornerRadius, + topLeft: trackInsideCornerRadius, + bottomLeft: trackInsideCornerRadius, + ); + + context.canvas..save()..clipRRect(trackRRect); + final bool drawLeftTrack = thumbCenter.dx > (leftRRect.left + (sliderTheme.trackHeight! / 2)); + final bool drawRightTrack = thumbCenter.dx < (rightRRect.right - (sliderTheme.trackHeight! / 2)); + if (drawLeftTrack) { + context.canvas.drawRRect(leftRRect, leftTrackPaint); + } + if (drawRightTrack) { + context.canvas.drawRRect(rightRRect, rightTrackPaint); + } + + final bool isLTR = textDirection == TextDirection.ltr; + final bool showSecondaryTrack = (secondaryOffset != null) && switch (isLTR) { + true => secondaryOffset.dx > thumbCenter.dx + trackGap, + false => secondaryOffset.dx < thumbCenter.dx - trackGap, + }; + + if (showSecondaryTrack) { + final ColorTween secondaryTrackColorTween = ColorTween(begin: sliderTheme.disabledSecondaryActiveTrackColor, end: sliderTheme.secondaryActiveTrackColor); + final Paint secondaryTrackPaint = Paint()..color = secondaryTrackColorTween.evaluate(enableAnimation)!; + if (isLTR) { + context.canvas.drawRRect( + RRect.fromLTRBAndCorners( + thumbCenter.dx + trackGap, + trackRect.top, + secondaryOffset.dx, + trackRect.bottom, + topLeft: trackInsideCornerRadius, + bottomLeft: trackInsideCornerRadius, + topRight: trackCornerRadius, + bottomRight: trackCornerRadius, + ), + secondaryTrackPaint, + ); + } else { + context.canvas.drawRRect( + RRect.fromLTRBAndCorners( + secondaryOffset.dx - trackGap, + trackRect.top, + thumbCenter.dx, + trackRect.bottom, + topLeft: trackInsideCornerRadius, + bottomLeft: trackInsideCornerRadius, + topRight: trackCornerRadius, + bottomRight: trackCornerRadius, + ), + secondaryTrackPaint, + ); + } + } + context.canvas.restore(); + + const double stopIndicatorRadius = 2.0; + final double stopIndicatorTrailingSpace = sliderTheme.trackHeight! / 2; + final Offset stopIndicatorOffset = Offset( + (textDirection == TextDirection.ltr) + ? trackRect.centerRight.dx - stopIndicatorTrailingSpace + : trackRect.centerLeft.dx + stopIndicatorTrailingSpace, + trackRect.center.dy, + ); + + final bool showStopIndicator = (textDirection == TextDirection.ltr) + ? thumbCenter.dx < stopIndicatorOffset.dx + : thumbCenter.dx > stopIndicatorOffset.dx; + if (showStopIndicator && !isDiscrete) { + final Rect stopIndicatorRect = Rect.fromCircle(center: stopIndicatorOffset, radius: stopIndicatorRadius); + context.canvas.drawCircle(stopIndicatorRect.center, stopIndicatorRadius, activePaint); + } + } + + @override + bool get isRounded => true; +} + +/// The rounded rectangle shape of a [Slider]'s value indicator. +/// +/// If the [SliderThemeData.valueIndicatorColor] is null, then the shape uses the [ColorScheme.inverseSurface] +/// color to draw the value indicator. +/// +/// If the [SliderThemeData.valueIndicatorTextStyle] is null, then the indicator label text style +/// defaults to [TextTheme.labelMedium] with the color set to [ColorScheme.onInverseSurface]. If the +/// [ThemeData.useMaterial3] is set to false, then the indicator label text style defaults to +/// [TextTheme.bodyLarge] with the color set to [ColorScheme.onInverseSurface]. +/// +/// If the [SliderThemeData.valueIndicatorStrokeColor] is provided, then the value indicator is drawn with a +/// stroke border with the color provided. +/// +/// This is the default value indicator shape for [Slider]. If [ThemeData.useMaterial3] is false, +/// then the default value indicator shape is [RectangularSliderValueIndicatorShape]. +/// +/// 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 RoundedRectSliderValueIndicatorShape extends SliderComponentShape { + /// Create a slider value indicator that resembles a rounded rectangle. + const RoundedRectSliderValueIndicatorShape(); + + static const _RoundedRectSliderValueIndicatorPathPainter _pathPainter = _RoundedRectSliderValueIndicatorPathPainter(); + + @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 activationAnimation, + required Animation 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!, + strokePaintColor: sliderTheme.valueIndicatorStrokeColor, + ); + } +} + +class _RoundedRectSliderValueIndicatorPathPainter { + const _RoundedRectSliderValueIndicatorPathPainter(); + + static const double _labelPadding = 10.0; + static const double _preferredHeight = 32.0; + static const double _minLabelWidth = 16.0; + static const double _rectYOffset = 10.0; + static const double _bottomTipYOffset = 16.0; + static const double _preferredHalfHeight = _preferredHeight / 2; + + Size getPreferredSize( + TextPainter labelPainter, + double textScaleFactor, + ) { + 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 * 2); + return unscaledWidth * scale; + } + + 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 - _preferredHeight, + rectangleWidth, + _preferredHeight, + ); + + final Paint fillPaint = Paint()..color = backgroundPaintColor; + + canvas.save(); + // Prepare the canvas for the base of the tooltip, which is relative to the + // center of the thumb. + canvas.translate(center.dx, center.dy - _bottomTipYOffset); + canvas.scale(scale, scale); + + final RRect rrect = RRect.fromRectAndRadius(upperRect, Radius.circular(upperRect.height / 2)); + if (strokePaintColor != null) { + final Paint strokePaint = Paint() + ..color = strokePaintColor + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + canvas.drawRRect(rrect, strokePaint); + } + + canvas.drawRRect(rrect, 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 / 2.3); + final Offset halfLabelPainterOffset = Offset(labelPainter.width / 2, labelPainter.height / 2); + final Offset labelOffset = boxCenter - halfLabelPainterOffset; + labelPainter.paint(canvas, labelOffset); + canvas.restore(); + } +} diff --git a/packages/flutter/test/material/slider_test.dart b/packages/flutter/test/material/slider_test.dart index a1be488cbd..09711842cb 100644 --- a/packages/flutter/test/material/slider_test.dart +++ b/packages/flutter/test/material/slider_test.dart @@ -4687,4 +4687,210 @@ void main() { ), ); }); + + testWidgets('Default Slider when year2023 is false', (WidgetTester tester) async { + debugDisableShadows = false; + try { + final ThemeData theme = ThemeData(); + final ColorScheme colorScheme = theme.colorScheme; + final Color activeTrackColor = colorScheme.primary; + final Color inactiveTrackColor = colorScheme.secondaryContainer; + final Color secondaryActiveTrackColor = colorScheme.primary.withOpacity(0.54); + final Color disabledActiveTrackColor = colorScheme.onSurface.withOpacity(0.38); + final Color disabledInactiveTrackColor = colorScheme.onSurface.withOpacity(0.12); + final Color disabledSecondaryActiveTrackColor = colorScheme.onSurface.withOpacity(0.38); + final Color activeTickMarkColor = colorScheme.onPrimary; + final Color inactiveTickMarkColor = colorScheme.onSecondaryContainer; + final Color disabledActiveTickMarkColor = colorScheme.onInverseSurface; + final Color disabledInactiveTickMarkColor = colorScheme.onSurface; + final Color thumbColor = colorScheme.primary; + final Color disabledThumbColor = colorScheme.onSurface.withOpacity(0.38); + final Color valueIndicatorColor = colorScheme.inverseSurface; + double value = 0.45; + Widget buildApp({ + int? divisions, + bool enabled = true, + }) { + final ValueChanged? onChanged = !enabled + ? null + : (double d) { + value = d; + }; + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Theme( + data: theme, + child: Slider( + year2023: false, + value: value, + secondaryTrackValue: 0.75, + label: '$value', + divisions: divisions, + onChanged: onChanged, + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Test default track shape. + const Radius trackOuterCornerRadius = Radius.circular(8.0); + const Radius trackInnerCornderRadius = Radius.circular(2.0); + expect( + material, + paints + // Active track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, 292.0, 356.4, 308.0, + topLeft: trackOuterCornerRadius, + topRight: trackInnerCornderRadius, + bottomRight: trackInnerCornderRadius, + bottomLeft: trackOuterCornerRadius, + ), + color: activeTrackColor, + ) + // Inctive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 368.4, 292.0, 776.0, 308.0, + topLeft: trackInnerCornderRadius, + topRight: trackOuterCornerRadius, + bottomRight: trackOuterCornerRadius, + bottomLeft: trackInnerCornderRadius, + ), + color: inactiveTrackColor, + ) + ); + + // Test default colors for enabled slider. + expect(material, paints..circle()..rrect(color: thumbColor)); + expect(material, isNot(paints..circle()..circle(color: disabledThumbColor))); + expect(material, isNot(paints..rrect(color: disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledSecondaryActiveTrackColor))); + + // Test defaults colors for discrete slider. + await tester.pumpWidget(buildApp(divisions: 3)); + expect( + material, + paints + ..rrect(color: activeTrackColor) + ..rrect(color: inactiveTrackColor) + ..rrect(color: secondaryActiveTrackColor) + ..circle(color: activeTickMarkColor) + ..circle(color: activeTickMarkColor) + ..circle(color: inactiveTickMarkColor) + ..circle(color: inactiveTickMarkColor) + ); + expect(material, isNot(paints..circle(color: disabledThumbColor))); + expect(material, isNot(paints..rrect(color: disabledActiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledInactiveTrackColor))); + expect(material, isNot(paints..rrect(color: disabledSecondaryActiveTrackColor))); + + // Test defaults colors for disabled slider. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + material, + paints + ..rrect(color: disabledActiveTrackColor) + ..rrect(color: disabledInactiveTrackColor) + ..rrect(color: disabledSecondaryActiveTrackColor), + ); + expect(material, paints..circle()..rrect(color: disabledThumbColor)); + expect(material, isNot(paints..circle()..rrect(color: thumbColor))); + expect(material, isNot(paints..rrect(color: activeTrackColor))); + expect(material, isNot(paints..rrect(color: inactiveTrackColor))); + expect(material, isNot(paints..rrect(color: secondaryActiveTrackColor))); + + // Test defaults colors for disabled discrete slider. + await tester.pumpWidget(buildApp(divisions: 3, enabled: false)); + expect( + material, + paints + ..rrect(color: disabledActiveTrackColor) + ..rrect(color: disabledInactiveTrackColor) + ..rrect(color: disabledSecondaryActiveTrackColor) + ..circle(color: disabledActiveTickMarkColor) + ..circle(color: disabledActiveTickMarkColor) + ..circle(color: disabledInactiveTickMarkColor) + ..circle(color: disabledInactiveTickMarkColor) + ..rrect(color: disabledThumbColor), + ); + expect(material, isNot(paints..circle()..rrect(color: thumbColor))); + expect(material, isNot(paints..rrect(color: activeTrackColor))); + expect(material, isNot(paints..rrect(color: inactiveTrackColor))); + expect(material, isNot(paints..rrect(color: secondaryActiveTrackColor))); + + await tester.pumpWidget(buildApp(divisions: 3)); + await tester.pumpAndSettle(); + + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + final RenderBox valueIndicatorBox = tester.renderObject(find.byType(Overlay)); + expect( + valueIndicatorBox, + paints + ..scale() + ..rrect(color: valueIndicatorColor) + ); + await gesture.up(); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets('Slider value indicator text when year2023 is false', (WidgetTester tester) async { + const double value = 50; + final List log = []; + final LoggingValueIndicatorShape loggingValueIndicatorShape = LoggingValueIndicatorShape(log); + final ThemeData theme = ThemeData( + sliderTheme: SliderThemeData( + valueIndicatorShape: loggingValueIndicatorShape, + ), + ); + + Widget buildSlider() { + return MaterialApp( + theme: theme, + home: Material( + child: Center( + child: Slider( + year2023: false, + max: 100.0, + divisions: 4, + label: '${value.round()}', + value: value, + onChanged: (double newValue) { }, + ), + ), + ), + ); + } + // Normal text + await tester.pumpWidget(buildSlider()); + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect(log.last.toPlainText(), '50'); + expect(log.last.style!.fontSize, 14.0); + expect(log.last.style!.color, theme.colorScheme.onInverseSurface); + + await gesture.up(); + await tester.pumpAndSettle(); + }); } diff --git a/packages/flutter/test/material/slider_theme_test.dart b/packages/flutter/test/material/slider_theme_test.dart index 01a5c65e93..815c643986 100644 --- a/packages/flutter/test/material/slider_theme_test.dart +++ b/packages/flutter/test/material/slider_theme_test.dart @@ -2640,6 +2640,175 @@ void main() { ); }); + testWidgets('Can customize track gap when year2023 is false', (WidgetTester tester) async { + debugDisableShadows = false; + try { + Widget buildSlider({ double? trackGap }) { + return MaterialApp( + theme: ThemeData( + sliderTheme: SliderThemeData( + trackGap: trackGap, + ), + ), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Slider( + year2023: false, + value: 0.5, + onChanged: (double value) { }, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSlider(trackGap: 0)); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + + // Test default track shape. + const Radius trackOuterCornerRadius = Radius.circular(8.0); + const Radius trackInnerCornderRadius = Radius.circular(2.0); + expect( + material, + paints + // Active track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, 292.0, 400.0, 308.0, + topLeft: trackOuterCornerRadius, + topRight: trackInnerCornderRadius, + bottomRight: trackInnerCornderRadius, + bottomLeft: trackOuterCornerRadius, + ), + ) + // Inctive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 400.0, 292.0, 776.0, 308.0, + topLeft: trackInnerCornderRadius, + topRight: trackOuterCornerRadius, + bottomRight: trackOuterCornerRadius, + bottomLeft: trackInnerCornderRadius, + ), + ) + ); + + await tester.pumpWidget(buildSlider(trackGap: 10)); + await tester.pumpAndSettle(); + expect( + material, + paints + // Active track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 24.0, 292.0, 390.0, 308.0, + topLeft: trackOuterCornerRadius, + topRight: trackInnerCornderRadius, + bottomRight: trackInnerCornderRadius, + bottomLeft: trackOuterCornerRadius, + ), + ) + // Inctive track. + ..rrect( + rrect: RRect.fromLTRBAndCorners( + 410.0, 292.0, 776.0, 308.0, + topLeft: trackInnerCornderRadius, + topRight: trackOuterCornerRadius, + bottomRight: trackOuterCornerRadius, + bottomLeft: trackInnerCornderRadius, + ), + ) + ); + } finally { + debugDisableShadows = true; + } + }); + + testWidgets('Can customize thumb size when year2023 is false', (WidgetTester tester) async { + debugDisableShadows = false; + try { + Widget buildSlider({ WidgetStateProperty? thumbSize }) { + return MaterialApp( + theme: ThemeData( + sliderTheme: SliderThemeData( + thumbSize: thumbSize, + ), + ), + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: Center( + child: Slider( + year2023: false, + value: 0.5, + onChanged: (double value) { }, + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildSlider(thumbSize: const WidgetStatePropertyAll(Size(20, 20)))); + + final MaterialInkController material = Material.of(tester.element(find.byType(Slider))); + expect( + material, + paints + ..circle() + ..rrect( + rrect: RRect.fromLTRBR( + 390.0, 290.0, 410.0, 310.0, + const Radius.circular(10.0), + ), + )); + + await tester.pumpWidget(buildSlider(thumbSize: const WidgetStateProperty.fromMap( + { + WidgetState.pressed: Size(20, 20), + WidgetState.any: Size(10, 10), + }, + ))); + await tester.pumpAndSettle(); + + expect( + material, + paints + ..circle() + ..rrect( + rrect: RRect.fromLTRBR( + 395.0, 295.0, 405.0, 305.0, + const Radius.circular(5.0), + ), + )); + + + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect( + material, + paints + ..circle() + ..rrect( + rrect: RRect.fromLTRBR( + 390.0, 295.0, 410.0, 305.0, + const Radius.circular(5.0), + ), + )); + + await gesture.up(); + await tester.pumpAndSettle(); + } finally { + debugDisableShadows = true; + } + }); + group('Material 2', () { // These tests are only relevant for Material 2. Once Material 2 // support is deprecated and the APIs are removed, these tests