Unverified Commit 0891a113 authored by Jonah Williams's avatar Jonah Williams Committed by GitHub

update a11y for material slider (#18005)

parent 3bc906d3
...@@ -167,7 +167,7 @@ class _SliderDemoState extends State<SliderDemo> { ...@@ -167,7 +167,7 @@ class _SliderDemoState extends State<SliderDemo> {
new Slider( new Slider(
value: _discreteValue, value: _discreteValue,
min: 0.0, min: 0.0,
max: 100.0, max: 200.0,
divisions: 5, divisions: 5,
label: '${_discreteValue.round()}', label: '${_discreteValue.round()}',
onChanged: (double value) { onChanged: (double value) {
...@@ -198,8 +198,9 @@ class _SliderDemoState extends State<SliderDemo> { ...@@ -198,8 +198,9 @@ class _SliderDemoState extends State<SliderDemo> {
child: new Slider( child: new Slider(
value: _discreteValue, value: _discreteValue,
min: 0.0, min: 0.0,
max: 100.0, max: 200.0,
divisions: 5, divisions: 5,
semanticFormatterCallback: (double value) => value.round().toString(),
label: '${_discreteValue.round()}', label: '${_discreteValue.round()}',
onChanged: (double value) { onChanged: (double value) {
setState(() { setState(() {
......
...@@ -5,6 +5,7 @@ ...@@ -5,6 +5,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart' show timeDilation; import 'package:flutter/scheduler.dart' show timeDilation;
...@@ -17,8 +18,16 @@ import 'slider_theme.dart'; ...@@ -17,8 +18,16 @@ import 'slider_theme.dart';
import 'theme.dart'; import 'theme.dart';
// Examples can assume: // Examples can assume:
// int _dollars = 0;
// int _duelCommandment = 1; // int _duelCommandment = 1;
/// A callback that formats a numeric value from a [Slider] widget.
///
/// See also:
///
/// * [Slider.semanticFormatterCallback], which shows an example use case.
typedef String SemanticFormatterCallback(double value);
/// A Material Design slider. /// A Material Design slider.
/// ///
/// Used to select from a range of values. /// Used to select from a range of values.
...@@ -109,6 +118,7 @@ class Slider extends StatefulWidget { ...@@ -109,6 +118,7 @@ class Slider extends StatefulWidget {
this.label, this.label,
this.activeColor, this.activeColor,
this.inactiveColor, this.inactiveColor,
this.semanticFormatterCallback,
}) : assert(value != null), }) : assert(value != null),
assert(min != null), assert(min != null),
assert(max != null), assert(max != null),
...@@ -287,6 +297,36 @@ class Slider extends StatefulWidget { ...@@ -287,6 +297,36 @@ class Slider extends StatefulWidget {
/// appearance of various components of the slider. /// appearance of various components of the slider.
final Color inactiveColor; final Color inactiveColor;
/// The callback used to create a semantic value from a slider value.
///
/// Defaults to formatting values as a percentage.
///
/// This is used by accessibility frameworks like TalkBack on Android to
/// inform users what the currently selected value is with more context.
///
/// ## Sample code:
///
/// In the example below, a slider for currency values is configured to
/// announce a value with a currency label.
///
/// ```dart
/// new Slider(
/// value: _dollars.toDouble(),
/// min: 20.0,
/// max: 330.0,
/// label: '$_dollars dollars',
/// onChanged: (double newValue) {
/// setState(() {
/// _dollars = newValue.round();
/// });
/// },
/// semanticFormatterCallback: (double newValue) {
/// return '${newValue.round()} dollars';
/// }
/// )
/// ```
final SemanticFormatterCallback semanticFormatterCallback;
@override @override
_SliderState createState() => new _SliderState(); _SliderState createState() => new _SliderState();
...@@ -414,6 +454,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin { ...@@ -414,6 +454,7 @@ class _SliderState extends State<Slider> with TickerProviderStateMixin {
onChangeStart: widget.onChangeStart != null ? _handleDragStart : null, onChangeStart: widget.onChangeStart != null ? _handleDragStart : null,
onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null, onChangeEnd: widget.onChangeEnd != null ? _handleDragEnd : null,
state: this, state: this,
semanticFormatterCallback: widget.semanticFormatterCallback,
); );
} }
} }
...@@ -430,6 +471,7 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { ...@@ -430,6 +471,7 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget {
this.onChangeStart, this.onChangeStart,
this.onChangeEnd, this.onChangeEnd,
this.state, this.state,
this.semanticFormatterCallback,
}) : super(key: key); }) : super(key: key);
final double value; final double value;
...@@ -440,6 +482,7 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { ...@@ -440,6 +482,7 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget {
final ValueChanged<double> onChanged; final ValueChanged<double> onChanged;
final ValueChanged<double> onChangeStart; final ValueChanged<double> onChangeStart;
final ValueChanged<double> onChangeEnd; final ValueChanged<double> onChangeEnd;
final SemanticFormatterCallback semanticFormatterCallback;
final _SliderState state; final _SliderState state;
@override @override
...@@ -456,6 +499,8 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { ...@@ -456,6 +499,8 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget {
onChangeEnd: onChangeEnd, onChangeEnd: onChangeEnd,
state: state, state: state,
textDirection: Directionality.of(context), textDirection: Directionality.of(context),
semanticFormatterCallback: semanticFormatterCallback,
platform: Theme.of(context).platform,
); );
} }
...@@ -471,7 +516,9 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget { ...@@ -471,7 +516,9 @@ class _SliderRenderObjectWidget extends LeafRenderObjectWidget {
..onChanged = onChanged ..onChanged = onChanged
..onChangeStart = onChangeStart ..onChangeStart = onChangeStart
..onChangeEnd = onChangeEnd ..onChangeEnd = onChangeEnd
..textDirection = Directionality.of(context); ..textDirection = Directionality.of(context)
..semanticFormatterCallback = semanticFormatterCallback
..platform = Theme.of(context).platform;
// Ticker provider cannot change since there's a 1:1 relationship between // Ticker provider cannot change since there's a 1:1 relationship between
// the _SliderRenderObjectWidget object and the _SliderState object. // the _SliderRenderObjectWidget object and the _SliderState object.
} }
...@@ -485,7 +532,9 @@ class _RenderSlider extends RenderBox { ...@@ -485,7 +532,9 @@ class _RenderSlider extends RenderBox {
SliderThemeData sliderTheme, SliderThemeData sliderTheme,
ThemeData theme, ThemeData theme,
MediaQueryData mediaQueryData, MediaQueryData mediaQueryData,
TargetPlatform platform,
ValueChanged<double> onChanged, ValueChanged<double> onChanged,
SemanticFormatterCallback semanticFormatterCallback,
this.onChangeStart, this.onChangeStart,
this.onChangeEnd, this.onChangeEnd,
@required _SliderState state, @required _SliderState state,
...@@ -493,6 +542,8 @@ class _RenderSlider extends RenderBox { ...@@ -493,6 +542,8 @@ class _RenderSlider extends RenderBox {
}) : assert(value != null && value >= 0.0 && value <= 1.0), }) : assert(value != null && value >= 0.0 && value <= 1.0),
assert(state != null), assert(state != null),
assert(textDirection != null), assert(textDirection != null),
_platform = platform,
_semanticFormatterCallback = semanticFormatterCallback,
_label = label, _label = label,
_value = value, _value = value,
_divisions = divisions, _divisions = divisions,
...@@ -536,7 +587,6 @@ class _RenderSlider extends RenderBox { ...@@ -536,7 +587,6 @@ class _RenderSlider extends RenderBox {
static const double _preferredTrackWidth = 144.0; static const double _preferredTrackWidth = 144.0;
static const double _preferredTotalWidth = _preferredTrackWidth + _overlayDiameter; static const double _preferredTotalWidth = _preferredTrackWidth + _overlayDiameter;
static const Duration _minimumInteractionTime = const Duration(milliseconds: 500); static const Duration _minimumInteractionTime = const Duration(milliseconds: 500);
static const double _adjustmentUnit = 0.1; // Matches iOS implementation of material slider.
static final Tween<double> _overlayRadiusTween = new Tween<double>(begin: 0.0, end: _overlayRadius); static final Tween<double> _overlayRadiusTween = new Tween<double>(begin: 0.0, end: _overlayRadius);
_SliderState _state; _SliderState _state;
...@@ -577,6 +627,25 @@ class _RenderSlider extends RenderBox { ...@@ -577,6 +627,25 @@ class _RenderSlider extends RenderBox {
} else { } else {
_state.positionController.value = convertedValue; _state.positionController.value = convertedValue;
} }
markNeedsSemanticsUpdate();
}
TargetPlatform _platform;
TargetPlatform get platform => _platform;
set platform(TargetPlatform value) {
if (_platform == value)
return;
_platform = value;
markNeedsSemanticsUpdate();
}
SemanticFormatterCallback _semanticFormatterCallback;
SemanticFormatterCallback get semanticFormatterCallback => _semanticFormatterCallback;
set semanticFormatterCallback(SemanticFormatterCallback value) {
if (_semanticFormatterCallback == value)
return;
_semanticFormatterCallback = value;
markNeedsSemanticsUpdate();
} }
int get divisions => _divisions; int get divisions => _divisions;
...@@ -683,6 +752,19 @@ class _RenderSlider extends RenderBox { ...@@ -683,6 +752,19 @@ class _RenderSlider extends RenderBox {
return showValueIndicator; return showValueIndicator;
} }
double get _adjustmentUnit {
switch (_platform) {
case TargetPlatform.iOS:
// Matches iOS implementation of material slider.
return 0.1;
case TargetPlatform.android:
case TargetPlatform.fuchsia:
default:
// Matches Android implementation of material slider.
return 0.05;
}
}
void _updateLabelPainter() { void _updateLabelPainter() {
if (label != null) { if (label != null) {
_labelPainter _labelPainter
...@@ -1002,8 +1084,18 @@ class _RenderSlider extends RenderBox { ...@@ -1002,8 +1084,18 @@ class _RenderSlider extends RenderBox {
config.isSemanticBoundary = isInteractive; config.isSemanticBoundary = isInteractive;
if (isInteractive) { if (isInteractive) {
config.textDirection = textDirection;
config.onIncrease = _increaseAction; config.onIncrease = _increaseAction;
config.onDecrease = _decreaseAction; config.onDecrease = _decreaseAction;
if (semanticFormatterCallback != null) {
config.value = semanticFormatterCallback(_state._lerp(value));
config.increasedValue = semanticFormatterCallback(_state._lerp((value + _semanticActionUnit).clamp(0.0, 1.0)));
config.decreasedValue = semanticFormatterCallback(_state._lerp((value - _semanticActionUnit).clamp(0.0, 1.0)));
} else {
config.value = '${(value * 100).round()}%';
config.increasedValue = '${((value + _semanticActionUnit).clamp(0.0, 1.0) * 100).round()}%';
config.decreasedValue = '${((value - _semanticActionUnit).clamp(0.0, 1.0) * 100).round()}%';
}
} }
} }
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
...@@ -1123,6 +1124,10 @@ void main() { ...@@ -1123,6 +1124,10 @@ void main() {
new TestSemantics.root(children: <TestSemantics>[ new TestSemantics.root(children: <TestSemantics>[
new TestSemantics.rootChild( new TestSemantics.rootChild(
id: 1, id: 1,
value: '50%',
increasedValue: '55%',
decreasedValue: '45%',
textDirection: TextDirection.ltr,
actions: SemanticsAction.decrease.index | SemanticsAction.increase.index, actions: SemanticsAction.decrease.index | SemanticsAction.increase.index,
), ),
]), ]),
...@@ -1155,6 +1160,89 @@ void main() { ...@@ -1155,6 +1160,89 @@ void main() {
semantics.dispose(); semantics.dispose();
}); });
testWidgets('Slider Semantics - iOS', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
await tester.pumpWidget(
new Theme(
data: ThemeData.light().copyWith(
platform: TargetPlatform.iOS,
),
child: new Directionality(
textDirection: TextDirection.ltr,
child: new MediaQuery(
data: new MediaQueryData.fromWindow(window),
child: new Material(
child: new Slider(
value: 100.0,
min: 0.0,
max: 200.0,
onChanged: (double v) {},
),
),
),
),
),
);
expect(
semantics,
hasSemantics(
new TestSemantics.root(children: <TestSemantics>[
new TestSemantics.rootChild(
id: 2,
value: '50%',
increasedValue: '60%',
decreasedValue: '40%',
textDirection: TextDirection.ltr,
actions: SemanticsAction.decrease.index | SemanticsAction.increase.index,
),
]),
ignoreRect: true,
ignoreTransform: true,
));
semantics.dispose();
});
testWidgets('Slider semantics with custom formatter', (WidgetTester tester) async {
final SemanticsTester semantics = new SemanticsTester(tester);
await tester.pumpWidget(new Directionality(
textDirection: TextDirection.ltr,
child: new MediaQuery(
data: new MediaQueryData.fromWindow(window),
child: new Material(
child: new Slider(
value: 40.0,
min: 0.0,
max: 200.0,
divisions: 10,
semanticFormatterCallback: (double value) => value.round().toString(),
onChanged: (double v) {},
),
),
),
));
expect(
semantics,
hasSemantics(
new TestSemantics.root(children: <TestSemantics>[
new TestSemantics.rootChild(
id: 3,
value: '40',
increasedValue: '60',
decreasedValue: '20',
textDirection: TextDirection.ltr,
actions: SemanticsAction.decrease.index | SemanticsAction.increase.index,
),
]),
ignoreRect: true,
ignoreTransform: true,
));
semantics.dispose();
});
testWidgets('Value indicator appears when it should', (WidgetTester tester) async { testWidgets('Value indicator appears when it should', (WidgetTester tester) async {
final ThemeData baseTheme = new ThemeData( final ThemeData baseTheme = new ThemeData(
platform: TargetPlatform.android, platform: TargetPlatform.android,
......
...@@ -318,7 +318,7 @@ void main() { ...@@ -318,7 +318,7 @@ void main() {
// interpreted as a gesture by the semantics debugger and sent to the widget // interpreted as a gesture by the semantics debugger and sent to the widget
// as a semantic action that always moves by 10% of the complete track. // as a semantic action that always moves by 10% of the complete track.
await tester.fling(find.byType(Slider), const Offset(-100.0, 0.0), 2000.0); await tester.fling(find.byType(Slider), const Offset(-100.0, 0.0), 2000.0);
expect(value, equals(0.65)); expect(value, equals(0.70));
}); });
testWidgets('SemanticsDebugger checkbox', (WidgetTester tester) async { testWidgets('SemanticsDebugger checkbox', (WidgetTester tester) async {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment