Skip to content

Commit 4f6c887

Browse files
authored
[a11y] CupertinoSwitch On/Off labels (#127776)
Adds optional visual labels to Cupertino's on/off switch for accessibility.
1 parent eebb1d6 commit 4f6c887

File tree

4 files changed

+383
-0
lines changed

4 files changed

+383
-0
lines changed

packages/flutter/lib/src/cupertino/switch.dart

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ class CupertinoSwitch extends StatefulWidget {
7575
this.thumbColor,
7676
this.applyTheme,
7777
this.focusColor,
78+
this.onLabelColor,
79+
this.offLabelColor,
7880
this.focusNode,
7981
this.onFocusChange,
8082
this.autofocus = false,
@@ -133,6 +135,17 @@ class CupertinoSwitch extends StatefulWidget {
133135
/// Defaults to a slightly transparent [activeColor].
134136
final Color? focusColor;
135137

138+
/// The color to use for the accessibility label when the switch is on.
139+
///
140+
/// Defaults to [CupertinoColors.white] when null.
141+
final Color? onLabelColor;
142+
143+
/// The color to use for the accessibility label when the switch is off.
144+
///
145+
/// Defaults to [Color.fromARGB(255, 179, 179, 179)]
146+
/// (or [Color.fromARGB(255, 255, 255, 255)] in high contrast) when null.
147+
final Color? offLabelColor;
148+
136149
/// {@macro flutter.widgets.Focus.focusNode}
137150
final FocusNode? focusNode;
138151

@@ -357,6 +370,19 @@ class _CupertinoSwitchState extends State<CupertinoSwitch> with TickerProviderSt
357370
?? CupertinoColors.systemGreen,
358371
context,
359372
);
373+
final (Color onLabelColor, Color offLabelColor)? onOffLabelColors =
374+
MediaQuery.onOffSwitchLabelsOf(context)
375+
? (
376+
CupertinoDynamicColor.resolve(
377+
widget.onLabelColor ?? CupertinoColors.white,
378+
context,
379+
),
380+
CupertinoDynamicColor.resolve(
381+
widget.offLabelColor ?? _kOffLabelColor,
382+
context,
383+
),
384+
)
385+
: null;
360386
if (needsPositionAnimation) {
361387
_resumePositionAnimation();
362388
}
@@ -389,6 +415,7 @@ class _CupertinoSwitchState extends State<CupertinoSwitch> with TickerProviderSt
389415
textDirection: Directionality.of(context),
390416
isFocused: isFocused,
391417
state: this,
418+
onOffLabelColors: onOffLabelColors,
392419
),
393420
),
394421
),
@@ -417,6 +444,7 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget {
417444
required this.textDirection,
418445
required this.isFocused,
419446
required this.state,
447+
required this.onOffLabelColors,
420448
});
421449

422450
final bool value;
@@ -428,6 +456,7 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget {
428456
final _CupertinoSwitchState state;
429457
final TextDirection textDirection;
430458
final bool isFocused;
459+
final (Color onLabelColor, Color offLabelColor)? onOffLabelColors;
431460

432461
@override
433462
_RenderCupertinoSwitch createRenderObject(BuildContext context) {
@@ -441,6 +470,7 @@ class _CupertinoSwitchRenderObjectWidget extends LeafRenderObjectWidget {
441470
textDirection: textDirection,
442471
isFocused: isFocused,
443472
state: state,
473+
onOffLabelColors: onOffLabelColors,
444474
);
445475
}
446476

@@ -467,6 +497,24 @@ const double _kTrackInnerEnd = _kTrackWidth - _kTrackInnerStart;
467497
const double _kTrackInnerLength = _kTrackInnerEnd - _kTrackInnerStart;
468498
const double _kSwitchWidth = 59.0;
469499
const double _kSwitchHeight = 39.0;
500+
// Label sizes and padding taken from xcode inspector.
501+
// See https://github.com/flutter/flutter/issues/4830#issuecomment-528495360
502+
const double _kOnLabelWidth = 1.0;
503+
const double _kOnLabelHeight = 10.0;
504+
const double _kOnLabelPaddingHorizontal = 11.0;
505+
const double _kOffLabelWidth = 1.0;
506+
const double _kOffLabelPaddingHorizontal = 12.0;
507+
const double _kOffLabelRadius = 5.0;
508+
const CupertinoDynamicColor _kOffLabelColor = CupertinoDynamicColor.withBrightnessAndContrast(
509+
debugLabel: 'offSwitchLabel',
510+
// Source: https://github.com/flutter/flutter/pull/39993#discussion_r321946033
511+
color: Color.fromARGB(255, 179, 179, 179),
512+
// Source: https://github.com/flutter/flutter/pull/39993#issuecomment-535196665
513+
darkColor: Color.fromARGB(255, 179, 179, 179),
514+
// Source: https://github.com/flutter/flutter/pull/127776#discussion_r1244208264
515+
highContrastColor: Color.fromARGB(255, 255, 255, 255),
516+
darkHighContrastColor: Color.fromARGB(255, 255, 255, 255),
517+
);
470518
// Opacity of a disabled switch, as eye-balled from iOS Simulator on Mac.
471519
const double _kCupertinoSwitchDisabledOpacity = 0.5;
472520

@@ -484,6 +532,7 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox {
484532
required TextDirection textDirection,
485533
required bool isFocused,
486534
required _CupertinoSwitchState state,
535+
required (Color onLabelColor, Color offLabelColor)? onOffLabelColors,
487536
}) : _value = value,
488537
_activeColor = activeColor,
489538
_trackColor = trackColor,
@@ -493,6 +542,7 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox {
493542
_textDirection = textDirection,
494543
_isFocused = isFocused,
495544
_state = state,
545+
_onOffLabelColors = onOffLabelColors,
496546
super(additionalConstraints: const BoxConstraints.tightFor(width: _kSwitchWidth, height: _kSwitchHeight)) {
497547
state.position.addListener(markNeedsPaint);
498548
state._reaction.addListener(markNeedsPaint);
@@ -584,6 +634,16 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox {
584634
markNeedsPaint();
585635
}
586636

637+
(Color onLabelColor, Color offLabelColor)? get onOffLabelColors => _onOffLabelColors;
638+
(Color onLabelColor, Color offLabelColor)? _onOffLabelColors;
639+
set onOffLabelColors((Color onLabelColor, Color offLabelColor)? value) {
640+
if (value == _onOffLabelColors) {
641+
return;
642+
}
643+
_onOffLabelColors = value;
644+
markNeedsPaint();
645+
}
646+
587647
bool get isInteractive => onChanged != null;
588648

589649
@override
@@ -649,6 +709,52 @@ class _RenderCupertinoSwitch extends RenderConstrainedBox {
649709
canvas.drawRRect(borderTrackRRect, borderPaint);
650710
}
651711

712+
if (_onOffLabelColors != null) {
713+
final (Color onLabelColor, Color offLabelColor) = onOffLabelColors!;
714+
715+
final double leftLabelOpacity = visualPosition * (1.0 - currentReactionValue);
716+
final double rightLabelOpacity = (1.0 - visualPosition) * (1.0 - currentReactionValue);
717+
final (double onLabelOpacity, double offLabelOpacity) =
718+
switch (textDirection) {
719+
TextDirection.ltr => (leftLabelOpacity, rightLabelOpacity),
720+
TextDirection.rtl => (rightLabelOpacity, leftLabelOpacity),
721+
};
722+
723+
final (Offset onLabelOffset, Offset offLabelOffset) =
724+
switch (textDirection) {
725+
TextDirection.ltr => (
726+
trackRect.centerLeft.translate(_kOnLabelPaddingHorizontal, 0),
727+
trackRect.centerRight.translate(-_kOffLabelPaddingHorizontal, 0),
728+
),
729+
TextDirection.rtl => (
730+
trackRect.centerRight.translate(-_kOnLabelPaddingHorizontal, 0),
731+
trackRect.centerLeft.translate(_kOffLabelPaddingHorizontal, 0),
732+
),
733+
};
734+
735+
// Draws '|' label
736+
final Rect onLabelRect = Rect.fromCenter(
737+
center: onLabelOffset,
738+
width: _kOnLabelWidth,
739+
height: _kOnLabelHeight,
740+
);
741+
final Paint onLabelPaint = Paint()
742+
..color = onLabelColor.withOpacity(onLabelOpacity)
743+
..style = PaintingStyle.fill;
744+
canvas.drawRect(onLabelRect, onLabelPaint);
745+
746+
// Draws 'O' label
747+
final Paint offLabelPaint = Paint()
748+
..color = offLabelColor.withOpacity(offLabelOpacity)
749+
..style = PaintingStyle.stroke
750+
..strokeWidth = _kOffLabelWidth;
751+
canvas.drawCircle(
752+
offLabelOffset,
753+
_kOffLabelRadius,
754+
offLabelPaint,
755+
);
756+
}
757+
652758
final double currentThumbExtension = CupertinoThumbPainter.extension * currentReactionValue;
653759
final double thumbLeft = lerpDouble(
654760
trackRect.left + _kTrackInnerStart - CupertinoThumbPainter.radius,

packages/flutter/lib/src/widgets/media_query.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ enum _MediaQueryAspect {
6060
invertColors,
6161
/// Specifies the aspect corresponding to [MediaQueryData.highContrast].
6262
highContrast,
63+
/// Specifies the aspect corresponding to [MediaQueryData.onOffSwitchLabels].
64+
onOffSwitchLabels,
6365
/// Specifies the aspect corresponding to [MediaQueryData.disableAnimations].
6466
disableAnimations,
6567
/// Specifies the aspect corresponding to [MediaQueryData.boldText].
@@ -153,6 +155,7 @@ class MediaQueryData {
153155
this.accessibleNavigation = false,
154156
this.invertColors = false,
155157
this.highContrast = false,
158+
this.onOffSwitchLabels = false,
156159
this.disableAnimations = false,
157160
this.boldText = false,
158161
this.navigationMode = NavigationMode.traditional,
@@ -220,6 +223,7 @@ class MediaQueryData {
220223
disableAnimations = platformData?.disableAnimations ?? view.platformDispatcher.accessibilityFeatures.disableAnimations,
221224
boldText = platformData?.boldText ?? view.platformDispatcher.accessibilityFeatures.boldText,
222225
highContrast = platformData?.highContrast ?? view.platformDispatcher.accessibilityFeatures.highContrast,
226+
onOffSwitchLabels = platformData?.onOffSwitchLabels ?? view.platformDispatcher.accessibilityFeatures.onOffSwitchLabels,
223227
alwaysUse24HourFormat = platformData?.alwaysUse24HourFormat ?? view.platformDispatcher.alwaysUse24HourFormat,
224228
navigationMode = platformData?.navigationMode ?? NavigationMode.traditional,
225229
gestureSettings = DeviceGestureSettings.fromView(view),
@@ -416,6 +420,15 @@ class MediaQueryData {
416420
/// or above.
417421
final bool highContrast;
418422

423+
/// Whether the user requested to show on/off labels inside switches on iOS,
424+
/// via Settings -> Accessibility -> Display & Text Size -> On/Off Labels.
425+
///
426+
/// See also:
427+
///
428+
/// * [dart:ui.PlatformDispatcher.accessibilityFeatures], where the setting
429+
/// originates.
430+
final bool onOffSwitchLabels;
431+
419432
/// Whether the platform is requesting that animations be disabled or reduced
420433
/// as much as possible.
421434
///
@@ -488,6 +501,7 @@ class MediaQueryData {
488501
EdgeInsets? systemGestureInsets,
489502
bool? alwaysUse24HourFormat,
490503
bool? highContrast,
504+
bool? onOffSwitchLabels,
491505
bool? disableAnimations,
492506
bool? invertColors,
493507
bool? accessibleNavigation,
@@ -508,6 +522,7 @@ class MediaQueryData {
508522
alwaysUse24HourFormat: alwaysUse24HourFormat ?? this.alwaysUse24HourFormat,
509523
invertColors: invertColors ?? this.invertColors,
510524
highContrast: highContrast ?? this.highContrast,
525+
onOffSwitchLabels: onOffSwitchLabels ?? this.onOffSwitchLabels,
511526
disableAnimations: disableAnimations ?? this.disableAnimations,
512527
accessibleNavigation: accessibleNavigation ?? this.accessibleNavigation,
513528
boldText: boldText ?? this.boldText,
@@ -699,6 +714,7 @@ class MediaQueryData {
699714
&& other.systemGestureInsets == systemGestureInsets
700715
&& other.alwaysUse24HourFormat == alwaysUse24HourFormat
701716
&& other.highContrast == highContrast
717+
&& other.onOffSwitchLabels == onOffSwitchLabels
702718
&& other.disableAnimations == disableAnimations
703719
&& other.invertColors == invertColors
704720
&& other.accessibleNavigation == accessibleNavigation
@@ -719,6 +735,7 @@ class MediaQueryData {
719735
viewInsets,
720736
alwaysUse24HourFormat,
721737
highContrast,
738+
onOffSwitchLabels,
722739
disableAnimations,
723740
invertColors,
724741
accessibleNavigation,
@@ -742,6 +759,7 @@ class MediaQueryData {
742759
'alwaysUse24HourFormat: $alwaysUse24HourFormat',
743760
'accessibleNavigation: $accessibleNavigation',
744761
'highContrast: $highContrast',
762+
'onOffSwitchLabels: $onOffSwitchLabels',
745763
'disableAnimations: $disableAnimations',
746764
'invertColors: $invertColors',
747765
'boldText: $boldText',
@@ -1255,6 +1273,25 @@ class MediaQuery extends InheritedModel<_MediaQueryAspect> {
12551273
/// the [MediaQueryData.highContrast] property of the ancestor [MediaQuery] changes.
12561274
static bool? maybeHighContrastOf(BuildContext context) => _maybeOf(context, _MediaQueryAspect.highContrast)?.highContrast;
12571275

1276+
/// Returns onOffSwitchLabels for the nearest MediaQuery ancestor or false, if no
1277+
/// such ancestor exists.
1278+
///
1279+
/// See also:
1280+
///
1281+
/// * [MediaQueryData.onOffSwitchLabels], which indicates the platform's
1282+
/// desire to show on/off labels inside switches.
1283+
///
1284+
/// Use of this method will cause the given [context] to rebuild any time that
1285+
/// the [MediaQueryData.onOffSwitchLabels] property of the ancestor [MediaQuery] changes.
1286+
static bool onOffSwitchLabelsOf(BuildContext context) => maybeOnOffSwitchLabelsOf(context) ?? false;
1287+
1288+
/// Returns onOffSwitchLabels for the nearest MediaQuery ancestor or
1289+
/// null, if no such ancestor exists.
1290+
///
1291+
/// Use of this method will cause the given [context] to rebuild any time that
1292+
/// the [MediaQueryData.onOffSwitchLabels] property of the ancestor [MediaQuery] changes.
1293+
static bool? maybeOnOffSwitchLabelsOf(BuildContext context) => _maybeOf(context, _MediaQueryAspect.onOffSwitchLabels)?.onOffSwitchLabels;
1294+
12581295
/// Returns disableAnimations for the nearest MediaQuery ancestor or
12591296
/// [Brightness.light], if no such ancestor exists.
12601297
///
@@ -1406,6 +1443,10 @@ class MediaQuery extends InheritedModel<_MediaQueryAspect> {
14061443
if (data.highContrast != oldWidget.data.highContrast) {
14071444
return true;
14081445
}
1446+
case _MediaQueryAspect.onOffSwitchLabels:
1447+
if (data.onOffSwitchLabels != oldWidget.data.onOffSwitchLabels) {
1448+
return true;
1449+
}
14091450
case _MediaQueryAspect.disableAnimations:
14101451
if (data.disableAnimations != oldWidget.data.disableAnimations) {
14111452
return true;

0 commit comments

Comments
 (0)