| // 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. | |
| /// @docImport 'package:flutter/material.dart'; | |
| /// | |
| /// @docImport 'matchers.dart'; | |
| library; | |
| import 'dart:async'; | |
| import 'dart:ui' as ui; | |
| import 'package:flutter/foundation.dart'; | |
| import 'package:flutter/rendering.dart'; | |
| import 'package:flutter/widgets.dart'; | |
| import 'finders.dart'; | |
| import 'widget_tester.dart'; | |
| /// The result of evaluating a semantics node by a [AccessibilityGuideline]. | |
| class Evaluation { | |
| /// Create a passing evaluation. | |
| const Evaluation.pass() : passed = true, reason = null; | |
| /// Create a failing evaluation, with an optional [reason] explaining the | |
| /// result. | |
| const Evaluation.fail([this.reason]) : passed = false; | |
| // private constructor for adding cases together. | |
| const Evaluation._(this.passed, this.reason); | |
| /// Whether the given tree or node passed the policy evaluation. | |
| final bool passed; | |
| /// If [passed] is false, contains the reason for failure. | |
| final String? reason; | |
| /// Combines two evaluation results. | |
| /// | |
| /// The [reason] will be concatenated with a newline, and [passed] will be | |
| /// combined with an `&&` operator. | |
| Evaluation operator +(Evaluation? other) { | |
| if (other == null) { | |
| return this; | |
| } | |
| final buffer = StringBuffer(); | |
| if (reason != null && reason!.isNotEmpty) { | |
| buffer.write(reason); | |
| buffer.writeln(); | |
| } | |
| if (other.reason != null && other.reason!.isNotEmpty) { | |
| buffer.write(other.reason); | |
| } | |
| return Evaluation._(passed && other.passed, buffer.isEmpty ? null : buffer.toString()); | |
| } | |
| } | |
| // Examples can assume: | |
| // typedef HomePage = Placeholder; | |
| /// An accessibility guideline describes a recommendation an application should | |
| /// meet to be considered accessible. | |
| /// | |
| /// Use [meetsGuideline] matcher to test whether a screen meets the | |
| /// accessibility guideline. | |
| /// | |
| /// {@tool snippet} | |
| /// | |
| /// This sample demonstrates how to run an accessibility guideline in a unit | |
| /// test against a single screen. | |
| /// | |
| /// ```dart | |
| /// testWidgets('HomePage meets androidTapTargetGuideline', (WidgetTester tester) async { | |
| /// final SemanticsHandle handle = tester.ensureSemantics(); | |
| /// await tester.pumpWidget(const MaterialApp(home: HomePage())); | |
| /// await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); | |
| /// handle.dispose(); | |
| /// }); | |
| /// ``` | |
| /// {@end-tool} | |
| /// | |
| /// See also: | |
| /// * [androidTapTargetGuideline], which checks that tappable nodes have a | |
| /// minimum size of 48 by 48 pixels. | |
| /// * [iOSTapTargetGuideline], which checks that tappable nodes have a minimum | |
| /// size of 44 by 44 pixels. | |
| /// * [textContrastGuideline], which provides guidance for text contrast | |
| /// requirements specified by [WCAG](https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#contrast-ratiodef). | |
| /// * [labeledTapTargetGuideline], which enforces that all nodes with a tap or | |
| /// long press action also have a label. | |
| abstract class AccessibilityGuideline { | |
| /// A const constructor allows subclasses to be const. | |
| const AccessibilityGuideline(); | |
| /// Evaluate whether the current state of the `tester` conforms to the rule. | |
| FutureOr<Evaluation> evaluate(WidgetTester tester); | |
| /// A description of the policy restrictions and criteria. | |
| String get description; | |
| } | |
| /// A guideline which enforces that all tappable semantics nodes have a minimum | |
| /// size. | |
| /// | |
| /// Each platform defines its own guidelines for minimum tap areas. | |
| /// | |
| /// See also: | |
| /// * [AccessibilityGuideline], which provides a general overview of | |
| /// accessibility guidelines and how to use them. | |
| /// * [androidTapTargetGuideline], which checks that tappable nodes have a | |
| /// minimum size of 48 by 48 pixels. | |
| /// * [iOSTapTargetGuideline], which checks that tappable nodes have a minimum | |
| /// size of 44 by 44 pixels. | |
| @visibleForTesting | |
| class MinimumTapTargetGuideline extends AccessibilityGuideline { | |
| /// Create a new [MinimumTapTargetGuideline]. | |
| const MinimumTapTargetGuideline({required this.size, required this.link}); | |
| /// The minimum allowed size of a tappable node. | |
| final Size size; | |
| /// A link describing the tap target guidelines for a platform. | |
| final String link; | |
| /// The gap between targets to their parent scrollables to be consider as valid | |
| /// tap targets. | |
| /// | |
| /// This avoid cases where a tap target is partially scrolled off-screen that | |
| /// result in a smaller tap area. | |
| static const double _kMinimumGapToBoundary = 0.001; | |
| @override | |
| FutureOr<Evaluation> evaluate(WidgetTester tester) { | |
| var result = const Evaluation.pass(); | |
| for (final RenderView view in tester.binding.renderViews) { | |
| result += _traverse(view.flutterView, view.owner!.semanticsOwner!.rootSemanticsNode!); | |
| } | |
| return result; | |
| } | |
| Evaluation _traverse(ui.FlutterView view, SemanticsNode node) { | |
| var result = const Evaluation.pass(); | |
| node.visitChildren((SemanticsNode child) { | |
| result += _traverse(view, child); | |
| return true; | |
| }); | |
| if (node.isMergedIntoParent) { | |
| return result; | |
| } | |
| if (shouldSkipNode(node)) { | |
| return result; | |
| } | |
| Rect paintBounds = node.rect; | |
| SemanticsNode? current = node; | |
| while (current != null) { | |
| final Matrix4? transform = current.transform; | |
| if (transform != null) { | |
| paintBounds = MatrixUtils.transformRect(transform, paintBounds); | |
| } | |
| // skip node if it is touching the edge scrollable, since it might | |
| // be partially scrolled offscreen. | |
| if (current.flagsCollection.hasImplicitScrolling && | |
| _isAtBoundary(paintBounds, current.rect)) { | |
| return result; | |
| } | |
| current = current.parent; | |
| } | |
| final Rect viewRect = Offset.zero & view.physicalSize; | |
| if (_isAtBoundary(paintBounds, viewRect)) { | |
| return result; | |
| } | |
| // shrink by device pixel ratio. | |
| final Size candidateSize = paintBounds.size / view.devicePixelRatio; | |
| if (candidateSize.width < size.width - precisionErrorTolerance || | |
| candidateSize.height < size.height - precisionErrorTolerance) { | |
| result += Evaluation.fail( | |
| '$node: expected tap target size of at least $size, ' | |
| 'but found $candidateSize\n' | |
| 'See also: $link', | |
| ); | |
| } | |
| return result; | |
| } | |
| static bool _isAtBoundary(Rect child, Rect parent) { | |
| if (child.left - parent.left > _kMinimumGapToBoundary && | |
| parent.right - child.right > _kMinimumGapToBoundary && | |
| child.top - parent.top > _kMinimumGapToBoundary && | |
| parent.bottom - child.bottom > _kMinimumGapToBoundary) { | |
| return false; | |
| } | |
| return true; | |
| } | |
| /// Returns whether [SemanticsNode] should be skipped for minimum tap target | |
| /// guideline. | |
| /// | |
| /// Skips nodes which are link, hidden, or do not have actions. | |
| bool shouldSkipNode(SemanticsNode node) { | |
| final SemanticsData data = node.getSemanticsData(); | |
| // Skip node if it has no actions, or is marked as hidden. | |
| if ((!data.hasAction(ui.SemanticsAction.longPress) && | |
| !data.hasAction(ui.SemanticsAction.tap)) || | |
| data.flagsCollection.isHidden) { | |
| return true; | |
| } | |
| // Skip links https://www.w3.org/WAI/WCAG21/Understanding/target-size.html | |
| if (data.flagsCollection.isLink) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| @override | |
| String get description => 'Tappable objects should be at least $size'; | |
| } | |
| /// A guideline which enforces that all nodes with a tap or long press action | |
| /// also have a label. | |
| /// | |
| /// See also: | |
| /// * [AccessibilityGuideline], which provides a general overview of | |
| /// accessibility guidelines and how to use them. | |
| @visibleForTesting | |
| class LabeledTapTargetGuideline extends AccessibilityGuideline { | |
| const LabeledTapTargetGuideline._(); | |
| @override | |
| String get description => 'Tappable widgets should have a semantic label'; | |
| @override | |
| FutureOr<Evaluation> evaluate(WidgetTester tester) { | |
| var result = const Evaluation.pass(); | |
| for (final RenderView view in tester.binding.renderViews) { | |
| result += _traverse(view.owner!.semanticsOwner!.rootSemanticsNode!); | |
| } | |
| return result; | |
| } | |
| Evaluation _traverse(SemanticsNode node) { | |
| var result = const Evaluation.pass(); | |
| node.visitChildren((SemanticsNode child) { | |
| result += _traverse(child); | |
| return true; | |
| }); | |
| if (node.isMergedIntoParent || | |
| node.isInvisible || | |
| node.flagsCollection.isHidden || | |
| node.flagsCollection.isTextField) { | |
| return result; | |
| } | |
| final SemanticsData data = node.getSemanticsData(); | |
| // Skip node if it has no actions, or is marked as hidden. | |
| if (!data.hasAction(ui.SemanticsAction.longPress) && !data.hasAction(ui.SemanticsAction.tap)) { | |
| return result; | |
| } | |
| if ((data.label.isEmpty) && (data.tooltip.isEmpty)) { | |
| result += Evaluation.fail( | |
| '$node: expected tappable node to have semantic label, ' | |
| 'but none was found.', | |
| ); | |
| } | |
| return result; | |
| } | |
| } | |
| /// A guideline which verifies that all nodes that contribute semantics via text | |
| /// meet minimum contrast levels. | |
| /// | |
| /// The guidelines are defined by the Web Content Accessibility Guidelines, | |
| /// http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html. | |
| /// | |
| /// See also: | |
| /// * [AccessibilityGuideline], which provides a general overview of | |
| /// accessibility guidelines and how to use them. | |
| /// * [MinimumTextContrastGuidelineAAA], which follows the WCAG AAA level. | |
| @visibleForTesting | |
| class MinimumTextContrastGuideline extends AccessibilityGuideline { | |
| /// Create a new [MinimumTextContrastGuideline]. | |
| const MinimumTextContrastGuideline(); | |
| /// The minimum text size considered large for contrast checking. | |
| /// | |
| /// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html | |
| static const int kLargeTextMinimumSize = 18; | |
| /// The minimum text size for bold text to be considered large for contrast | |
| /// checking. | |
| /// | |
| /// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html | |
| static const int kBoldTextMinimumSize = 14; | |
| /// The minimum contrast ratio for normal text. | |
| /// | |
| /// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html | |
| static const double kMinimumRatioNormalText = 4.5; | |
| /// The minimum contrast ratio for large text. | |
| /// | |
| /// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html | |
| static const double kMinimumRatioLargeText = 3.0; | |
| static const double _kDefaultFontSize = 12.0; | |
| static const double _tolerance = -0.01; | |
| @override | |
| Future<Evaluation> evaluate(WidgetTester tester) async { | |
| var result = const Evaluation.pass(); | |
| for (final RenderView renderView in tester.binding.renderViews) { | |
| final layer = renderView.debugLayer! as OffsetLayer; | |
| final SemanticsNode root = renderView.owner!.semanticsOwner!.rootSemanticsNode!; | |
| late 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. | |
| final double ratio = 1 / renderView.flutterView.devicePixelRatio; | |
| image = await layer.toImage(renderView.paintBounds, pixelRatio: ratio); | |
| final ByteData? data = await image.toByteData(); | |
| image.dispose(); | |
| return data; | |
| }); | |
| result += await _evaluateNode(root, tester, image, byteData!, renderView); | |
| } | |
| return result; | |
| } | |
| Future<Evaluation> _evaluateNode( | |
| SemanticsNode node, | |
| WidgetTester tester, | |
| ui.Image image, | |
| ByteData byteData, | |
| RenderView renderView, | |
| ) async { | |
| var result = const Evaluation.pass(); | |
| // Skip disabled nodes, as they not required to pass contrast check. | |
| final isDisabled = node.flagsCollection.isEnabled == ui.Tristate.isFalse; | |
| if (node.isInvisible || | |
| node.isMergedIntoParent || | |
| node.flagsCollection.isHidden || | |
| isDisabled) { | |
| return result; | |
| } | |
| final SemanticsData data = node.getSemanticsData(); | |
| final children = <SemanticsNode>[]; | |
| node.visitChildren((SemanticsNode child) { | |
| children.add(child); | |
| return true; | |
| }); | |
| for (final child in children) { | |
| result += await _evaluateNode(child, tester, image, byteData, renderView); | |
| } | |
| if (shouldSkipNode(data)) { | |
| return result; | |
| } | |
| final String text = data.label.isEmpty ? data.value : data.label; | |
| final Iterable<Element> elements = find.text(text).hitTestable().evaluate(); | |
| for (final element in elements) { | |
| result += await _evaluateElement(node, element, tester, image, byteData, renderView); | |
| } | |
| return result; | |
| } | |
| Future<Evaluation> _evaluateElement( | |
| SemanticsNode node, | |
| Element element, | |
| WidgetTester tester, | |
| ui.Image image, | |
| ByteData byteData, | |
| RenderView renderView, | |
| ) async { | |
| // Look up inherited text properties to determine text size and weight. | |
| late bool isBold; | |
| double? fontSize; | |
| late final Rect screenBounds; | |
| late final Rect paintBoundsWithOffset; | |
| final RenderObject? renderBox = element.renderObject; | |
| if (renderBox is! RenderBox) { | |
| throw StateError('Unexpected renderObject type: $renderBox'); | |
| } | |
| final Matrix4 globalTransform = renderBox.getTransformTo(null); | |
| paintBoundsWithOffset = MatrixUtils.transformRect( | |
| globalTransform, | |
| renderBox.paintBounds.inflate(4.0), | |
| ); | |
| // The semantics node transform will include root view transform, which is | |
| // not included in renderBox.getTransformTo(null). Manually multiply the | |
| // root transform to the global transform. | |
| final rootTransform = Matrix4.identity(); | |
| renderView.applyPaintTransform(renderView.child!, rootTransform); | |
| rootTransform.multiply(globalTransform); | |
| screenBounds = MatrixUtils.transformRect(rootTransform, renderBox.paintBounds); | |
| Rect nodeBounds = node.rect; | |
| SemanticsNode? current = node; | |
| while (current != null) { | |
| final Matrix4? transform = current.transform; | |
| if (transform != null) { | |
| nodeBounds = MatrixUtils.transformRect(transform, nodeBounds); | |
| } | |
| current = current.parent; | |
| } | |
| final Rect intersection = nodeBounds.intersect(screenBounds); | |
| if (intersection.width <= 0 || intersection.height <= 0) { | |
| // Skip this element since it doesn't correspond to the given semantic | |
| // node. | |
| return const Evaluation.pass(); | |
| } | |
| final Widget widget = element.widget; | |
| final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(element); | |
| if (widget is Text) { | |
| final TextStyle? style = widget.style; | |
| final TextStyle effectiveTextStyle = style == null || style.inherit | |
| ? defaultTextStyle.style.merge(widget.style) | |
| : style; | |
| isBold = effectiveTextStyle.fontWeight == FontWeight.bold; | |
| fontSize = effectiveTextStyle.fontSize; | |
| } else if (widget is EditableText) { | |
| isBold = widget.style.fontWeight == FontWeight.bold; | |
| fontSize = widget.style.fontSize; | |
| } else { | |
| throw StateError('Unexpected widget type: ${widget.runtimeType}'); | |
| } | |
| if (isNodeOffScreen(paintBoundsWithOffset, renderView.flutterView)) { | |
| return const Evaluation.pass(); | |
| } | |
| final Map<Color, int> colorHistogram = _colorsWithinRect( | |
| byteData, | |
| paintBoundsWithOffset, | |
| image.width, | |
| image.height, | |
| ); | |
| // Node was too far off screen. | |
| if (colorHistogram.isEmpty) { | |
| return const Evaluation.pass(); | |
| } | |
| final report = _ContrastReport(colorHistogram); | |
| final double contrastRatio = report.contrastRatio(); | |
| final double targetContrastRatio = this.targetContrastRatio(fontSize, bold: isBold); | |
| if (contrastRatio - targetContrastRatio >= _tolerance) { | |
| return const Evaluation.pass(); | |
| } | |
| return Evaluation.fail( | |
| '$node:\n' | |
| 'Expected contrast ratio of at least $targetContrastRatio ' | |
| 'but found ${contrastRatio.toStringAsFixed(2)} ' | |
| 'for a font size of $fontSize.\n' | |
| 'The computed colors was:\n' | |
| 'light - ${report.lightColor}, dark - ${report.darkColor}\n' | |
| 'See also: ' | |
| 'https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html', | |
| ); | |
| } | |
| /// Returns whether node should be skipped. | |
| /// | |
| /// Skip routes which might have labels, and nodes without any text. | |
| bool shouldSkipNode(SemanticsData data) => | |
| data.flagsCollection.scopesRoute || (data.label.trim().isEmpty && data.value.trim().isEmpty); | |
| /// Returns if a rectangle of node is off the screen. | |
| /// | |
| /// Allows node to be of screen partially before culling the node. | |
| bool isNodeOffScreen(Rect paintBounds, ui.FlutterView window) { | |
| final Size windowPhysicalSize = window.physicalSize * window.devicePixelRatio; | |
| return paintBounds.top < -50.0 || | |
| paintBounds.left < -50.0 || | |
| paintBounds.bottom > windowPhysicalSize.height + 50.0 || | |
| paintBounds.right > windowPhysicalSize.width + 50.0; | |
| } | |
| /// Returns the required contrast ratio for the [fontSize] and [bold] setting. | |
| /// | |
| /// Defined by http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html | |
| double targetContrastRatio(double? fontSize, {required bool bold}) { | |
| final double fontSizeOrDefault = fontSize ?? _kDefaultFontSize; | |
| if ((bold && fontSizeOrDefault >= kBoldTextMinimumSize) || | |
| fontSizeOrDefault >= kLargeTextMinimumSize) { | |
| return kMinimumRatioLargeText; | |
| } | |
| return kMinimumRatioNormalText; | |
| } | |
| @override | |
| String get description => 'Text contrast should follow WCAG guidelines'; | |
| } | |
| /// A guideline which verifies that all nodes that contribute semantics via text | |
| /// meet **WCAG AAA** contrast levels. | |
| /// | |
| /// The AAA level is defined by the Web Content Accessibility Guidelines: | |
| /// https://www.w3.org/WAI/WCAG22/Understanding/contrast-enhanced | |
| /// | |
| /// This guideline enforces a stricter contrast ratio: | |
| /// * Normal text must have a contrast ratio of at least 7.0 | |
| /// * Large or bold text must have a contrast ratio of at least 4.5 | |
| /// | |
| /// See also: | |
| /// * [MinimumTextContrastGuideline], which follows the WCAG AA level. | |
| /// * [AccessibilityGuideline], which provides an overview of guidelines. | |
| @visibleForTesting | |
| class MinimumTextContrastGuidelineAAA extends MinimumTextContrastGuideline { | |
| /// Create a new [MinimumTextContrastGuidelineAAA]. | |
| const MinimumTextContrastGuidelineAAA(); | |
| /// The minimum contrast ratio for large text (bold ≥14px or ≥18px). | |
| /// | |
| /// Defined by WCAG AAA standard http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html | |
| static const double kAAAMinimumRatioLargeText = 4.5; | |
| /// The minimum contrast ratio for normal text. | |
| /// | |
| /// Defined by WCAG AAA standard http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html | |
| static const double kAAAMinimumRatioNormalText = 7.0; | |
| @override | |
| double targetContrastRatio(double? fontSize, {required bool bold}) { | |
| final double fontSizeOrDefault = fontSize ?? MinimumTextContrastGuideline._kDefaultFontSize; | |
| if ((bold && fontSizeOrDefault >= MinimumTextContrastGuideline.kBoldTextMinimumSize) || | |
| fontSizeOrDefault >= MinimumTextContrastGuideline.kLargeTextMinimumSize) { | |
| return kAAAMinimumRatioLargeText; | |
| } | |
| return kAAAMinimumRatioNormalText; | |
| } | |
| @override | |
| String get description => 'Text contrast should follow WCAG AAA guidelines'; | |
| } | |
| /// A guideline which verifies that all elements specified by [finder] | |
| /// meet minimum contrast levels. | |
| /// | |
| /// See also: | |
| /// * [AccessibilityGuideline], which provides a general overview of | |
| /// accessibility guidelines and how to use them. | |
| 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, | |
| this._description = 'Contrast should follow custom guidelines', | |
| }); | |
| /// 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 { | |
| // Collate all evaluations into a final evaluation, then return. | |
| var result = const Evaluation.pass(); | |
| final images = <ui.FlutterView, ui.Image>{}; | |
| try { | |
| // Compute elements to be evaluated. | |
| final List<Element> elements = finder.evaluate().toList(); | |
| final byteDatas = <ui.FlutterView, ByteData>{}; | |
| for (final element in elements) { | |
| final ui.FlutterView view = tester.viewOf( | |
| find.byElementPredicate((Element e) => e == element), | |
| ); | |
| final RenderView renderView = tester.binding.renderViews.firstWhere( | |
| (RenderView r) => r.flutterView == view, | |
| ); | |
| final layer = renderView.debugLayer! as OffsetLayer; | |
| late final ui.Image image; | |
| late final ByteData byteData; | |
| // Obtain a previously rendered image or render one for a new view. | |
| await tester.binding.runAsync(() async { | |
| image = images[view] ??= await layer.toImage( | |
| renderView.paintBounds, | |
| // Needs to be the same pixel ratio otherwise our dimensions | |
| // won't match the last transform layer. | |
| pixelRatio: 1 / view.devicePixelRatio, | |
| ); | |
| byteData = byteDatas[view] ??= (await image.toByteData())!; | |
| }); | |
| result = result + _evaluateElement(element, byteData, image); | |
| } | |
| } finally { | |
| for (final ui.Image image in images.values) { | |
| image.dispose(); | |
| } | |
| } | |
| return result; | |
| } | |
| // How to evaluate a single element. | |
| Evaluation _evaluateElement(Element element, ByteData byteData, ui.Image image) { | |
| final renderObject = element.renderObject! as RenderBox; | |
| final Rect originalPaintBounds = renderObject.paintBounds; | |
| final Rect inflatedPaintBounds = originalPaintBounds.inflate(4.0); | |
| final paintBounds = Rect.fromPoints( | |
| renderObject.localToGlobal(inflatedPaintBounds.topLeft), | |
| renderObject.localToGlobal(inflatedPaintBounds.bottomRight), | |
| ); | |
| final Map<Color, int> colorHistogram = _colorsWithinRect( | |
| byteData, | |
| paintBounds, | |
| image.width, | |
| image.height, | |
| ); | |
| if (colorHistogram.isEmpty) { | |
| return const Evaluation.pass(); | |
| } | |
| final report = _ContrastReport(colorHistogram); | |
| final double contrastRatio = report.contrastRatio(); | |
| if (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', | |
| ); | |
| } | |
| } | |
| } | |
| /// 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 color histogram. | |
| /// | |
| /// 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(Map<Color, int> colorHistogram) { | |
| // To determine the lighter and darker color, partition the colors | |
| // by HSL lightness and then choose the mode from each group. | |
| var totalLightness = 0.0; | |
| var count = 0; | |
| for (final MapEntry<Color, int> entry in colorHistogram.entries) { | |
| totalLightness += HSLColor.fromColor(entry.key).lightness * entry.value; | |
| count += entry.value; | |
| } | |
| final double averageLightness = totalLightness / count; | |
| assert(!averageLightness.isNaN); | |
| MapEntry<Color, int>? lightColor; | |
| MapEntry<Color, int>? darkColor; | |
| // Find the most frequently occurring light and dark color. | |
| for (final MapEntry<Color, int> entry in colorHistogram.entries) { | |
| final double lightness = HSLColor.fromColor(entry.key).lightness; | |
| final int count = entry.value; | |
| if (lightness <= averageLightness) { | |
| if (count > (darkColor?.value ?? 0)) { | |
| darkColor = entry; | |
| } | |
| } else if (count > (lightColor?.value ?? 0)) { | |
| lightColor = entry; | |
| } | |
| } | |
| // If there is only single color, it is reported as both dark and light. | |
| return _ContrastReport._(lightColor?.key ?? darkColor!.key, darkColor?.key ?? lightColor!.key); | |
| } | |
| const _ContrastReport._(this.lightColor, this.darkColor); | |
| /// 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; | |
| /// Computes the contrast ratio as defined by the WCAG. | |
| /// | |
| /// Source: https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html | |
| double contrastRatio() => | |
| (lightColor.computeLuminance() + 0.05) / (darkColor.computeLuminance() + 0.05); | |
| } | |
| /// Gives the color histogram 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 color histogram. | |
| Map<Color, 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 rgbaToCount = <int, int>{}; | |
| int getPixel(ByteData data, int x, int y) { | |
| final int offset = (y * width + x) * 4; | |
| return data.getUint32(offset); | |
| } | |
| for (var x = leftX; x < rightX; x++) { | |
| for (var y = topY; y < bottomY; y++) { | |
| rgbaToCount.update(getPixel(data, x, y), (int count) => count + 1, ifAbsent: () => 1); | |
| } | |
| } | |
| return rgbaToCount.map<Color, int>((int rgba, int count) { | |
| final int argb = (rgba << 24) | (rgba >> 8) & 0xFFFFFFFF; | |
| return MapEntry<Color, int>(Color(argb), count); | |
| }); | |
| } | |
| /// A guideline which requires tappable semantic nodes a minimum size of | |
| /// 48 by 48. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [Android tap target guidelines](https://support.google.com/accessibility/android/answer/7101858?hl=en). | |
| /// * [AccessibilityGuideline], which provides a general overview of | |
| /// accessibility guidelines and how to use them. | |
| /// * [iOSTapTargetGuideline], which checks that tappable nodes have a minimum | |
| /// size of 44 by 44 pixels. | |
| const AccessibilityGuideline androidTapTargetGuideline = MinimumTapTargetGuideline( | |
| size: Size(48.0, 48.0), | |
| link: 'https://support.google.com/accessibility/android/answer/7101858?hl=en', | |
| ); | |
| /// A guideline which requires tappable semantic nodes a minimum size of | |
| /// 44 by 44. | |
| /// | |
| /// See also: | |
| /// | |
| /// * [iOS human interface guidelines](https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/). | |
| /// * [AccessibilityGuideline], which provides a general overview of | |
| /// accessibility guidelines and how to use them. | |
| /// * [androidTapTargetGuideline], which checks that tappable nodes have a | |
| /// minimum size of 48 by 48 pixels. | |
| const AccessibilityGuideline iOSTapTargetGuideline = MinimumTapTargetGuideline( | |
| size: Size(44.0, 44.0), | |
| link: 'https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/', | |
| ); | |
| /// A guideline which requires text contrast to meet minimum values. | |
| /// | |
| /// This guideline traverses the semantics tree looking for nodes with values or | |
| /// labels that corresponds to a Text or Editable text widget. Given the | |
| /// background pixels for the area around this widget, it performs a very naive | |
| /// partitioning of the colors into "light" and "dark" and then chooses the most | |
| /// frequently occurring color in each partition as a representative of the | |
| /// foreground and background colors. The contrast ratio is calculated from | |
| /// these colors according to the [WCAG](https://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html#contrast-ratiodef) | |
| /// | |
| /// * [AccessibilityGuideline], which provides a general overview of | |
| /// accessibility guidelines and how to use them. | |
| const AccessibilityGuideline textContrastGuideline = MinimumTextContrastGuideline(); | |
| /// A guideline which enforces that all nodes with a tap or long press action | |
| /// also have a label. | |
| /// | |
| /// * [AccessibilityGuideline], which provides a general overview of | |
| /// accessibility guidelines and how to use them. | |
| const AccessibilityGuideline labeledTapTargetGuideline = LabeledTapTargetGuideline._(); |