blob: bfd57691382b90b086146b70a7877967e2d31159 [file]
// 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';
import 'title.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});
/// The minimum allowed size of a tappable node.
final Size size;
/// 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',
),
);
}
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;
}
}
/// Base class for evaluations that verify nodes meet minimum contrast levels.
abstract class _ContrastEvaluation extends AccessibilityEvaluation {
const _ContrastEvaluation();
static const double _kContrastTolerance = -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!;
final double ratio = 1 / renderView.flutterView.devicePixelRatio;
final ui.Image 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>[];
if (_shouldSkipNodeTraversal(node)) {
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 (_shouldSkipNodeEvaluation(data)) {
return violations;
}
return evaluateNodeContent(node, data, image, byteData, renderView);
}
bool _shouldSkipNodeTraversal(SemanticsNode node) {
final isDisabled = node.flagsCollection.isEnabled == ui.Tristate.isFalse;
return node.isInvisible ||
node.isMergedIntoParent ||
node.flagsCollection.isHidden ||
isDisabled;
}
bool _shouldSkipNodeEvaluation(SemanticsData data);
Future<List<Violation>> evaluateNodeContent(
SemanticsNode node,
SemanticsData data,
ui.Image image,
ByteData byteData,
RenderView renderView,
);
/// 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 windowLogicalSize = window.physicalSize / window.devicePixelRatio;
return paintBounds.top < -50.0 ||
paintBounds.left < -50.0 ||
paintBounds.bottom > windowLogicalSize.height + 50.0 ||
paintBounds.right > windowLogicalSize.width + 50.0;
}
}
/// {@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 _ContrastEvaluation {
/// Create a new [MinimumTextContrastEvaluation].
const MinimumTextContrastEvaluation({
required this.minNormalTextContrastRatio,
required this.minLargeTextContrastRatio,
});
/// The minimum contrast ratio for normal text.
///
/// Normal text is text that is smaller than [kLargeTextMinimumSize] (18.0) or
/// smaller than [kBoldTextMinimumSize] (14.0) if bold.
final double minNormalTextContrastRatio;
/// The minimum contrast ratio for large text.
///
/// Large text is text that is at least [kLargeTextMinimumSize] (18.0) or at
/// least [kBoldTextMinimumSize] (14.0) if bold.
final double minLargeTextContrastRatio;
/// 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;
@override
bool _shouldSkipNodeEvaluation(SemanticsData data) =>
data.flagsCollection.scopesRoute || (data.label.trim().isEmpty && data.value.trim().isEmpty);
@override
Future<List<Violation>> evaluateNodeContent(
SemanticsNode node,
SemanticsData data,
ui.Image image,
ByteData byteData,
RenderView renderView,
) async {
final violations = <Violation>[];
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 >= _ContrastEvaluation._kContrastTolerance) {
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 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 minLargeTextContrastRatio;
}
return minNormalTextContrastRatio;
}
}
/// {@macro flutter.widgets.accessibility_evaluations.internal}
///
/// An evaluation which verifies that all nodes that represent non-text controls
/// meet minimum contrast levels of 3.0.
///
/// The evaluations are defined by the Web Content Accessibility Guidelines,
/// https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html
@internal
class MinimumNonTextContrastEvaluation extends _ContrastEvaluation {
/// Create a new [MinimumNonTextContrastEvaluation].
const MinimumNonTextContrastEvaluation();
/// The minimum contrast ratio for non-text controls.
///
/// Defined by http://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html
static const double _kMinimumRatioNonText = 3.0;
@override
bool _shouldSkipNodeEvaluation(SemanticsData data) {
if (data.flagsCollection.scopesRoute) {
return true;
}
final bool isControl =
data.flagsCollection.isButton ||
data.flagsCollection.isSlider ||
data.flagsCollection.isTextField ||
data.flagsCollection.isChecked != ui.CheckedState.none ||
data.flagsCollection.isToggled != ui.Tristate.none ||
data.hasAction(ui.SemanticsAction.tap) ||
data.hasAction(ui.SemanticsAction.longPress);
return !isControl;
}
@override
Future<List<Violation>> evaluateNodeContent(
SemanticsNode node,
SemanticsData data,
ui.Image image,
ByteData byteData,
RenderView renderView,
) async {
final violations = <Violation>[];
Rect nodeBounds = node.rect;
SemanticsNode? current = node;
while (current != null) {
final Matrix4? transform = current.transform;
if (transform != null && current.parent != null) {
nodeBounds = MatrixUtils.transformRect(transform, nodeBounds);
}
current = current.parent;
}
final double devicePixelRatio = renderView.flutterView.devicePixelRatio;
final logicalBounds = Rect.fromLTRB(
nodeBounds.left / devicePixelRatio,
nodeBounds.top / devicePixelRatio,
nodeBounds.right / devicePixelRatio,
nodeBounds.bottom / devicePixelRatio,
);
final Rect inflatedBounds = logicalBounds.inflate(4.0);
if (_isNodeOffScreen(inflatedBounds, renderView.flutterView)) {
return violations;
}
final Map<Color, int> colorHistogram = _colorsWithinRect(
byteData,
inflatedBounds,
image.width,
image.height,
);
if (colorHistogram.length <= 1) {
return violations;
}
final report = _ContrastReport(colorHistogram);
final double contrastRatio = report.contrastRatio();
if (contrastRatio - _kMinimumRatioNonText >= _ContrastEvaluation._kContrastTolerance) {
return violations;
}
violations.add(
Violation(
node,
'$node:\n'
'Expected non-text control contrast ratio of at least ${_kMinimumRatioNonText.toStringAsFixed(1)} '
'but found ${contrastRatio.toStringAsFixed(2)}.\n'
'The computed colors were:\n'
'light - ${report.lightColor}, dark - ${report.darkColor}\n'
'See also: '
'https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html',
),
);
return violations;
}
}
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;
}
final int _scrollingActions =
SemanticsAction.scrollUp.index |
SemanticsAction.scrollDown.index |
SemanticsAction.scrollLeft.index |
SemanticsAction.scrollRight.index |
SemanticsAction.scrollToOffset.index;
/// Whether or not the node is important for accessibility. Should match most cases
/// on the platforms, but certain edge cases will be inconsistent.
///
/// Based on:
///
/// * [flutter/engine/AccessibilityBridge.java#SemanticsNode.isFocusable()](https://github.com/flutter/flutter/blob/main/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java#L2641)
/// * [flutter/engine/SemanticsObject.mm#SemanticsObject.isAccessibilityElement](https://github.com/flutter/flutter/blob/main/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject.mm#L449)
bool _isImportantForAccessibility(SemanticsNode node) {
if (node.isMergedIntoParent) {
// If this node is merged, all its information are present on an ancestor
// node.
return false;
}
final SemanticsData data = node.getSemanticsData();
// If the node scopes a route, it doesn't matter what other flags/actions it
// has, it is _not_ important for accessibility, so we short circuit.
if (data.flagsCollection.scopesRoute) {
return false;
}
final hasNonScrollingAction = data.actions & ~_scrollingActions != 0;
if (hasNonScrollingAction) {
return true;
}
/// Based on Android's FOCUSABLE_FLAGS. See [flutter/engine/AccessibilityBridge.java](https://github.com/flutter/flutter/blob/main/engine/src/flutter/shell/platform/android/io/flutter/view/AccessibilityBridge.java).
final bool hasImportantFlag =
data.flagsCollection.isChecked != ui.CheckedState.none ||
data.flagsCollection.isToggled != ui.Tristate.none ||
data.flagsCollection.isEnabled != ui.Tristate.none ||
data.flagsCollection.isButton ||
data.flagsCollection.isTextField ||
data.flagsCollection.isFocused != ui.Tristate.none ||
data.flagsCollection.isSlider ||
data.flagsCollection.isInMutuallyExclusiveGroup;
if (hasImportantFlag) {
return true;
}
final bool hasContent =
data.label.isNotEmpty ||
data.value.isNotEmpty ||
data.hint.isNotEmpty ||
data.tooltip.isNotEmpty;
if (hasContent) {
return true;
}
return false;
}
/// {@macro flutter.widgets.accessibility_evaluations.internal}
///
/// An evaluation which enforces that all leaf semantics nodes have a label,
/// value, hint, or tooltip.
@internal
class UnlabeledLeafNodeEvaluation extends AccessibilityEvaluation {
const UnlabeledLeafNodeEvaluation();
@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>[];
var hasChildren = false;
node.visitChildren((SemanticsNode child) {
hasChildren = true;
violations.addAll(_traverse(child));
return true;
});
if (node.isInvisible || node.flagsCollection.isHidden) {
return violations;
}
// If not merging descendants and has children, it's not a leaf.
if (hasChildren && !node.mergeAllDescendantsIntoThisNode) {
return violations;
}
if (!_isImportantForAccessibility(node)) {
return violations;
}
final SemanticsData data = node.getSemanticsData();
if (data.label.trim().isEmpty &&
data.value.trim().isEmpty &&
data.hint.trim().isEmpty &&
data.tooltip.trim().isEmpty) {
violations.add(
Violation(
node,
'$node: expected leaf semantics node to have a label, value, hint, or tooltip, '
'but none was found.',
),
);
}
return violations;
}
}
/// {@macro flutter.widgets.accessibility_evaluations.internal}
///
/// An evaluation which enforces that the application has at least one [Title]
/// widget to set the web page title.
@internal
class TitleEvaluation extends AccessibilityEvaluation {
/// Create a new [TitleEvaluation].
const TitleEvaluation();
@override
FutureOr<EvaluationResult> _evaluate(WidgetsBinding binding) {
final violations = <Violation>[];
if (binding.rootElement != null && !_hasTitleWidget(binding.rootElement!)) {
final SemanticsNode rootNode =
binding.renderViews.first.owner!.semanticsOwner!.rootSemanticsNode!;
violations.add(
Violation(rootNode, 'Expected to find at least one Title widget, but none was found.'),
);
}
return EvaluationResult(violations);
}
bool _hasTitleWidget(Element element) {
if (element.widget is Title) {
return true;
}
var found = false;
element.visitChildren((Element child) {
if (!found) {
found = _hasTitleWidget(child);
}
});
return found;
}
}