diff --git a/packages/material_ui/lib/src/range_slider.dart b/packages/material_ui/lib/src/range_slider.dart index ca85f3276947..97201ed81320 100644 --- a/packages/material_ui/lib/src/range_slider.dart +++ b/packages/material_ui/lib/src/range_slider.dart @@ -21,6 +21,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart' show timeDilation; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'color_scheme.dart'; @@ -484,6 +485,8 @@ class _RangeSliderState extends State with TickerProviderStateMixin final FocusNode startFocusNode = FocusNode(); final FocusNode endFocusNode = FocusNode(); + final GlobalKey _renderObjectKey = GlobalKey(); + // Animation controller that is run when the overlay (a.k.a radial reaction) // changes visibility in response to user interaction. late AnimationController overlayController; @@ -541,6 +544,27 @@ class _RangeSliderState extends State with TickerProviderStateMixin debugLabel: 'RangeSlider ValueIndicator', )..show(); + // Keyboard mapping for a focused range slider. + static const Map _traditionalNavShortcutMap = + { + SingleActivator(LogicalKeyboardKey.arrowUp): _AdjustSliderIntent.up(), + SingleActivator(LogicalKeyboardKey.arrowDown): _AdjustSliderIntent.down(), + SingleActivator(LogicalKeyboardKey.arrowLeft): _AdjustSliderIntent.left(), + SingleActivator(LogicalKeyboardKey.arrowRight): _AdjustSliderIntent.right(), + }; + + // Keyboard mapping for a focused range slider when using directional navigation. + static const Map _directionalNavShortcutMap = + { + SingleActivator(LogicalKeyboardKey.arrowLeft): _AdjustSliderIntent.left(), + SingleActivator(LogicalKeyboardKey.arrowRight): _AdjustSliderIntent.right(), + }; + + // Action mapping for the start thumb. + late Map> _startActionMap; + // Action mapping for the end thumb. + late Map> _endActionMap; + @override void initState() { super.initState(); @@ -564,6 +588,35 @@ class _RangeSliderState extends State with TickerProviderStateMixin vsync: this, value: _unlerp(widget.values.end), ); + _startActionMap = >{ + _AdjustSliderIntent: CallbackAction<_AdjustSliderIntent>( + onInvoke: (_AdjustSliderIntent intent) => _actionHandler(intent, Thumb.start), + ), + }; + _endActionMap = >{ + _AdjustSliderIntent: CallbackAction<_AdjustSliderIntent>( + onInvoke: (_AdjustSliderIntent intent) => _actionHandler(intent, Thumb.end), + ), + }; + } + + void _actionHandler(_AdjustSliderIntent intent, Thumb thumb) { + final slider = _renderObjectKey.currentContext!.findRenderObject()! as _RenderRangeSlider; + final TextDirection directionality = Directionality.of(_renderObjectKey.currentContext!); + + final bool increase = switch (intent.type) { + _SliderAdjustmentType.up => true, + _SliderAdjustmentType.down => false, + _SliderAdjustmentType.left => directionality == TextDirection.rtl, + _SliderAdjustmentType.right => directionality == TextDirection.ltr, + }; + + switch (thumb) { + case Thumb.start: + increase ? slider.increaseStartAction() : slider.decreaseStartAction(); + case Thumb.end: + increase ? slider.increaseEndAction() : slider.decreaseEndAction(); + } } @override @@ -643,12 +696,16 @@ class _RangeSliderState extends State with TickerProviderStateMixin Widget _buildThumbFocusDetector({ required FocusNode focusNode, required ValueChanged onShowFocusHighlight, + required Map shortcuts, + required Map> actions, }) { return FocusableActionDetector( focusNode: focusNode, enabled: _enabled, includeFocusSemantics: false, onShowFocusHighlight: onShowFocusHighlight, + shortcuts: shortcuts, + actions: actions, child: const SizedBox.shrink(), ); } @@ -812,6 +869,7 @@ class _RangeSliderState extends State with TickerProviderStateMixin return _buildValueIndicator(sliderTheme.showValueIndicator!); }, child: _RangeSliderRenderObjectWidget( + key: _renderObjectKey, values: _unlerpRangeValues(widget.values), divisions: widget.divisions, labels: widget.labels, @@ -835,6 +893,16 @@ class _RangeSliderState extends State with TickerProviderStateMixin result = Padding(padding: padding, child: result); } + // Support directional navigation mode. In this mode, + // arrow keys should not change the value until the user enters an + // "editing" state, to allow moving focus. + final Map shortcutMap = switch (MediaQuery.navigationModeOf( + context, + )) { + NavigationMode.directional => _directionalNavShortcutMap, + NavigationMode.traditional => _traditionalNavShortcutMap, + }; + return Stack( children: [ // Adds two invisible focus nodes to the range slider for its two thumbs. @@ -843,10 +911,14 @@ class _RangeSliderState extends State with TickerProviderStateMixin _buildThumbFocusDetector( focusNode: startFocusNode, onShowFocusHighlight: _handleStartFocusHighlightChanged, + shortcuts: shortcutMap, + actions: _startActionMap, ), _buildThumbFocusDetector( focusNode: endFocusNode, onShowFocusHighlight: _handleEndFocusHighlightChanged, + shortcuts: shortcutMap, + actions: _endActionMap, ), ], ), @@ -879,8 +951,25 @@ class _RangeSliderState extends State with TickerProviderStateMixin } } +class _AdjustSliderIntent extends Intent { + const _AdjustSliderIntent({required this.type}); + + const _AdjustSliderIntent.right() : type = _SliderAdjustmentType.right; + + const _AdjustSliderIntent.left() : type = _SliderAdjustmentType.left; + + const _AdjustSliderIntent.up() : type = _SliderAdjustmentType.up; + + const _AdjustSliderIntent.down() : type = _SliderAdjustmentType.down; + + final _SliderAdjustmentType type; +} + +enum _SliderAdjustmentType { right, left, up, down } + class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget { const _RangeSliderRenderObjectWidget({ + super.key, required this.values, required this.divisions, required this.labels, @@ -2014,16 +2103,16 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix values.start, _increasedStartValue, _decreasedStartValue, - _increaseStartAction, - _decreaseStartAction, + increaseStartAction, + decreaseStartAction, focused: _state.startFocusNode.hasFocus, ); final SemanticsConfiguration endSemanticsConfiguration = _createSemanticsConfiguration( values.end, _increasedEndValue, _decreasedEndValue, - _increaseEndAction, - _decreaseEndAction, + increaseEndAction, + decreaseEndAction, focused: _state.endFocusNode.hasFocus, ); @@ -2074,55 +2163,82 @@ class _RenderRangeSlider extends RenderBox with RelayoutWhenSystemFontsChangeMix double get _semanticActionUnit => divisions != null ? 1.0 / divisions! : _adjustmentUnit; - void _increaseStartAction() { + void increaseStartAction() { if (isEnabled) { - onChanged!(RangeValues(_increasedStartValue, values.end)); + onChangeStart?.call(values); + final newValues = RangeValues(_increasedStartValue, values.end); + onChanged!(newValues); + onChangeEnd?.call(newValues); } } - void _decreaseStartAction() { + void decreaseStartAction() { if (isEnabled) { - onChanged!(RangeValues(_decreasedStartValue, values.end)); + onChangeStart?.call(values); + final newValues = RangeValues(_decreasedStartValue, values.end); + onChanged!(newValues); + onChangeEnd?.call(newValues); } } - void _increaseEndAction() { + void increaseEndAction() { if (isEnabled) { - onChanged!(RangeValues(values.start, _increasedEndValue)); + onChangeStart?.call(values); + final newValues = RangeValues(values.start, _increasedEndValue); + onChanged!(newValues); + onChangeEnd?.call(newValues); } } - void _decreaseEndAction() { + void decreaseEndAction() { if (isEnabled) { - onChanged!(RangeValues(values.start, _decreasedEndValue)); + onChangeStart?.call(values); + final newValues = RangeValues(values.start, _decreasedEndValue); + onChanged!(newValues); + onChangeEnd?.call(newValues); } } - double get _increasedStartValue { - // Due to floating-point operations, this value can actually be greater than - // expected (e.g. 0.4 + 0.2 = 0.600000000001), so we limit to 2 decimal points. - final double increasedStartValue = double.parse( - (values.start + _semanticActionUnit).toStringAsFixed(2), - ); - return increasedStartValue <= values.end - _minThumbSeparationValue - ? increasedStartValue - : values.start; + double _roundToSemanticPrecision(double value) { + return double.parse(value.toStringAsFixed(2)); } - double get _decreasedStartValue { - return clampDouble(values.start - _semanticActionUnit, 0.0, 1.0); + double get _increasedStartValue { + final double tentativeStart = values.start + _semanticActionUnit; + final double roundedTentativeStart = _roundToSemanticPrecision(tentativeStart); + + final double maxAllowedStart = _roundToSemanticPrecision(values.end - _minThumbSeparationValue); + + if (roundedTentativeStart > maxAllowedStart) { + return values.start; + } + return roundedTentativeStart; + } + + double get _decreasedStartValue { + final double decreasedStartValue = _roundToSemanticPrecision( + values.start - _semanticActionUnit, + ); + return clampDouble(decreasedStartValue, 0.0, 1.0); } double get _increasedEndValue { - return clampDouble(values.end + _semanticActionUnit, 0.0, 1.0); + final double increasedEndValue = _roundToSemanticPrecision(values.end + _semanticActionUnit); + return clampDouble(increasedEndValue, 0.0, 1.0); } double get _decreasedEndValue { - final double decreasedEndValue = values.end - _semanticActionUnit; - return decreasedEndValue >= values.start + _minThumbSeparationValue - ? decreasedEndValue - : values.end; + final double tentativeEnd = values.end - _semanticActionUnit; + final double roundedTentativeEnd = _roundToSemanticPrecision(tentativeEnd); + + final double minAllowedEnd = _roundToSemanticPrecision(values.start + _minThumbSeparationValue); + + if (roundedTentativeEnd < minAllowedEnd) { + return values.end; + } + return roundedTentativeEnd; } + } class _ValueIndicatorRenderObjectWidget extends LeafRenderObjectWidget { diff --git a/packages/material_ui/test/range_slider_test.dart b/packages/material_ui/test/range_slider_test.dart index 3091cc671e7c..4496ce1b6be1 100644 --- a/packages/material_ui/test/range_slider_test.dart +++ b/packages/material_ui/test/range_slider_test.dart @@ -2882,6 +2882,363 @@ void main() { ); }); + testWidgets( + 'RangeSlider can be incremented and decremented by keyboard shortcuts - LTR', + (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var startValues = const RangeValues(0.0, 0.0); + var currentValues = const RangeValues(0.3, 0.7); + var endValues = const RangeValues(0.0, 0.0); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RangeSlider( + values: currentValues, + onChangeStart: (RangeValues newValues) { + setState(() { + startValues = newValues; + }); + }, + onChanged: (RangeValues newValues) { + setState(() { + currentValues = newValues; + }); + }, + onChangeEnd: (RangeValues newValues) { + setState(() { + endValues = newValues; + }); + }, + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Focus on the start thumb + final startFocusNode = + (tester.firstState(find.byType(RangeSlider)) as dynamic).startFocusNode as FocusNode; + startFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Test start thumb - right arrow (increase) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(startValues.start, 0.3); + expect(currentValues.start, 0.35); + expect(endValues.start, 0.35); + expect(currentValues.end, 0.7); + + // Test start thumb - left arrow (decrease) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(startValues.start, 0.35); + expect(currentValues.start, 0.3); + expect(endValues.start, 0.3); + expect(currentValues.end, 0.7); + + // Test start thumb - up arrow (increase) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(startValues.start, 0.3); + expect(currentValues.start, 0.35); + expect(endValues.start, 0.35); + + // Test start thumb - down arrow (decrease) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(startValues.start, 0.35); + expect(currentValues.start, 0.3); + expect(endValues.start, 0.3); + + // Focus on the end thumb + final endFocusNode = + (tester.firstState(find.byType(RangeSlider)) as dynamic).endFocusNode as FocusNode; + endFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Test end thumb - right arrow (increase) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(startValues.end, 0.7); + expect(currentValues.end, 0.75); + expect(endValues.end, 0.75); + expect(currentValues.start, 0.3); + + // Test end thumb - left arrow (decrease) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(startValues.end, 0.75); + expect(currentValues.end, 0.7); + expect(endValues.end, 0.7); + + // Test end thumb - up arrow (increase) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(startValues.end, 0.7); + expect(currentValues.end, 0.75); + expect(endValues.end, 0.75); + + // Test end thumb - down arrow (decrease) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(startValues.end, 0.75); + expect(currentValues.end, 0.7); + expect(endValues.end, 0.7); + }, + variant: const TargetPlatformVariant({ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets( + 'RangeSlider can be incremented and decremented by keyboard shortcuts - RTL', + (WidgetTester tester) async { + tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional; + var startValues = const RangeValues(0.0, 0.0); + var currentValues = const RangeValues(0.3, 0.7); + var endValues = const RangeValues(0.0, 0.0); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Directionality( + textDirection: TextDirection.rtl, + child: RangeSlider( + values: currentValues, + onChangeStart: (RangeValues newValues) { + setState(() { + startValues = newValues; + }); + }, + onChanged: (RangeValues newValues) { + setState(() { + currentValues = newValues; + }); + }, + onChangeEnd: (RangeValues newValues) { + setState(() { + endValues = newValues; + }); + }, + ), + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Focus on the start thumb + final startFocusNode = + (tester.firstState(find.byType(RangeSlider)) as dynamic).startFocusNode as FocusNode; + startFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Test start thumb - right arrow (decrease in RTL) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(startValues.start, 0.3); + expect(currentValues.start, 0.25); + expect(endValues.start, 0.25); + expect(currentValues.end, 0.7); + + // Test start thumb - left arrow (increase in RTL) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(startValues.start, 0.25); + expect(currentValues.start, 0.3); + expect(endValues.start, 0.3); + + // Test start thumb - up arrow (increase) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(startValues.start, 0.3); + expect(currentValues.start, 0.35); + expect(endValues.start, 0.35); + + // Test start thumb - down arrow (decrease) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(startValues.start, 0.35); + expect(currentValues.start, 0.3); + expect(endValues.start, 0.3); + + // Focus on the end thumb + final endFocusNode = + (tester.firstState(find.byType(RangeSlider)) as dynamic).endFocusNode as FocusNode; + endFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Test end thumb - right arrow (decrease in RTL) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(startValues.end, 0.7); + expect(currentValues.end, 0.65); + expect(endValues.end, 0.65); + expect(currentValues.start, 0.3); + + // Test end thumb - left arrow (increase in RTL) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(startValues.end, 0.65); + expect(currentValues.end, 0.7); + expect(endValues.end, 0.7); + + // Test end thumb - up arrow (increase) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + expect(startValues.end, 0.7); + expect(currentValues.end, 0.75); + expect(endValues.end, 0.75); + + // Test end thumb - down arrow (decrease) + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(startValues.end, 0.75); + expect(currentValues.end, 0.7); + expect(endValues.end, 0.7); + }, + variant: const TargetPlatformVariant({ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + + testWidgets('RangeSlider can be focused using keyboard focus', (WidgetTester tester) async { + var values = const RangeValues(0.2, 0.8); + + await tester.pumpWidget( + MaterialApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: Material( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return Center( + child: RangeSlider( + values: values, + max: 100, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + onChangeStart: (RangeValues newValues) {}, + onChangeEnd: (RangeValues newValues) {}, + ), + ); + }, + ), + ), + ), + ), + ); + + // Focus on the start thumb + final Finder rangeSliderFinder = find.byType(RangeSlider); + expect(rangeSliderFinder, findsOneWidget); + final startFocusNode = + (tester.firstState(find.byType(RangeSlider)) as dynamic).startFocusNode as FocusNode; + final endFocusNode = + (tester.firstState(find.byType(RangeSlider)) as dynamic).endFocusNode as FocusNode; + + startFocusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, startFocusNode); + + // Tab to focus on the end thumb + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pumpAndSettle(); + expect(FocusManager.instance.primaryFocus, endFocusNode); + }); + + testWidgets( + 'RangeSlider with divisions respects keyboard input', + (WidgetTester tester) async { + var values = const RangeValues(20, 80); + + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Center( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return RangeSlider( + values: values, + max: 100, + divisions: 10, + onChanged: (RangeValues newValues) { + setState(() { + values = newValues; + }); + }, + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // Focus on the start thumb + final startFocusNode = + (tester.firstState(find.byType(RangeSlider)) as dynamic).startFocusNode as FocusNode; + startFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Increase start thumb by one division + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(values.start, 30); + + // Decrease start thumb by one division + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(values.start, 20); + + // Focus on the end thumb + final endFocusNode = + (tester.firstState(find.byType(RangeSlider)) as dynamic).endFocusNode as FocusNode; + endFocusNode.requestFocus(); + await tester.pumpAndSettle(); + + // Increase end thumb by one division + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(values.end, 90); + + // Decrease end thumb by one division + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + expect(values.end, 80); + }, + variant: const TargetPlatformVariant({ + TargetPlatform.android, + TargetPlatform.fuchsia, + TargetPlatform.linux, + TargetPlatform.windows, + }), + ); + testWidgets('RangeSlider overlayColor supports hovered and dragged states', ( WidgetTester tester, ) async {