blob: c086641d7a43241cc8a4bc6753ccee9ef8e8233f [file] [log] [blame]
// 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 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import '../foundation/_features.dart';
import 'binding.dart';
import 'editable_text.dart';
import 'framework.dart';
import 'text.dart';
const String _kAccessibilityEvaluationsDisabledErrorMessage = '''
Accessibility evaluations APIs are not enabled.
Accessibility evaluations APIs are currently experimental. Do not use accessibility evaluations APIs in
production applications or plugins published to pub.dev.
To try experimental accessibility evaluations APIs:
1. Switch to Flutter's main release channel.
2. Turn on the accessibility evaluations feature flag. (See flutter config --help)
''';
/// {@template flutter.widgets.accessibility_evaluations.internal}
/// Do not use in production.
///
/// Flutter will make breaking changes to this API, even in patch versions.
/// {@endtemplate}
///
/// A violation of a semantics node.
@internal
class Violation {
/// Creates a violation.
const Violation(this.node, this.reason);
/// The semantics node that violates the policy.
final SemanticsNode node;
/// The reason for the violation.
final String reason;
}
/// {@macro flutter.widgets.accessibility_evaluations.internal}
///
/// The result of evaluating a semantics node by an [AccessibilityEvaluation].
@internal
class EvaluationResult {
/// Creates a passing evaluation.
EvaluationResult(this.violations);
/// A list of violations found. An empty list means the evaluation passed.
final List<Violation> violations;
}
/// {@macro flutter.widgets.accessibility_evaluations.internal}
///
/// A class that evaluates a single accessibility rule.
@internal
abstract class AccessibilityEvaluation {
/// A const constructor allows subclasses to be const.
const AccessibilityEvaluation();
/// Evaluate whether the current state of the `binding` conforms to the rule.
FutureOr<EvaluationResult> evaluate(WidgetsBinding binding) {
if (!isAccessibilityEvaluationsEnabled) {
throw UnsupportedError(_kAccessibilityEvaluationsDisabledErrorMessage);
}
return _evaluate(binding);
}
FutureOr<EvaluationResult> _evaluate(WidgetsBinding binding);
}
/// {@macro flutter.widgets.accessibility_evaluations.internal}
///
/// An evaluation which enforces that all tappable semantics nodes have a minimum
/// size.
@internal
class MinimumTapTargetEvaluation extends AccessibilityEvaluation {
/// Create a new [MinimumTapTargetEvaluation].
const MinimumTapTargetEvaluation({required this.size, required this.link});
/// The minimum allowed size of a tappable node.
final Size size;
/// A link describing the tap target evaluations for a platform.
final String link;
/// The gap between targets to their parent scrollables to be considered valid
/// tap targets.
///
/// This avoids 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<EvaluationResult> _evaluate(WidgetsBinding binding) {
final violations = <Violation>[];
for (final RenderView view in binding.renderViews) {
violations.addAll(
_traverse(view.flutterView, view.owner!.semanticsOwner!.rootSemanticsNode!),
);
}
return EvaluationResult(violations);
}
List<Violation> _traverse(ui.FlutterView view, SemanticsNode node) {
final violations = <Violation>[];
node.visitChildren((SemanticsNode child) {
violations.addAll(_traverse(view, child));
return true;
});
if (node.isMergedIntoParent) {
return violations;
}
if (shouldSkipNode(node)) {
return violations;
}
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 of the scrollable, since it might
// be partially scrolled offscreen.
if (current.flagsCollection.hasImplicitScrolling &&
_isAtBoundary(paintBounds, current.rect)) {
return violations;
}
current = current.parent;
}
final Rect viewRect = Offset.zero & view.physicalSize;
if (_isAtBoundary(paintBounds, viewRect)) {
return violations;
}
// Shrink by device pixel ratio.
final Size candidateSize = paintBounds.size / view.devicePixelRatio;
if (candidateSize.width < size.width - precisionErrorTolerance ||
candidateSize.height < size.height - precisionErrorTolerance) {
violations.add(
Violation(
node,
'$node: expected tap target size of at least $size, '
'but found $candidateSize\n'
'See also: $link',
),
);
}
return violations;
}
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
/// evaluation.
///
/// 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;
}
}
/// {@macro flutter.widgets.accessibility_evaluations.internal}
///
/// An evaluation which enforces that all nodes with a tap or long press action
/// also have a label.
@internal
class LabeledTapTargetEvaluation extends AccessibilityEvaluation {
const LabeledTapTargetEvaluation();
@override
FutureOr<EvaluationResult> _evaluate(WidgetsBinding binding) {
final violations = <Violation>[];
for (final RenderView view in binding.renderViews) {
violations.addAll(_traverse(view.owner!.semanticsOwner!.rootSemanticsNode!));
}
return EvaluationResult(violations);
}
List<Violation> _traverse(SemanticsNode node) {
final violations = <Violation>[];
node.visitChildren((SemanticsNode child) {
violations.addAll(_traverse(child));
return true;
});
if (node.isMergedIntoParent ||
node.isInvisible ||
node.flagsCollection.isHidden ||
node.flagsCollection.isTextField) {
return violations;
}
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 violations;
}
if ((data.label.isEmpty) && (data.tooltip.isEmpty)) {
violations.add(
Violation(
node,
'$node: expected tappable node to have semantic label, '
'but none was found.',
),
);
}
return violations;
}
}
/// {@macro flutter.widgets.accessibility_evaluations.internal}
///
/// An evaluation which verifies that all nodes that contribute semantics via text
/// meet minimum contrast levels.
///
/// The evaluations are defined by the Web Content Accessibility Guidelines,
/// http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html.
@internal
class MinimumTextContrastEvaluation extends AccessibilityEvaluation {
/// Create a new [MinimumTextContrastEvaluation].
const MinimumTextContrastEvaluation();
/// 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<EvaluationResult> _evaluate(WidgetsBinding binding) async {
final violations = <Violation>[];
for (final RenderView renderView in binding.renderViews) {
final layer = renderView.debugLayer! as OffsetLayer;
final SemanticsNode root = renderView.owner!.semanticsOwner!.rootSemanticsNode!;
late ui.Image image;
// 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? byteData = await image.toByteData();
violations.addAll(await _evaluateNode(root, image, byteData!, renderView));
image.dispose();
}
return EvaluationResult(violations);
}
Future<List<Violation>> _evaluateNode(
SemanticsNode node,
ui.Image image,
ByteData byteData,
RenderView renderView,
) async {
final violations = <Violation>[];
// Skip disabled nodes, as they are not required to pass contrast checks.
final isDisabled = node.flagsCollection.isEnabled == ui.Tristate.isFalse;
if (node.isInvisible ||
node.isMergedIntoParent ||
node.flagsCollection.isHidden ||
isDisabled) {
return violations;
}
final SemanticsData data = node.getSemanticsData();
final children = <SemanticsNode>[];
node.visitChildren((SemanticsNode child) {
children.add(child);
return true;
});
for (final child in children) {
violations.addAll(await _evaluateNode(child, image, byteData, renderView));
}
if (_shouldSkipNode(data)) {
return violations;
}
final String text = data.label.isEmpty ? data.value : data.label;
final Iterable<Element> elements = _collectElementsByText(
WidgetsBinding.instance.rootElement!,
text,
);
for (final element in elements) {
violations.addAll(await _evaluateElement(node, element, image, byteData, renderView));
}
return violations;
}
Future<List<Violation>> _evaluateElement(
SemanticsNode node,
Element element,
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 <Violation>[];
}
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 <Violation>[];
}
final Map<Color, int> colorHistogram = _colorsWithinRect(
byteData,
paintBoundsWithOffset,
image.width,
image.height,
);
// Node was too far off screen.
if (colorHistogram.isEmpty) {
return <Violation>[];
}
final report = _ContrastReport(colorHistogram);
final double contrastRatio = report.contrastRatio();
final double targetContrastRatio = _targetContrastRatio(fontSize, bold: isBold);
if (contrastRatio - targetContrastRatio >= _tolerance) {
return <Violation>[];
}
return <Violation>[
Violation(
node,
'$node:\n'
'Expected contrast ratio of at least $targetContrastRatio '
'but found ${contrastRatio.toStringAsFixed(2)} '
'for a font size of $fontSize.\n'
'The computed colors were:\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 off 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;
}
}
/// {@macro flutter.widgets.accessibility_evaluations.internal}
///
/// An evaluation 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 evaluation 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
@internal
class MinimumTextContrastEvaluationAAA extends MinimumTextContrastEvaluation {
/// Create a new [MinimumTextContrastEvaluationAAA].
const MinimumTextContrastEvaluationAAA();
/// 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 ?? MinimumTextContrastEvaluation._kDefaultFontSize;
if ((bold && fontSizeOrDefault >= MinimumTextContrastEvaluation.kBoldTextMinimumSize) ||
fontSizeOrDefault >= MinimumTextContrastEvaluation.kLargeTextMinimumSize) {
return kAAAMinimumRatioLargeText;
}
return kAAAMinimumRatioNormalText;
}
}
/// A class that reports the contrast ratio of a part of the screen.
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 colors, 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 colors.
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 a 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] are [width] and [height].
/// 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);
});
}
Iterable<Element> _collectElementsByText(Element root, String text) {
final result = <Element>[];
root.visitChildren((Element child) {
if (child.widget is Text && (child.widget as Text).data == text) {
result.add(child);
}
result.addAll(_collectElementsByText(child, text));
});
return result;
}