diff --git a/packages/flutter/lib/src/material/slider.dart b/packages/flutter/lib/src/material/slider.dart index c3890b8566..c3f59516e1 100644 --- a/packages/flutter/lib/src/material/slider.dart +++ b/packages/flutter/lib/src/material/slider.dart @@ -601,7 +601,7 @@ class _RenderSlider extends RenderBox { _sliderTheme.thumbShape.getPreferredSize(isInteractive, isDiscrete), _sliderTheme.tickMarkShape.getPreferredSize(isEnabled: isInteractive, sliderTheme: sliderTheme), ]; - double get _minPreferredTrackHeight =>_sliderTheme.trackHeight; + double get _minPreferredTrackHeight => _sliderTheme.trackHeight; _SliderState _state; Animation _overlayAnimation; diff --git a/packages/flutter/lib/src/material/slider_theme.dart b/packages/flutter/lib/src/material/slider_theme.dart index 8355c75533..3b38ff3d94 100644 --- a/packages/flutter/lib/src/material/slider_theme.dart +++ b/packages/flutter/lib/src/material/slider_theme.dart @@ -846,9 +846,16 @@ abstract class SliderComponentShape { /// * [SliderTrackShape] Base component for creating other custom track /// shapes. class RectangularSliderTrackShape extends SliderTrackShape { - /// Abstract const constructor. This constructor enables subclasses to provide - /// const constructors so that they can be used in const expressions. - const RectangularSliderTrackShape(); + /// Create a slider track that draws 2 rectangles. + const RectangularSliderTrackShape({ this.disabledThumbGapWidth = 2.0 }); + + /// Horizontal spacing, or gap, between the disabled thumb and the track. + /// + /// This is only used when the slider is disabled. There is no gap around + /// the thumb and any part of the track when the slider is enabled. The + /// Material spec defaults this gap width 2, which is half of the disabled + /// thumb radius. + final double disabledThumbGapWidth; @override Rect getPreferredRect({ @@ -874,8 +881,6 @@ class RectangularSliderTrackShape extends SliderTrackShape { return Rect.fromLTWH(trackLeft, trackTop, trackWidth, trackHeight); } - // Spacing for disabled slider state. - static const double _thumbGap = 2.0; @override void paint( @@ -909,11 +914,15 @@ class RectangularSliderTrackShape extends SliderTrackShape { } // Used to create a gap around the thumb iff the slider is disabled. + // If the slider is enabled, the track can be drawn beneath the thumb + // without a gap. But when the slider is disabled, the track is shortened + // and this gap helps determine how much shorter it should be. + // TODO(clocksmith): The new Material spec has a gray circle in place of this gap. double horizontalAdjustment = 0.0; if (!isEnabled) { - final double thumbRadius = sliderTheme.thumbShape.getPreferredSize(isEnabled, isDiscrete).width / 2.0; - final double gap = _thumbGap * (1.0 - enableAnimation.value); - horizontalAdjustment = thumbRadius + gap; + final double disabledThumbRadius = sliderTheme.thumbShape.getPreferredSize(false, isDiscrete).width / 2.0; + final double gap = disabledThumbGapWidth * (1.0 - enableAnimation.value); + horizontalAdjustment = disabledThumbRadius + gap; } final Rect trackRect = getPreferredRect( @@ -949,16 +958,22 @@ class RectangularSliderTrackShape extends SliderTrackShape { /// * [SliderTheme], which can be used to configure the tick mark shape of all /// sliders in a widget subtree. class RoundSliderTickMarkShape extends SliderTickMarkShape { - /// Abstract const constructor. This constructor enables subclasses to provide - /// const constructors so that they can be used in const expressions. - const RoundSliderTickMarkShape(); + /// Create a slider tick mark that draws a circle. + const RoundSliderTickMarkShape({ this.tickMarkRadius }); + + /// The preferred radius of the round tick mark. + /// + /// If it is not provided, then half of the track height is used. + final double tickMarkRadius; @override Size getPreferredSize({ bool isEnabled, SliderThemeData sliderTheme, }) { - return Size.fromRadius(sliderTheme.trackHeight / 2); + // 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); } @override @@ -991,7 +1006,10 @@ class RoundSliderTickMarkShape extends SliderTickMarkShape { final Paint paint = Paint()..color = ColorTween(begin: begin, end: end).evaluate(enableAnimation); // The tick marks are tiny circles that are the same height as the track. - final double tickMarkRadius = sliderTheme.trackHeight / 2; + final double tickMarkRadius = getPreferredSize( + isEnabled: isEnabled, + sliderTheme: sliderTheme, + ).width / 2; context.canvas.drawCircle(center, tickMarkRadius, paint); } } @@ -1005,14 +1023,30 @@ class RoundSliderTickMarkShape extends SliderTickMarkShape { /// sliders in a widget subtree. class RoundSliderThumbShape extends SliderComponentShape { /// Create a slider thumb that draws a circle. - const RoundSliderThumbShape(); + // TODO(clocksmith): This needs to be changed to 10 according to spec. + const RoundSliderThumbShape({ + this.enabledThumbRadius = 6.0, + this.disabledThumbRadius + }); - static const double _thumbRadius = 6.0; - static const double _disabledThumbRadius = 4.0; + /// The preferred radius of the round thumb shape when the slider is enabled. + /// + /// If it is not provided, then the material default is used. + final double enabledThumbRadius; + + /// The preferred radius of the round thumb shape when the slider is disabled. + /// + /// If no disabledRadius is provided, then it is is derived from the enabled + /// thumb radius and has the same ratio of enabled size to disabled size as + /// the Material spec. The default resolves to 4, which is 2 / 3 of the + /// default enabled thumb. + final double disabledThumbRadius; + // TODO(clocksmith): This needs to be updated once the thumb size is updated to the Material spec. + double get _disabledThumbRadius => disabledThumbRadius ?? enabledThumbRadius * 2 / 3; @override Size getPreferredSize(bool isEnabled, bool isDiscrete) { - return Size.fromRadius(isEnabled ? _thumbRadius : _disabledThumbRadius); + return Size.fromRadius(isEnabled ? enabledThumbRadius : _disabledThumbRadius); } @override @@ -1031,7 +1065,7 @@ class RoundSliderThumbShape extends SliderComponentShape { final Canvas canvas = context.canvas; final Tween radiusTween = Tween( begin: _disabledThumbRadius, - end: _thumbRadius, + end: enabledThumbRadius, ); final ColorTween colorTween = ColorTween( begin: sliderTheme.disabledThumbColor, @@ -1062,13 +1096,17 @@ class RoundSliderThumbShape extends SliderComponentShape { /// sliders in a widget subtree. class RoundSliderOverlayShape extends SliderComponentShape { /// Create a slider thumb overlay that draws a circle. - const RoundSliderOverlayShape(); + // TODO(clocksmith): This needs to be changed to 24 according to spec. + const RoundSliderOverlayShape({ this.overlayRadius = 16.0 }); - static const double _overlayRadius = 16.0; + /// The preferred radius of the round thumb shape when enabled. + /// + /// If it is not provided, then half of the track height is used. + final double overlayRadius; @override Size getPreferredSize(bool isEnabled, bool isDiscrete) { - return const Size.fromRadius(_overlayRadius); + return Size.fromRadius(overlayRadius); } @override @@ -1087,7 +1125,7 @@ class RoundSliderOverlayShape extends SliderComponentShape { final Canvas canvas = context.canvas; final Tween radiusTween = Tween( begin: 0.0, - end: _overlayRadius, + end: overlayRadius, ); // TODO(gspencer): We don't really follow the spec here for overlays. diff --git a/packages/flutter/test/material/slider_theme_test.dart b/packages/flutter/test/material/slider_theme_test.dart index d8639dfc2d..4519f010bd 100644 --- a/packages/flutter/test/material/slider_theme_test.dart +++ b/packages/flutter/test/material/slider_theme_test.dart @@ -30,32 +30,15 @@ void main() { ); final SliderThemeData sliderTheme = theme.sliderTheme; - Widget buildSlider(SliderThemeData data) { - return Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Theme( - data: theme, - child: const Slider( - value: 0.5, - label: '0.5', - onChanged: null, - ), - ), - ), - ), - ), - ); - } - - await tester.pumpWidget(buildSlider(sliderTheme)); - + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5, enabled: false)); final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); - expect(sliderBox, paints..rect(color: sliderTheme.disabledActiveTrackColor)..rect(color: sliderTheme.disabledInactiveTrackColor)); + expect( + sliderBox, + paints + ..rect(color: sliderTheme.disabledActiveTrackColor) + ..rect(color: sliderTheme.disabledInactiveTrackColor), + ); }); testWidgets('Slider overrides ThemeData theme if SliderTheme present', (WidgetTester tester) async { @@ -69,35 +52,15 @@ void main() { inactiveTrackColor: Colors.purple.withAlpha(0x3d), ); - Widget buildSlider(SliderThemeData data) { - return Directionality( - textDirection: TextDirection.ltr, - child: MediaQuery( - data: MediaQueryData.fromWindow(window), - child: Material( - child: Center( - child: Theme( - data: theme, - child: SliderTheme( - data: customTheme, - child: const Slider( - value: 0.5, - label: '0.5', - onChanged: null, - ), - ), - ), - ), - ), - ), - ); - } - - await tester.pumpWidget(buildSlider(sliderTheme)); - + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5, enabled: false)); final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); - expect(sliderBox, paints..rect(color: customTheme.disabledActiveTrackColor)..rect(color: customTheme.disabledInactiveTrackColor)); + expect( + sliderBox, + paints + ..rect(color: customTheme.disabledActiveTrackColor) + ..rect(color: customTheme.disabledInactiveTrackColor), + ); }); testWidgets('SliderThemeData assigns the correct default shapes', (WidgetTester tester) async { @@ -180,33 +143,12 @@ void main() { primarySwatch: Colors.blue, ); final SliderThemeData sliderTheme = theme.sliderTheme.copyWith(thumbColor: Colors.red.shade500); - double value = 0.25; - Widget buildApp({ 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: SliderTheme( - data: sliderTheme, - child: Slider( - value: value, - label: '$value', - onChanged: onChanged, - ), - ), - ), - ), - ), - ); - } - - await tester.pumpWidget(buildApp()); + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25)); final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + // The enabled slider thumb has track segments that extend to and from + // the center of the thumb. expect( sliderBox, paints @@ -214,10 +156,15 @@ void main() { ..rect(rect: Rect.fromLTRB(208.0, 299.0, 784.0, 301.0), color: sliderTheme.inactiveTrackColor) ); - await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, enabled: false)); await tester.pumpAndSettle(); // wait for disable animation - // The disabled thumb is smaller so the track has to paint longer to get - // to the edge. + + // 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. expect( sliderBox, paints @@ -232,31 +179,8 @@ void main() { primarySwatch: Colors.blue, ); final SliderThemeData sliderTheme = theme.sliderTheme.copyWith(thumbColor: Colors.red.shade500); - double value = 0.25; - Widget buildApp({ 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: SliderTheme( - data: sliderTheme, - child: Slider( - value: value, - label: '$value', - onChanged: onChanged, - ), - ), - ), - ), - ), - ); - } - - await tester.pumpWidget(buildApp()); + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25)); final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); // With no touch, paints only the thumb. @@ -296,6 +220,7 @@ void main() { await gesture.up(); await tester.pumpAndSettle(); + // After the gesture is up and complete, it again paints only the thumb. expect( sliderBox, @@ -315,45 +240,20 @@ void main() { primarySwatch: Colors.blue, ); final SliderThemeData sliderTheme = theme.sliderTheme.copyWith(thumbColor: Colors.red.shade500); - double value = 0.45; - Widget buildApp({ - 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: SliderTheme( - data: sliderTheme, - child: Slider( - value: value, - label: '$value', - divisions: divisions, - onChanged: onChanged, - ), - ), - ), - ), - ), - ); - } - - await tester.pumpWidget(buildApp()); + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.45)); final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); expect(sliderBox, paints..circle(color: sliderTheme.thumbColor, radius: 6.0)); - await tester.pumpWidget(buildApp(enabled: false)); + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.45, enabled: false)); await tester.pumpAndSettle(); // wait for disable animation + expect(sliderBox, paints..circle(color: sliderTheme.disabledThumbColor, radius: 4.0)); - await tester.pumpWidget(buildApp(divisions: 3)); - await tester.pumpAndSettle(); // wait for disable animation + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.45, divisions: 3)); + await tester.pumpAndSettle(); // wait for enable animation + expect( sliderBox, paints @@ -364,8 +264,9 @@ void main() { ..circle(color: sliderTheme.thumbColor, radius: 6.0) ); - await tester.pumpWidget(buildApp(divisions: 3, enabled: false)); + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.45, divisions: 3, enabled: false)); await tester.pumpAndSettle(); // wait for disable animation + expect( sliderBox, paints @@ -548,4 +449,169 @@ void main() { ); await gesture.up(); }); + + testWidgets('The slider track height can be overridden', (WidgetTester tester) async { + final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith(trackHeight: 16); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25)); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + + // Top and bottom are centerY (300) + and - trackRadius (8). + expect( + sliderBox, + paints + ..rect(rect: Rect.fromLTRB(16.0, 292.0, 208.0, 308.0), color: sliderTheme.activeTrackColor) + ..rect(rect: Rect.fromLTRB(208.0, 292.0, 784.0, 308.0), color: sliderTheme.inactiveTrackColor) + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, enabled: false)); + 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 + ..rect(rect: Rect.fromLTRB(16.0, 292.0, 202.0, 308.0), color: sliderTheme.disabledActiveTrackColor) + ..rect(rect: Rect.fromLTRB(214.0, 292.0, 784.0, 308.0), 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( + enabledThumbRadius: 7, + disabledThumbRadius: 11, + ), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25)); + final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + + expect( + sliderBox, + paints..circle(x: 208, y: 300, radius: 7, color: sliderTheme.thumbColor) + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, enabled: false)); + await tester.pumpAndSettle(); // wait for disable animation + + expect( + sliderBox, + paints..circle(x: 208, y: 300, radius: 11, color: sliderTheme.disabledThumbColor) + ); + }); + + testWidgets('The default slider thumb shape disabled size can be inferred from the enabled size', (WidgetTester tester) async { + final SliderThemeData sliderTheme = ThemeData().sliderTheme.copyWith( + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 9, + ), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25)); + final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + + expect( + sliderBox, + paints..circle(x: 208, y: 300, radius: 9, color: sliderTheme.thumbColor) + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.25, enabled: false)); + await tester.pumpAndSettle(); // wait for disable animation + // Radius should be 6, or 2/3 of 9. 2/3 because the default disabled thumb + // radius is 4 and the default enabled thumb radius is 6. + // TODO(clocksmith): This ratio will change once thumb sizes are updated to spec. + expect( + sliderBox, + paints..circle(x: 208, y: 300, radius: 6, color: sliderTheme.disabledThumbColor) + ); + }); + + + 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 + ), + 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)); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + + expect( + sliderBox, + paints + ..circle(x: 21, y: 300, radius: 5, color: sliderTheme.activeTickMarkColor) + ..circle(x: 400, y: 300, radius: 5, color: sliderTheme.activeTickMarkColor) + ..circle(x: 779, y: 300, radius: 5, color: sliderTheme.inactiveTickMarkColor) + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5, divisions: 2, enabled: false)); + await tester.pumpAndSettle(); + + expect( + sliderBox, + paints + ..circle(x: 21, y: 300, radius: 5, color: sliderTheme.disabledActiveTickMarkColor) + ..circle(x: 400, y: 300, radius: 5, color: sliderTheme.disabledActiveTickMarkColor) + ..circle(x: 779, 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( + overlayShape: const RoundSliderOverlayShape( + overlayRadius: uniqueOverlayRadius, + ), + ); + + await tester.pumpWidget(_buildApp(sliderTheme, value: 0.5)); + // Tap center and wait for animation. + final Offset center = tester.getCenter(find.byType(Slider)); + await tester.startGesture(center); + await tester.pumpAndSettle(); + + final RenderBox sliderBox = tester.firstRenderObject(find.byType(Slider)); + expect( + sliderBox, + paints..circle( + x: center.dx, + y: center.dy, + radius: uniqueOverlayRadius, + color: sliderTheme.overlayColor, + ) + ); + }); +} + +Widget _buildApp( + SliderThemeData sliderTheme, { + double value = 0.0, + bool enabled = true, + int divisions, +}) { + final ValueChanged onChanged = enabled ? (double d) => value = d : null; + return MaterialApp( + home: Scaffold( + body: Center( + child: SliderTheme( + data: sliderTheme, + child: Slider( + value: value, + label: '$value', + onChanged: onChanged, + divisions: divisions, + ), + ), + ), + ), + ); } \ No newline at end of file