blob: 2d3acbfdc99e286ef0936e8bb129c8e4e3b43585 [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:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'basic.dart';
import 'binding.dart';
import 'framework.dart';
import 'gesture_detector.dart';
/// A widget that visualizes the semantics for the child.
///
/// This widget is useful for understand how an app presents itself to
/// accessibility technology.
class SemanticsDebugger extends StatefulWidget {
/// Creates a widget that visualizes the semantics for the child.
///
/// The [child] argument must not be null.
///
/// [labelStyle] dictates the [TextStyle] used for the semantics labels.
const SemanticsDebugger({
super.key,
required this.child,
this.labelStyle = const TextStyle(
color: Color(0xFF000000),
fontSize: 10.0,
height: 0.8,
),
}) : assert(child != null),
assert(labelStyle != null);
/// The widget below this widget in the tree.
///
/// {@macro flutter.widgets.ProxyWidget.child}
final Widget child;
/// The [TextStyle] to use when rendering semantics labels.
final TextStyle labelStyle;
@override
State<SemanticsDebugger> createState() => _SemanticsDebuggerState();
}
class _SemanticsDebuggerState extends State<SemanticsDebugger> with WidgetsBindingObserver {
late _SemanticsClient _client;
@override
void initState() {
super.initState();
// TODO(abarth): We shouldn't reach out to the WidgetsBinding.instance
// static here because we might not be in a tree that's attached to that
// binding. Instead, we should find a way to get to the PipelineOwner from
// the BuildContext.
_client = _SemanticsClient(WidgetsBinding.instance.pipelineOwner)
..addListener(_update);
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
_client
..removeListener(_update)
..dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeMetrics() {
setState(() {
// The root transform may have changed, we have to repaint.
});
}
void _update() {
SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
// Semantic information are only available at the end of a frame and our
// only chance to paint them on the screen is the next frame. To achieve
// this, we call setState() in a post-frame callback.
if (mounted) {
// If we got disposed this frame, we will still get an update,
// because the inactive list is flushed after the semantics updates
// are transmitted to the semantics clients.
setState(() {
// The generation of the _SemanticsDebuggerListener has changed.
});
}
});
}
Offset? _lastPointerDownLocation;
void _handlePointerDown(PointerDownEvent event) {
setState(() {
_lastPointerDownLocation = event.position * WidgetsBinding.instance.window.devicePixelRatio;
});
// TODO(ianh): Use a gesture recognizer so that we can reset the
// _lastPointerDownLocation when none of the other gesture recognizers win.
}
void _handleTap() {
assert(_lastPointerDownLocation != null);
_performAction(_lastPointerDownLocation!, SemanticsAction.tap);
setState(() {
_lastPointerDownLocation = null;
});
}
void _handleLongPress() {
assert(_lastPointerDownLocation != null);
_performAction(_lastPointerDownLocation!, SemanticsAction.longPress);
setState(() {
_lastPointerDownLocation = null;
});
}
void _handlePanEnd(DragEndDetails details) {
final double vx = details.velocity.pixelsPerSecond.dx;
final double vy = details.velocity.pixelsPerSecond.dy;
if (vx.abs() == vy.abs()) {
return;
}
if (vx.abs() > vy.abs()) {
if (vx.sign < 0) {
_performAction(_lastPointerDownLocation!, SemanticsAction.decrease);
_performAction(_lastPointerDownLocation!, SemanticsAction.scrollLeft);
} else {
_performAction(_lastPointerDownLocation!, SemanticsAction.increase);
_performAction(_lastPointerDownLocation!, SemanticsAction.scrollRight);
}
} else {
if (vy.sign < 0) {
_performAction(_lastPointerDownLocation!, SemanticsAction.scrollUp);
} else {
_performAction(_lastPointerDownLocation!, SemanticsAction.scrollDown);
}
}
setState(() {
_lastPointerDownLocation = null;
});
}
void _performAction(Offset position, SemanticsAction action) {
_pipelineOwner.semanticsOwner?.performActionAt(position, action);
}
// TODO(abarth): This shouldn't be a static. We should get the pipeline owner
// from [context] somehow.
PipelineOwner get _pipelineOwner => WidgetsBinding.instance.pipelineOwner;
@override
Widget build(BuildContext context) {
return CustomPaint(
foregroundPainter: _SemanticsDebuggerPainter(
_pipelineOwner,
_client.generation,
_lastPointerDownLocation, // in physical pixels
WidgetsBinding.instance.window.devicePixelRatio,
widget.labelStyle,
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _handleTap,
onLongPress: _handleLongPress,
onPanEnd: _handlePanEnd,
excludeFromSemantics: true, // otherwise if you don't hit anything, we end up receiving it, which causes an infinite loop...
child: Listener(
onPointerDown: _handlePointerDown,
behavior: HitTestBehavior.opaque,
child: IgnorePointer(
ignoringSemantics: false,
child: widget.child,
),
),
),
);
}
}
class _SemanticsClient extends ChangeNotifier {
_SemanticsClient(PipelineOwner pipelineOwner) {
_semanticsHandle = pipelineOwner.ensureSemantics(
listener: _didUpdateSemantics,
);
}
SemanticsHandle? _semanticsHandle;
@override
void dispose() {
_semanticsHandle!.dispose();
_semanticsHandle = null;
super.dispose();
}
int generation = 0;
void _didUpdateSemantics() {
generation += 1;
notifyListeners();
}
}
class _SemanticsDebuggerPainter extends CustomPainter {
const _SemanticsDebuggerPainter(this.owner, this.generation, this.pointerPosition, this.devicePixelRatio, this.labelStyle);
final PipelineOwner owner;
final int generation;
final Offset? pointerPosition; // in physical pixels
final double devicePixelRatio;
final TextStyle labelStyle;
SemanticsNode? get _rootSemanticsNode {
return owner.semanticsOwner?.rootSemanticsNode;
}
@override
void paint(Canvas canvas, Size size) {
final SemanticsNode? rootNode = _rootSemanticsNode;
canvas.save();
canvas.scale(1.0 / devicePixelRatio, 1.0 / devicePixelRatio);
if (rootNode != null) {
_paint(canvas, rootNode, _findDepth(rootNode));
}
if (pointerPosition != null) {
final Paint paint = Paint();
paint.color = const Color(0x7F0090FF);
canvas.drawCircle(pointerPosition!, 10.0 * devicePixelRatio, paint);
}
canvas.restore();
}
@override
bool shouldRepaint(_SemanticsDebuggerPainter oldDelegate) {
return owner != oldDelegate.owner
|| generation != oldDelegate.generation
|| pointerPosition != oldDelegate.pointerPosition;
}
@visibleForTesting
String getMessage(SemanticsNode node) {
final SemanticsData data = node.getSemanticsData();
final List<String> annotations = <String>[];
bool wantsTap = false;
if (data.hasFlag(SemanticsFlag.hasCheckedState)) {
annotations.add(data.hasFlag(SemanticsFlag.isChecked) ? 'checked' : 'unchecked');
wantsTap = true;
}
if (data.hasFlag(SemanticsFlag.isTextField)) {
annotations.add('textfield');
wantsTap = true;
}
if (data.hasAction(SemanticsAction.tap)) {
if (!wantsTap) {
annotations.add('button');
}
} else {
if (wantsTap) {
annotations.add('disabled');
}
}
if (data.hasAction(SemanticsAction.longPress)) {
annotations.add('long-pressable');
}
final bool isScrollable = data.hasAction(SemanticsAction.scrollLeft)
|| data.hasAction(SemanticsAction.scrollRight)
|| data.hasAction(SemanticsAction.scrollUp)
|| data.hasAction(SemanticsAction.scrollDown);
final bool isAdjustable = data.hasAction(SemanticsAction.increase)
|| data.hasAction(SemanticsAction.decrease);
if (isScrollable) {
annotations.add('scrollable');
}
if (isAdjustable) {
annotations.add('adjustable');
}
assert(data.attributedLabel != null);
final String message;
final String tooltipAndLabel = <String>[
if (data.tooltip.isNotEmpty)
data.tooltip,
if (data.attributedLabel.string.isNotEmpty)
data.attributedLabel.string,
].join('\n');
if (tooltipAndLabel.isEmpty) {
message = annotations.join('; ');
} else {
final String effectivelabel;
if (data.textDirection == null) {
effectivelabel = '${Unicode.FSI}$tooltipAndLabel${Unicode.PDI}';
annotations.insert(0, 'MISSING TEXT DIRECTION');
} else {
switch (data.textDirection!) {
case TextDirection.rtl:
effectivelabel = '${Unicode.RLI}$tooltipAndLabel${Unicode.PDF}';
break;
case TextDirection.ltr:
effectivelabel = tooltipAndLabel;
break;
}
}
if (annotations.isEmpty) {
message = effectivelabel;
} else {
message = '$effectivelabel (${annotations.join('; ')})';
}
}
return message.trim();
}
void _paintMessage(Canvas canvas, SemanticsNode node) {
final String message = getMessage(node);
if (message.isEmpty) {
return;
}
final Rect rect = node.rect;
canvas.save();
canvas.clipRect(rect);
final TextPainter textPainter = TextPainter()
..text = TextSpan(
style: labelStyle,
text: message,
)
..textDirection = TextDirection.ltr // _getMessage always returns LTR text, even if node.label is RTL
..textAlign = TextAlign.center
..layout(maxWidth: rect.width);
textPainter.paint(canvas, Alignment.center.inscribe(textPainter.size, rect).topLeft);
textPainter.dispose();
canvas.restore();
}
int _findDepth(SemanticsNode node) {
if (!node.hasChildren || node.mergeAllDescendantsIntoThisNode) {
return 1;
}
int childrenDepth = 0;
node.visitChildren((SemanticsNode child) {
childrenDepth = math.max(childrenDepth, _findDepth(child));
return true;
});
return childrenDepth + 1;
}
void _paint(Canvas canvas, SemanticsNode node, int rank) {
canvas.save();
if (node.transform != null) {
canvas.transform(node.transform!.storage);
}
final Rect rect = node.rect;
if (!rect.isEmpty) {
final Color lineColor = Color(0xFF000000 + math.Random(node.id).nextInt(0xFFFFFF));
final Rect innerRect = rect.deflate(rank * 1.0);
if (innerRect.isEmpty) {
final Paint fill = Paint()
..color = lineColor
..style = PaintingStyle.fill;
canvas.drawRect(rect, fill);
} else {
final Paint fill = Paint()
..color = const Color(0xFFFFFFFF)
..style = PaintingStyle.fill;
canvas.drawRect(rect, fill);
final Paint line = Paint()
..strokeWidth = rank * 2.0
..color = lineColor
..style = PaintingStyle.stroke;
canvas.drawRect(innerRect, line);
}
_paintMessage(canvas, node);
}
if (!node.mergeAllDescendantsIntoThisNode) {
final int childRank = rank - 1;
node.visitChildren((SemanticsNode child) {
_paint(canvas, child, childRank);
return true;
});
}
canvas.restore();
}
}