Unverified Commit 4f6c8877 authored by Gil Nobrega's avatar Gil Nobrega Committed by GitHub

[a11y] CupertinoSwitch On/Off labels (#127776)

Adds optional visual labels to Cupertino's on/off switch for accessibility.
parent eebb1d6d
......@@ -75,6 +75,8 @@ class CupertinoSwitch extends StatefulWidget {
this.thumbColor,
this.applyTheme,
this.focusColor,
this.onLabelColor,
this.offLabelColor,
this.focusNode,
this.onFocusChange,
this.autofocus = false,
......@@ -133,6 +135,17 @@ class CupertinoSwitch extends StatefulWidget {
/// Defaults to a slightly transparent [activeColor].
final Color? focusColor;
/// The color to use for the accessibility label when the switch is on.
///
/// Defaults to [CupertinoColors.white] when null.
final Color? onLabelColor;
/// The color to use for the accessibility label when the switch is off.
///
/// Defaults to [Color.fromARGB(255, 179, 179, 179)]
/// (or [Color.fromARGB(255, 255, 255, 255)] in high contrast) when null.
final Color? offLabelColor;
/// {@macro flutter.widgets.Focus.focusNode}
final FocusNode? focusNode;
......@@ -357,6 +370,19 @@ class _CupertinoSwitchState extends State<CupertinoSwitch> with TickerProviderSt
?? CupertinoColors.systemGreen,
context,
);
final (Color onLabelColor, Color offLabelColor)? onOffLabelColors =
MediaQuery.onOffSwitchLabelsOf(context)
? (
CupertinoDynamicColor.resolve(
widget.onLabelColor ?? CupertinoColors.white,
context,
),
CupertinoDynamicColor.resolve(
widget.offLabelColor ?? _kOffLabelColor,
context,
),
)
: null;
if (needsPositionAnimation) {
_resumePositionAnimation();
}
......@@ -389,6 +415,7 @@ class _CupertinoSwitchState extends State<CupertinoSwitch> with TickerProviderSt
textDirection: Directionality.of(context),
isFocused: isFocused,
state: this,
onOffLabelColors: onOffLabelColors,
),
),
),
......@@ -417,6 +444,7 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget {
required this.textDirection,
required this.isFocused,
required this.state,
required this.onOffLabelColors,
});
final bool value;
......@@ -428,6 +456,7 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget {
final _CupertinoSwitchState state;
final TextDirection textDirection;
final bool isFocused;
final (Color onLabelColor, Color offLabelColor)? onOffLabelColors;
@override
_RenderCupertinoSwitch createRenderObject(BuildContext context) {
......@@ -441,6 +470,7 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget {
textDirection: textDirection,
isFocused: isFocused,
state: state,
onOffLabelColors: onOffLabelColors,
);
}
......@@ -467,6 +497,24 @@ const double _kTrackInnerEnd = _kTrackWidth - _kTrackInnerStart;
const double _kTrackInnerLength = _kTrackInnerEnd - _kTrackInnerStart;
const double _kSwitchWidth = 59.0;
const double _kSwitchHeight = 39.0;
// Label sizes and padding taken from xcode inspector.
// See https://github.com/flutter/flutter/issues/4830#issuecomment-528495360
const double _kOnLabelWidth = 1.0;
const double _kOnLabelHeight = 10.0;
const double _kOnLabelPaddingHorizontal = 11.0;
const double _kOffLabelWidth = 1.0;
const double _kOffLabelPaddingHorizontal = 12.0;
const double _kOffLabelRadius = 5.0;
const CupertinoDynamicColor _kOffLabelColor = CupertinoDynamicColor.withBrightnessAndContrast(
debugLabel: 'offSwitchLabel',
// Source: https://github.com/flutter/flutter/pull/39993#discussion_r321946033
color: Color.fromARGB(255, 179, 179, 179),
// Source: https://github.com/flutter/flutter/pull/39993#issuecomment-535196665
darkColor: Color.fromARGB(255, 179, 179, 179),
// Source: https://github.com/flutter/flutter/pull/127776#discussion_r1244208264
highContrastColor: Color.fromARGB(255, 255, 255, 255),
darkHighContrastColor: Color.fromARGB(255, 255, 255, 255),
);
// Opacity of a disabled switch, as eye-balled from iOS Simulator on Mac.
const double _kCupertinoSwitchDisabledOpacity = 0.5;
......@@ -484,6 +532,7 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox {
required TextDirection textDirection,
required bool isFocused,
required _CupertinoSwitchState state,
required (Color onLabelColor, Color offLabelColor)? onOffLabelColors,
}) : _value = value,
_activeColor = activeColor,
_trackColor = trackColor,
......@@ -493,6 +542,7 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox {
_textDirection = textDirection,
_isFocused = isFocused,
_state = state,
_onOffLabelColors = onOffLabelColors,
super(additionalConstraints: const BoxConstraints.tightFor(width: _kSwitchWidth, height: _kSwitchHeight)) {
state.position.addListener(markNeedsPaint);
state._reaction.addListener(markNeedsPaint);
......@@ -584,6 +634,16 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox {
markNeedsPaint();
}
(Color onLabelColor, Color offLabelColor)? get onOffLabelColors => _onOffLabelColors;
(Color onLabelColor, Color offLabelColor)? _onOffLabelColors;
set onOffLabelColors((Color onLabelColor, Color offLabelColor)? value) {
if (value == _onOffLabelColors) {
return;
}
_onOffLabelColors = value;
markNeedsPaint();
}
bool get isInteractive => onChanged != null;
@override
......@@ -649,6 +709,52 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox {
canvas.drawRRect(borderTrackRRect, borderPaint);
}
if (_onOffLabelColors != null) {
final (Color onLabelColor, Color offLabelColor) = onOffLabelColors!;
final double leftLabelOpacity = visualPosition * (1.0 - currentReactionValue);
final double rightLabelOpacity = (1.0 - visualPosition) * (1.0 - currentReactionValue);
final (double onLabelOpacity, double offLabelOpacity) =
switch (textDirection) {
TextDirection.ltr => (leftLabelOpacity, rightLabelOpacity),
TextDirection.rtl => (rightLabelOpacity, leftLabelOpacity),
};
final (Offset onLabelOffset, Offset offLabelOffset) =
switch (textDirection) {
TextDirection.ltr => (
trackRect.centerLeft.translate(_kOnLabelPaddingHorizontal, 0),
trackRect.centerRight.translate(-_kOffLabelPaddingHorizontal, 0),
),
TextDirection.rtl => (
trackRect.centerRight.translate(-_kOnLabelPaddingHorizontal, 0),
trackRect.centerLeft.translate(_kOffLabelPaddingHorizontal, 0),
),
};
// Draws '|' label
final Rect onLabelRect = Rect.fromCenter(
center: onLabelOffset,
width: _kOnLabelWidth,
height: _kOnLabelHeight,
);
final Paint onLabelPaint = Paint()
..color = onLabelColor.withOpacity(onLabelOpacity)
..style = PaintingStyle.fill;
canvas.drawRect(onLabelRect, onLabelPaint);
// Draws 'O' label
final Paint offLabelPaint = Paint()
..color = offLabelColor.withOpacity(offLabelOpacity)
..style = PaintingStyle.stroke
..strokeWidth = _kOffLabelWidth;
canvas.drawCircle(
offLabelOffset,
_kOffLabelRadius,
offLabelPaint,
);
}
final double currentThumbExtension = CupertinoThumbPainter.extension * currentReactionValue;
final double thumbLeft = lerpDouble(
trackRect.left + _kTrackInnerStart - CupertinoThumbPainter.radius,
......
......@@ -60,6 +60,8 @@ enum _MediaQueryAspect {
invertColors,
/// Specifies the aspect corresponding to [MediaQueryData.highContrast].
highContrast,
/// Specifies the aspect corresponding to [MediaQueryData.onOffSwitchLabels].
onOffSwitchLabels,
/// Specifies the aspect corresponding to [MediaQueryData.disableAnimations].
disableAnimations,
/// Specifies the aspect corresponding to [MediaQueryData.boldText].
......@@ -153,6 +155,7 @@ class MediaQueryData {
this.accessibleNavigation = false,
this.invertColors = false,
this.highContrast = false,
this.onOffSwitchLabels = false,
this.disableAnimations = false,
this.boldText = false,
this.navigationMode = NavigationMode.traditional,
......@@ -220,6 +223,7 @@ class MediaQueryData {
disableAnimations = platformData?.disableAnimations ?? view.platformDispatcher.accessibilityFeatures.disableAnimations,
boldText = platformData?.boldText ?? view.platformDispatcher.accessibilityFeatures.boldText,
highContrast = platformData?.highContrast ?? view.platformDispatcher.accessibilityFeatures.highContrast,
onOffSwitchLabels = platformData?.onOffSwitchLabels ?? view.platformDispatcher.accessibilityFeatures.onOffSwitchLabels,
alwaysUse24HourFormat = platformData?.alwaysUse24HourFormat ?? view.platformDispatcher.alwaysUse24HourFormat,
navigationMode = platformData?.navigationMode ?? NavigationMode.traditional,
gestureSettings = DeviceGestureSettings.fromView(view),
......@@ -416,6 +420,15 @@ class MediaQueryData {
/// or above.
final bool highContrast;
/// Whether the user requested to show on/off labels inside switches on iOS,
/// via Settings -> Accessibility -> Display & Text Size -> On/Off Labels.
///
/// See also:
///
/// * [dart:ui.PlatformDispatcher.accessibilityFeatures], where the setting
/// originates.
final bool onOffSwitchLabels;
/// Whether the platform is requesting that animations be disabled or reduced
/// as much as possible.
///
......@@ -488,6 +501,7 @@ class MediaQueryData {
EdgeInsets? systemGestureInsets,
bool? alwaysUse24HourFormat,
bool? highContrast,
bool? onOffSwitchLabels,
bool? disableAnimations,
bool? invertColors,
bool? accessibleNavigation,
......@@ -508,6 +522,7 @@ class MediaQueryData {
alwaysUse24HourFormat: alwaysUse24HourFormat ?? this.alwaysUse24HourFormat,
invertColors: invertColors ?? this.invertColors,
highContrast: highContrast ?? this.highContrast,
onOffSwitchLabels: onOffSwitchLabels ?? this.onOffSwitchLabels,
disableAnimations: disableAnimations ?? this.disableAnimations,
accessibleNavigation: accessibleNavigation ?? this.accessibleNavigation,
boldText: boldText ?? this.boldText,
......@@ -699,6 +714,7 @@ class MediaQueryData {
&& other.systemGestureInsets == systemGestureInsets
&& other.alwaysUse24HourFormat == alwaysUse24HourFormat
&& other.highContrast == highContrast
&& other.onOffSwitchLabels == onOffSwitchLabels
&& other.disableAnimations == disableAnimations
&& other.invertColors == invertColors
&& other.accessibleNavigation == accessibleNavigation
......@@ -719,6 +735,7 @@ class MediaQueryData {
viewInsets,
alwaysUse24HourFormat,
highContrast,
onOffSwitchLabels,
disableAnimations,
invertColors,
accessibleNavigation,
......@@ -742,6 +759,7 @@ class MediaQueryData {
'alwaysUse24HourFormat: $alwaysUse24HourFormat',
'accessibleNavigation: $accessibleNavigation',
'highContrast: $highContrast',
'onOffSwitchLabels: $onOffSwitchLabels',
'disableAnimations: $disableAnimations',
'invertColors: $invertColors',
'boldText: $boldText',
......@@ -1255,6 +1273,25 @@ class MediaQuery extends InheritedModel<_MediaQueryAspect> {
/// the [MediaQueryData.highContrast] property of the ancestor [MediaQuery] changes.
static bool? maybeHighContrastOf(BuildContext context) => _maybeOf(context, _MediaQueryAspect.highContrast)?.highContrast;
/// Returns onOffSwitchLabels for the nearest MediaQuery ancestor or false, if no
/// such ancestor exists.
///
/// See also:
///
/// * [MediaQueryData.onOffSwitchLabels], which indicates the platform's
/// desire to show on/off labels inside switches.
///
/// Use of this method will cause the given [context] to rebuild any time that
/// the [MediaQueryData.onOffSwitchLabels] property of the ancestor [MediaQuery] changes.
static bool onOffSwitchLabelsOf(BuildContext context) => maybeOnOffSwitchLabelsOf(context) ?? false;
/// Returns onOffSwitchLabels for the nearest MediaQuery ancestor or
/// null, if no such ancestor exists.
///
/// Use of this method will cause the given [context] to rebuild any time that
/// the [MediaQueryData.onOffSwitchLabels] property of the ancestor [MediaQuery] changes.
static bool? maybeOnOffSwitchLabelsOf(BuildContext context) => _maybeOf(context, _MediaQueryAspect.onOffSwitchLabels)?.onOffSwitchLabels;
/// Returns disableAnimations for the nearest MediaQuery ancestor or
/// [Brightness.light], if no such ancestor exists.
///
......@@ -1406,6 +1443,10 @@ class MediaQuery extends InheritedModel<_MediaQueryAspect> {
if (data.highContrast != oldWidget.data.highContrast) {
return true;
}
case _MediaQueryAspect.onOffSwitchLabels:
if (data.onOffSwitchLabels != oldWidget.data.onOffSwitchLabels) {
return true;
}
case _MediaQueryAspect.disableAnimations:
if (data.disableAnimations != oldWidget.data.disableAnimations) {
return true;
......
......@@ -753,6 +753,187 @@ void main() {
);
});
PaintPattern onLabelPaintPattern({
required int alpha,
bool isRtl = false,
}) =>
paints
..rect(
rect: Rect.fromLTWH(isRtl ? 43.5 : 14.5, 14.5, 1.0, 10.0),
color: const Color(0xffffffff).withAlpha(alpha),
style: PaintingStyle.fill,
);
PaintPattern offLabelPaintPattern({
required int alpha,
bool highContrast = false,
bool isRtl = false,
}) =>
paints
..circle(
x: isRtl ? 16.0 : 43.0,
y: 19.5,
radius: 5.0,
color:
(highContrast ? const Color(0xffffffff) : const Color(0xffb3b3b3))
.withAlpha(alpha),
strokeWidth: 1.0,
style: PaintingStyle.stroke,
);
testWidgets('Switch renders switch labels correctly before, during, and after being tapped', (WidgetTester tester) async {
final Key switchKey = UniqueKey();
bool value = false;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(onOffSwitchLabels: true),
child: Directionality(
textDirection: TextDirection.ltr,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Center(
child: RepaintBoundary(
child: CupertinoSwitch(
key: switchKey,
value: value,
dragStartBehavior: DragStartBehavior.down,
onChanged: (bool newValue) {
setState(() {
value = newValue;
});
},
),
),
);
},
),
),
),
);
final RenderObject switchRenderObject =
tester.element(find.byType(CupertinoSwitch)).renderObject!;
expect(switchRenderObject, offLabelPaintPattern(alpha: 255));
expect(switchRenderObject, onLabelPaintPattern(alpha: 0));
await tester.tap(find.byKey(switchKey));
expect(value, isTrue);
// Kick off animation, then advance to intermediate frame.
await tester.pump();
await tester.pump(const Duration(milliseconds: 60));
expect(switchRenderObject, onLabelPaintPattern(alpha: 131));
expect(switchRenderObject, offLabelPaintPattern(alpha: 124));
await tester.pumpAndSettle();
expect(switchRenderObject, onLabelPaintPattern(alpha: 255));
expect(switchRenderObject, offLabelPaintPattern(alpha: 0));
});
testWidgets('Switch renders switch labels correctly before, during, and after being tapped in high contrast', (WidgetTester tester) async {
final Key switchKey = UniqueKey();
bool value = false;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(
onOffSwitchLabels: true,
highContrast: true,
),
child: Directionality(
textDirection: TextDirection.ltr,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Center(
child: RepaintBoundary(
child: CupertinoSwitch(
key: switchKey,
value: value,
dragStartBehavior: DragStartBehavior.down,
onChanged: (bool newValue) {
setState(() {
value = newValue;
});
},
),
),
);
},
),
),
),
);
final RenderObject switchRenderObject =
tester.element(find.byType(CupertinoSwitch)).renderObject!;
expect(switchRenderObject, offLabelPaintPattern(highContrast: true, alpha: 255));
expect(switchRenderObject, onLabelPaintPattern(alpha: 0));
await tester.tap(find.byKey(switchKey));
expect(value, isTrue);
// Kick off animation, then advance to intermediate frame.
await tester.pump();
await tester.pump(const Duration(milliseconds: 60));
expect(switchRenderObject, onLabelPaintPattern(alpha: 131));
expect(switchRenderObject, offLabelPaintPattern(highContrast: true, alpha: 124));
await tester.pumpAndSettle();
expect(switchRenderObject, onLabelPaintPattern(alpha: 255));
expect(switchRenderObject, offLabelPaintPattern(highContrast: true, alpha: 0));
});
testWidgets('Switch renders switch labels correctly before, during, and after being tapped with direction rtl', (WidgetTester tester) async {
final Key switchKey = UniqueKey();
bool value = false;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(onOffSwitchLabels: true),
child: Directionality(
textDirection: TextDirection.rtl,
child: StatefulBuilder(
builder: (BuildContext context, StateSetter setState) {
return Center(
child: RepaintBoundary(
child: CupertinoSwitch(
key: switchKey,
value: value,
dragStartBehavior: DragStartBehavior.down,
onChanged: (bool newValue) {
setState(() {
value = newValue;
});
},
),
),
);
},
),
),
),
);
final RenderObject switchRenderObject =
tester.element(find.byType(CupertinoSwitch)).renderObject!;
expect(switchRenderObject, offLabelPaintPattern(isRtl: true, alpha: 255));
expect(switchRenderObject, onLabelPaintPattern(isRtl: true, alpha: 0));
await tester.tap(find.byKey(switchKey));
expect(value, isTrue);
// Kick off animation, then advance to intermediate frame.
await tester.pump();
await tester.pump(const Duration(milliseconds: 60));
expect(switchRenderObject, onLabelPaintPattern(isRtl: true, alpha: 131));
expect(switchRenderObject, offLabelPaintPattern(isRtl: true, alpha: 124));
await tester.pumpAndSettle();
expect(switchRenderObject, onLabelPaintPattern(isRtl: true, alpha: 255));
expect(switchRenderObject, offLabelPaintPattern(isRtl: true, alpha: 0));
});
testWidgets('Switch renders correctly in dark mode', (WidgetTester tester) async {
final Key switchKey = UniqueKey();
bool value = false;
......
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