diff --git a/dev/integration_tests/flutter_gallery/lib/demo/material/slider_demo.dart b/dev/integration_tests/flutter_gallery/lib/demo/material/slider_demo.dart index 25ece5b356e..4cadff475b4 100644 --- a/dev/integration_tests/flutter_gallery/lib/demo/material/slider_demo.dart +++ b/dev/integration_tests/flutter_gallery/lib/demo/material/slider_demo.dart @@ -69,6 +69,7 @@ class _CustomRangeThumbShape extends RangeSliderThumbShape { @required SliderThemeData sliderTheme, TextDirection textDirection, Thumb thumb, + bool isPressed, }) { final Canvas canvas = context.canvas; final ColorTween colorTween = ColorTween( @@ -130,6 +131,8 @@ class _CustomThumbShape extends SliderComponentShape { SliderThemeData sliderTheme, TextDirection textDirection, double value, + double textScaleFactor, + Size sizeWithOverflow, }) { final Canvas canvas = context.canvas; final ColorTween colorTween = ColorTween( @@ -169,6 +172,8 @@ class _CustomValueIndicatorShape extends SliderComponentShape { SliderThemeData sliderTheme, TextDirection textDirection, double value, + double textScaleFactor, + Size sizeWithOverflow, }) { final Canvas canvas = context.canvas; final ColorTween enableColor = ColorTween( @@ -268,15 +273,21 @@ class _SlidersState extends State<_Sliders> { ), ), ), - Slider.adaptive( - value: _continuousValue, - min: 0.0, - max: 100.0, - onChanged: (double value) { - setState(() { - _continuousValue = value; - }); - }, + SliderTheme( + data: const SliderThemeData( + showValueIndicator: ShowValueIndicator.always, + ), + child: Slider.adaptive( + label: _continuousValue.toStringAsFixed(6).toString(), + value: _continuousValue, + min: 0.0, + max: 100.0, + onChanged: (double value) { + setState(() { + _continuousValue = value; + }); + }, + ), ), const Text('Continuous with Editable Numerical Value'), ], @@ -314,7 +325,7 @@ class _SlidersState extends State<_Sliders> { activeTrackColor: Colors.deepPurple, inactiveTrackColor: theme.colorScheme.onSurface.withOpacity(0.5), activeTickMarkColor: theme.colorScheme.onSurface.withOpacity(0.7), - inactiveTickMarkColor: theme.colorScheme.surface.withOpacity(0.7), + inactiveTickMarkColor: theme.colorScheme.surface.withOpacity(0.7), overlayColor: theme.colorScheme.onSurface.withOpacity(0.12), thumbColor: Colors.deepPurple, valueIndicatorColor: Colors.deepPurpleAccent, diff --git a/dev/integration_tests/flutter_gallery/lib/gallery/themes.dart b/dev/integration_tests/flutter_gallery/lib/gallery/themes.dart index 2f271c7c73b..4099bfc09a2 100644 --- a/dev/integration_tests/flutter_gallery/lib/gallery/themes.dart +++ b/dev/integration_tests/flutter_gallery/lib/gallery/themes.dart @@ -21,10 +21,12 @@ ThemeData _buildDarkTheme() { final ColorScheme colorScheme = const ColorScheme.dark().copyWith( primary: primaryColor, secondary: secondaryColor, + onPrimary: secondaryColor, ); final ThemeData base = ThemeData( brightness: Brightness.dark, accentColorBrightness: Brightness.dark, + colorScheme: colorScheme, primaryColor: primaryColor, primaryColorDark: const Color(0xFF0050a0), primaryColorLight: secondaryColor, diff --git a/packages/flutter/lib/src/material/range_slider.dart b/packages/flutter/lib/src/material/range_slider.dart index d33b4e52ffa..fbe1e5a9f1a 100644 --- a/packages/flutter/lib/src/material/range_slider.dart +++ b/packages/flutter/lib/src/material/range_slider.dart @@ -22,6 +22,11 @@ import 'theme.dart'; // RangeValues _dollarsRange = RangeValues(50, 100); // void setState(VoidCallback fn) { } +/// [RangeSlider] uses this callback to paint the value indicator on the overlay. +/// Since the value indicator is painted on the Overlay; this method paints the +/// value indicator in a [RenderBox] that appears in the [Overlay]. +typedef PaintRangeValueIndicator = void Function(PaintingContext context, Offset offset); + /// A Material Design range slider. /// /// Used to select a range from a range of values. @@ -127,6 +132,7 @@ class RangeSlider extends StatefulWidget { this.activeColor, this.inactiveColor, this.semanticFormatterCallback, + this.useV2Slider = false, }) : assert(values != null), assert(min != null), assert(max != null), @@ -135,6 +141,7 @@ class RangeSlider extends StatefulWidget { assert(values.start >= min && values.start <= max), assert(values.end >= min && values.end <= max), assert(divisions == null || divisions > 0), + assert(useV2Slider != null), super(key: key); /// The currently selected values for this range slider. @@ -333,6 +340,19 @@ class RangeSlider extends StatefulWidget { /// {@end-tool} final RangeSemanticFormatterCallback semanticFormatterCallback; + /// Whether to use the updated Material spec version of the [RangeSlider]. + /// * The v2 [RangeSlider] has an updated value indicator that matches the latest specs. + /// * The value indicator is painted on the Overlay. + /// * The active track is bigger than the inactive track. + /// * The thumb that is activated has elevation. + /// * Updated value indicators in case they overlap with each other. + /// * + /// + /// This is a temporary flag for migrating the slider from v1 to v2. Currently + /// this defaults to false, because the changes may break existing tests. This + /// value will be defaulted to true in the future. + final bool useV2Slider; + // Touch width for the tap boundary of the slider thumbs. static const double _minTouchTargetWidth = kMinInteractiveDimension; @@ -354,6 +374,7 @@ class RangeSlider extends StatefulWidget { properties.add(StringProperty('labelEnd', labels?.end)); properties.add(ColorProperty('activeColor', activeColor)); properties.add(ColorProperty('inactiveColor', inactiveColor)); + properties.add(FlagProperty('useV2Slider', value: useV2Slider, ifFalse: 'useV1Slider')); properties.add(ObjectFlagProperty>.has('semanticFormatterCallback', semanticFormatterCallback)); } } @@ -377,6 +398,10 @@ class _RangeSliderState extends State with TickerProviderStateMixin AnimationController startPositionController; AnimationController endPositionController; Timer interactionTimer; + // Value Indicator paint Animation that appears on the Overlay. + PaintRangeValueIndicator paintTopValueIndicator; + PaintRangeValueIndicator paintBottomValueIndicator; + @override void initState() { @@ -520,14 +545,7 @@ class _RangeSliderState extends State with TickerProviderStateMixin return null; }; - static const double _defaultTrackHeight = 2; - static const RangeSliderTrackShape _defaultTrackShape = RoundedRectRangeSliderTrackShape(); - static const RangeSliderTickMarkShape _defaultTickMarkShape = RoundRangeSliderTickMarkShape(); - static const SliderComponentShape _defaultOverlayShape = RoundSliderOverlayShape(); - static const RangeSliderThumbShape _defaultThumbShape = RoundRangeSliderThumbShape(); - static const RangeSliderValueIndicatorShape _defaultValueIndicatorShape = PaddleRangeSliderValueIndicatorShape(); - static const ShowValueIndicator _defaultShowValueIndicator = ShowValueIndicator.onlyForDiscrete; - static const double _defaultMinThumbSeparation = 8; + @override Widget build(BuildContext context) { @@ -543,6 +561,29 @@ class _RangeSliderState extends State with TickerProviderStateMixin // colors come from the ThemeData.colorScheme. These colors, along with // the default shapes and text styles are aligned to the Material // Guidelines. + + final bool useV2Slider = widget.useV2Slider; + final double _defaultTrackHeight = useV2Slider ? 4 : 2; + final RangeSliderTrackShape _defaultTrackShape = RoundedRectRangeSliderTrackShape(useV2Slider: useV2Slider); + final RangeSliderTickMarkShape _defaultTickMarkShape = RoundRangeSliderTickMarkShape(useV2Slider: useV2Slider); + const SliderComponentShape _defaultOverlayShape = RoundSliderOverlayShape(); + final RangeSliderThumbShape _defaultThumbShape = RoundRangeSliderThumbShape(useV2Slider: useV2Slider); + final RangeSliderValueIndicatorShape _defaultValueIndicatorShape = useV2Slider ? const RectangularRangeSliderValueIndicatorShape() : const PaddleRangeSliderValueIndicatorShape(); + const ShowValueIndicator _defaultShowValueIndicator = ShowValueIndicator.onlyForDiscrete; + const double _defaultMinThumbSeparation = 8; + + // The value indicator's color is not the same as the thumb and active track + // (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 RangeSliderValueIndicatorShape valueIndicatorShape = sliderTheme.rangeValueIndicatorShape ?? _defaultValueIndicatorShape; + Color valueIndicatorColor; + if (valueIndicatorShape is RectangularRangeSliderValueIndicatorShape) { + 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; + } + sliderTheme = sliderTheme.copyWith( trackHeight: sliderTheme.trackHeight ?? _defaultTrackHeight, activeTrackColor: widget.activeColor ?? sliderTheme.activeTrackColor ?? theme.colorScheme.primary, @@ -555,14 +596,14 @@ class _RangeSliderState extends State with TickerProviderStateMixin disabledInactiveTickMarkColor: sliderTheme.disabledInactiveTickMarkColor ?? theme.colorScheme.onSurface.withOpacity(0.12), thumbColor: widget.activeColor ?? sliderTheme.thumbColor ?? theme.colorScheme.primary, overlappingShapeStrokeColor: sliderTheme.overlappingShapeStrokeColor ?? theme.colorScheme.surface, - disabledThumbColor: sliderTheme.disabledThumbColor ?? theme.colorScheme.onSurface.withOpacity(0.38), + disabledThumbColor: sliderTheme.disabledThumbColor ?? Color.alphaBlend(theme.colorScheme.onSurface.withOpacity(.38), const Color(0xFFFFFFFF)), overlayColor: widget.activeColor?.withOpacity(0.12) ?? sliderTheme.overlayColor ?? theme.colorScheme.primary.withOpacity(0.12), - valueIndicatorColor: widget.activeColor ?? sliderTheme.valueIndicatorColor ?? theme.colorScheme.primary, + valueIndicatorColor: valueIndicatorColor, rangeTrackShape: sliderTheme.rangeTrackShape ?? _defaultTrackShape, rangeTickMarkShape: sliderTheme.rangeTickMarkShape ?? _defaultTickMarkShape, rangeThumbShape: sliderTheme.rangeThumbShape ?? _defaultThumbShape, overlayShape: sliderTheme.overlayShape ?? _defaultOverlayShape, - rangeValueIndicatorShape: sliderTheme.rangeValueIndicatorShape ?? _defaultValueIndicatorShape, + rangeValueIndicatorShape: valueIndicatorShape, showValueIndicator: sliderTheme.showValueIndicator ?? _defaultShowValueIndicator, valueIndicatorTextStyle: sliderTheme.valueIndicatorTextStyle ?? theme.textTheme.bodyText1.copyWith( color: theme.colorScheme.onPrimary, @@ -571,19 +612,49 @@ class _RangeSliderState extends State with TickerProviderStateMixin thumbSelector: sliderTheme.thumbSelector ?? _defaultRangeThumbSelector, ); - return _RangeSliderRenderObjectWidget( - values: _unlerpRangeValues(widget.values), - divisions: widget.divisions, - labels: widget.labels, - sliderTheme: sliderTheme, - textScaleFactor: MediaQuery.of(context).textScaleFactor, - onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null, - onChangeStart: widget.onChangeStart != null ? _handleDragStart : null, - onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null, - state: this, - semanticFormatterCallback: widget.semanticFormatterCallback, + // This size is used as the max bounds for the painting of the value + // indicators. It must be kept in sync with the function with the same name + // in slider.dart. + Size _screenSize() => MediaQuery.of(context).size; + + return CompositedTransformTarget( + link: _layerLink, + child: _RangeSliderRenderObjectWidget( + values: _unlerpRangeValues(widget.values), + divisions: widget.divisions, + labels: widget.labels, + sliderTheme: sliderTheme, + textScaleFactor: MediaQuery.of(context).textScaleFactor, + screenSize: _screenSize(), + onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null, + onChangeStart: widget.onChangeStart != null ? _handleDragStart : null, + onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null, + state: this, + semanticFormatterCallback: widget.semanticFormatterCallback, + useV2Slider: widget.useV2Slider, + ), ); } + + final LayerLink _layerLink = LayerLink(); + + OverlayEntry overlayEntry; + + void showValueIndicator() { + if (overlayEntry == null) { + overlayEntry = OverlayEntry( + builder: (BuildContext context) { + return CompositedTransformFollower( + link: _layerLink, + child: _ValueIndicatorRenderObjectWidget( + state: this, + ), + ); + }, + ); + Overlay.of(context).insert(overlayEntry); + } + } } class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget { @@ -594,11 +665,13 @@ class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget { this.labels, this.sliderTheme, this.textScaleFactor, + this.screenSize, this.onChanged, this.onChangeStart, this.onChangeEnd, this.state, this.semanticFormatterCallback, + this.useV2Slider, }) : super(key: key); final RangeValues values; @@ -606,11 +679,13 @@ class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget { final RangeLabels labels; final SliderThemeData sliderTheme; final double textScaleFactor; + final Size screenSize; final ValueChanged onChanged; final ValueChanged onChangeStart; final ValueChanged onChangeEnd; final RangeSemanticFormatterCallback semanticFormatterCallback; final _RangeSliderState state; + final bool useV2Slider; @override _RenderRangeSlider createRenderObject(BuildContext context) { @@ -621,6 +696,7 @@ class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget { sliderTheme: sliderTheme, theme: Theme.of(context), textScaleFactor: textScaleFactor, + screenSize: screenSize, onChanged: onChanged, onChangeStart: onChangeStart, onChangeEnd: onChangeEnd, @@ -628,6 +704,7 @@ class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget { textDirection: Directionality.of(context), semanticFormatterCallback: semanticFormatterCallback, platform: Theme.of(context).platform, + useV2Slider: useV2Slider, ); } @@ -640,6 +717,7 @@ class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget { ..sliderTheme = sliderTheme ..theme = Theme.of(context) ..textScaleFactor = textScaleFactor + ..screenSize = screenSize ..onChanged = onChanged ..onChangeStart = onChangeStart ..onChangeEnd = onChangeEnd @@ -657,6 +735,7 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix SliderThemeData sliderTheme, ThemeData theme, double textScaleFactor, + Size screenSize, TargetPlatform platform, ValueChanged onChanged, RangeSemanticFormatterCallback semanticFormatterCallback, @@ -664,6 +743,7 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix this.onChangeEnd, @required _RangeSliderState state, @required TextDirection textDirection, + bool useV2Slider, }) : assert(values != null), assert(values.start >= 0.0 && values.start <= 1.0), assert(values.end >= 0.0 && values.end <= 1.0), @@ -677,9 +757,11 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix _sliderTheme = sliderTheme, _theme = theme, _textScaleFactor = textScaleFactor, + _screenSize = screenSize, _onChanged = onChanged, _state = state, - _textDirection = textDirection { + _textDirection = textDirection, + _useV2Slider = useV2Slider { _updateLabelPainters(); final GestureArenaTeam team = GestureArenaTeam(); _drag = HorizontalDragGestureRecognizer() @@ -700,7 +782,12 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix _valueIndicatorAnimation = CurvedAnimation( parent: _state.valueIndicatorController, curve: Curves.fastOutSlowIn, - ); + )..addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.dismissed && _state.overlayEntry != null) { + _state.overlayEntry.remove(); + _state.overlayEntry = null; + } + }); _enableAnimation = CurvedAnimation( parent: _state.enableController, curve: Curves.easeInOut, @@ -849,6 +936,15 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix _updateLabelPainters(); } + Size get screenSize => _screenSize; + Size _screenSize; + set screenSize(Size value) { + if (value == screenSize) + return; + _screenSize = value; + markNeedsPaint(); + } + ValueChanged get onChanged => _onChanged; ValueChanged _onChanged; set onChanged(ValueChanged value) { @@ -913,6 +1009,8 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix return 0.05; } + final bool _useV2Slider; + void _updateLabelPainters() { _updateLabelPainter(Thumb.start); _updateLabelPainter(Thumb.end); @@ -1009,6 +1107,7 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix } void _startInteraction(Offset globalPosition) { + _state.showValueIndicator(); final double tapValue = _getValueFromGlobalPosition(globalPosition).clamp(0.0, 1.0) as double; _lastThumbSelection = sliderTheme.thumbSelector(textDirection, values, tapValue, _thumbSize, size, 0); @@ -1200,8 +1299,11 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix isEnabled: isEnabled, ); + final bool startThumbSelected = _lastThumbSelection == Thumb.start; + final bool endThumbSelected = _lastThumbSelection == Thumb.end; + if (!_overlayAnimation.isDismissed) { - if (_lastThumbSelection == Thumb.start) { + if (startThumbSelected) { _sliderTheme.overlayShape.paint( context, startThumbCenter, @@ -1215,7 +1317,7 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix value: startValue, ); } - if (_lastThumbSelection == Thumb.end) { + if (endThumbSelected) { _sliderTheme.overlayShape.paint( context, endThumbCenter, @@ -1236,7 +1338,8 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix isEnabled: isEnabled, sliderTheme: _sliderTheme, ).width; - final double adjustedTrackWidth = trackRect.width - tickMarkWidth; + final double padding = _useV2Slider ? trackRect.height : tickMarkWidth; + final double adjustedTrackWidth = trackRect.width - padding; // If the tick marks would be too dense, don't bother painting them. if (adjustedTrackWidth / divisions >= 3.0 * tickMarkWidth) { final double dy = trackRect.center.dy; @@ -1244,7 +1347,7 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix final double value = i / divisions; // The ticks are mapped to be within the track, so the tick mark width // must be subtracted from the track width. - final double dx = trackRect.left + value * adjustedTrackWidth + tickMarkWidth / 2; + final double dx = trackRect.left + value * adjustedTrackWidth + padding / 2; final Offset tickMarkOffset = Offset(dx, dy); _sliderTheme.rangeTickMarkShape.paint( context, @@ -1273,22 +1376,27 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix final double bottomValue = isLastThumbStart ? endValue : startValue; final double topValue = isLastThumbStart ? startValue : endValue; final bool shouldPaintValueIndicators = isEnabled && labels != null && !_valueIndicatorAnimation.isDismissed && showValueIndicator; + final Size resolvedscreenSize = screenSize.isEmpty ? size : screenSize; if (shouldPaintValueIndicators) { - _sliderTheme.rangeValueIndicatorShape.paint( - context, - bottomThumbCenter, - activationAnimation: _valueIndicatorAnimation, - enableAnimation: _enableAnimation, - isDiscrete: isDiscrete, - isOnTop: false, - labelPainter: bottomLabelPainter, - parentBox: this, - sliderTheme: _sliderTheme, - textDirection: _textDirection, - thumb: bottomThumb, - value: bottomValue, - ); + _state.paintBottomValueIndicator = (PaintingContext context, Offset offset) { + _sliderTheme.rangeValueIndicatorShape.paint( + context, + bottomThumbCenter, + activationAnimation: _valueIndicatorAnimation, + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + isOnTop: false, + labelPainter: bottomLabelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + thumb: bottomThumb, + value: bottomValue, + textScaleFactor: textScaleFactor, + sizeWithOverflow: resolvedscreenSize, + ); + }; } _sliderTheme.rangeThumbShape.paint( @@ -1301,6 +1409,7 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix textDirection: textDirection, sliderTheme: _sliderTheme, thumb: bottomThumb, + isPressed: bottomThumb == Thumb.start ? startThumbSelected : endThumbSelected, ); if (shouldPaintValueIndicators) { @@ -1309,15 +1418,29 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix center: startThumbCenter, labelPainter: _startLabelPainter, activationAnimation: _valueIndicatorAnimation, + textScaleFactor: textScaleFactor, + sizeWithOverflow: resolvedscreenSize, ); final double endOffset = sliderTheme.rangeValueIndicatorShape.getHorizontalShift( parentBox: this, center: endThumbCenter, labelPainter: _endLabelPainter, activationAnimation: _valueIndicatorAnimation, + textScaleFactor: textScaleFactor, + sizeWithOverflow: resolvedscreenSize, ); - final double startHalfWidth = sliderTheme.rangeValueIndicatorShape.getPreferredSize(isEnabled, isDiscrete, labelPainter: _startLabelPainter).width / 2; - final double endHalfWidth = sliderTheme.rangeValueIndicatorShape.getPreferredSize(isEnabled, isDiscrete, labelPainter: _endLabelPainter).width / 2; + final double startHalfWidth = sliderTheme.rangeValueIndicatorShape.getPreferredSize( + isEnabled, + isDiscrete, + labelPainter: _startLabelPainter, + textScaleFactor: textScaleFactor, + ).width / 2; + final double endHalfWidth = sliderTheme.rangeValueIndicatorShape.getPreferredSize( + isEnabled, + isDiscrete, + labelPainter: _endLabelPainter, + textScaleFactor: textScaleFactor, + ).width / 2; double innerOverflow = startHalfWidth + endHalfWidth; switch (textDirection) { case TextDirection.ltr: @@ -1330,32 +1453,37 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix break; } - _sliderTheme.rangeValueIndicatorShape.paint( - context, - topThumbCenter, - activationAnimation: _valueIndicatorAnimation, - enableAnimation: _enableAnimation, - isDiscrete: isDiscrete, - isOnTop: thumbDelta < innerOverflow, - labelPainter: topLabelPainter, - parentBox: this, - sliderTheme: _sliderTheme, - textDirection: _textDirection, - thumb: topThumb, - value: topValue, - ); + _state.paintTopValueIndicator = (PaintingContext context, Offset offset) { + _sliderTheme.rangeValueIndicatorShape.paint( + context, + topThumbCenter, + activationAnimation: _valueIndicatorAnimation, + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + isOnTop: thumbDelta < innerOverflow, + labelPainter: topLabelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + thumb: topThumb, + value: topValue, + textScaleFactor: textScaleFactor, + sizeWithOverflow: resolvedscreenSize, + ); + }; } _sliderTheme.rangeThumbShape.paint( context, topThumbCenter, - activationAnimation: _valueIndicatorAnimation, + activationAnimation: _overlayAnimation, enableAnimation: _enableAnimation, isDiscrete: isDiscrete, isOnTop: thumbDelta < sliderTheme.rangeThumbShape.getPreferredSize(isEnabled, isDiscrete).width, textDirection: textDirection, sliderTheme: _sliderTheme, thumb: topThumb, + isPressed: topThumb == Thumb.start ? startThumbSelected : endThumbSelected, ); } @@ -1419,3 +1547,66 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix return (value - _semanticActionUnit).clamp(0.0, 1.0) as double; } } + + +class _ValueIndicatorRenderObjectWidget extends LeafRenderObjectWidget { + const _ValueIndicatorRenderObjectWidget({ + this.state, + }); + + final _RangeSliderState state; + + @override + _RenderValueIndicator createRenderObject(BuildContext context) { + return _RenderValueIndicator( + state: state, + ); + } + @override + void updateRenderObject(BuildContext context, _RenderValueIndicator renderObject) { + renderObject._state = state; + } +} + +class _RenderValueIndicator extends RenderBox with RelayoutWhenSystemFontsChangeMixin { + _RenderValueIndicator({ + _RangeSliderState state, + }) :_state = state { + _valueIndicatorAnimation = CurvedAnimation( + parent: _state.valueIndicatorController, + curve: Curves.fastOutSlowIn, + ); + } + + Animation _valueIndicatorAnimation; + _RangeSliderState _state; + + @override + bool get sizedByParent => true; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _valueIndicatorAnimation.addListener(markNeedsPaint); + _state.startPositionController.addListener(markNeedsPaint); + _state.endPositionController.addListener(markNeedsPaint); + } + + @override + void detach() { + _valueIndicatorAnimation.removeListener(markNeedsPaint); + _state.startPositionController.removeListener(markNeedsPaint); + _state.endPositionController.removeListener(markNeedsPaint); + super.detach(); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (_state.paintBottomValueIndicator != null) { + _state.paintBottomValueIndicator(context, offset); + } + if (_state.paintTopValueIndicator != null) { + _state.paintTopValueIndicator(context, offset); + } + } +} diff --git a/packages/flutter/lib/src/material/slider.dart b/packages/flutter/lib/src/material/slider.dart index 6a473b10f2c..f920b4d579a 100644 --- a/packages/flutter/lib/src/material/slider.dart +++ b/packages/flutter/lib/src/material/slider.dart @@ -31,6 +31,11 @@ import 'theme.dart'; /// * [Slider.semanticFormatterCallback], which shows an example use case. typedef SemanticFormatterCallback = String Function(double value); +/// [Slider] uses this callback to paint the value indicator on the overlay. +/// Since the value indicator is painted on the Overlay; this method paints the +/// value indicator in a [RenderBox] that appears in the [Overlay]. +typedef PaintValueIndicator = void Function(PaintingContext context, Offset offset); + enum _SliderType { material, adaptive } /// A Material Design slider. @@ -124,6 +129,7 @@ class Slider extends StatefulWidget { this.activeColor, this.inactiveColor, this.semanticFormatterCallback, + this.useV2Slider = false, }) : _sliderType = _SliderType.material, assert(value != null), assert(min != null), @@ -131,6 +137,7 @@ class Slider extends StatefulWidget { assert(min <= max), assert(value >= min && value <= max), assert(divisions == null || divisions > 0), + assert(useV2Slider != null), super(key: key); /// Creates a [CupertinoSlider] if the target platform is iOS, creates a @@ -153,6 +160,7 @@ class Slider extends StatefulWidget { this.activeColor, this.inactiveColor, this.semanticFormatterCallback, + this.useV2Slider = false, }) : _sliderType = _SliderType.adaptive, assert(value != null), assert(min != null), @@ -160,6 +168,7 @@ class Slider extends StatefulWidget { assert(min <= max), assert(value >= min && value <= max), assert(divisions == null || divisions > 0), + assert(useV2Slider != null), super(key: key); /// The currently selected value for this slider. @@ -374,6 +383,19 @@ class Slider extends StatefulWidget { /// Ignored if this slider is created with [Slider.adaptive] final SemanticFormatterCallback semanticFormatterCallback; + /// Whether to use the updated Material spec version of the [Slider]. + /// * The v2 Slider has an updated value indicator that matches the latest specs. + /// * The value indicator is painted on the Overlay. + /// * The active track is bigger than the inactive track. + /// * The thumb that is activated has elevation. + /// * Updated value indicators in case they overlap with each other. + /// * + /// + /// This is a temporary flag for migrating the slider from v1 to v2. To avoid + /// unexpected breaking changes, this value should be set to true. Setting + /// this to false is considered deprecated. + final bool useV2Slider; + final _SliderType _sliderType ; @override @@ -392,6 +414,7 @@ class Slider extends StatefulWidget { properties.add(StringProperty('label', label)); properties.add(ColorProperty('activeColor', activeColor)); properties.add(ColorProperty('inactiveColor', inactiveColor)); + properties.add(FlagProperty('useV2Slider', value: useV2Slider, ifFalse: 'useV1Slider')); properties.add(ObjectFlagProperty>.has('semanticFormatterCallback', semanticFormatterCallback)); } } @@ -412,6 +435,8 @@ class _SliderState extends State with TickerProviderStateMixin { // and the next on a discrete slider. AnimationController positionController; Timer interactionTimer; + // Value Indicator Animation that appears on the Overlay. + PaintValueIndicator paintValueIndicator; @override void initState() { @@ -479,14 +504,6 @@ class _SliderState extends State with TickerProviderStateMixin { return widget.max > widget.min ? (value - widget.min) / (widget.max - widget.min) : 0.0; } - static const double _defaultTrackHeight = 2; - static const SliderTrackShape _defaultTrackShape = RoundedRectSliderTrackShape(); - static const SliderTickMarkShape _defaultTickMarkShape = RoundSliderTickMarkShape(); - static const SliderComponentShape _defaultOverlayShape = RoundSliderOverlayShape(); - static const SliderComponentShape _defaultThumbShape = RoundSliderThumbShape(); - static const SliderComponentShape _defaultValueIndicatorShape = PaddleSliderValueIndicatorShape(); - static const ShowValueIndicator _defaultShowValueIndicator = ShowValueIndicator.onlyForDiscrete; - @override Widget build(BuildContext context) { assert(debugCheckHasMaterial(context)); @@ -525,6 +542,28 @@ class _SliderState extends State with TickerProviderStateMixin { // colors come from the ThemeData.colorScheme. These colors, along with // the default shapes and text styles are aligned to the Material // Guidelines. + + final bool useV2Slider = widget.useV2Slider; + final double _defaultTrackHeight = useV2Slider ? 4 : 2; + final SliderTrackShape _defaultTrackShape = RoundedRectSliderTrackShape(useV2Slider: useV2Slider); + final SliderTickMarkShape _defaultTickMarkShape = RoundSliderTickMarkShape(useV2Slider: useV2Slider); + const SliderComponentShape _defaultOverlayShape = RoundSliderOverlayShape(); + final SliderComponentShape _defaultThumbShape = RoundSliderThumbShape(useV2Slider: useV2Slider); + final SliderComponentShape _defaultValueIndicatorShape = useV2Slider ? const RectangularSliderValueIndicatorShape() : const PaddleSliderValueIndicatorShape(); + const ShowValueIndicator _defaultShowValueIndicator = ShowValueIndicator.onlyForDiscrete; + + // The value indicator's color is not the same as the thumb and active track + // (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; + Color valueIndicatorColor; + if (valueIndicatorShape is RectangularSliderValueIndicatorShape) { + 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; + } + sliderTheme = sliderTheme.copyWith( trackHeight: sliderTheme.trackHeight ?? _defaultTrackHeight, activeTrackColor: widget.activeColor ?? sliderTheme.activeTrackColor ?? theme.colorScheme.primary, @@ -536,31 +575,41 @@ class _SliderState extends State with TickerProviderStateMixin { disabledActiveTickMarkColor: sliderTheme.disabledActiveTickMarkColor ?? theme.colorScheme.onPrimary.withOpacity(0.12), disabledInactiveTickMarkColor: sliderTheme.disabledInactiveTickMarkColor ?? theme.colorScheme.onSurface.withOpacity(0.12), thumbColor: widget.activeColor ?? sliderTheme.thumbColor ?? theme.colorScheme.primary, - disabledThumbColor: sliderTheme.disabledThumbColor ?? theme.colorScheme.onSurface.withOpacity(0.38), + disabledThumbColor: sliderTheme.disabledThumbColor ?? Color.alphaBlend(theme.colorScheme.onSurface.withOpacity(.38), const Color(0xFFFFFFFF)), overlayColor: widget.activeColor?.withOpacity(0.12) ?? sliderTheme.overlayColor ?? theme.colorScheme.primary.withOpacity(0.12), - valueIndicatorColor: widget.activeColor ?? sliderTheme.valueIndicatorColor ?? theme.colorScheme.primary, + valueIndicatorColor: valueIndicatorColor, trackShape: sliderTheme.trackShape ?? _defaultTrackShape, tickMarkShape: sliderTheme.tickMarkShape ?? _defaultTickMarkShape, thumbShape: sliderTheme.thumbShape ?? _defaultThumbShape, overlayShape: sliderTheme.overlayShape ?? _defaultOverlayShape, - valueIndicatorShape: sliderTheme.valueIndicatorShape ?? _defaultValueIndicatorShape, + valueIndicatorShape: valueIndicatorShape, showValueIndicator: sliderTheme.showValueIndicator ?? _defaultShowValueIndicator, valueIndicatorTextStyle: sliderTheme.valueIndicatorTextStyle ?? theme.textTheme.bodyText1.copyWith( color: theme.colorScheme.onPrimary, ), ); - return _SliderRenderObjectWidget( - value: _unlerp(widget.value), - divisions: widget.divisions, - label: widget.label, - sliderTheme: sliderTheme, - mediaQueryData: MediaQuery.of(context), - onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null, - onChangeStart: widget.onChangeStart != null ? _handleDragStart : null, - onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null, - state: this, - semanticFormatterCallback: widget.semanticFormatterCallback, + // This size is used as the max bounds for the painting of the value + // indicators It must be kept in sync with the function with the same name + // in range_slider.dart. + Size _screenSize() => MediaQuery.of(context).size; + + return CompositedTransformTarget( + link: _layerLink, + child: _SliderRenderObjectWidget( + value: _unlerp(widget.value), + divisions: widget.divisions, + label: widget.label, + sliderTheme: sliderTheme, + textScaleFactor: MediaQuery.of(context).textScaleFactor, + screenSize: _screenSize(), + onChanged: (widget.onChanged != null) && (widget.max > widget.min) ? _handleChanged : null, + onChangeStart: widget.onChangeStart != null ? _handleDragStart : null, + onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null, + state: this, + semanticFormatterCallback: widget.semanticFormatterCallback, + useV2Slider: widget.useV2Slider, + ), ); } @@ -582,8 +631,28 @@ class _SliderState extends State with TickerProviderStateMixin { ), ); } + final LayerLink _layerLink = LayerLink(); + + OverlayEntry overlayEntry; + + void showValueIndicator() { + if (overlayEntry == null) { + overlayEntry = OverlayEntry( + builder: (BuildContext context) { + return CompositedTransformFollower( + link: _layerLink, + child: _ValueIndicatorRenderObjectWidget( + state: this, + ), + ); + }, + ); + Overlay.of(context).insert(overlayEntry); + } + } } + class _SliderRenderObjectWidget extends LeafRenderObjectWidget { const _SliderRenderObjectWidget({ Key key, @@ -591,24 +660,28 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { this.divisions, this.label, this.sliderTheme, - this.mediaQueryData, + this.textScaleFactor, + this.screenSize, this.onChanged, this.onChangeStart, this.onChangeEnd, this.state, this.semanticFormatterCallback, + this.useV2Slider, }) : super(key: key); final double value; final int divisions; final String label; final SliderThemeData sliderTheme; - final MediaQueryData mediaQueryData; + final double textScaleFactor; + final Size screenSize; final ValueChanged onChanged; final ValueChanged onChangeStart; final ValueChanged onChangeEnd; final SemanticFormatterCallback semanticFormatterCallback; final _SliderState state; + final bool useV2Slider; @override _RenderSlider createRenderObject(BuildContext context) { @@ -617,7 +690,8 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { divisions: divisions, label: label, sliderTheme: sliderTheme, - mediaQueryData: mediaQueryData, + textScaleFactor: textScaleFactor, + screenSize: screenSize, onChanged: onChanged, onChangeStart: onChangeStart, onChangeEnd: onChangeEnd, @@ -625,6 +699,7 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { textDirection: Directionality.of(context), semanticFormatterCallback: semanticFormatterCallback, platform: Theme.of(context).platform, + useV2Slider: useV2Slider, ); } @@ -636,7 +711,8 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { ..label = label ..sliderTheme = sliderTheme ..theme = Theme.of(context) - ..mediaQueryData = mediaQueryData + ..textScaleFactor = textScaleFactor + ..screenSize = screenSize ..onChanged = onChanged ..onChangeStart = onChangeStart ..onChangeEnd = onChangeEnd @@ -654,7 +730,8 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { int divisions, String label, SliderThemeData sliderTheme, - MediaQueryData mediaQueryData, + double textScaleFactor, + Size screenSize, TargetPlatform platform, ValueChanged onChanged, SemanticFormatterCallback semanticFormatterCallback, @@ -662,19 +739,22 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { this.onChangeEnd, @required _SliderState state, @required TextDirection textDirection, + bool useV2Slider, }) : assert(value != null && value >= 0.0 && value <= 1.0), - assert(state != null), - assert(textDirection != null), - _platform = platform, - _semanticFormatterCallback = semanticFormatterCallback, - _label = label, - _value = value, - _divisions = divisions, - _sliderTheme = sliderTheme, - _mediaQueryData = mediaQueryData, - _onChanged = onChanged, - _state = state, - _textDirection = textDirection { + assert(state != null), + assert(textDirection != null), + _platform = platform, + _semanticFormatterCallback = semanticFormatterCallback, + _label = label, + _value = value, + _divisions = divisions, + _sliderTheme = sliderTheme, + _textScaleFactor = textScaleFactor, + _screenSize = screenSize, + _onChanged = onChanged, + _state = state, + _textDirection = textDirection, + _useV2Slider = useV2Slider { _updateLabelPainter(); final GestureArenaTeam team = GestureArenaTeam(); _drag = HorizontalDragGestureRecognizer() @@ -695,7 +775,12 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _valueIndicatorAnimation = CurvedAnimation( parent: _state.valueIndicatorController, curve: Curves.fastOutSlowIn, - ); + )..addStatusListener((AnimationStatus status) { + if (status == AnimationStatus.dismissed && _state.overlayEntry != null) { + _state.overlayEntry.remove(); + _state.overlayEntry = null; + } + }); _enableAnimation = CurvedAnimation( parent: _state.enableController, curve: Curves.easeInOut, @@ -826,18 +911,26 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { markNeedsPaint(); } - MediaQueryData get mediaQueryData => _mediaQueryData; - MediaQueryData _mediaQueryData; - set mediaQueryData(MediaQueryData value) { - if (value == _mediaQueryData) { + double get textScaleFactor => _textScaleFactor; + double _textScaleFactor; + set textScaleFactor(double value) { + if (value == _textScaleFactor) { return; } - _mediaQueryData = value; - // Media query data includes the textScaleFactor, so we need to update the - // label painter. + _textScaleFactor = value; _updateLabelPainter(); } + Size get screenSize => _screenSize; + Size _screenSize; + set screenSize(Size value) { + if (value == _screenSize) { + return; + } + _screenSize = value; + markNeedsPaint(); + } + ValueChanged get onChanged => _onChanged; ValueChanged _onChanged; set onChanged(ValueChanged value) { @@ -871,6 +964,8 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _updateLabelPainter(); } + final bool _useV2Slider; + bool get showValueIndicator { bool showValueIndicator; switch (_sliderTheme.showValueIndicator) { @@ -915,7 +1010,7 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { text: label, ) ..textDirection = textDirection - ..textScaleFactor = _mediaQueryData.textScaleFactor + ..textScaleFactor = textScaleFactor ..layout(); } else { _labelPainter.text = null; @@ -975,6 +1070,7 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { } void _startInteraction(Offset globalPosition) { + _state.showValueIndicator(); if (isInteractive) { _active = true; // We supply the *current* value as the start location, so that if we have @@ -1008,6 +1104,7 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { _active = false; _currentDragValue = 0.0; _state.overlayController.reverse(); + if (showValueIndicator && _state.interactionTimer == null) { _state.valueIndicatorController.reverse(); } @@ -1130,7 +1227,8 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { isEnabled: isInteractive, sliderTheme: _sliderTheme, ).width; - final double adjustedTrackWidth = trackRect.width - tickMarkWidth; + final double padding = _useV2Slider ? trackRect.height : tickMarkWidth; + final double adjustedTrackWidth = trackRect.width - padding; // If the tick marks would be too dense, don't bother painting them. if (adjustedTrackWidth / divisions >= 3.0 * tickMarkWidth) { final double dy = trackRect.center.dy; @@ -1138,7 +1236,7 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { final double value = i / divisions; // The ticks are mapped to be within the track, so the tick mark width // must be subtracted from the track width. - final double dx = trackRect.left + value * adjustedTrackWidth + tickMarkWidth / 2; + final double dx = trackRect.left + value * adjustedTrackWidth + padding / 2; final Offset tickMarkOffset = Offset(dx, dy); _sliderTheme.tickMarkShape.paint( context, @@ -1156,31 +1254,36 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { if (isInteractive && label != null && !_valueIndicatorAnimation.isDismissed) { if (showValueIndicator) { - _sliderTheme.valueIndicatorShape.paint( - context, - thumbCenter, - activationAnimation: _valueIndicatorAnimation, - enableAnimation: _enableAnimation, - isDiscrete: isDiscrete, - labelPainter: _labelPainter, - parentBox: this, - sliderTheme: _sliderTheme, - textDirection: _textDirection, - value: _value, - ); + _state.paintValueIndicator = (PaintingContext context, Offset offset) { + _sliderTheme.valueIndicatorShape.paint( + context, + offset + thumbCenter, + activationAnimation: _valueIndicatorAnimation, + enableAnimation: _enableAnimation, + isDiscrete: isDiscrete, + labelPainter: _labelPainter, + parentBox: this, + sliderTheme: _sliderTheme, + textDirection: _textDirection, + value: _value, + textScaleFactor: textScaleFactor, + sizeWithOverflow: screenSize.isEmpty ? size : screenSize, + ); + }; } } _sliderTheme.thumbShape.paint( context, thumbCenter, - activationAnimation: _valueIndicatorAnimation, + activationAnimation: _overlayAnimation, enableAnimation: _enableAnimation, isDiscrete: isDiscrete, labelPainter: _labelPainter, parentBox: this, sliderTheme: _sliderTheme, textDirection: _textDirection, + sizeWithOverflow: screenSize.isEmpty ? size : screenSize, value: _value, ); } @@ -1220,3 +1323,59 @@ class _RenderSlider extends RenderBox with RelayoutWhenSystemFontsChangeMixin { } } } + +class _ValueIndicatorRenderObjectWidget extends LeafRenderObjectWidget { + const _ValueIndicatorRenderObjectWidget({ + this.state, + }); + + final _SliderState state; + + @override + _RenderValueIndicator createRenderObject(BuildContext context) { + return _RenderValueIndicator( + state: state, + ); + } + @override + void updateRenderObject(BuildContext context, _RenderValueIndicator renderObject) { + renderObject._state = state; + } +} + +class _RenderValueIndicator extends RenderBox with RelayoutWhenSystemFontsChangeMixin { + _RenderValueIndicator({ + _SliderState state, + }) : _state = state { + _valueIndicatorAnimation = CurvedAnimation( + parent: _state.valueIndicatorController, + curve: Curves.fastOutSlowIn, + ); + } + Animation _valueIndicatorAnimation; + _SliderState _state; + + @override + bool get sizedByParent => true; + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _valueIndicatorAnimation.addListener(markNeedsPaint); + _state.positionController.addListener(markNeedsPaint); + } + + @override + void detach() { + _valueIndicatorAnimation.removeListener(markNeedsPaint); + _state.positionController.removeListener(markNeedsPaint); + super.detach(); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (_state.paintValueIndicator != null) { + _state.paintValueIndicator(context, offset); + } + } +} diff --git a/packages/flutter/lib/src/material/slider_theme.dart b/packages/flutter/lib/src/material/slider_theme.dart index c9579d81f12..8cfe6d4541d 100644 --- a/packages/flutter/lib/src/material/slider_theme.dart +++ b/packages/flutter/lib/src/material/slider_theme.dart @@ -108,6 +108,28 @@ import 'theme_data.dart'; /// track segments. In [TextDirection.ltr], the start of the slider is on the /// left, and in [TextDirection.rtl], the start of the slider is on the right. /// {@endtemplate} +/// +/// {@template flutter.material.slider.useV2Slider} +/// Whether to use the updated Material spec version of the slider shape. +/// +/// This is a temporary flag for migrating the slider from v1 to v2. To avoid +/// unexpected breaking changes, this value should be set to true. Setting +/// this to false is considered deprecated. +/// {@endtemplate} +/// +/// {@template flutter.material.slider.shape.textScaleFactor} +/// Can be used to determine whether the component should +/// paint larger or smaller, depending on whether [textScaleFactor] is greater +/// than 1 for larger, and between 0 and 1 for smaller. It usually comes from +/// [MediaQueryData.textScaleFactor]. +/// {@endtemplate} +/// +/// {@template flutter.material.rangeSlider.shape.sizeWithOverflow} +/// Can be used to determine the bounds the drawing of the +/// components that are outside of the regular slider bounds. It's the size of +/// the box, whose center is aligned with the slider's bounds, that the value +/// indicators must be drawn within. Typically, it is bigger than the slider. +/// {@endtemplate} /// Applies a slider theme to descendant [Slider] widgets. /// @@ -278,7 +300,7 @@ enum Thumb { /// by creating subclasses of [SliderTrackShape], /// [SliderComponentShape], and/or [SliderTickMarkShape]. See /// [RoundSliderThumbShape], [RectangularSliderTrackShape], -/// [RoundSliderTickMarkShape], [PaddleSliderValueIndicatorShape], and +/// [RoundSliderTickMarkShape], [RectangularSliderValueIndicatorShape], and /// [RoundSliderOverlayShape] for examples. /// /// The track painting can be skipped by specifying 0 for [trackHeight]. @@ -490,6 +512,7 @@ class SliderThemeData with Diagnosticable { /// The color given to the [valueIndicatorShape] to draw itself with. final Color valueIndicatorColor; + /// The shape that will be used to draw the [Slider]'s overlay. /// /// Both the [overlayColor] and a non default [overlayShape] may be specified. @@ -976,6 +999,10 @@ abstract class SliderComponentShape { /// [labelPainter] already has the [textDirection] set. /// /// [value] is the current parametric value (from 0.0 to 1.0) of the slider. + /// + /// {@macro flutter.material.slider.shape.textScaleFactor} + /// + /// {@macro flutter.material.slider.shape.sizeWithOverflow} void paint( PaintingContext context, Offset center, { @@ -987,6 +1014,8 @@ abstract class SliderComponentShape { SliderThemeData sliderTheme, TextDirection textDirection, double value, + double textScaleFactor, + Size sizeWithOverflow, }); /// Special instance of [SliderComponentShape] to skip the thumb drawing. @@ -1202,6 +1231,9 @@ abstract class RangeSliderThumbShape { /// left and right thumb. /// /// {@macro flutter.material.rangeSlider.shape.thumb} + /// + /// [isPressed] can be used to give the selected thumb additional selected + /// or pressed state visual feedback, such as a larger shadow. void paint( PaintingContext context, Offset center, { @@ -1213,6 +1245,7 @@ abstract class RangeSliderThumbShape { TextDirection textDirection, SliderThemeData sliderTheme, Thumb thumb, + bool isPressed, }); } @@ -1238,7 +1271,14 @@ abstract class RangeSliderValueIndicatorShape { /// /// [labelPainter] helps determine the width of the shape. It is variable /// width because it is derived from a formatted string. - Size getPreferredSize(bool isEnabled, bool isDiscrete, { TextPainter labelPainter }); + /// + /// {@macro flutter.material.slider.shape.textScaleFactor} + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + TextPainter labelPainter, + double textScaleFactor, + }); /// Determines the best offset to keep this shape on the screen. /// @@ -1249,6 +1289,8 @@ abstract class RangeSliderValueIndicatorShape { Offset center, TextPainter labelPainter, Animation activationAnimation, + double textScaleFactor, + Size sizeWithOverflow, }) { return 0; } @@ -1270,6 +1312,12 @@ abstract class RangeSliderValueIndicatorShape { /// the default case, this is used to paint a stroke around the top indicator /// for better visibility between the two indicators. /// + /// {@macro flutter.material.slider.shape.textScaleFactor} + /// + /// {@macro flutter.material.slider.shape.sizeWithOverflow} + /// + /// {@macro flutter.material.rangeSlider.shape.parentBox} + /// /// {@macro flutter.material.rangeSlider.shape.sliderTheme} /// /// [textDirection] can be used to determine how any extra text or graphics, @@ -1287,6 +1335,8 @@ abstract class RangeSliderValueIndicatorShape { bool isDiscrete, bool isOnTop, TextPainter labelPainter, + double textScaleFactor, + Size sizeWithOverflow, RenderBox parentBox, SliderThemeData sliderTheme, TextDirection textDirection, @@ -1552,17 +1602,17 @@ class RectangularSliderTrackShape extends SliderTrackShape with BaseSliderTrackS assert(thumbCenter != null); assert(isEnabled != null); assert(isDiscrete != null); - // If the slider track height 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 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 <= 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 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); Paint leftTrackPaint; @@ -1586,11 +1636,10 @@ class RectangularSliderTrackShape extends SliderTrackShape with BaseSliderTrackS isDiscrete: isDiscrete, ); - final Size thumbSize = sliderTheme.thumbShape.getPreferredSize(isEnabled, isDiscrete); - final Rect leftTrackSegment = Rect.fromLTRB(trackRect.left + trackRect.height / 2, trackRect.top, thumbCenter.dx - thumbSize.width / 2, trackRect.bottom); + final Rect leftTrackSegment = Rect.fromLTRB(trackRect.left + trackRect.height / 2, trackRect.top, thumbCenter.dx, trackRect.bottom); if (!leftTrackSegment.isEmpty) context.canvas.drawRect(leftTrackSegment, leftTrackPaint); - final Rect rightTrackSegment = Rect.fromLTRB(thumbCenter.dx + thumbSize.width / 2, trackRect.top, trackRect.right, trackRect.bottom); + final Rect rightTrackSegment = Rect.fromLTRB(thumbCenter.dx, trackRect.top, trackRect.right, trackRect.bottom); if (!rightTrackSegment.isEmpty) context.canvas.drawRect(rightTrackSegment, rightTrackPaint); } @@ -1620,7 +1669,10 @@ class RectangularSliderTrackShape extends SliderTrackShape with BaseSliderTrackS /// * [RectangularSliderTrackShape], for a similar track with sharp edges. class RoundedRectSliderTrackShape extends SliderTrackShape with BaseSliderTrackShape { /// Create a slider track that draws two rectangles with rounded outer edges. - const RoundedRectSliderTrackShape(); + const RoundedRectSliderTrackShape({ this.useV2Slider = false }); + + /// {@macro flutter.material.slider.useV2Slider} + final bool useV2Slider; @override void paint( @@ -1633,6 +1685,7 @@ class RoundedRectSliderTrackShape extends SliderTrackShape with BaseSliderTrackS @required Offset thumbCenter, bool isDiscrete = false, bool isEnabled = false, + double additionalActiveTrackHeight = 2, }) { assert(context != null); assert(offset != null); @@ -1646,9 +1699,9 @@ class RoundedRectSliderTrackShape extends SliderTrackShape with BaseSliderTrackS assert(enableAnimation != null); assert(textDirection != null); assert(thumbCenter != null); - // If the slider track height 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 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 <= 0) { return; } @@ -1679,22 +1732,49 @@ class RoundedRectSliderTrackShape extends SliderTrackShape with BaseSliderTrackS isEnabled: isEnabled, isDiscrete: isDiscrete, ); + final Radius trackRadius = Radius.circular(trackRect.height / 2); + final Radius activeTrackRadius = Radius.circular(trackRect.height / 2 + 1); - // The arc rects create a semi-circle with radius equal to track height. - final Rect leftTrackArcRect = Rect.fromLTWH(trackRect.left, trackRect.top, trackRect.height, trackRect.height); - if (!leftTrackArcRect.isEmpty) - context.canvas.drawArc(leftTrackArcRect, math.pi / 2, math.pi, false, leftTrackPaint); - final Rect rightTrackArcRect = Rect.fromLTWH(trackRect.right - trackRect.height / 2, trackRect.top, trackRect.height, trackRect.height); - if (!rightTrackArcRect.isEmpty) - context.canvas.drawArc(rightTrackArcRect, -math.pi / 2, math.pi, false, rightTrackPaint); + if (useV2Slider) { + context.canvas.drawRRect( + RRect.fromLTRBAndCorners( + trackRect.left, + (textDirection == TextDirection.ltr) ? trackRect.top - (additionalActiveTrackHeight / 2): trackRect.top, + thumbCenter.dx, + (textDirection == TextDirection.ltr) ? trackRect.bottom + (additionalActiveTrackHeight / 2) : trackRect.bottom, + topLeft: (textDirection == TextDirection.ltr) ? activeTrackRadius : trackRadius, + bottomLeft: (textDirection == TextDirection.ltr) ? activeTrackRadius: trackRadius, + ), + leftTrackPaint, + ); + context.canvas.drawRRect( + RRect.fromLTRBAndCorners( + thumbCenter.dx, + (textDirection == TextDirection.rtl) ? trackRect.top - (additionalActiveTrackHeight / 2) : trackRect.top, + trackRect.right, + (textDirection == TextDirection.rtl) ? trackRect.bottom + (additionalActiveTrackHeight / 2) : trackRect.bottom, + topRight: (textDirection == TextDirection.rtl) ? activeTrackRadius : trackRadius, + bottomRight: (textDirection == TextDirection.rtl) ? activeTrackRadius : trackRadius, + ), + rightTrackPaint, + ); + } else { + // The arc rects create a semi-circle with radius equal to track height. + final Rect leftTrackArcRect = Rect.fromLTWH(trackRect.left, trackRect.top, trackRect.height, trackRect.height); + if (!leftTrackArcRect.isEmpty) + context.canvas.drawArc(leftTrackArcRect, math.pi / 2, math.pi, false, leftTrackPaint); + final Rect rightTrackArcRect = Rect.fromLTWH(trackRect.right - trackRect.height / 2, trackRect.top, trackRect.height, trackRect.height); + if (!rightTrackArcRect.isEmpty) + context.canvas.drawArc(rightTrackArcRect, -math.pi / 2, math.pi, false, rightTrackPaint); - final Size thumbSize = sliderTheme.thumbShape.getPreferredSize(isEnabled, isDiscrete); - final Rect leftTrackSegment = Rect.fromLTRB(trackRect.left + trackRect.height / 2, trackRect.top, thumbCenter.dx - thumbSize.width / 2, trackRect.bottom); - if (!leftTrackSegment.isEmpty) - context.canvas.drawRect(leftTrackSegment, leftTrackPaint); - final Rect rightTrackSegment = Rect.fromLTRB(thumbCenter.dx + thumbSize.width / 2, trackRect.top, trackRect.right, trackRect.bottom); - if (!rightTrackSegment.isEmpty) - context.canvas.drawRect(rightTrackSegment, rightTrackPaint); + final Size thumbSize = sliderTheme.thumbShape.getPreferredSize(isEnabled, isDiscrete); + final Rect leftTrackSegment = Rect.fromLTRB(trackRect.left + trackRect.height / 2, trackRect.top, thumbCenter.dx - thumbSize.width / 2, trackRect.bottom); + if (!leftTrackSegment.isEmpty) + context.canvas.drawRect(leftTrackSegment, leftTrackPaint); + final Rect rightTrackSegment = Rect.fromLTRB(thumbCenter.dx + thumbSize.width / 2, trackRect.top, trackRect.right, trackRect.bottom); + if (!rightTrackSegment.isEmpty) + context.canvas.drawRect(rightTrackSegment, rightTrackPaint); + } } } @@ -1726,7 +1806,10 @@ class RectangularRangeSliderTrackShape extends RangeSliderTrackShape { /// /// The middle track segment is the selected range and is active, and the two /// outer track segments are inactive. - const RectangularRangeSliderTrackShape(); + const RectangularRangeSliderTrackShape({this.useV2Slider}); + + /// {@macro flutter.material.slider.useV2Slider} + final bool useV2Slider; @override Rect getPreferredRect({ @@ -1785,8 +1868,8 @@ class RectangularRangeSliderTrackShape extends RangeSliderTrackShape { assert(textDirection != null); // 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 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); @@ -1852,7 +1935,10 @@ class RoundedRectRangeSliderTrackShape extends RangeSliderTrackShape { /// /// The middle track segment is the selected range and is active, and the two /// outer track segments are inactive. - const RoundedRectRangeSliderTrackShape(); + const RoundedRectRangeSliderTrackShape({ this.useV2Slider }); + + /// {@macro flutter.material.slider.useV2Slider} + final bool useV2Slider; @override Rect getPreferredRect({ @@ -1894,6 +1980,7 @@ class RoundedRectRangeSliderTrackShape extends RangeSliderTrackShape { bool isEnabled = false, bool isDiscrete = false, @required TextDirection textDirection, + double additionalActiveTrackHeight = 2, }) { assert(context != null); assert(offset != null); @@ -1910,12 +1997,23 @@ class RoundedRectRangeSliderTrackShape extends RangeSliderTrackShape { assert(isEnabled != null); assert(isDiscrete != null); assert(textDirection != null); + + if (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 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); Offset leftThumbOffset; Offset rightThumbOffset; @@ -1940,25 +2038,62 @@ class RoundedRectRangeSliderTrackShape extends RangeSliderTrackShape { isEnabled: isEnabled, isDiscrete: isDiscrete, ); - final double trackRadius = trackRect.height / 2; - final Rect leftTrackArcRect = Rect.fromLTWH(trackRect.left, trackRect.top, trackRect.height, trackRect.height); - if (!leftTrackArcRect.isEmpty) - context.canvas.drawArc(leftTrackArcRect, math.pi / 2, math.pi, false, inactivePaint); + if (useV2Slider) { + final Radius trackRadius = Radius.circular(trackRect.height / 2); - final Rect leftTrackSegment = Rect.fromLTRB(trackRect.left + trackRadius, trackRect.top, leftThumbOffset.dx - thumbRadius, trackRect.bottom); - if (!leftTrackSegment.isEmpty) - context.canvas.drawRect(leftTrackSegment, inactivePaint); - final Rect middleTrackSegment = Rect.fromLTRB(leftThumbOffset.dx + thumbRadius, trackRect.top, rightThumbOffset.dx - thumbRadius, trackRect.bottom); - if (!middleTrackSegment.isEmpty) - context.canvas.drawRect(middleTrackSegment, activePaint); - final Rect rightTrackSegment = Rect.fromLTRB(rightThumbOffset.dx + thumbRadius, trackRect.top, trackRect.right - trackRadius, trackRect.bottom); - if (!rightTrackSegment.isEmpty) - context.canvas.drawRect(rightTrackSegment, inactivePaint); + context.canvas.drawRRect( + RRect.fromLTRBAndCorners( + trackRect.left, + trackRect.top, + leftThumbOffset.dx, + trackRect.bottom, + topLeft: trackRadius, + bottomLeft: trackRadius, + ), + inactivePaint, + ); + context.canvas.drawRect( + Rect.fromLTRB( + leftThumbOffset.dx, + trackRect.top - (additionalActiveTrackHeight / 2), + rightThumbOffset.dx, + trackRect.bottom + (additionalActiveTrackHeight / 2), + ), + activePaint, + ); + context.canvas.drawRRect( + RRect.fromLTRBAndCorners( + rightThumbOffset.dx, + trackRect.top, + trackRect.right, + trackRect.bottom, + topRight: trackRadius, + bottomRight: trackRadius, + ), + inactivePaint, + ); + } else { + final double trackRadius = trackRect.height / 2; - final Rect rightTrackArcRect = Rect.fromLTWH(trackRect.right - trackRect.height, trackRect.top, trackRect.height, trackRect.height); - if (!rightTrackArcRect.isEmpty) - context.canvas.drawArc(rightTrackArcRect, -math.pi / 2, math.pi, false, inactivePaint); + final Rect leftTrackArcRect = Rect.fromLTWH(trackRect.left, trackRect.top, trackRect.height, trackRect.height); + if (!leftTrackArcRect.isEmpty) + context.canvas.drawArc(leftTrackArcRect, math.pi / 2, math.pi, false, inactivePaint); + + final Rect leftTrackSegment = Rect.fromLTRB(trackRect.left + trackRadius, trackRect.top, leftThumbOffset.dx - thumbRadius, trackRect.bottom); + if (!leftTrackSegment.isEmpty) + context.canvas.drawRect(leftTrackSegment, inactivePaint); + final Rect middleTrackSegment = Rect.fromLTRB(leftThumbOffset.dx + thumbRadius, trackRect.top, rightThumbOffset.dx - thumbRadius, trackRect.bottom); + if (!middleTrackSegment.isEmpty) + context.canvas.drawRect(middleTrackSegment, activePaint); + final Rect rightTrackSegment = Rect.fromLTRB(rightThumbOffset.dx + thumbRadius, trackRect.top, trackRect.right - trackRadius, trackRect.bottom); + if (!rightTrackSegment.isEmpty) + context.canvas.drawRect(rightTrackSegment, inactivePaint); + + final Rect rightTrackArcRect = Rect.fromLTWH(trackRect.right - trackRect.height, trackRect.top, trackRect.height, trackRect.height); + if (!rightTrackArcRect.isEmpty) + context.canvas.drawArc(rightTrackArcRect, -math.pi / 2, math.pi, false, inactivePaint); + } } } @@ -1982,13 +2117,21 @@ class RoundedRectRangeSliderTrackShape extends RangeSliderTrackShape { /// sliders in a widget subtree. class RoundSliderTickMarkShape extends SliderTickMarkShape { /// Create a slider tick mark that draws a circle. - const RoundSliderTickMarkShape({ this.tickMarkRadius }); + const RoundSliderTickMarkShape({ + this.tickMarkRadius, + this.useV2Slider = false, + }); /// The preferred radius of the round tick mark. /// - /// If it is not provided, then half of the track height is used. + /// If it is not provided, and [useV2Slider] is true, then 1/4 of the + /// [SliderThemeData.trackHeight] is used. If it is not provided, and + /// [useV2Slider] is false, then half of the track height is used. final double tickMarkRadius; + /// {@macro flutter.material.slider.useV2Slider} + final bool useV2Slider; + @override Size getPreferredSize({ @required SliderThemeData sliderTheme, @@ -1997,9 +2140,11 @@ class RoundSliderTickMarkShape extends SliderTickMarkShape { assert(sliderTheme != null); assert(sliderTheme.trackHeight != null); assert(isEnabled != null); - // The tick marks are tiny circles. If no radius is provided, then they are - // defaulted to be the same height as the track. - return Size.fromRadius(tickMarkRadius ?? sliderTheme.trackHeight / 2); + // The tick marks are tiny circles. If no radius is provided, then the + // radius is defaulted to be a fraction of the + // [SliderThemeData.trackHeight]. The fraction is 1/4 when [useV2Slider] is + // true, and 1/2 when it is false. + return Size.fromRadius(tickMarkRadius ?? sliderTheme.trackHeight / (useV2Slider ? 4 : 2)); } @override @@ -2045,9 +2190,9 @@ class RoundSliderTickMarkShape extends SliderTickMarkShape { // The tick marks are tiny circles that are the same height as the track. final double tickMarkRadius = getPreferredSize( - isEnabled: isEnabled, - sliderTheme: sliderTheme, - ).width / 2; + isEnabled: isEnabled, + sliderTheme: sliderTheme, + ).width / 2; if (tickMarkRadius > 0) { context.canvas.drawCircle(center, tickMarkRadius, paint); } @@ -2074,13 +2219,21 @@ class RoundSliderTickMarkShape extends SliderTickMarkShape { /// sliders in a widget subtree. class RoundRangeSliderTickMarkShape extends RangeSliderTickMarkShape { /// Create a range slider tick mark that draws a circle. - const RoundRangeSliderTickMarkShape({ this.tickMarkRadius }); + const RoundRangeSliderTickMarkShape({ + this.tickMarkRadius, + this.useV2Slider = false, + }); /// The preferred radius of the round tick mark. /// - /// If it is not provided, then half of the track height is used. + /// If it is not provided, and [useV2Slider] is true, then 1/4 of the + /// [SliderThemeData.trackHeight] is used. If it is not provided, and + /// [useV2Slider] is false, then half of the track height is used. final double tickMarkRadius; + /// {@macro flutter.material.slider.useV2Slider} + final bool useV2Slider; + @override Size getPreferredSize({ @required SliderThemeData sliderTheme, @@ -2089,7 +2242,7 @@ class RoundRangeSliderTickMarkShape extends RangeSliderTickMarkShape { assert(sliderTheme != null); assert(sliderTheme.trackHeight != null); assert(isEnabled != null); - return Size.fromRadius(tickMarkRadius ?? sliderTheme.trackHeight / 2); + return Size.fromRadius(tickMarkRadius ?? sliderTheme.trackHeight / (useV2Slider ? 4 : 2)); } @override @@ -2198,6 +2351,8 @@ class _EmptySliderComponentShape extends SliderComponentShape { SliderThemeData sliderTheme, TextDirection textDirection, double value, + double textScaleFactor, + Size sizeWithOverflow, }) { // no-op. } @@ -2205,6 +2360,9 @@ class _EmptySliderComponentShape extends SliderComponentShape { /// The default shape of a [Slider]'s thumb. /// +/// If [useV2Slider] is true, then there is a shadow for the resting and +/// pressed state. +/// /// See also: /// /// * [Slider], which includes a thumb defined by this shape. @@ -2215,6 +2373,9 @@ class RoundSliderThumbShape extends SliderComponentShape { const RoundSliderThumbShape({ this.enabledThumbRadius = 10.0, this.disabledThumbRadius, + this.elevation = 1.0, + this.pressedElevation = 6.0, + this.useV2Slider = false, }); /// The preferred radius of the round thumb shape when the slider is enabled. @@ -2227,7 +2388,31 @@ class RoundSliderThumbShape extends SliderComponentShape { /// If no disabledRadius is provided, then it is equal to the /// [enabledThumbRadius] final double disabledThumbRadius; - double get _disabledThumbRadius => disabledThumbRadius ?? enabledThumbRadius; + double get _disabledThumbRadius => disabledThumbRadius ?? enabledThumbRadius; + + /// The resting elevation adds shadow to the unpressed thumb. + /// + /// This value is only used when [useV2Slider] is true. + /// + /// The default is 1. + /// + /// Use 0 for no shadow. The higher the value, the larger the shadow. For + /// example, a value of 12 will create a very large shadow. + /// + final double elevation; + + /// The pressed elevation adds shadow to the pressed thumb. + /// + /// This value is only used when [useV2Slider] is true. + /// + /// The default is 6. + /// + /// Use 0 for no shadow. The higher the value, the larger the shadow. For + /// example, a value of 12 will create a very large shadow. + final double pressedElevation; + + /// {@macro flutter.material.slider.useV2Slider} + final bool useV2Slider; @override Size getPreferredSize(bool isEnabled, bool isDiscrete) { @@ -2246,6 +2431,8 @@ class RoundSliderThumbShape extends SliderComponentShape { @required SliderThemeData sliderTheme, TextDirection textDirection, double value, + double textScaleFactor, + Size sizeWithOverflow, }) { assert(context != null); assert(center != null); @@ -2253,6 +2440,7 @@ class RoundSliderThumbShape extends SliderComponentShape { assert(sliderTheme != null); assert(sliderTheme.disabledThumbColor != null); assert(sliderTheme.thumbColor != null); + assert(!sizeWithOverflow.isEmpty); final Canvas canvas = context.canvas; final Tween radiusTween = Tween( @@ -2263,16 +2451,35 @@ class RoundSliderThumbShape extends SliderComponentShape { begin: sliderTheme.disabledThumbColor, end: sliderTheme.thumbColor, ); + + final Color color = colorTween.evaluate(enableAnimation); + final double radius = radiusTween.evaluate(enableAnimation); + + if (useV2Slider) { + final Tween elevationTween = Tween( + begin: elevation, + end: pressedElevation, + ); + + final double evaluatedElevation = elevationTween.evaluate(activationAnimation); + final Path path = Path() + ..addArc(Rect.fromCenter(center: center, width: 2 * radius, height: 2 * radius), 0, math.pi * 2); + canvas.drawShadow(path, Colors.black, evaluatedElevation, true); + } + canvas.drawCircle( center, - radiusTween.evaluate(enableAnimation), - Paint()..color = colorTween.evaluate(enableAnimation), + radius, + Paint()..color = color, ); } } /// The default shape of a [RangeSlider]'s thumbs. /// +/// If [useV2Slider] is true, then there is a shadow for the resting and +/// pressed state. +/// /// See also: /// /// * [RangeSlider], which includes thumbs defined by this shape. @@ -2283,8 +2490,14 @@ class RoundRangeSliderThumbShape extends RangeSliderThumbShape { const RoundRangeSliderThumbShape({ this.enabledThumbRadius = 10.0, this.disabledThumbRadius, + this.elevation = 1.0, + this.pressedElevation = 6.0, + this.useV2Slider = false, }) : assert(enabledThumbRadius != null); + /// {@macro flutter.material.slider.useV2Slider} + final bool useV2Slider; + /// The preferred radius of the round thumb shape when the slider is enabled. /// /// If it is not provided, then the material default of 10 is used. @@ -2295,7 +2508,17 @@ class RoundRangeSliderThumbShape extends RangeSliderThumbShape { /// If no disabledRadius is provided, then it is equal to the /// [enabledThumbRadius]. final double disabledThumbRadius; - double get _disabledThumbRadius => disabledThumbRadius ?? enabledThumbRadius; + double get _disabledThumbRadius => disabledThumbRadius ?? enabledThumbRadius; + + /// The resting elevation adds shadow to the unpressed thumb. + /// + /// The default is 1. + final double elevation; + + /// The pressed elevation adds shadow to the pressed thumb. + /// + /// The default is 6. + final double pressedElevation; @override Size getPreferredSize(bool isEnabled, bool isDiscrete) { @@ -2314,6 +2537,7 @@ class RoundRangeSliderThumbShape extends RangeSliderThumbShape { @required SliderThemeData sliderTheme, TextDirection textDirection, Thumb thumb, + bool isPressed, }) { assert(context != null); assert(center != null); @@ -2332,6 +2556,10 @@ class RoundRangeSliderThumbShape extends RangeSliderThumbShape { end: sliderTheme.thumbColor, ); final double radius = radiusTween.evaluate(enableAnimation); + final Tween elevationTween = Tween( + begin: elevation, + end: pressedElevation, + ); // Add a stroke of 1dp around the circle if this thumb would overlap // the other thumb. @@ -2361,10 +2589,19 @@ class RoundRangeSliderThumbShape extends RangeSliderThumbShape { } } + final Color color = colorTween.evaluate(enableAnimation); + + if (useV2Slider) { + final double evaluatedElevation = isPressed ? elevationTween.evaluate(activationAnimation) : elevation; + final Path shadowPath = Path() + ..addArc(Rect.fromCenter(center: center, width: 2 * radius, height: 2 * radius), 0, math.pi * 2); + canvas.drawShadow(shadowPath, Colors.black, evaluatedElevation, true); + } + canvas.drawCircle( center, radius, - Paint()..color = colorTween.evaluate(enableAnimation), + Paint()..color = color, ); } } @@ -2390,7 +2627,8 @@ class RoundSliderOverlayShape extends SliderComponentShape { /// The preferred radius of the round thumb shape when enabled. /// - /// If it is not provided, then half of the track height is used. + /// If it is not provided, then half of the [SliderThemeData.trackHeight] is + /// used. final double overlayRadius; @override @@ -2410,6 +2648,8 @@ class RoundSliderOverlayShape extends SliderComponentShape { @required SliderThemeData sliderTheme, @required TextDirection textDirection, @required double value, + double textScaleFactor, + Size sizeWithOverflow, }) { assert(context != null); assert(center != null); @@ -2442,16 +2682,22 @@ class RoundSliderOverlayShape extends SliderComponentShape { /// * [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 PaddleSliderValueIndicatorShape extends SliderComponentShape { - /// Create a slider value indicator in the shape of an upside-down pear. - const PaddleSliderValueIndicatorShape(); +class RectangularSliderValueIndicatorShape extends SliderComponentShape { + /// Create a slider value indicator that resembles a rectangular tooltip. + const RectangularSliderValueIndicatorShape(); - static const _PaddleSliderTrackShapePathPainter _pathPainter = _PaddleSliderTrackShapePathPainter(); + static const _RectangularSliderValueIndicatorPathPainter _pathPainter = _RectangularSliderValueIndicatorPathPainter(); @override - Size getPreferredSize(bool isEnabled, bool isDiscrete, { @required TextPainter labelPainter }) { - assert(labelPainter != null); - return _pathPainter.getPreferredSize(isEnabled, isDiscrete, labelPainter); + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + @required TextPainter labelPainter, + @required double textScaleFactor, + }) { + assert(labelPainter != null); + assert(textScaleFactor != null && textScaleFactor >= 0); + return _pathPainter.getPreferredSize(isEnabled, isDiscrete, labelPainter, textScaleFactor); } @override @@ -2466,27 +2712,20 @@ class PaddleSliderValueIndicatorShape extends SliderComponentShape { @required SliderThemeData sliderTheme, TextDirection textDirection, double value, + double textScaleFactor, + Size sizeWithOverflow, }) { - assert(context != null); - assert(center != null); - assert(activationAnimation != null); - assert(enableAnimation != null); - assert(labelPainter != null); - assert(parentBox != null); - assert(sliderTheme != null); - final ColorTween enableColor = ColorTween( - begin: sliderTheme.disabledThumbColor, - end: sliderTheme.valueIndicatorColor, - ); - _pathPainter.drawValueIndicator( - parentBox, - context.canvas, - center, - Paint()..color = enableColor.evaluate(enableAnimation), - activationAnimation.value, - labelPainter, - null, - ); + 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); } } @@ -2497,16 +2736,23 @@ class PaddleSliderValueIndicatorShape extends SliderComponentShape { /// * [RangeSlider], which includes value indicators defined by this shape. /// * [SliderTheme], which can be used to configure the range slider value /// indicator of all sliders in a widget subtree. -class PaddleRangeSliderValueIndicatorShape extends RangeSliderValueIndicatorShape { - /// Create a slider value indicator in the shape of an upside-down pear. - const PaddleRangeSliderValueIndicatorShape(); +class RectangularRangeSliderValueIndicatorShape + extends RangeSliderValueIndicatorShape { + /// Create a range slider value indicator that resembles a rectangular tooltip. + const RectangularRangeSliderValueIndicatorShape(); - static const _PaddleSliderTrackShapePathPainter _pathPainter = _PaddleSliderTrackShapePathPainter(); + static const _RectangularSliderValueIndicatorPathPainter _pathPainter = _RectangularSliderValueIndicatorPathPainter(); @override - Size getPreferredSize(bool isEnabled, bool isDiscrete, { @required TextPainter labelPainter }) { + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + @required TextPainter labelPainter, + @required double textScaleFactor, + }) { assert(labelPainter != null); - return _pathPainter.getPreferredSize(isEnabled, isDiscrete, labelPainter); + assert(textScaleFactor != null && textScaleFactor >= 0); + return _pathPainter.getPreferredSize(isEnabled, isDiscrete, labelPainter, textScaleFactor); } @override @@ -2515,12 +2761,281 @@ class PaddleRangeSliderValueIndicatorShape extends RangeSliderValueIndicatorShap Offset center, TextPainter labelPainter, Animation activationAnimation, + double textScaleFactor, + Size sizeWithOverflow, + }) { + return _pathPainter.getHorizontalShift( + parentBox: parentBox, + center: center, + labelPainter: labelPainter, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + scale: activationAnimation.value, + ); + } + + @override + void paint( + PaintingContext context, + Offset center, { + Animation activationAnimation, + Animation enableAnimation, + bool isDiscrete, + bool isOnTop, + TextPainter labelPainter, + double textScaleFactor, + Size sizeWithOverflow, + RenderBox parentBox, + SliderThemeData sliderTheme, + TextDirection textDirection, + double value, + Thumb thumb, + }) { + 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: isOnTop ? sliderTheme.overlappingShapeStrokeColor : null, + ); + } +} + +class _RectangularSliderValueIndicatorPathPainter { + const _RectangularSliderValueIndicatorPathPainter(); + + static const double _triangleHeight = 8.0; + static const double _labelPadding = 16.0; + static const double _preferredHeight = 32.0; + static const double _minLabelWidth = 16.0; + static const double _bottomTipYOffset = 14.0; + static const double _preferredHalfHeight = _preferredHeight / 2; + static const double _upperRectRadius = 4; + + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, + TextPainter labelPainter, + double textScaleFactor, + ) { + assert(labelPainter != null); + return Size( + _upperRectangleWidth(labelPainter, 1, textScaleFactor), + labelPainter.height + _labelPadding, + ); + } + + double getHorizontalShift({ + RenderBox parentBox, + Offset center, + TextPainter labelPainter, + double textScaleFactor, + Size sizeWithOverflow, + double scale, + }) { + assert(!sizeWithOverflow.isEmpty); + const double edgePadding = 8.0; + final double rectangleWidth = _upperRectangleWidth(labelPainter, scale, textScaleFactor); + + // 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 - center.dx + edgePadding); + final double overflowRight = math.max(0, rectangleWidth / 2 - (sizeWithOverflow.width - center.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, double textScaleFactor) { + final double unscaledWidth = math.max(_minLabelWidth * textScaleFactor, labelPainter.width) + _labelPadding * 2; + return unscaledWidth * scale; + } + + void paint({ + RenderBox parentBox, + Canvas canvas, + Offset center, + double scale, + TextPainter labelPainter, + double textScaleFactor, + Size sizeWithOverflow, + 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, textScaleFactor); + final double horizontalShift = getHorizontalShift( + parentBox: parentBox, + center: center, + labelPainter: labelPainter, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, + scale: scale, + ); + + final double rectHeight = labelPainter.height + _labelPadding; + final Rect upperRect = Rect.fromLTWH( + -rectangleWidth / 2 + horizontalShift, + -_triangleHeight - rectHeight, + rectangleWidth, + rectHeight, + ); + + final Path trianglePath = Path() + ..lineTo(-_triangleHeight, -_triangleHeight) + ..lineTo(_triangleHeight, -_triangleHeight) + ..close(); + final Paint fillPaint = Paint()..color = backgroundPaintColor; + final RRect upperRRect = RRect.fromRectAndRadius(upperRect, const Radius.circular(_upperRectRadius)); + trianglePath.addRRect(upperRRect); + + 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); + if (strokePaintColor != null) { + final Paint strokePaint = Paint() + ..color = strokePaintColor + ..strokeWidth = 1.0 + ..style = PaintingStyle.stroke; + canvas.drawPath(trianglePath, strokePaint); + } + canvas.drawPath(trianglePath, 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); + final Offset halfLabelPainterOffset = Offset(labelPainter.width / 2, labelPainter.height / 2); + final Offset labelOffset = boxCenter - halfLabelPainterOffset; + labelPainter.paint(canvas, labelOffset); + canvas.restore(); + } +} + +/// A variant shape of a [Slider]'s value indicator . The value indicator is in +/// the shape of an upside-down pear. +/// +/// 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 PaddleSliderValueIndicatorShape extends SliderComponentShape { + /// Create a slider value indicator in the shape of an upside-down pear. + const PaddleSliderValueIndicatorShape(); + + static const _PaddleSliderValueIndicatorPathPainter _pathPainter = _PaddleSliderValueIndicatorPathPainter(); + + @override + Size getPreferredSize(bool isEnabled, bool isDiscrete, {@required TextPainter labelPainter, @required double textScaleFactor,}) { + assert(labelPainter != null); + assert(textScaleFactor != null && textScaleFactor >= 0); + return _pathPainter.getPreferredSize(isEnabled, isDiscrete, labelPainter, textScaleFactor); + } + + @override + void paint( + PaintingContext context, + Offset center, { + @required Animation activationAnimation, + @required Animation enableAnimation, + bool isDiscrete, + @required TextPainter labelPainter, + @required RenderBox parentBox, + @required SliderThemeData sliderTheme, + TextDirection textDirection, + double value, + double textScaleFactor, + Size sizeWithOverflow, + }) { + assert(context != null); + assert(center != null); + assert(activationAnimation != null); + assert(enableAnimation != null); + assert(labelPainter != null); + assert(parentBox != null); + assert(sliderTheme != null); + assert(!sizeWithOverflow.isEmpty); + final ColorTween enableColor = ColorTween( + begin: sliderTheme.disabledThumbColor, + end: sliderTheme.valueIndicatorColor, + ); + _pathPainter.paint( + parentBox, + context.canvas, + center, + Paint()..color = enableColor.evaluate(enableAnimation), + activationAnimation.value, + labelPainter, + textScaleFactor, + sizeWithOverflow, + null, + ); + } +} + +/// A variant shape of a [RangeSlider]'s value indicators. The value indicator +/// is in the shape of an upside-down pear. +/// +/// See also: +/// +/// * [RangeSlider], which includes value indicators defined by this shape. +/// * [SliderTheme], which can be used to configure the range slider value +/// indicator of all sliders in a widget subtree. +class PaddleRangeSliderValueIndicatorShape extends RangeSliderValueIndicatorShape { + /// Create a slider value indicator in the shape of an upside-down pear. + const PaddleRangeSliderValueIndicatorShape(); + + static const _PaddleSliderValueIndicatorPathPainter _pathPainter = _PaddleSliderValueIndicatorPathPainter(); + + @override + Size getPreferredSize( + bool isEnabled, + bool isDiscrete, { + @required TextPainter labelPainter, + @required double textScaleFactor, + }) { + assert(labelPainter != null); + assert(textScaleFactor != null && textScaleFactor >= 0); + return _pathPainter.getPreferredSize(isEnabled, isDiscrete, labelPainter, textScaleFactor); + } + + @override + double getHorizontalShift({ + RenderBox parentBox, + Offset center, + TextPainter labelPainter, + Animation activationAnimation, + double textScaleFactor, + Size sizeWithOverflow, }) { return _pathPainter.getHorizontalShift( parentBox: parentBox, center: center, labelPainter: labelPainter, scale: activationAnimation.value, + textScaleFactor: textScaleFactor, + sizeWithOverflow: sizeWithOverflow, ); } @@ -2538,6 +3053,8 @@ class PaddleRangeSliderValueIndicatorShape extends RangeSliderValueIndicatorShap TextDirection textDirection, Thumb thumb, double value, + double textScaleFactor, + Size sizeWithOverflow, }) { assert(context != null); assert(center != null); @@ -2546,25 +3063,28 @@ class PaddleRangeSliderValueIndicatorShape extends RangeSliderValueIndicatorShap assert(labelPainter != null); assert(parentBox != null); assert(sliderTheme != null); + assert(!sizeWithOverflow.isEmpty); final ColorTween enableColor = ColorTween( begin: sliderTheme.disabledThumbColor, end: sliderTheme.valueIndicatorColor, ); // Add a stroke of 1dp around the top paddle. - _pathPainter.drawValueIndicator( + _pathPainter.paint( parentBox, context.canvas, center, Paint()..color = enableColor.evaluate(enableAnimation), activationAnimation.value, labelPainter, + textScaleFactor, + sizeWithOverflow, isOnTop ? sliderTheme.overlappingShapeStrokeColor : null, ); } } -class _PaddleSliderTrackShapePathPainter { - const _PaddleSliderTrackShapePathPainter(); +class _PaddleSliderValueIndicatorPathPainter { + const _PaddleSliderValueIndicatorPathPainter(); // These constants define the shape of the default value indicator. // The value indicator changes shape based on the size of @@ -2574,14 +3094,12 @@ class _PaddleSliderTrackShapePathPainter { // Radius of the top lobe of the value indicator. static const double _topLobeRadius = 16.0; - // Designed size of the label text. This is the size that the value indicator - // was designed to contain. We scale it from here to fit other sizes. - static const double _labelTextDesignSize = 14.0; + static const double _minLabelWidth = 16.0; // Radius of the bottom lobe of the value indicator. static const double _bottomLobeRadius = 10.0; static const double _labelPadding = 8.0; static const double _distanceBetweenTopBottomCenters = 40.0; - static const double _middleNeckWidth = 2.0; + static const double _middleNeckWidth = 3.0; static const double _bottomNeckRadius = 4.5; // The base of the triangle between the top lobe center and the centers of // the two top neck arcs. @@ -2609,10 +3127,12 @@ class _PaddleSliderTrackShapePathPainter { bool isEnabled, bool isDiscrete, TextPainter labelPainter, + double textScaleFactor, ) { assert(labelPainter != null); - final double textScaleFactor = labelPainter.height / _labelTextDesignSize; - return Size(labelPainter.width + 2 * _labelPadding * textScaleFactor, _preferredHeight * textScaleFactor); + assert(textScaleFactor != null && textScaleFactor >= 0); + final double width = math.max(_minLabelWidth * textScaleFactor, labelPainter.width) + _labelPadding * 2 * textScaleFactor; + return Size(width, _preferredHeight * textScaleFactor); } // Adds an arc to the path that has the attributes passed in. This is @@ -2628,15 +3148,17 @@ class _PaddleSliderTrackShapePathPainter { Offset center, TextPainter labelPainter, double scale, + double textScaleFactor, + Size sizeWithOverflow, }) { - final double textScaleFactor = labelPainter.height / _labelTextDesignSize; + assert(!sizeWithOverflow.isEmpty); final double inverseTextScale = textScaleFactor != 0 ? 1.0 / textScaleFactor : 0.0; final double labelHalfWidth = labelPainter.width / 2.0; final double halfWidthNeeded = math.max( 0.0, inverseTextScale * labelHalfWidth - (_topLobeRadius - _labelPadding), ); - final double shift = _getIdealOffset(parentBox, halfWidthNeeded, textScaleFactor * scale, center); + final double shift = _getIdealOffset(parentBox, halfWidthNeeded, textScaleFactor * scale, center, sizeWithOverflow.width); return shift * textScaleFactor; } @@ -2647,8 +3169,9 @@ class _PaddleSliderTrackShapePathPainter { double halfWidthNeeded, double scale, Offset center, + double widthWithOverflow, ) { - const double edgeMargin = 4.0; + const double edgeMargin = 8.0; final Rect topLobeRect = Rect.fromLTWH( -_topLobeRadius - halfWidthNeeded, -_topLobeRadius - _distanceBetweenTopBottomCenters, @@ -2661,12 +3184,11 @@ class _PaddleSliderTrackShapePathPainter { final Offset bottomRight = (topLobeRect.bottomRight * scale) + center; double shift = 0.0; - final double startGlobal = parentBox.localToGlobal(Offset.zero).dx; - if (topLeft.dx < startGlobal + edgeMargin) { - shift = startGlobal + edgeMargin - topLeft.dx; + if (topLeft.dx < edgeMargin) { + shift = edgeMargin - topLeft.dx; } - final double endGlobal = parentBox.localToGlobal(Offset(parentBox.size.width, parentBox.size.height)).dx; + final double endGlobal = widthWithOverflow; if (bottomRight.dx > endGlobal - edgeMargin) { shift = endGlobal - edgeMargin - bottomRight.dx; } @@ -2682,13 +3204,15 @@ class _PaddleSliderTrackShapePathPainter { return shift; } - void drawValueIndicator( + void paint( RenderBox parentBox, Canvas canvas, Offset center, Paint paint, double scale, TextPainter labelPainter, + double textScaleFactor, + Size sizeWithOverflow, Color strokePaintColor, ) { if (scale == 0.0) { @@ -2696,10 +3220,10 @@ class _PaddleSliderTrackShapePathPainter { // our math below will attempt to divide by zero and send needless NaNs to the engine. return; } + assert(!sizeWithOverflow.isEmpty); // The entire value indicator should scale with the size of the label, // to keep it large enough to encompass the label text. - final double textScaleFactor = labelPainter.height / _labelTextDesignSize; final double overallScale = scale * textScaleFactor; final double inverseTextScale = textScaleFactor != 0 ? 1.0 / textScaleFactor : 0.0; final double labelHalfWidth = labelPainter.width / 2.0; @@ -2741,7 +3265,7 @@ class _PaddleSliderTrackShapePathPainter { inverseTextScale * labelHalfWidth - (_topLobeRadius - _labelPadding), ); - final double shift = _getIdealOffset(parentBox, halfWidthNeeded, overallScale, center); + final double shift = _getIdealOffset(parentBox, halfWidthNeeded, overallScale, center, sizeWithOverflow.width); final double leftWidthNeeded = halfWidthNeeded - shift; final double rightWidthNeeded = halfWidthNeeded + shift; @@ -2750,7 +3274,8 @@ class _PaddleSliderTrackShapePathPainter { final double leftAmount = math.max(0.0, math.min(1.0, leftWidthNeeded / _neckTriangleBase)); final double rightAmount = math.max(0.0, math.min(1.0, rightWidthNeeded / _neckTriangleBase)); // The angle between the top neck arc's center and the top lobe's center - // and vertical. + // and vertical. The base amount is chosen so that the neck is smooth, + // even when the lobe is shifted due to its size. final double leftTheta = (1.0 - leftAmount) * _thirtyDegrees; final double rightTheta = (1.0 - rightAmount) * _thirtyDegrees; // The center of the top left neck arc. diff --git a/packages/flutter/test/material/range_slider_test.dart b/packages/flutter/test/material/range_slider_test.dart index 22ebb25132d..2d579a50cab 100644 --- a/packages/flutter/test/material/range_slider_test.dart +++ b/packages/flutter/test/material/range_slider_test.dart @@ -17,26 +17,28 @@ void main() { RangeValues values = const RangeValues(0.3, 0.7); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -44,7 +46,7 @@ void main() { // No thumbs get select when tapping between the thumbs outside the touch // boundaries expect(values, equals(const RangeValues(0.3, 0.7))); - // taps at 0.5 + // taps at 0.5 await tester.tap(find.byType(RangeSlider)); await tester.pump(); expect(values, equals(const RangeValues(0.3, 0.7))); @@ -72,26 +74,28 @@ void main() { RangeValues values = const RangeValues(0.3, 0.7); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.rtl, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -127,29 +131,31 @@ void main() { RangeValues values = const RangeValues(30, 70); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - min: 0.0, - max: 100.0, - divisions: 10, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + min: 0.0, + max: 100.0, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -187,29 +193,31 @@ void main() { RangeValues values = const RangeValues(30, 70); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.rtl, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - min: 0, - max: 100, - divisions: 10, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + min: 0, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -247,26 +255,28 @@ void main() { RangeValues values = const RangeValues(0.3, 0.7); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -292,26 +302,28 @@ void main() { RangeValues values = const RangeValues(0.3, 0.7); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.rtl, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -337,29 +349,31 @@ void main() { RangeValues values = const RangeValues(30, 70); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - min: 0, - max: 100, - divisions: 10, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + min: 0, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -385,29 +399,31 @@ void main() { RangeValues values = const RangeValues(30, 70); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.rtl, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - min: 0, - max: 100, - divisions: 10, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + min: 0, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -433,26 +449,28 @@ void main() { RangeValues values = const RangeValues(0.3, 0.7); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -484,26 +502,28 @@ void main() { RangeValues values = const RangeValues(0.3, 0.7); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.rtl, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -535,29 +555,31 @@ void main() { RangeValues values = const RangeValues(30, 70); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - min: 0, - max: 100, - divisions: 10, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + min: 0, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -589,29 +611,31 @@ void main() { RangeValues values = const RangeValues(30, 70); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.rtl, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - min: 0, - max: 100, - divisions: 10, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + min: 0, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -643,26 +667,28 @@ void main() { RangeValues values = const RangeValues(0.3, 0.7); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -694,26 +720,28 @@ void main() { RangeValues values = const RangeValues(0.3, 0.7); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.rtl, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -745,29 +773,31 @@ void main() { RangeValues values = const RangeValues(30, 70); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - min: 0, - max: 100, - divisions: 10, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + min: 0, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -799,29 +829,31 @@ void main() { RangeValues values = const RangeValues(30, 70); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.rtl, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - min: 0, - max: 100, - divisions: 10, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + min: 0, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -855,34 +887,36 @@ void main() { RangeValues endValues; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - min: 0, - max: 100, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, - onChangeStart: (RangeValues newValues) { - startValues = newValues; - }, - onChangeEnd: (RangeValues newValues) { - endValues = newValues; - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + min: 0, + max: 100, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + onChangeStart: (RangeValues newValues) { + startValues = newValues; + }, + onChangeEnd: (RangeValues newValues) { + endValues = newValues; + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -911,34 +945,36 @@ void main() { RangeValues endValues; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: RangeSlider( - values: values, - min: 0, - max: 100, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, - onChangeStart: (RangeValues newValues) { - startValues = newValues; - }, - onChangeEnd: (RangeValues newValues) { - endValues = newValues; - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: RangeSlider( + values: values, + min: 0, + max: 100, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + onChangeStart: (RangeValues newValues) { + startValues = newValues; + }, + onChangeEnd: (RangeValues newValues) { + endValues = newValues; + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -995,26 +1031,30 @@ void main() { Color inactiveColor, int divisions, bool enabled = true, + bool useV2Slider = false, }) { RangeValues values = const RangeValues(0.5, 0.75); final ValueChanged onChanged = !enabled ? null : (RangeValues newValues) { values = newValues; }; - return Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Theme( - data: theme, - child: RangeSlider( - values: values, - labels: RangeLabels(values.start.toStringAsFixed(2), values.end.toStringAsFixed(2)), - divisions: divisions, - activeColor: activeColor, - inactiveColor: inactiveColor, - onChanged: onChanged, + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toStringAsFixed(2), values.end.toStringAsFixed(2)), + divisions: divisions, + activeColor: activeColor, + inactiveColor: inactiveColor, + onChanged: onChanged, + useV2Slider: useV2Slider, + ), ), ), ), @@ -1023,6 +1063,31 @@ void main() { ); } + testWidgets('Range Slider V2 uses the right theme colors for the right shapes for a default enabled slider', (WidgetTester tester) async { + final ThemeData theme = _buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(_buildThemedApp(theme: theme, useV2Slider: true)); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(RangeSlider)); + + // Check default theme for enabled widget. + expect(sliderBox, paints + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rect(color: sliderTheme.activeTrackColor) + ..rrect(color: sliderTheme.inactiveTrackColor)); + expect(sliderBox, paints + ..circle(color: sliderTheme.thumbColor) + ..circle(color: sliderTheme.thumbColor)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.activeTickMarkColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.inactiveTickMarkColor))); + }); + testWidgets('Range Slider uses the right theme colors for the right shapes for a default enabled slider', (WidgetTester tester) async { final ThemeData theme = _buildTheme(); final SliderThemeData sliderTheme = theme.sliderTheme; @@ -1046,6 +1111,34 @@ void main() { expect(sliderBox, isNot(paints..circle(color: sliderTheme.inactiveTickMarkColor))); }); + testWidgets('Range Slider V2 uses the right theme colors for the right shapes when setting the active color', (WidgetTester tester) async { + const Color activeColor = Color(0xcafefeed); + final ThemeData theme = _buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(_buildThemedApp(theme: theme, activeColor: activeColor, useV2Slider: true)); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rect(color: activeColor) + ..rrect(color: sliderTheme.inactiveTrackColor)); + expect( + sliderBox, + paints + ..circle(color: activeColor) + ..circle(color: activeColor)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + }); + testWidgets('Range Slider uses the right theme colors for the right shapes when setting the active color', (WidgetTester tester) async { const Color activeColor = Color(0xcafefeed); final ThemeData theme = _buildTheme(); @@ -1072,6 +1165,33 @@ void main() { expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); }); + testWidgets('Range Slider V2 uses the right theme colors for the right shapes when setting the inactive color', (WidgetTester tester) async { + const Color inactiveColor = Color(0xdeadbeef); + final ThemeData theme = _buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(_buildThemedApp(theme: theme, inactiveColor: inactiveColor, useV2Slider: true)); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: inactiveColor) + ..rect(color: sliderTheme.activeTrackColor) + ..rrect(color: inactiveColor)); + expect( + sliderBox, + paints + ..circle(color: sliderTheme.thumbColor) + ..circle(color: sliderTheme.thumbColor)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + }); + testWidgets('Range Slider uses the right theme colors for the right shapes when setting the inactive color', (WidgetTester tester) async { const Color inactiveColor = Color(0xdeadbeef); final ThemeData theme = _buildTheme(); @@ -1097,6 +1217,38 @@ void main() { expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); }); + testWidgets('Range Slider V2 uses the right theme colors for the right shapes with active and inactive colors', (WidgetTester tester) async { + const Color activeColor = Color(0xcafefeed); + const Color inactiveColor = Color(0xdeadbeef); + final ThemeData theme = _buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(_buildThemedApp( + theme: theme, + activeColor: activeColor, + inactiveColor: inactiveColor, + useV2Slider: true, + )); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: inactiveColor) + ..rect(color: activeColor) + ..rrect(color: inactiveColor)); + expect( + sliderBox, + paints + ..circle(color: activeColor) + ..circle(color: activeColor)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + }); + testWidgets('Range Slider uses the right theme colors for the right shapes with active and inactive colors', (WidgetTester tester) async { const Color activeColor = Color(0xcafefeed); const Color inactiveColor = Color(0xdeadbeef); @@ -1128,6 +1280,36 @@ void main() { expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); }); + testWidgets('Range Slider V2 uses the right theme colors for the right shapes for a discrete slider', (WidgetTester tester) async { + final ThemeData theme = _buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(_buildThemedApp(theme: theme, divisions: 3, useV2Slider: true)); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: sliderTheme.inactiveTrackColor) + ..rect(color: sliderTheme.activeTrackColor) + ..rrect(color: sliderTheme.inactiveTrackColor)); + expect( + sliderBox, + paints + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..circle(color: sliderTheme.activeTickMarkColor) + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..circle(color: sliderTheme.thumbColor) + ..circle(color: sliderTheme.thumbColor)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + }); + testWidgets('Range Slider uses the right theme colors for the right shapes for a discrete slider', (WidgetTester tester) async { final ThemeData theme = _buildTheme(); final SliderThemeData sliderTheme = theme.sliderTheme; @@ -1156,6 +1338,48 @@ void main() { expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); }); + testWidgets('Range Slider V2 uses the right theme colors for the right shapes for a discrete slider with active and inactive colors', (WidgetTester tester) async { + const Color activeColor = Color(0xcafefeed); + const Color inactiveColor = Color(0xdeadbeef); + final ThemeData theme = _buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + + await tester.pumpWidget(_buildThemedApp( + theme: theme, + activeColor: activeColor, + inactiveColor: inactiveColor, + divisions: 3, + useV2Slider: true, + )); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: inactiveColor) + ..rect(color: activeColor) + ..rrect(color: inactiveColor)); + expect( + sliderBox, + paints + ..circle(color: activeColor) + ..circle(color: activeColor) + ..circle(color: inactiveColor) + ..circle(color: activeColor) + ..circle(color: activeColor) + ..circle(color: activeColor)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.disabledInactiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.activeTickMarkColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.inactiveTickMarkColor))); + }); + testWidgets('Range Slider uses the right theme colors for the right shapes for a discrete slider with active and inactive colors', (WidgetTester tester) async { const Color activeColor = Color(0xcafefeed); const Color inactiveColor = Color(0xdeadbeef); @@ -1195,6 +1419,27 @@ void main() { expect(sliderBox, isNot(paints..circle(color: sliderTheme.inactiveTickMarkColor))); }); + testWidgets('Range Slider V2 uses the right theme colors for the right shapes for a default disabled slider', (WidgetTester tester) async { + final ThemeData theme = _buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(_buildThemedApp(theme: theme, enabled: false, useV2Slider: true)); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: sliderTheme.disabledInactiveTrackColor) + ..rect(color: sliderTheme.disabledActiveTrackColor) + ..rrect(color: sliderTheme.disabledInactiveTrackColor)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.activeTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.inactiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.activeTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.inactiveTrackColor))); + }); + testWidgets('Range Slider uses the right theme colors for the right shapes for a default disabled slider', (WidgetTester tester) async { final ThemeData theme = _buildTheme(); final SliderThemeData sliderTheme = theme.sliderTheme; @@ -1215,6 +1460,35 @@ void main() { }); + testWidgets('Range Slider V2 uses the right theme colors for the right shapes for a disabled slider with active and inactive colors', (WidgetTester tester) async { + const Color activeColor = Color(0xcafefeed); + const Color inactiveColor = Color(0xdeadbeef); + final ThemeData theme = _buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(_buildThemedApp( + theme: theme, + activeColor: activeColor, + inactiveColor: inactiveColor, + enabled: false, + useV2Slider: true, + )); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(RangeSlider)); + + expect( + sliderBox, + paints + ..rrect(color: sliderTheme.disabledInactiveTrackColor) + ..rect(color: sliderTheme.disabledActiveTrackColor) + ..rrect(color: sliderTheme.disabledInactiveTrackColor)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.activeTrackColor))); + expect(sliderBox, isNot(paints..rect(color: sliderTheme.inactiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.activeTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.inactiveTrackColor))); + }); + testWidgets('Range Slider uses the right theme colors for the right shapes for a disabled slider with active and inactive colors', (WidgetTester tester) async { const Color activeColor = Color(0xcafefeed); const Color inactiveColor = Color(0xdeadbeef); @@ -1241,6 +1515,66 @@ void main() { expect(sliderBox, isNot(paints..rect(color: sliderTheme.inactiveTrackColor))); }); + testWidgets('Range Slider V2 uses the right theme colors for the right shapes when the value indicators are showing', (WidgetTester tester) async { + final ThemeData theme = _buildTheme(); + final SliderThemeData sliderTheme = theme.sliderTheme; + RangeValues values = const RangeValues(0.5, 0.75); + + Widget buildApp({ + Color activeColor, + Color inactiveColor, + int divisions, + bool enabled = true, + }) { + final ValueChanged onChanged = !enabled ? null : (RangeValues newValues) { + values = newValues; + }; + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toStringAsFixed(2), values.end.toStringAsFixed(2)), + divisions: divisions, + activeColor: activeColor, + inactiveColor: inactiveColor, + onChanged: onChanged, + useV2Slider: true, + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp(divisions: 3)); + + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); + + final Offset topRight = tester.getTopRight(find.byType(RangeSlider)).translate(-24, 0); + final TestGesture gesture = await tester.startGesture(topRight); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect(values.end, equals(1)); + expect( + valueIndicatorBox, + paints + ..path(color: sliderTheme.valueIndicatorColor) + ..path(color: sliderTheme.valueIndicatorColor), + ); + await gesture.up(); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + }); + testWidgets('Range Slider uses the right theme colors for the right shapes when the value indicators are showing', (WidgetTester tester) async { const Color customColor1 = Color(0xcafefeed); const Color customColor2 = Color(0xdeadbeef); @@ -1257,21 +1591,23 @@ void main() { final ValueChanged onChanged = !enabled ? null : (RangeValues newValues) { values = newValues; }; - return Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Theme( - data: theme, - child: RangeSlider( - values: values, - labels: RangeLabels(values.start.toStringAsFixed(2), values.end.toStringAsFixed(2)), - divisions: divisions, - activeColor: activeColor, - inactiveColor: inactiveColor, - onChanged: onChanged, + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toStringAsFixed(2), values.end.toStringAsFixed(2)), + divisions: divisions, + activeColor: activeColor, + inactiveColor: inactiveColor, + onChanged: onChanged, + ), ), ), ), @@ -1282,7 +1618,7 @@ void main() { await tester.pumpWidget(buildApp(divisions: 3)); - final RenderBox sliderBox = tester.firstRenderObject(find.byType(RangeSlider)); + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); final Offset topRight = tester.getTopRight(find.byType(RangeSlider)).translate(-24, 0); TestGesture gesture = await tester.startGesture(topRight); @@ -1290,7 +1626,7 @@ void main() { await tester.pumpAndSettle(); expect(values.end, equals(1)); expect( - sliderBox, + valueIndicatorBox, paints ..path(color: sliderTheme.valueIndicatorColor) ..path(color: sliderTheme.valueIndicatorColor), @@ -1310,7 +1646,7 @@ void main() { await tester.pumpAndSettle(); expect(values.end, equals(1)); expect( - sliderBox, + valueIndicatorBox, paints ..path(color: customColor1) ..path(color: customColor1), @@ -1318,7 +1654,7 @@ void main() { await gesture.up(); }); - testWidgets('Range Slider top thumb gets stroked when overlapping', (WidgetTester tester) async { + testWidgets('Range Slider V2 top thumb gets stroked when overlapping', (WidgetTester tester) async { RangeValues values = const RangeValues(0.3, 0.7); final ThemeData theme = ThemeData( @@ -1332,29 +1668,102 @@ void main() { final SliderThemeData sliderTheme = theme.sliderTheme; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Theme( - data: theme, - child: RangeSlider( - values: values, - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + useV2Slider: true, + ), ), ), ), - ), - ); - }, + ); + }, + ), + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the thumbs towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + expect(values.start, closeTo(0.5, 0.03)); + expect(values.end, closeTo(0.5, 0.03)); + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, + paints + ..circle(color: sliderTheme.thumbColor) + ..circle(color: sliderTheme.overlappingShapeStrokeColor) + ..circle(color: sliderTheme.thumbColor), + ); + }); + + testWidgets('Range Slider top thumb gets stroked when overlapping', (WidgetTester tester) async { + RangeValues values = const RangeValues(0.3, 0.7); + + final ThemeData theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + sliderTheme: const SliderThemeData( + thumbColor: Color(0xff000001), + overlappingShapeStrokeColor: Color(0xff000002), + ), + ); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ), + ), + ); + }, + ), ), ), ); @@ -1386,7 +1795,7 @@ void main() { ); }); - testWidgets('Range Slider top value indicator gets stroked when overlapping', (WidgetTester tester) async { + testWidgets('Range Slider V2 top value indicator gets stroked when overlapping', (WidgetTester tester) async { RangeValues values = const RangeValues(0.3, 0.7); final ThemeData theme = ThemeData( @@ -1401,35 +1810,38 @@ void main() { final SliderThemeData sliderTheme = theme.sliderTheme; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Theme( - data: theme, - child: RangeSlider( - values: values, - labels: RangeLabels(values.start.toStringAsFixed(2), values.end.toStringAsFixed(2)), - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toStringAsFixed(2), values.end.toStringAsFixed(2)), + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + useV2Slider: true, + ), ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); - final RenderBox sliderBox = tester.firstRenderObject(find.byType(RangeSlider)); + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); // Get the bounds of the track by finding the slider edges and translating // inwards by the overlay radius. @@ -1450,7 +1862,7 @@ void main() { await tester.pumpAndSettle(); expect( - sliderBox, + valueIndicatorBox, paints ..path(color: sliderTheme.valueIndicatorColor) ..path(color: sliderTheme.overlappingShapeStrokeColor) @@ -1460,50 +1872,52 @@ void main() { await gesture.up(); }); - testWidgets('Range Slider top value indicator gets stroked when overlapping with large text scale', (WidgetTester tester) async { + testWidgets('Range Slider top value indicator gets stroked when overlapping', (WidgetTester tester) async { RangeValues values = const RangeValues(0.3, 0.7); final ThemeData theme = ThemeData( - platform: TargetPlatform.android, - primarySwatch: Colors.blue, - sliderTheme: const SliderThemeData( - valueIndicatorColor: Color(0xff000001), - overlappingShapeStrokeColor: Color(0xff000002), - showValueIndicator: ShowValueIndicator.always, - ), + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + sliderTheme: const SliderThemeData( + valueIndicatorColor: Color(0xff000001), + overlappingShapeStrokeColor: Color(0xff000002), + showValueIndicator: ShowValueIndicator.always, + ), ); final SliderThemeData sliderTheme = theme.sliderTheme; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window).copyWith(textScaleFactor: 2.0), - child: Material( - child: Center( - child: Theme( - data: theme, - child: RangeSlider( - values: values, - labels: RangeLabels(values.start.toStringAsFixed(2), values.end.toStringAsFixed(2)), - onChanged: (RangeValues newValues) { - setState(() { - values = newValues; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toStringAsFixed(2), values.end.toStringAsFixed(2)), + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); - final RenderBox sliderBox = tester.firstRenderObject(find.byType(RangeSlider)); + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); // Get the bounds of the track by finding the slider edges and translating // inwards by the overlay radius. @@ -1524,7 +1938,160 @@ void main() { await tester.pumpAndSettle(); expect( - sliderBox, + valueIndicatorBox, + paints + ..path(color: sliderTheme.valueIndicatorColor) + ..path(color: sliderTheme.overlappingShapeStrokeColor) + ..path(color: sliderTheme.valueIndicatorColor), + ); + + await gesture.up(); + }); + + testWidgets('Range Slider V2 top value indicator gets stroked when overlapping with large text scale', (WidgetTester tester) async { + RangeValues values = const RangeValues(0.3, 0.7); + + final ThemeData theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + sliderTheme: const SliderThemeData( + valueIndicatorColor: Color(0xff000001), + overlappingShapeStrokeColor: Color(0xff000002), + showValueIndicator: ShowValueIndicator.always, + ), + ); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window).copyWith(textScaleFactor: 2.0), + child: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toStringAsFixed(2), values.end.toStringAsFixed(2)), + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + useV2Slider: true, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the thumbs towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + await tester.pumpAndSettle(); + expect(values.start, closeTo(0.5, 0.03)); + expect(values.end, closeTo(0.5, 0.03)); + final TestGesture gesture = await tester.startGesture(middle); + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, + paints + ..path(color: sliderTheme.valueIndicatorColor) + ..path(color: sliderTheme.overlappingShapeStrokeColor) + ..path(color: sliderTheme.valueIndicatorColor), + ); + + await gesture.up(); + }); + + testWidgets('Range Slider top value indicator gets stroked when overlapping with large text scale', (WidgetTester tester) async { + RangeValues values = const RangeValues(0.3, 0.7); + + final ThemeData theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + sliderTheme: const SliderThemeData( + valueIndicatorColor: Color(0xff000001), + overlappingShapeStrokeColor: Color(0xff000002), + showValueIndicator: ShowValueIndicator.always, + ), + ); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window).copyWith(textScaleFactor: 2.0), + child: Material( + child: Center( + child: Theme( + data: theme, + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toStringAsFixed(2), values.end.toStringAsFixed(2)), + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); + + // Get the bounds of the track by finding the slider edges and translating + // inwards by the overlay radius. + final Offset topLeft = tester.getTopLeft(find.byType(RangeSlider)).translate(24, 0); + final Offset bottomRight = tester.getBottomRight(find.byType(RangeSlider)).translate(-24, 0); + final Offset middle = topLeft + bottomRight / 2; + + // Drag the thumbs towards the center. + final Offset leftTarget = topLeft + (bottomRight - topLeft) * 0.3; + await tester.dragFrom(leftTarget, middle - leftTarget); + await tester.pumpAndSettle(); + final Offset rightTarget = topLeft + (bottomRight - topLeft) * 0.7; + await tester.dragFrom(rightTarget, middle - rightTarget); + await tester.pumpAndSettle(); + expect(values.start, closeTo(0.5, 0.03)); + expect(values.end, closeTo(0.5, 0.03)); + final TestGesture gesture = await tester.startGesture(middle); + await tester.pumpAndSettle(); + + expect( + valueIndicatorBox, paints ..path(color: sliderTheme.valueIndicatorColor) ..path(color: sliderTheme.overlappingShapeStrokeColor) @@ -1565,7 +2132,8 @@ void main() { 'labelStart: "lowerValue"', 'labelEnd: "upperValue"', 'activeColor: MaterialColor(primary value: Color(0xff2196f3))', - 'inactiveColor: MaterialColor(primary value: Color(0xff9e9e9e))' + 'inactiveColor: MaterialColor(primary value: Color(0xff9e9e9e))', + 'useV1Slider', ]); }); } diff --git a/packages/flutter/test/material/slider_test.dart b/packages/flutter/test/material/slider_test.dart index 3dd11ff2911..3a81021bb33 100644 --- a/packages/flutter/test/material/slider_test.dart +++ b/packages/flutter/test/material/slider_test.dart @@ -39,6 +39,8 @@ class LoggingThumbShape extends SliderComponentShape { SliderThemeData sliderTheme, TextDirection textDirection, double value, + double textScaleFactor, + Size sizeWithOverflow, }) { log.add(thumbCenter); final Paint thumbPaint = Paint()..color = Colors.red; @@ -76,33 +78,35 @@ void main() { double endValue; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Slider( - key: sliderKey, - value: value, - onChanged: (double newValue) { - setState(() { - value = newValue; - }); - }, - onChangeStart: (double value) { - startValue = value; - }, - onChangeEnd: (double value) { - endValue = value; - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Slider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + onChangeStart: (double value) { + startValue = value; + }, + onChangeEnd: (double value) { + endValue = value; + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -134,27 +138,29 @@ void main() { double value = 0.0; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.rtl, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Slider( - key: sliderKey, - value: value, - onChanged: (double newValue) { - setState(() { - value = newValue; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Slider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -184,37 +190,40 @@ void main() { int startValueUpdates = 0; int endValueUpdates = 0; + await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Slider( - key: sliderKey, - value: value, - onChanged: (double newValue) { - setState(() { - updates++; - value = newValue; - }); - }, - onChangeStart: (double value) { - startValueUpdates++; - startValue = value; - }, - onChangeEnd: (double value) { - endValueUpdates++; - endValue = value; - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Slider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + updates++; + value = newValue; + }); + }, + onChangeStart: (double value) { + startValueUpdates++; + startValue = value; + }, + onChangeEnd: (double value) { + endValueUpdates++; + endValue = value; + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -238,28 +247,30 @@ void main() { double value = 0.0; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Slider( - key: sliderKey, - value: value, - divisions: 4, - onChanged: (double newValue) { - setState(() { - value = newValue; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -290,32 +301,34 @@ void main() { final List log = []; final LoggingThumbShape loggingThumb = LoggingThumbShape(log); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - final SliderThemeData sliderTheme = SliderTheme.of(context).copyWith(thumbShape: loggingThumb); - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: SliderTheme( - data: sliderTheme, - child: Slider( - key: sliderKey, - value: value, - divisions: 4, - onChanged: (double newValue) { - setState(() { - value = newValue; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + final SliderThemeData sliderTheme = SliderTheme.of(context).copyWith(thumbShape: loggingThumb); + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: SliderTheme( + data: sliderTheme, + child: Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -359,28 +372,30 @@ void main() { int updates = 0; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Slider( - key: sliderKey, - value: value, - onChanged: (double newValue) { - setState(() { - updates++; - value = newValue; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Slider( + key: sliderKey, + value: value, + onChanged: (double newValue) { + setState(() { + updates++; + value = newValue; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -401,32 +416,34 @@ void main() { final List log = []; final LoggingThumbShape loggingThumb = LoggingThumbShape(log); await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - final SliderThemeData sliderTheme = SliderTheme.of(context).copyWith(thumbShape: loggingThumb); - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: SliderTheme( - data: sliderTheme, - child: Slider( - key: sliderKey, - value: value, - divisions: 4, - onChanged: (double newValue) { - setState(() { - value = newValue; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + final SliderThemeData sliderTheme = SliderTheme.of(context).copyWith(thumbShape: loggingThumb); + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: SliderTheme( + data: sliderTheme, + child: Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -469,33 +486,35 @@ void main() { double value = 0.0; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: SizedBox( - width: 144.0 + 2 * 16.0, // _kPreferredTotalWidth - child: Slider( - key: sliderKey, - min: 0.0, - max: 100.0, - divisions: 10, - value: value, - onChanged: (double newValue) { - setState(() { - value = newValue; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: SizedBox( + width: 144.0 + 2 * 16.0, // _kPreferredTotalWidth + child: Slider( + key: sliderKey, + min: 0.0, + max: 100.0, + divisions: 10, + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -520,50 +539,58 @@ void main() { testWidgets('Slider can be given zero values', (WidgetTester tester) async { final List log = []; - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Slider( - value: 0.0, - min: 0.0, - max: 1.0, - onChanged: (double newValue) { - log.add(newValue); - }, + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Slider( + value: 0.0, + min: 0.0, + max: 1.0, + onChanged: (double newValue) { + log.add(newValue); + }, + ), + ), ), ), ), - )); + ); await tester.tap(find.byType(Slider)); expect(log, [0.5]); log.clear(); - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Slider( - value: 0.0, - min: 0.0, - max: 0.0, - onChanged: (double newValue) { - log.add(newValue); - }, + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Slider( + value: 0.0, + min: 0.0, + max: 0.0, + onChanged: (double newValue) { + log.add(newValue); + }, + ), + ), ), ), ), - )); + ); await tester.tap(find.byType(Slider)); expect(log, []); log.clear(); }); - testWidgets('Slider uses the right theme colors for the right components', (WidgetTester tester) async { + testWidgets('Slider V2 uses the right theme colors for the right components', (WidgetTester tester) async { const Color customColor1 = Color(0xcafefeed); const Color customColor2 = Color(0xdeadbeef); final ThemeData theme = ThemeData( @@ -597,21 +624,24 @@ void main() { : (double d) { value = d; }; - return Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Theme( - data: theme, - child: Slider( - value: value, - label: '$value', - divisions: divisions, - activeColor: activeColor, - inactiveColor: inactiveColor, - onChanged: onChanged, + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Theme( + data: theme, + child: Slider( + value: value, + label: '$value', + divisions: divisions, + activeColor: activeColor, + inactiveColor: inactiveColor, + onChanged: onChanged, + useV2Slider: true, + ), ), ), ), @@ -623,6 +653,228 @@ void main() { await tester.pumpWidget(buildApp()); final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); + + // Check default theme for enabled widget. + expect(sliderBox, paints..rrect(color: sliderTheme.activeTrackColor)..rrect(color: sliderTheme.inactiveTrackColor)); + expect(sliderBox, paints..shadow(color: const Color(0xff000000))); + expect(sliderBox, paints..circle(color: sliderTheme.thumbColor)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.activeTickMarkColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.inactiveTickMarkColor))); + + // Test setting only the activeColor. + await tester.pumpWidget(buildApp(activeColor: customColor1)); + expect(sliderBox, paints..rrect(color: customColor1)..rrect(color: sliderTheme.inactiveTrackColor)); + expect(sliderBox, paints..shadow(color: Colors.black)); + expect(sliderBox, paints..circle(color: customColor1)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + + // Test setting only the inactiveColor. + await tester.pumpWidget(buildApp(inactiveColor: customColor1)); + expect(sliderBox, paints..rrect(color: sliderTheme.activeTrackColor)..rrect(color: customColor1)); + expect(sliderBox, paints..shadow(color: Colors.black)); + expect(sliderBox, paints..circle(color: sliderTheme.thumbColor)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + + // Test setting both activeColor and inactiveColor. + await tester.pumpWidget(buildApp(activeColor: customColor1, inactiveColor: customColor2)); + expect(sliderBox, paints..rrect(color: customColor1)..rrect(color: customColor2)); + expect(sliderBox, paints..shadow(color: Colors.black)); + expect(sliderBox, paints..circle(color: customColor1)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + + // Test colors for discrete slider. + await tester.pumpWidget(buildApp(divisions: 3)); + expect(sliderBox, paints..rrect(color: sliderTheme.activeTrackColor)..rrect(color: sliderTheme.inactiveTrackColor)); + expect( + sliderBox, + paints + ..circle(color: sliderTheme.activeTickMarkColor) + ..circle(color: sliderTheme.activeTickMarkColor) + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..shadow(color: Colors.black) + ..circle(color: sliderTheme.thumbColor) + ); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + + // Test colors for discrete slider with inactiveColor and activeColor set. + await tester.pumpWidget(buildApp( + activeColor: customColor1, + inactiveColor: customColor2, + divisions: 3, + )); + expect(sliderBox, paints..rrect(color: customColor1)..rrect(color: customColor2)); + expect( + sliderBox, + paints + ..circle(color: customColor2) + ..circle(color: customColor2) + ..circle(color: customColor1) + ..circle(color: customColor1) + ..shadow(color: Colors.black) + ..circle(color: customColor1)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.disabledThumbColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledActiveTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.disabledInactiveTrackColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.activeTickMarkColor))); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.inactiveTickMarkColor))); + + // Test default theme for disabled widget. + await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpAndSettle(); + expect( + sliderBox, + paints + ..rrect(color: sliderTheme.disabledActiveTrackColor) + ..rrect(color: sliderTheme.disabledInactiveTrackColor)); + expect(sliderBox, paints..shadow(color: Colors.black)..circle(color: sliderTheme.disabledThumbColor)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.activeTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.inactiveTrackColor))); + + // Test setting the activeColor and inactiveColor for disabled widget. + await tester.pumpWidget(buildApp(activeColor: customColor1, inactiveColor: customColor2, enabled: false)); + expect( + sliderBox, + paints + ..rrect(color: sliderTheme.disabledActiveTrackColor) + ..rrect(color: sliderTheme.disabledInactiveTrackColor)); + expect(sliderBox, paints..circle(color: sliderTheme.disabledThumbColor)); + expect(sliderBox, isNot(paints..circle(color: sliderTheme.thumbColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.activeTrackColor))); + expect(sliderBox, isNot(paints..rrect(color: sliderTheme.inactiveTrackColor))); + + // Test that the default value indicator has the right colors. + await tester.pumpWidget(buildApp(divisions: 3)); + Offset center = tester.getCenter(find.byType(Slider)); + TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect(value, equals(2.0 / 3.0)); + expect( + valueIndicatorBox, + paints + ..rrect(color: sliderTheme.activeTrackColor) + ..rrect(color: sliderTheme.inactiveTrackColor) + ..circle(color: sliderTheme.overlayColor) + ..circle(color: sliderTheme.activeTickMarkColor) + ..circle(color: sliderTheme.activeTickMarkColor) + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..circle(color: sliderTheme.inactiveTickMarkColor) + ..shadow(color: Colors.black) + ..circle(color: sliderTheme.thumbColor) + ..path(color: sliderTheme.valueIndicatorColor), + ); + await gesture.up(); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + + // Testing the custom colors are used for the indicator. + await tester.pumpWidget(buildApp( + divisions: 3, + activeColor: customColor1, + inactiveColor: customColor2, + )); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect(value, equals(2.0 / 3.0)); + expect( + valueIndicatorBox, + paints + ..rrect(color: customColor1) // active track + ..rrect(color: customColor2) // inactive track + ..circle(color: customColor1.withOpacity(0.12)) // overlay + ..circle(color: customColor2) // 1st tick mark + ..circle(color: customColor2) // 2nd tick mark + ..circle(color: customColor2) // 3rd tick mark + ..circle(color: customColor1) // 4th tick mark + ..shadow(color: Colors.black) + ..circle(color: customColor1) // thumb + ..path(color: sliderTheme.valueIndicatorColor), // indicator + ); + await gesture.up(); + }); + + testWidgets('Slider uses the right theme colors for the right components', (WidgetTester tester) async { + const Color customColor1 = Color(0xcafefeed); + const Color customColor2 = Color(0xdeadbeef); + final ThemeData theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + sliderTheme: const SliderThemeData( + disabledThumbColor: Color(0xff000001), + disabledActiveTickMarkColor: Color(0xff000002), + disabledActiveTrackColor: Color(0xff000003), + disabledInactiveTickMarkColor: Color(0xff000004), + disabledInactiveTrackColor: Color(0xff000005), + activeTrackColor: Color(0xff000006), + activeTickMarkColor: Color(0xff000007), + inactiveTrackColor: Color(0xff000008), + inactiveTickMarkColor: Color(0xff000009), + overlayColor: Color(0xff000010), + thumbColor: Color(0xff000011), + valueIndicatorColor: Color(0xff000012), + ), + ); + final SliderThemeData sliderTheme = theme.sliderTheme; + double value = 0.45; + Widget buildApp({ + Color activeColor, + Color inactiveColor, + int divisions, + bool enabled = true, + }) { + final ValueChanged onChanged = !enabled + ? null + : (double d) { + value = d; + }; + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Theme( + data: theme, + child: Slider( + value: value, + label: '$value', + divisions: divisions, + activeColor: activeColor, + inactiveColor: inactiveColor, + onChanged: onChanged, + ), + ), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp()); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); // Check default theme for enabled widget. expect(sliderBox, paints..rect(color: sliderTheme.activeTrackColor)..rect(color: sliderTheme.inactiveTrackColor)); @@ -729,7 +981,7 @@ void main() { await tester.pumpAndSettle(); expect(value, equals(2.0 / 3.0)); expect( - sliderBox, + valueIndicatorBox, paints ..rect(color: sliderTheme.activeTrackColor) ..rect(color: sliderTheme.inactiveTrackColor) @@ -738,8 +990,8 @@ void main() { ..circle(color: sliderTheme.activeTickMarkColor) ..circle(color: sliderTheme.inactiveTickMarkColor) ..circle(color: sliderTheme.inactiveTickMarkColor) - ..path(color: sliderTheme.valueIndicatorColor) - ..circle(color: sliderTheme.thumbColor), + ..circle(color: sliderTheme.thumbColor) + ..path(color: sliderTheme.valueIndicatorColor), ); await gesture.up(); // Wait for value indicator animation to finish. @@ -757,7 +1009,7 @@ void main() { await tester.pumpAndSettle(); expect(value, equals(2.0 / 3.0)); expect( - sliderBox, + valueIndicatorBox, paints ..rect(color: customColor1) // active track ..rect(color: customColor2) // inactive track @@ -766,35 +1018,39 @@ void main() { ..circle(color: customColor2) // 2nd tick mark ..circle(color: customColor2) // 3rd tick mark ..circle(color: customColor1) // 4th tick mark - ..path(color: customColor1) // indicator - ..circle(color: customColor1), // thumb + ..circle(color: customColor1) // thumb + ..path(color: customColor1), // indicator ); await gesture.up(); }); testWidgets('Slider can tap in vertical scroller', (WidgetTester tester) async { double value = 0.0; - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: ListView( - children: [ - Slider( - value: value, - onChanged: (double newValue) { - value = newValue; - }, + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: ListView( + children: [ + Slider( + value: value, + onChanged: (double newValue) { + value = newValue; + }, + ), + Container( + height: 2000.0, + ), + ], ), - Container( - height: 2000.0, - ), - ], + ), ), ), ), - )); + ); await tester.tap(find.byType(Slider)); expect(value, equals(0.5)); @@ -802,22 +1058,26 @@ void main() { testWidgets('Slider drags immediately (LTR)', (WidgetTester tester) async { double value = 0.0; - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Slider( - value: value, - onChanged: (double newValue) { - value = newValue; - }, + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Slider( + value: value, + onChanged: (double newValue) { + value = newValue; + }, + ), + ), ), ), ), ), - )); + ); final Offset center = tester.getCenter(find.byType(Slider)); final TestGesture gesture = await tester.startGesture(center); @@ -833,22 +1093,26 @@ void main() { testWidgets('Slider drags immediately (RTL)', (WidgetTester tester) async { double value = 0.0; - await tester.pumpWidget(Directionality( - textDirection: TextDirection.rtl, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Slider( - value: value, - onChanged: (double newValue) { - value = newValue; - }, + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.rtl, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Slider( + value: value, + onChanged: (double newValue) { + value = newValue; + }, + ), + ), ), ), ), ), - )); + ); final Offset center = tester.getCenter(find.byType(Slider)); final TestGesture gesture = await tester.startGesture(center); @@ -863,62 +1127,74 @@ void main() { }); testWidgets('Slider sizing', (WidgetTester tester) async { - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: const Material( - child: Center( - child: Slider( - value: 0.5, - onChanged: null, + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: const Material( + child: Center( + child: Slider( + value: 0.5, + onChanged: null, + ), + ), ), ), ), ), - )); + ); expect(tester.renderObject(find.byType(Slider)).size, const Size(800.0, 600.0)); - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: const Material( - child: Center( - child: IntrinsicWidth( - child: Slider( - value: 0.5, - onChanged: null, + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: const Material( + child: Center( + child: IntrinsicWidth( + child: Slider( + value: 0.5, + onChanged: null, + ), + ), ), ), ), ), ), - )); + ); expect(tester.renderObject(find.byType(Slider)).size, const Size(144.0 + 2.0 * 24.0, 600.0)); - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: const Material( - child: Center( - child: OverflowBox( - maxWidth: double.infinity, - maxHeight: double.infinity, - child: Slider( - value: 0.5, - onChanged: null, + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: const Material( + child: Center( + child: OverflowBox( + maxWidth: double.infinity, + maxHeight: double.infinity, + child: Slider( + value: 0.5, + onChanged: null, + ), + ), ), ), ), ), ), - )); + ); expect(tester.renderObject(find.byType(Slider)).size, const Size(144.0 + 2.0 * 24.0, 48.0)); }); - testWidgets('Slider respects textScaleFactor', (WidgetTester tester) async { + testWidgets('Slider V2 respects textScaleFactor', (WidgetTester tester) async { final Key sliderKey = UniqueKey(); double value = 0.0; @@ -927,40 +1203,43 @@ void main() { bool isDiscrete = true, ShowValueIndicator show = ShowValueIndicator.onlyForDiscrete, }) { - return Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData(textScaleFactor: textScaleFactor), - child: Material( - child: Theme( - data: Theme.of(context).copyWith( - sliderTheme: Theme.of(context).sliderTheme.copyWith(showValueIndicator: show), - ), - child: Center( - child: OverflowBox( - maxWidth: double.infinity, - maxHeight: double.infinity, - child: Slider( - key: sliderKey, - min: 0.0, - max: 100.0, - divisions: isDiscrete ? 10 : null, - label: '${value.round()}', - value: value, - onChanged: (double newValue) { - setState(() { - value = newValue; - }); - }, + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData(textScaleFactor: textScaleFactor), + child: Material( + child: Theme( + data: Theme.of(context).copyWith( + sliderTheme: Theme.of(context).sliderTheme.copyWith(showValueIndicator: show), + ), + child: Center( + child: OverflowBox( + maxWidth: double.infinity, + maxHeight: double.infinity, + child: Slider( + key: sliderKey, + min: 0.0, + max: 100.0, + divisions: isDiscrete ? 10 : null, + label: '${value.round()}', + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + useV2Slider: true, + ), ), ), ), ), - ), - ); - }, + ); + }, + ), ), ); } @@ -970,7 +1249,19 @@ void main() { TestGesture gesture = await tester.startGesture(center); await tester.pumpAndSettle(); - expect(tester.renderObject(find.byType(Slider)), paints..scale(x: 1.0, y: 1.0)); + expect( + tester.firstRenderObject(find.byType(Overlay)), + paints + ..path( + includes: const [ + Offset(0.0, 0.0), + Offset(0.0, -38.0), + Offset(-30.0, -16.0), + Offset(30.0, -16.0), + ], + color: const Color(0xf55f5f5f), + ), + ); await gesture.up(); await tester.pumpAndSettle(); @@ -980,7 +1271,19 @@ void main() { gesture = await tester.startGesture(center); await tester.pumpAndSettle(); - expect(tester.renderObject(find.byType(Slider)), paints..scale(x: 2.0, y: 2.0)); + expect( + tester.firstRenderObject(find.byType(Overlay)), + paints + ..path( + includes: const [ + Offset(0.0, 0.0), + Offset(0.0, -52.0), + Offset(-44.0, -16.0), + Offset(44.0, -16.0), + ], + color: const Color(0xf55f5f5f), + ), + ); await gesture.up(); await tester.pumpAndSettle(); @@ -995,7 +1298,18 @@ void main() { gesture = await tester.startGesture(center); await tester.pumpAndSettle(); - expect(tester.renderObject(find.byType(Slider)), paints..scale(x: 1.0, y: 1.0)); + expect(tester.firstRenderObject(find.byType(Overlay)), + paints + ..path( + includes: const [ + Offset(0.0, 0.0), + Offset(0.0, -38.0), + Offset(-30.0, -16.0), + Offset(30.0, -16.0), + ], + color: const Color(0xf55f5f5f), + ), + ); await gesture.up(); await tester.pumpAndSettle(); @@ -1009,7 +1323,118 @@ void main() { gesture = await tester.startGesture(center); await tester.pumpAndSettle(); - expect(tester.renderObject(find.byType(Slider)), paints..scale(x: 2.0, y: 2.0)); + expect( + tester.firstRenderObject(find.byType(Overlay)), + paints + ..path( + includes: const [ + Offset(0.0, 0.0), + Offset(0.0, -52.0), + Offset(-44.0, -16.0), + Offset(44.0, -16.0), + ], + color: const Color(0xf55f5f5f), + ), + ); + + await gesture.up(); + await tester.pumpAndSettle(); + }, skip: isBrowser); + + testWidgets('Slider respects textScaleFactor', (WidgetTester tester) async { + final Key sliderKey = UniqueKey(); + double value = 0.0; + + Widget buildSlider({ + double textScaleFactor, + bool isDiscrete = true, + ShowValueIndicator show = ShowValueIndicator.onlyForDiscrete, + }) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData(textScaleFactor: textScaleFactor), + child: Material( + child: Theme( + data: Theme.of(context).copyWith( + sliderTheme: Theme.of(context).sliderTheme.copyWith(showValueIndicator: show), + ), + child: Center( + child: OverflowBox( + maxWidth: double.infinity, + maxHeight: double.infinity, + child: Slider( + key: sliderKey, + min: 0.0, + max: 100.0, + divisions: isDiscrete ? 10 : null, + label: '${value.round()}', + value: value, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), + ), + ), + ), + ), + ); + }, + ), + ), + ); + } + + await tester.pumpWidget(buildSlider(textScaleFactor: 1.0)); + Offset center = tester.getCenter(find.byType(Slider)); + TestGesture gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect(tester.firstRenderObject(find.byType(Overlay)), paints..scale(x: 1.0, y: 1.0)); + + await gesture.up(); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildSlider(textScaleFactor: 2.0)); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect(tester.firstRenderObject(find.byType(Overlay)), paints..scale(x: 2.0, y: 2.0)); + + await gesture.up(); + await tester.pumpAndSettle(); + + // Check continuous + await tester.pumpWidget(buildSlider( + textScaleFactor: 1.0, + isDiscrete: false, + show: ShowValueIndicator.onlyForContinuous, + )); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect(tester.firstRenderObject(find.byType(Overlay)), paints..scale(x: 1.0, y: 1.0)); + + await gesture.up(); + await tester.pumpAndSettle(); + + await tester.pumpWidget(buildSlider( + textScaleFactor: 2.0, + isDiscrete: false, + show: ShowValueIndicator.onlyForContinuous, + )); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + await tester.pumpAndSettle(); + + expect(tester.firstRenderObject(find.byType(Overlay)), paints..scale(x: 2.0, y: 2.0)); await gesture.up(); await tester.pumpAndSettle(); @@ -1019,18 +1444,20 @@ void main() { Widget buildSlider({ int divisions, }) { - return Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Slider( - min: 0.0, - max: 100.0, - divisions: divisions, - value: 0.25, - onChanged: (double newValue) { }, + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Slider( + min: 0.0, + max: 100.0, + divisions: divisions, + value: 0.25, + onChanged: (double newValue) { }, + ), ), ), ), @@ -1064,6 +1491,139 @@ void main() { expect(sliderBox, paintsExactlyCountTimes(#drawCircle, 1)); }); + testWidgets('Slider V2 has correct animations when reparented', (WidgetTester tester) async { + final Key sliderKey = GlobalKey(debugLabel: 'A'); + double value = 0.0; + + Widget buildSlider(int parents) { + Widget createParents(int parents, StateSetter setState) { + Widget slider = Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + useV2Slider: true, + ); + + for (int i = 0; i < parents; ++i) { + slider = Column(children: [slider]); + } + return slider; + } + + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: createParents(parents, setState), + ), + ); + }, + ), + ), + ); + } + + Future testReparenting(bool reparent) async { + final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + final Offset center = tester.getCenter(find.byType(Slider)); + // Move to 0.0. + TestGesture gesture = await tester.startGesture(Offset.zero); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + expect( + sliderBox, + paints + ..circle(x: 26.0, y: 24.0, radius: 1.0) + ..circle(x: 213.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 1.0) + ..circle(x: 587.0, y: 24.0, radius: 1.0) + ..circle(x: 774.0, y: 24.0, radius: 1.0) + ..circle(x: 24.0, y: 24.0, radius: 10.0), + ); + + gesture = await tester.startGesture(center); + await tester.pump(); + // Wait for animations to start. + await tester.pump(const Duration(milliseconds: 25)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(2)); + expect( + sliderBox, + paints + ..circle(x: 111.20703125, y: 24.0, radius: 5.687664985656738) + ..circle(x: 26.0, y: 24.0, radius: 1.0) + ..circle(x: 213.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 1.0) + ..circle(x: 587.0, y: 24.0, radius: 1.0) + ..circle(x: 774.0, y: 24.0, radius: 1.0) + ..circle(x: 111.20703125, y: 24.0, radius: 10.0), + ); + + // Reparenting in the middle of an animation should do nothing. + if (reparent) { + await tester.pumpWidget(buildSlider(2)); + } + + // Move a little further in the animations. + await tester.pump(const Duration(milliseconds: 10)); + expect(SchedulerBinding.instance.transientCallbackCount, equals(2)); + expect( + sliderBox, + paints + ..circle(x: 190.0135726928711, y: 24.0, radius: 12.0) + ..circle(x: 26.0, y: 24.0, radius: 1.0) + ..circle(x: 213.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 1.0) + ..circle(x: 587.0, y: 24.0, radius: 1.0) + ..circle(x: 774.0, y: 24.0, radius: 1.0) + ..circle(x: 190.0135726928711, y: 24.0, radius: 10.0), + ); + // Wait for animations to finish. + await tester.pumpAndSettle(); + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + expect( + sliderBox, + paints + ..circle(x: 400.0, y: 24.0, radius: 24.0) + ..circle(x: 26.0, y: 24.0, radius: 1.0) + ..circle(x: 213.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 1.0) + ..circle(x: 587.0, y: 24.0, radius: 1.0) + ..circle(x: 774.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 10.0), + ); + await gesture.up(); + await tester.pumpAndSettle(); + expect(SchedulerBinding.instance.transientCallbackCount, equals(0)); + expect( + sliderBox, + paints + ..circle(x: 26.0, y: 24.0, radius: 1.0) + ..circle(x: 213.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 1.0) + ..circle(x: 587.0, y: 24.0, radius: 1.0) + ..circle(x: 774.0, y: 24.0, radius: 1.0) + ..circle(x: 400.0, y: 24.0, radius: 10.0), + ); + } + + await tester.pumpWidget(buildSlider(1)); + // Do it once without reparenting in the middle of an animation + await testReparenting(false); + // Now do it again with reparenting in the middle of an animation. + await testReparenting(true); + }); + testWidgets('Slider has correct animations when reparented', (WidgetTester tester) async { final Key sliderKey = GlobalKey(debugLabel: 'A'); double value = 0.0; @@ -1087,17 +1647,19 @@ void main() { return slider; } - return Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: createParents(parents, setState), - ), - ); - }, + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: createParents(parents, setState), + ), + ); + }, + ), ), ); } @@ -1194,17 +1756,20 @@ void main() { await testReparenting(true); }); + testWidgets('Slider Semantics', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Slider( - value: 0.5, - onChanged: (double v) { }, + await tester.pumpWidget(MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Slider( + value: 0.5, + onChanged: (double v) { }, + ), ), ), ), @@ -1216,11 +1781,23 @@ void main() { TestSemantics.root(children: [ TestSemantics.rootChild( id: 1, - value: '50%', - increasedValue: '55%', - decreasedValue: '45%', textDirection: TextDirection.ltr, - actions: SemanticsAction.decrease.index | SemanticsAction.increase.index, + + children: [ + TestSemantics( + id: 2, + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 3, + actions: SemanticsAction.decrease.index | SemanticsAction.increase.index, + value: '50%', + increasedValue: '55%', + decreasedValue: '45%', + ), + ], + ), + ], ), ]), ignoreRect: true, @@ -1228,14 +1805,16 @@ void main() { )); // Disable slider - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: const Material( - child: Slider( - value: 0.5, - onChanged: null, + await tester.pumpWidget(MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: const Material( + child: Slider( + value: 0.5, + onChanged: null, + ), ), ), ), @@ -1244,7 +1823,18 @@ void main() { expect( semantics, hasSemantics( - TestSemantics.root(), + TestSemantics.root(children: [ + TestSemantics.rootChild( + id: 1, + textDirection: TextDirection.ltr, + children: [ + TestSemantics( + id: 2, + flags: [SemanticsFlag.scopesRoute], + ), + ], + ), + ]), ignoreRect: true, ignoreTransform: true, )); @@ -1252,11 +1842,12 @@ void main() { semantics.dispose(); }, variant: const TargetPlatformVariant({ TargetPlatform.android, TargetPlatform.fuchsia, TargetPlatform.linux, TargetPlatform.windows })); - testWidgets('Slider Semantics', (WidgetTester tester) async { - final SemanticsTester semantics = SemanticsTester(tester); +testWidgets('Slider Semantics', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget( - Theme( + await tester.pumpWidget( + MaterialApp( + home: Theme( data: ThemeData.light(), child: Directionality( textDirection: TextDirection.ltr, @@ -1273,42 +1864,56 @@ void main() { ), ), ), - ); + ), + ); - expect( - semantics, - hasSemantics( - TestSemantics.root(children: [ - TestSemantics.rootChild( - id: 1, - value: '50%', - increasedValue: '60%', - decreasedValue: '40%', - textDirection: TextDirection.ltr, - actions: SemanticsAction.decrease.index | SemanticsAction.increase.index, - ), - ]), - ignoreRect: true, - ignoreTransform: true, - )); - semantics.dispose(); - }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); + expect( + semantics, + hasSemantics( + TestSemantics.root(children: [ + TestSemantics.rootChild( + id: 1, + textDirection: TextDirection.ltr, + children: [ + TestSemantics( + id: 2, + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 3, + actions: SemanticsAction.decrease.index | SemanticsAction.increase.index, + value: '50%', + increasedValue: '60%', + decreasedValue: '40%', + ), + ], + ), + ], + ), + ]), + ignoreRect: true, + ignoreTransform: true, + )); + semantics.dispose(); +}, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); testWidgets('Slider semantics with custom formatter', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Slider( - value: 40.0, - min: 0.0, - max: 200.0, - divisions: 10, - semanticFormatterCallback: (double value) => value.round().toString(), - onChanged: (double v) { }, + await tester.pumpWidget(MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Slider( + value: 40.0, + min: 0.0, + max: 200.0, + divisions: 10, + semanticFormatterCallback: (double value) => value.round().toString(), + onChanged: (double v) { }, + ), ), ), ), @@ -1320,11 +1925,22 @@ void main() { TestSemantics.root(children: [ TestSemantics.rootChild( id: 1, - value: '40', - increasedValue: '60', - decreasedValue: '20', textDirection: TextDirection.ltr, - actions: SemanticsAction.decrease.index | SemanticsAction.increase.index, + children: [ + TestSemantics( + id: 2, + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 3, + actions: SemanticsAction.increase.index | SemanticsAction.decrease.index, + value: '40', + increasedValue: '60', + decreasedValue: '20', + ), + ], + ), + ], ), ]), ignoreRect: true, @@ -1342,21 +1958,23 @@ void main() { double value = 0.45; Widget buildApp({ SliderThemeData sliderTheme, int divisions, bool enabled = true }) { final ValueChanged onChanged = enabled ? (double d) => value = d : null; - return Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Theme( - data: baseTheme, - child: SliderTheme( - data: sliderTheme, - child: Slider( - value: value, - label: '$value', - divisions: divisions, - onChanged: onChanged, + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Theme( + data: baseTheme, + child: SliderTheme( + data: sliderTheme, + child: Slider( + value: value, + label: '$value', + divisions: divisions, + onChanged: onChanged, + ), ), ), ), @@ -1379,9 +1997,9 @@ void main() { // Wait for value indicator animation to finish. await tester.pumpAndSettle(); - final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); expect( - sliderBox, + valueIndicatorBox, isVisible ? (paints..path(color: theme.valueIndicatorColor)) : isNot(paints..path(color: theme.valueIndicatorColor)), @@ -1421,28 +2039,30 @@ void main() { final Key sliderKey = UniqueKey(); double value = 0.0; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Slider( - key: sliderKey, - value: value, - divisions: 4, - onChanged: (double newValue) { - setState(() { - value = newValue; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -1517,34 +2137,36 @@ void main() { final Key sliderKey = UniqueKey(); double value = 0.0; await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: StatefulBuilder( - builder: (BuildContext context, StateSetter setState) { - final SliderThemeData sliderTheme = SliderTheme.of(context).copyWith(tickMarkShape: TallSliderTickMarkShape()); - return MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: IntrinsicHeight( - child: SliderTheme( - data: sliderTheme, - child: Slider( - key: sliderKey, - value: value, - divisions: 4, - onChanged: (double newValue) { - setState(() { - value = newValue; - }); - }, + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + final SliderThemeData sliderTheme = SliderTheme.of(context).copyWith(tickMarkShape: TallSliderTickMarkShape()); + return MediaQuery( + data: MediaQueryData.fromWindow(window), + child: Material( + child: Center( + child: IntrinsicHeight( + child: SliderTheme( + data: sliderTheme, + child: Slider( + key: sliderKey, + value: value, + divisions: 4, + onChanged: (double newValue) { + setState(() { + value = newValue; + }); + }, + ), ), ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -1583,6 +2205,7 @@ void main() { 'label: "Set a value"', 'activeColor: MaterialColor(primary value: Color(0xff2196f3))', 'inactiveColor: MaterialColor(primary value: Color(0xff9e9e9e))', + 'useV1Slider', ]); }); } diff --git a/packages/flutter/test/material/slider_theme_test.dart b/packages/flutter/test/material/slider_theme_test.dart index 8ddf4730cec..a99270a7859 100644 --- a/packages/flutter/test/material/slider_theme_test.dart +++ b/packages/flutter/test/material/slider_theme_test.dart @@ -94,6 +94,24 @@ void main() { ]); }); + testWidgets('Slider V2 uses ThemeData slider theme if present', (WidgetTester tester) async { + final ThemeData theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.red, + ); + final SliderThemeData sliderTheme = theme.sliderTheme; + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5, enabled: false, useV2Slider: true)); + final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + + expect( + sliderBox, + paints + ..rrect(color: sliderTheme.disabledActiveTrackColor) + ..rrect(color: sliderTheme.disabledInactiveTrackColor), + ); + }); + testWidgets('Slider uses ThemeData slider theme if present', (WidgetTester tester) async { final ThemeData theme = ThemeData( platform: TargetPlatform.android, @@ -112,6 +130,28 @@ void main() { ); }); + testWidgets('Slider V2 overrides ThemeData theme if SliderTheme present', (WidgetTester tester) async { + final ThemeData theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.red, + ); + final SliderThemeData sliderTheme = theme.sliderTheme; + final SliderThemeData customTheme = sliderTheme.copyWith( + activeTrackColor: Colors.purple, + inactiveTrackColor: Colors.purple.withAlpha(0x3d), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5, enabled: false, useV2Slider: true)); + final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + + expect( + sliderBox, + paints + ..rrect(color: customTheme.disabledActiveTrackColor) + ..rrect(color: customTheme.disabledInactiveTrackColor), + ); + }); + testWidgets('Slider overrides ThemeData theme if SliderTheme present', (WidgetTester tester) async { final ThemeData theme = ThemeData( platform: TargetPlatform.android, @@ -218,6 +258,40 @@ void main() { expect(lerp.valueIndicatorTextStyle.color, equals(middleGrey.withAlpha(0xff))); }); + testWidgets('Slider V2 track draws correctly', (WidgetTester tester) async { + final ThemeData theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + ); + final SliderThemeData sliderTheme = theme.sliderTheme.copyWith(thumbColor: Colors.red.shade500); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, useV2Slider: true)); + final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + + const Radius radius = Radius.circular(2); + const Radius activatedRadius = Radius.circular(3); + + // The enabled slider thumb has track segments that extend to and from + // the center of the thumb. + expect( + sliderBox, + paints + ..rrect(rrect: RRect.fromLTRBAndCorners(24.0, 297.0, 212.0, 303.0, topLeft: activatedRadius, bottomLeft: activatedRadius), color: sliderTheme.activeTrackColor) + ..rrect(rrect: RRect.fromLTRBAndCorners(212.0, 298.0, 776.0, 302.0, topRight: radius, bottomRight: radius), color: sliderTheme.inactiveTrackColor), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, enabled: false, useV2Slider: true)); + await tester.pumpAndSettle(); // wait for disable animation + + // The disabled slider thumb is the same size as the enabled thumb. + expect( + sliderBox, + paints + ..rrect(rrect: RRect.fromLTRBAndCorners(24.0, 297.0, 212.0, 303.0, topLeft: activatedRadius, bottomLeft: activatedRadius), color: sliderTheme.disabledActiveTrackColor) + ..rrect(rrect: RRect.fromLTRBAndCorners(212.0, 298.0, 776.0, 302.0, topRight: radius, bottomRight: radius), color: sliderTheme.disabledInactiveTrackColor), + ); + }); + testWidgets('Default slider track draws correctly', (WidgetTester tester) async { final ThemeData theme = ThemeData( platform: TargetPlatform.android, @@ -240,12 +314,7 @@ void main() { await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, enabled: false)); await tester.pumpAndSettle(); // wait for disable animation - // The disabled slider thumb has a horizontal gap between itself and the - // track segments. Therefore, the track segments are shorter since they do - // not extend to the center of the thumb, but rather the outer edge of th - // gap. As a result, the `right` value of the first segment is less than it - // is above, and the `left` value of the second segment is more than it is - // above. + // The disabled slider thumb is the same size as the enabled thumb. expect( sliderBox, paints @@ -359,32 +428,38 @@ void main() { ); }); - testWidgets('Default slider value indicator shape draws correctly', (WidgetTester tester) async { + testWidgets('Slider V2 value indicator shape draws correctly', (WidgetTester tester) async { final ThemeData theme = ThemeData( platform: TargetPlatform.android, primarySwatch: Colors.blue, ); - final SliderThemeData sliderTheme = theme.sliderTheme.copyWith(thumbColor: Colors.red.shade500, showValueIndicator: ShowValueIndicator.always); + final SliderThemeData sliderTheme = theme.sliderTheme.copyWith( + thumbColor: Colors.red.shade500, + showValueIndicator: ShowValueIndicator.always, + ); Widget buildApp(String value, { double sliderValue = 0.5, double textScale = 1.0 }) { - return Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window).copyWith(textScaleFactor: textScale), - child: Material( - child: Row( - children: [ - Expanded( - child: SliderTheme( - data: sliderTheme, - child: Slider( - value: sliderValue, - label: value, - divisions: 3, - onChanged: (double d) { }, + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window).copyWith(textScaleFactor: textScale), + child: Material( + child: Row( + children: [ + Expanded( + child: SliderTheme( + data: sliderTheme, + child: Slider( + value: sliderValue, + label: value, + divisions: 3, + onChanged: (double d) { }, + useV2Slider: true, + ), ), ), - ), - ], + ], + ), ), ), ), @@ -393,14 +468,195 @@ void main() { await tester.pumpWidget(buildApp('1')); - final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); Offset center = tester.getCenter(find.byType(Slider)); TestGesture gesture = await tester.startGesture(center); // Wait for value indicator animation to finish. await tester.pumpAndSettle(); expect( - sliderBox, + valueIndicatorBox, + paints + ..path( + includes: const [ + Offset(0.0, 0.0), + Offset(-20.0, -12.0), + Offset(20.0, -34.0), + Offset(0.0, -38.0), + ], + color: const Color(0xf55f5f5f), + ), + ); + + await gesture.up(); + + // Test that it expands with a larger label. + await tester.pumpWidget(buildApp('1000')); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints + ..rrect() + ..rrect() + ..path( + includes: const [ + Offset(0.0, 0.0), + Offset(-30.0, -12.0), + Offset(30.0, -34.0), + Offset(0.0, -38.0), + ], + color: const Color(0xf55f5f5f), + ), + ); + await gesture.up(); + + // Test that it avoids the left edge of the screen. + await tester.pumpWidget(buildApp('1000000', sliderValue: 0.0)); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints + ..rrect() + ..rrect() + ..path( + includes: const [ + Offset(0.0, 0.0), + Offset(-12.0, -12.0), + Offset(110.0, -34.0), + Offset(0.0, -38.0), + ], + color: const Color(0xf55f5f5f), + ) + ); + await gesture.up(); + + // Test that it avoids the right edge of the screen. + await tester.pumpWidget(buildApp('1000000', sliderValue: 1.0)); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints + ..rrect() + ..rrect() + ..path( + includes: const [ + Offset(0.0, 0.0), + Offset(-110.0, -12.0), + Offset(12.0, -34.0), + Offset(0.0, -38.0), + ], + color: const Color(0xf55f5f5f), + ) + ); + await gesture.up(); + + // Test that the box decreases in height when the text scale gets smaller. + await tester.pumpWidget(buildApp('1000000', sliderValue: 0.0, textScale: 0.5)); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints + ..rrect() + ..rrect() + ..path( + includes: const [ + Offset(0.0, 0.0), + Offset(-12.0, -12.0), + Offset(61.0, -16.0), + Offset(0.0, -20.0), + ], + excludes: const [ + Offset(0.0, -38.0) + ], + color: const Color(0xf55f5f5f), + ) + ); + await gesture.up(); + + // Test that the box increases in height when the text scale gets bigger. + await tester.pumpWidget(buildApp('1000000', sliderValue: 0.0, textScale: 2.0)); + center = tester.getCenter(find.byType(Slider)); + gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, + paints + ..rrect() + ..rrect() + ..path( + includes: const [ + Offset(0.0, 0.0), + Offset(-12.0, -16.0), + Offset(208.0, -40.0), + Offset(0.0, -50.0), + ], + color: const Color(0xf55f5f5f), + ) + ); + await gesture.up(); + }, skip: isBrowser); + + testWidgets('Default paddle slider value indicator shape draws correctly', (WidgetTester tester) async { + final ThemeData theme = ThemeData( + platform: TargetPlatform.android, + primarySwatch: Colors.blue, + ); + final SliderThemeData sliderTheme = theme.sliderTheme.copyWith( + thumbColor: Colors.red.shade500, + showValueIndicator: ShowValueIndicator.always, + valueIndicatorShape: const PaddleSliderValueIndicatorShape(), + ); + Widget buildApp(String value, { double sliderValue = 0.5, double textScale = 1.0 }) { + return MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: MediaQuery( + data: MediaQueryData.fromWindow(window).copyWith(textScaleFactor: textScale), + child: Material( + child: Row( + children: [ + Expanded( + child: SliderTheme( + data: sliderTheme, + child: Slider( + value: sliderValue, + label: value, + divisions: 3, + onChanged: (double d) { }, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildApp('1')); + + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); + + Offset center = tester.getCenter(find.byType(Slider)); + TestGesture gesture = await tester.startGesture(center); + // Wait for value indicator animation to finish. + await tester.pumpAndSettle(); + expect( + valueIndicatorBox, paints ..path( color: sliderTheme.valueIndicatorColor, @@ -422,7 +678,7 @@ void main() { // Wait for value indicator animation to finish. await tester.pumpAndSettle(); expect( - sliderBox, + valueIndicatorBox, paints ..path( color: sliderTheme.valueIndicatorColor, @@ -443,7 +699,7 @@ void main() { // Wait for value indicator animation to finish. await tester.pumpAndSettle(); expect( - sliderBox, + valueIndicatorBox, paints ..path( color: sliderTheme.valueIndicatorColor, @@ -464,7 +720,7 @@ void main() { // Wait for value indicator animation to finish. await tester.pumpAndSettle(); expect( - sliderBox, + valueIndicatorBox, paints ..path( color: sliderTheme.valueIndicatorColor, @@ -485,7 +741,7 @@ void main() { // Wait for value indicator animation to finish. await tester.pumpAndSettle(); expect( - sliderBox, + valueIndicatorBox, paints ..path( color: sliderTheme.valueIndicatorColor, @@ -511,7 +767,7 @@ void main() { // Wait for value indicator animation to finish. await tester.pumpAndSettle(); expect( - sliderBox, + valueIndicatorBox, paints ..path( color: sliderTheme.valueIndicatorColor, @@ -558,6 +814,36 @@ void main() { ); }); + testWidgets('The slider V2 track height can be overridden', (WidgetTester tester) async { + final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith(trackHeight: 16); + const Radius radius = Radius.circular(8); + const Radius activatedRadius = Radius.circular(9); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, useV2Slider: true)); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + + // Top and bottom are centerY (300) + and - trackRadius (8). + expect( + sliderBox, + paints + ..rrect(rrect: RRect.fromLTRBAndCorners(24.0, 291.0, 212.0, 309.0, topLeft: activatedRadius, bottomLeft: activatedRadius), color: sliderTheme.activeTrackColor) + ..rrect(rrect: RRect.fromLTRBAndCorners(212.0, 292.0, 776.0, 308.0, topRight: radius, bottomRight: radius), color: sliderTheme.inactiveTrackColor), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, enabled: false, useV2Slider: true)); + await tester.pumpAndSettle(); // wait for disable animation + + // The disabled thumb is smaller so the active track has to paint longer to + // get to the edge. + expect( + sliderBox, + paints + ..rrect(rrect: RRect.fromLTRBAndCorners(24.0, 291.0, 212.0, 309.0, topLeft: activatedRadius, bottomLeft: activatedRadius), color: sliderTheme.disabledActiveTrackColor) + ..rrect(rrect: RRect.fromLTRBAndCorners(212.0, 292.0, 776.0, 308.0, topRight: radius, bottomRight: radius), color: sliderTheme.disabledInactiveTrackColor), + ); + }); + testWidgets('The default slider thumb shape sizes can be overridden', (WidgetTester tester) async { final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith( thumbShape: const RoundSliderThumbShape( @@ -606,7 +892,6 @@ void main() { ); }); - testWidgets('The default slider tick mark shape size can be overridden', (WidgetTester tester) async { final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith( tickMarkShape: const RoundSliderTickMarkShape(tickMarkRadius: 5), @@ -628,7 +913,7 @@ void main() { ..circle(x: 771, y: 300, radius: 5, color: sliderTheme.inactiveTickMarkColor), ); - await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5, divisions: 2, enabled: false)); + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5, divisions: 2, enabled: false)); await tester.pumpAndSettle(); expect( @@ -640,6 +925,39 @@ void main() { ); }); + testWidgets('The default slider V2 tick mark shape size can be overridden', (WidgetTester tester) async { + final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith( + tickMarkShape: const RoundSliderTickMarkShape(tickMarkRadius: 5, useV2Slider: true), + activeTickMarkColor: const Color(0xfadedead), + inactiveTickMarkColor: const Color(0xfadebeef), + disabledActiveTickMarkColor: const Color(0xfadecafe), + disabledInactiveTickMarkColor: const Color(0xfadeface), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5, divisions: 2, useV2Slider: true)); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + + expect( + sliderBox, + paints + ..circle(x: 26, y: 300, radius: 5, color: sliderTheme.activeTickMarkColor) + ..circle(x: 400, y: 300, radius: 5, color: sliderTheme.activeTickMarkColor) + ..circle(x: 774, y: 300, radius: 5, color: sliderTheme.inactiveTickMarkColor), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5, divisions: 2, enabled: false, useV2Slider: true)); + await tester.pumpAndSettle(); + + expect( + sliderBox, + paints + ..circle(x: 26, y: 300, radius: 5, color: sliderTheme.disabledActiveTickMarkColor) + ..circle(x: 400, y: 300, radius: 5, color: sliderTheme.disabledActiveTickMarkColor) + ..circle(x: 774, y: 300, radius: 5, color: sliderTheme.disabledInactiveTickMarkColor), + ); + }); + testWidgets('The default slider overlay shape size can be overridden', (WidgetTester tester) async { const double uniqueOverlayRadius = 23; final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith( @@ -800,7 +1118,7 @@ void main() { divisions: 4, )); - final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); // Tap the center of the track and wait for animations to finish. final Offset center = tester.getCenter(find.byType(Slider)); @@ -808,14 +1126,14 @@ void main() { await tester.pumpAndSettle(); // Only 1 value indicator. - expect(sliderBox, paintsExactlyCountTimes(#drawRect, 0)); - expect(sliderBox, paintsExactlyCountTimes(#drawCircle, 0)); - expect(sliderBox, paintsExactlyCountTimes(#drawPath, 1)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawRect, 0)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawCircle, 0)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 1)); await gesture.up(); }); - testWidgets('PaddleRangeSliderValueIndicatorShape skips all painting at zero scale', (WidgetTester tester) async { + testWidgets('PaddleSliderValueIndicatorShape skips all painting at zero scale', (WidgetTester tester) async { // Pump a slider with just a value indicator. await tester.pumpWidget(_buildApp( ThemeData().sliderTheme.copyWith( @@ -824,13 +1142,13 @@ void main() { thumbShape: SliderComponentShape.noThumb, tickMarkShape: SliderTickMarkShape.noTickMark, showValueIndicator: ShowValueIndicator.always, - rangeValueIndicatorShape: const PaddleRangeSliderValueIndicatorShape(), + valueIndicatorShape: const PaddleSliderValueIndicatorShape(), ), value: 0.5, divisions: 4, )); - final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); // Tap the center of the track to kick off the animation of the value indicator. final Offset center = tester.getCenter(find.byType(Slider)); @@ -838,11 +1156,102 @@ void main() { // Nothing to paint at scale 0. await tester.pump(); - expect(sliderBox, paintsNothing); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 0)); // Painting a path for the value indicator. await tester.pump(const Duration(milliseconds: 16)); - expect(sliderBox, paintsExactlyCountTimes(#drawPath, 1)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 1)); + + await gesture.up(); + }); + + testWidgets('Default slider value indicator shape skips all painting at zero scale', (WidgetTester tester) async { + // Pump a slider with just a value indicator. + await tester.pumpWidget(_buildApp( + ThemeData().sliderTheme.copyWith( + trackHeight: 0, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: SliderComponentShape.noThumb, + tickMarkShape: SliderTickMarkShape.noTickMark, + showValueIndicator: ShowValueIndicator.always, + ), + value: 0.5, + divisions: 4, + )); + + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); + + // Tap the center of the track to kick off the animation of the value indicator. + final Offset center = tester.getCenter(find.byType(Slider)); + final TestGesture gesture = await tester.startGesture(center); + + // Nothing to paint at scale 0. + await tester.pump(); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 0)); + + // Painting a path for the value indicator. + await tester.pump(const Duration(milliseconds: 16)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 1)); + + await gesture.up(); + }); + + testWidgets('PaddleRangeSliderValueIndicatorShape skips all painting at zero scale', (WidgetTester tester) async { + // Pump a slider with just a value indicator. + await tester.pumpWidget(_buildRangeApp( + ThemeData().sliderTheme.copyWith( + trackHeight: 0, + rangeValueIndicatorShape: const PaddleRangeSliderValueIndicatorShape(), + ), + values: const RangeValues(0, 0.5), + divisions: 4, + )); + +// final RenderBox sliderBox = tester.firstRenderObject(find.byType(RangeSlider)); + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); + + // Tap the center of the track to kick off the animation of the value indicator. + final Offset center = tester.getCenter(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(center); + + // No value indicator path to paint at scale 0. + await tester.pump(); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 0)); + + // Painting a path for each value indicator. + await tester.pump(const Duration(milliseconds: 16)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 2)); + + await gesture.up(); + }); + + testWidgets('Default range indicator shape skips all painting at zero scale', (WidgetTester tester) async { + // Pump a slider with just a value indicator. + await tester.pumpWidget(_buildRangeApp( + ThemeData().sliderTheme.copyWith( + trackHeight: 0, + overlayShape: SliderComponentShape.noOverlay, + thumbShape: SliderComponentShape.noThumb, + tickMarkShape: SliderTickMarkShape.noTickMark, + showValueIndicator: ShowValueIndicator.always, + ), + values: const RangeValues(0, 0.5), + divisions: 4, + )); + + final RenderBox valueIndicatorBox = tester.firstRenderObject(find.byType(Overlay)); + + // Tap the center of the track to kick off the animation of the value indicator. + final Offset center = tester.getCenter(find.byType(RangeSlider)); + final TestGesture gesture = await tester.startGesture(center); + + // No value indicator path to paint at scale 0. + await tester.pump(); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 0)); + + // Painting a path for each value indicator. + await tester.pump(const Duration(milliseconds: 16)); + expect(valueIndicatorBox, paintsExactlyCountTimes(#drawPath, 2)); await gesture.up(); }); @@ -853,6 +1262,7 @@ Widget _buildApp( double value = 0.0, bool enabled = true, int divisions, + bool useV2Slider = false, }) { final ValueChanged onChanged = enabled ? (double d) => value = d : null; return MaterialApp( @@ -865,6 +1275,31 @@ Widget _buildApp( label: '$value', onChanged: onChanged, divisions: divisions, + useV2Slider: useV2Slider + ), + ), + ), + ), + ); +} + +Widget _buildRangeApp( + SliderThemeData sliderTheme, { + RangeValues values = const RangeValues(0, 0), + bool enabled = true, + int divisions, + }) { + final ValueChanged onChanged = enabled ? (RangeValues d) => values = d : null; + return MaterialApp( + home: Scaffold( + body: Center( + child: SliderTheme( + data: sliderTheme, + child: RangeSlider( + values: values, + labels: RangeLabels(values.start.toString(), values.end.toString()), + onChanged: onChanged, + divisions: divisions, ), ), ), diff --git a/packages/flutter/test/painting/system_fonts_test.dart b/packages/flutter/test/painting/system_fonts_test.dart index ccb04db7aa2..9e58f5f378b 100644 --- a/packages/flutter/test/painting/system_fonts_test.dart +++ b/packages/flutter/test/painting/system_fonts_test.dart @@ -169,7 +169,10 @@ void main() { (ByteData data) { }, ); final RenderObject renderObject = tester.renderObject(find.byType(RangeSlider)); - expect(renderObject.debugNeedsLayout, isTrue); + + bool sliderBoxNeedsLayout; + renderObject.visitChildren((RenderObject child) {sliderBoxNeedsLayout = child.debugNeedsLayout;}); + expect(sliderBoxNeedsLayout, isTrue); }); testWidgets('Slider relayout upon system fonts changes', (WidgetTester tester) async { @@ -191,8 +194,11 @@ void main() { SystemChannels.system.codec.encodeMessage(data), (ByteData data) { }, ); - final RenderObject renderObject = tester.renderObject(find.byType(Slider)); - expect(renderObject.debugNeedsLayout, isTrue); + final RenderObject renderObject = tester.renderObject(find.byType(Slider)); + + bool sliderBoxNeedsLayout; + renderObject.visitChildren((RenderObject child) {sliderBoxNeedsLayout = child.debugNeedsLayout;}); + expect(sliderBoxNeedsLayout, isTrue); }); testWidgets('TimePicker relayout upon system fonts changes', (WidgetTester tester) async {