Commit e2aa9e13 authored by Tianguang's avatar Tianguang Committed by Flutter GitHub Bot

Achieve Color Contrast Accessibility for Menu Demo (#49099)

parent adc86806
......@@ -86,7 +86,11 @@ class MenuDemoState extends State<MenuDemo> {
),
],
),
body: ListView(
body: ListTileTheme(
iconColor: Theme.of(context).brightness == Brightness.light
? Colors.grey[600]
: Colors.grey[500],
child: ListView(
padding: kMaterialListPadding,
children: <Widget>[
// Pressing the PopupMenuButton on the right of this item shows
......@@ -214,6 +218,7 @@ class MenuDemoState extends State<MenuDemo> {
),
],
),
),
);
}
}
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_gallery/demo/material/menu_demo.dart';
import 'package:flutter_gallery/gallery/themes.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Menu icon satisfies accessibility contrast ratio guidelines, light mode', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
theme: kLightGalleryTheme,
home: const MenuDemo(),
));
await expectLater(tester, meetsGuideline(textContrastGuideline));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: find.byWidgetPredicate((Widget widget) => widget is Icon))));
});
testWidgets('Menu icon satisfies accessibility contrast ratio guidelines, dark mode', (WidgetTester tester) async {
await tester.pumpWidget(MaterialApp(
theme: kDarkGalleryTheme,
home: const MenuDemo(),
));
await expectLater(tester, meetsGuideline(textContrastGuideline));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: find.byWidgetPredicate((Widget widget) => widget is Icon))));
});
}
......@@ -7,6 +7,7 @@ import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart' as flutter_material;
import 'package:flutter/rendering.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/widgets.dart';
......@@ -263,12 +264,16 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
if (_isNodeOffScreen(paintBounds, tester.binding.window)) {
return result;
}
final List<int> subset = _subsetToRect(byteData, paintBounds, image.width, image.height);
final List<int> subset = _colorsWithinRect(byteData, paintBounds, image.width, image.height);
// Node was too far off screen.
if (subset.isEmpty) {
return result;
}
final _ContrastReport report = _ContrastReport(subset);
// If rectangle is empty, pass the test.
if (report.isEmptyRect) {
return result;
}
final double contrastRatio = report.contrastRatio();
const double delta = -0.01;
double targetContrastRatio;
......@@ -312,40 +317,130 @@ class MinimumTextContrastGuideline extends AccessibilityGuideline {
|| paintBounds.right > (window.physicalSize.width * window.devicePixelRatio) + 50.0;
}
List<int> _subsetToRect(ByteData data, Rect paintBounds, int width, int height) {
final int newWidth = paintBounds.size.width.ceil();
final int newHeight = paintBounds.size.height.ceil();
final int leftX = paintBounds.topLeft.dx.ceil();
final int rightX = leftX + newWidth;
final int topY = paintBounds.topLeft.dy.ceil();
final int bottomY = topY + newHeight;
final List<int> buffer = <int>[];
@override
String get description => 'Text contrast should follow WCAG guidelines';
}
// Data is stored in row major order.
for (int i = 0; i < data.lengthInBytes; i+=4) {
final int index = i ~/ 4;
final int dx = index % width;
final int dy = index ~/ width;
if (dx >= leftX && dx <= rightX && dy >= topY && dy <= bottomY) {
final int r = data.getUint8(i);
final int g = data.getUint8(i + 1);
final int b = data.getUint8(i + 2);
final int a = data.getUint8(i + 3);
final int color = (((a & 0xff) << 24) |
((r & 0xff) << 16) |
((g & 0xff) << 8) |
((b & 0xff) << 0)) & 0xFFFFFFFF;
buffer.add(color);
/// A guideline which verifies that all elements specified by [finder]
/// meet minimum contrast levels.
class CustomMinimumContrastGuideline extends AccessibilityGuideline {
/// Creates a custom guideline which verifies that all elements specified
/// by [finder] meet minimum contrast levels.
///
/// An optional description string can be given using the [description] parameter.
const CustomMinimumContrastGuideline({
@required this.finder,
this.minimumRatio = 4.5,
this.tolerance = 0.01,
String description = 'Contrast should follow custom guidelines',
}) : _description = description;
/// The minimum contrast ratio allowed.
///
/// Defaults to 4.5, the minimum contrast
/// ratio for normal text, defined by WCAG.
/// See http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html.
final double minimumRatio;
/// Tolerance for minimum contrast ratio.
///
/// Any contrast ratio greater than [minimumRatio] or within a distance of [tolerance]
/// from [minimumRatio] passes the test.
/// Defaults to 0.01.
final double tolerance;
/// The [Finder] used to find a subset of elements.
///
/// [finder] determines which subset of elements will be tested for
/// contrast ratio.
final Finder finder;
final String _description;
@override
String get description => _description;
@override
Future<Evaluation> evaluate(WidgetTester tester) async {
// Compute elements to be evaluated.
final List<Element> elements = finder.evaluate().toList();
// Obtain rendered image.
final RenderView renderView = tester.binding.renderView;
final OffsetLayer layer = renderView.debugLayer as OffsetLayer;
ui.Image image;
final ByteData byteData = await tester.binding.runAsync<ByteData>(() async {
// Needs to be the same pixel ratio otherwise our dimensions won't match the
// last transform layer.
image = await layer.toImage(renderView.paintBounds, pixelRatio: 1 / tester.binding.window.devicePixelRatio);
return image.toByteData();
});
// How to evaluate a single element.
Evaluation evaluateElement(Element element) {
final RenderBox renderObject = element.renderObject as RenderBox;
final Rect originalPaintBounds = renderObject.paintBounds;
final Rect inflatedPaintBounds = originalPaintBounds.inflate(4.0);
final Rect paintBounds = Rect.fromPoints(
renderObject.localToGlobal(inflatedPaintBounds.topLeft),
renderObject.localToGlobal(inflatedPaintBounds.bottomRight),
);
final List<int> subset = _colorsWithinRect(byteData, paintBounds, image.width, image.height);
if (subset.isEmpty) {
return const Evaluation.pass();
}
final _ContrastReport report = _ContrastReport(subset);
final double contrastRatio = report.contrastRatio();
if (report.isEmptyRect || contrastRatio >= minimumRatio - tolerance) {
return const Evaluation.pass();
} else {
return Evaluation.fail(
'$element:\nExpected contrast ratio of at least '
'$minimumRatio but found ${contrastRatio.toStringAsFixed(2)} \n'
'The computed light color was: ${report.lightColor}, '
'The computed dark color was: ${report.darkColor}\n'
'$description'
);
}
}
return buffer;
// Collate all evaluations into a final evaluation, then return.
Evaluation result = const Evaluation.pass();
for (final Element element in elements) {
result = result + evaluateElement(element);
}
@override
String get description => 'Text contrast should follow WCAG guidelines';
return result;
}
}
/// A class that reports the contrast ratio of a part of the screen.
///
/// Commonly used in accessibility testing to obtain the contrast ratio of
/// text widgets and other types of widgets.
class _ContrastReport {
/// Generates a contrast report given a list of colors.
///
/// Given a list of integers [colors], each representing the color of a pixel
/// on a part of the screen, generates a contrast ratio report.
/// Each colors is given in in ARGB format, as is the parameter for the
/// constructor [Color].
///
/// The contrast ratio of the most frequent light color and the most
/// frequent dark color is calculated. Colors are divided into light and
/// dark colors based on their lightness as an [HSLColor].
factory _ContrastReport(List<int> colors) {
final Map<int, int> colorHistogram = <int, int>{};
for (final int color in colors) {
......@@ -380,15 +475,49 @@ class _ContrastReport {
lightCount = count;
}
}
assert (lightColor != 0 && darkColor != 0);
// Depending on the number of colors present, return the correct contrast
// report.
if (lightCount > 0 && darkCount > 0) {
return _ContrastReport._(Color(lightColor), Color(darkColor));
} else if (lightCount > 0) {
return _ContrastReport.singleColor(Color(lightColor));
} else if (darkCount > 0) {
return _ContrastReport.singleColor(Color(darkColor));
} else {
return const _ContrastReport.emptyRect();
}
}
const _ContrastReport._(this.lightColor, this.darkColor)
: isSingleColor = false,
isEmptyRect = false;
const _ContrastReport._(this.lightColor, this.darkColor);
const _ContrastReport.singleColor(Color color)
: lightColor = color,
darkColor = color,
isSingleColor = true,
isEmptyRect = false;
const _ContrastReport.emptyRect()
: lightColor = flutter_material.Colors.transparent,
darkColor = flutter_material.Colors.transparent,
isSingleColor = false,
isEmptyRect = true;
/// The most frequently occurring light color. Uses [Colors.transparent] if
/// the rectangle is empty.
final Color lightColor;
/// The most frequently occurring dark color. Uses [Colors.transparent] if
/// the rectangle is empty.
final Color darkColor;
/// Whether the rectangle contains only one color.
final bool isSingleColor;
/// Whether the rectangle contains 0 pixels.
final bool isEmptyRect;
/// Computes the contrast ratio as defined by the WCAG.
///
/// source: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html
......@@ -419,6 +548,51 @@ class _ContrastReport {
}
}
/// Gives the colors of all pixels inside a given rectangle on the screen.
///
/// Given a [ByteData] object [data], which stores the color of each pixel
/// in row-first order, where each pixel is given in 4 bytes in RGBA order,
/// and [paintBounds], the rectangle,
/// and [width] and [height], the dimensions of the [ByteData],
/// returns a list of the colors of all pixels within the rectangle in
/// row-first order.
///
/// In the returned list, each color is represented as a 32-bit integer
/// in ARGB format, similar to the parameter for the [Color] constructor.
List<int> _colorsWithinRect(ByteData data, Rect paintBounds, int width, int height) {
final Rect truePaintBounds = paintBounds.intersect(
Rect.fromLTWH(0.0, 0.0, width.toDouble(), height.toDouble()),
);
final int leftX = truePaintBounds.left.floor();
final int rightX = truePaintBounds.right.ceil();
final int topY = truePaintBounds.top.floor();
final int bottomY = truePaintBounds.bottom.ceil();
final List<int> buffer = <int>[];
int _getPixel(ByteData data, int x, int y) {
final int offset = (y * width + x) * 4;
final int r = data.getUint8(offset);
final int g = data.getUint8(offset + 1);
final int b = data.getUint8(offset + 2);
final int a = data.getUint8(offset + 3);
final int color = (((a & 0xff) << 24) |
((r & 0xff) << 16) |
((g & 0xff) << 8) |
((b & 0xff) << 0)) & 0xFFFFFFFF;
return color;
}
for (int x = leftX; x < rightX; x ++) {
for (int y = topY; y < bottomY; y ++) {
buffer.add(_getPixel(data, x, y));
}
}
return buffer;
}
/// A guideline which requires tappable semantic nodes a minimum size of 48 by 48.
///
/// See also:
......
......@@ -178,6 +178,160 @@ void main() {
});
});
group('custom minimum contrast guideline', () {
Widget _icon ({IconData icon = Icons.search, Color color, Color background}) {
return Container(
padding: const EdgeInsets.all(8.0),
color: background,
child: Icon(icon, color: color),
);
}
Widget _text ({String text = 'Text', Color color, Color background}) {
return Container(
padding: const EdgeInsets.all(8.0),
color: background,
child: Text(text, style: TextStyle(color: color)),
);
}
Widget _row (List<Widget> widgets) => _boilerplate(Row(children: widgets));
final Finder _findIcons = find.byWidgetPredicate((Widget widget) => widget is Icon);
final Finder _findTexts = find.byWidgetPredicate((Widget widget) => widget is Text);
final Finder _findIconsAndTexts = find.byWidgetPredicate((Widget widget) => widget is Icon || widget is Text);
testWidgets('Black icons on white background', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[
_icon (color: Colors.black, background: Colors.white),
_icon (color: Colors.black, background: Colors.white),
]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
});
testWidgets('Black icons on black background', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[
_icon (color: Colors.black, background: Colors.black),
_icon (color: Colors.black, background: Colors.black),
]));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
});
testWidgets('White icons on black background ("dark mode")', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[
_icon (color: Colors.white, background: Colors.black),
_icon (color: Colors.white, background: Colors.black),
]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
});
testWidgets('Using different icons', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[
_icon (color: Colors.black, background: Colors.white, icon: Icons.more_horiz),
_icon (color: Colors.black, background: Colors.white, icon: Icons.description),
_icon (color: Colors.black, background: Colors.white, icon: Icons.image),
_icon (color: Colors.black, background: Colors.white, icon: Icons.beach_access),
]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
});
testWidgets('One invalid instance fails entire test', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[
_icon (color: Colors.black, background: Colors.white),
_icon (color: Colors.black, background: Colors.black),
]));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
});
testWidgets('White on different colors, passing', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[
_icon (color: Colors.white, background: Colors.red[800], icon: Icons.more_horiz),
_icon (color: Colors.white, background: Colors.green[800], icon: Icons.description),
_icon (color: Colors.white, background: Colors.blue[800], icon: Icons.image),
_icon (color: Colors.white, background: Colors.purple[800], icon: Icons.beach_access),
]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
});
testWidgets('White on different colors, failing', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[
_icon (color: Colors.white, background: Colors.red[200], icon: Icons.more_horiz),
_icon (color: Colors.white, background: Colors.green[400], icon: Icons.description),
_icon (color: Colors.white, background: Colors.blue[600], icon: Icons.image),
_icon (color: Colors.white, background: Colors.purple[800], icon: Icons.beach_access),
]));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
});
testWidgets('Absence of icons, passing', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
});
testWidgets('Absence of icons, passing - 2nd test', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[
_text (color: Colors.black, background: Colors.white),
_text (color: Colors.black, background: Colors.black),
]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
});
testWidgets('Guideline ignores widgets of other types', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[
_icon (color: Colors.black, background: Colors.white),
_icon (color: Colors.black, background: Colors.white),
_text (color: Colors.black, background: Colors.white),
_text (color: Colors.black, background: Colors.black),
]));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: _findTexts)));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: _findIconsAndTexts)));
});
testWidgets('Custom minimum ratio - Icons', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[
_icon (color: Colors.blue, background: Colors.white),
_icon (color: Colors.black, background: Colors.white),
]));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findIcons, minimumRatio: 3.0)));
});
testWidgets('Custom minimum ratio - Texts', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[
_text (color: Colors.blue, background: Colors.white),
_text (color: Colors.black, background: Colors.white),
]));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: _findTexts)));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findTexts, minimumRatio: 3.0)));
});
testWidgets('Custom minimum ratio - Different standards for icons and texts', (WidgetTester tester) async {
await tester.pumpWidget(_row(<Widget>[
_icon (color: Colors.blue, background: Colors.white),
_icon (color: Colors.black, background: Colors.white),
_text (color: Colors.blue, background: Colors.white),
_text (color: Colors.black, background: Colors.white),
]));
await expectLater(tester, doesNotMeetGuideline(CustomMinimumContrastGuideline(finder: _findIcons)));
await expectLater(tester, meetsGuideline(CustomMinimumContrastGuideline(finder: _findTexts, minimumRatio: 3.0)));
});
});
group('tap target size guideline', () {
testWidgets('Tappable box at 48 by 48', (WidgetTester tester) async {
final SemanticsHandle handle = tester.ensureSemantics();
......
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