1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:math' as math;
6
7import 'package:flutter/foundation.dart';
8import 'package:flutter/rendering.dart';
9import 'package:flutter/scheduler.dart';
10
11import 'basic.dart';
12import 'binding.dart';
13import 'framework.dart';
14import 'gesture_detector.dart';
15import 'view.dart';
16
17/// A widget that visualizes the semantics for the child.
18///
19/// This widget is useful for understand how an app presents itself to
20/// accessibility technology.
21class SemanticsDebugger extends StatefulWidget {
22 /// Creates a widget that visualizes the semantics for the child.
23 ///
24 /// [labelStyle] dictates the [TextStyle] used for the semantics labels.
25 const SemanticsDebugger({
26 super.key,
27 required this.child,
28 this.labelStyle = const TextStyle(color: Color(0xFF000000), fontSize: 10.0, height: 0.8),
29 });
30
31 /// The widget below this widget in the tree.
32 ///
33 /// {@macro flutter.widgets.ProxyWidget.child}
34 final Widget child;
35
36 /// The [TextStyle] to use when rendering semantics labels.
37 final TextStyle labelStyle;
38
39 @override
40 State<SemanticsDebugger> createState() => _SemanticsDebuggerState();
41}
42
43class _SemanticsDebuggerState extends State<SemanticsDebugger> with WidgetsBindingObserver {
44 PipelineOwner? _pipelineOwner;
45 SemanticsHandle? _semanticsHandle;
46 int _generation = 0;
47
48 @override
49 void initState() {
50 super.initState();
51 _semanticsHandle = SemanticsBinding.instance.ensureSemantics();
52 WidgetsBinding.instance.addObserver(this);
53 }
54
55 @override
56 void didChangeDependencies() {
57 super.didChangeDependencies();
58 final PipelineOwner newOwner = View.pipelineOwnerOf(context);
59 assert(newOwner.semanticsOwner != null);
60 if (newOwner != _pipelineOwner) {
61 _pipelineOwner?.semanticsOwner?.removeListener(_update);
62 newOwner.semanticsOwner!.addListener(_update);
63 _pipelineOwner = newOwner;
64 }
65 }
66
67 @override
68 void dispose() {
69 _pipelineOwner?.semanticsOwner?.removeListener(_update);
70 _semanticsHandle?.dispose();
71 WidgetsBinding.instance.removeObserver(this);
72 super.dispose();
73 }
74
75 @override
76 void didChangeMetrics() {
77 setState(() {
78 // The root transform may have changed, we have to repaint.
79 });
80 }
81
82 void _update() {
83 _generation++;
84 SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
85 // Semantic information are only available at the end of a frame and our
86 // only chance to paint them on the screen is the next frame. To achieve
87 // this, we call setState() in a post-frame callback.
88 if (mounted) {
89 // If we got disposed this frame, we will still get an update,
90 // because the inactive list is flushed after the semantics updates
91 // are transmitted to the semantics clients.
92 setState(() {
93 // The generation of the _SemanticsDebuggerListener has changed.
94 });
95 }
96 }, debugLabel: 'SemanticsDebugger.update');
97 }
98
99 Offset? _lastPointerDownLocation;
100 void _handlePointerDown(PointerDownEvent event) {
101 setState(() {
102 _lastPointerDownLocation = event.position * View.of(context).devicePixelRatio;
103 });
104 // TODO(ianh): Use a gesture recognizer so that we can reset the
105 // _lastPointerDownLocation when none of the other gesture recognizers win.
106 }
107
108 void _handleTap() {
109 assert(_lastPointerDownLocation != null);
110 _performAction(_lastPointerDownLocation!, SemanticsAction.tap);
111 setState(() {
112 _lastPointerDownLocation = null;
113 });
114 }
115
116 void _handleLongPress() {
117 assert(_lastPointerDownLocation != null);
118 _performAction(_lastPointerDownLocation!, SemanticsAction.longPress);
119 setState(() {
120 _lastPointerDownLocation = null;
121 });
122 }
123
124 void _handlePanEnd(DragEndDetails details) {
125 final double vx = details.velocity.pixelsPerSecond.dx;
126 final double vy = details.velocity.pixelsPerSecond.dy;
127 if (vx.abs() == vy.abs()) {
128 return;
129 }
130 if (vx.abs() > vy.abs()) {
131 if (vx.sign < 0) {
132 _performAction(_lastPointerDownLocation!, SemanticsAction.decrease);
133 _performAction(_lastPointerDownLocation!, SemanticsAction.scrollLeft);
134 } else {
135 _performAction(_lastPointerDownLocation!, SemanticsAction.increase);
136 _performAction(_lastPointerDownLocation!, SemanticsAction.scrollRight);
137 }
138 } else {
139 if (vy.sign < 0) {
140 _performAction(_lastPointerDownLocation!, SemanticsAction.scrollUp);
141 } else {
142 _performAction(_lastPointerDownLocation!, SemanticsAction.scrollDown);
143 }
144 }
145 setState(() {
146 _lastPointerDownLocation = null;
147 });
148 }
149
150 void _performAction(Offset position, SemanticsAction action) {
151 _pipelineOwner?.semanticsOwner?.performActionAt(position, action);
152 }
153
154 @override
155 Widget build(BuildContext context) {
156 return CustomPaint(
157 foregroundPainter: _SemanticsDebuggerPainter(
158 _pipelineOwner!,
159 _generation,
160 _lastPointerDownLocation, // in physical pixels
161 View.of(context).devicePixelRatio,
162 widget.labelStyle,
163 ),
164 child: GestureDetector(
165 behavior: HitTestBehavior.opaque,
166 onTap: _handleTap,
167 onLongPress: _handleLongPress,
168 onPanEnd: _handlePanEnd,
169 excludeFromSemantics:
170 true, // otherwise if you don't hit anything, we end up receiving it, which causes an infinite loop...
171 child: Listener(
172 onPointerDown: _handlePointerDown,
173 behavior: HitTestBehavior.opaque,
174 child: _IgnorePointerWithSemantics(child: widget.child),
175 ),
176 ),
177 );
178 }
179}
180
181class _SemanticsDebuggerPainter extends CustomPainter {
182 const _SemanticsDebuggerPainter(
183 this.owner,
184 this.generation,
185 this.pointerPosition,
186 this.devicePixelRatio,
187 this.labelStyle,
188 );
189
190 final PipelineOwner owner;
191 final int generation;
192 final Offset? pointerPosition; // in physical pixels
193 final double devicePixelRatio;
194 final TextStyle labelStyle;
195
196 SemanticsNode? get _rootSemanticsNode {
197 return owner.semanticsOwner?.rootSemanticsNode;
198 }
199
200 @override
201 void paint(Canvas canvas, Size size) {
202 final SemanticsNode? rootNode = _rootSemanticsNode;
203 canvas.save();
204 canvas.scale(1.0 / devicePixelRatio, 1.0 / devicePixelRatio);
205 if (rootNode != null) {
206 _paint(canvas, rootNode, _findDepth(rootNode), 0, 0);
207 }
208 if (pointerPosition != null) {
209 final Paint paint = Paint();
210 paint.color = const Color(0x7F0090FF);
211 canvas.drawCircle(pointerPosition!, 10.0 * devicePixelRatio, paint);
212 }
213 canvas.restore();
214 }
215
216 @override
217 bool shouldRepaint(_SemanticsDebuggerPainter oldDelegate) {
218 return owner != oldDelegate.owner ||
219 generation != oldDelegate.generation ||
220 pointerPosition != oldDelegate.pointerPosition;
221 }
222
223 @visibleForTesting
224 String getMessage(SemanticsNode node) {
225 final SemanticsData data = node.getSemanticsData();
226 final List<String> annotations = <String>[];
227
228 bool wantsTap = false;
229 if (data.flagsCollection.hasCheckedState) {
230 annotations.add(data.flagsCollection.isChecked ? 'checked' : 'unchecked');
231 wantsTap = true;
232 }
233 if (data.flagsCollection.isTextField) {
234 annotations.add('textfield');
235 wantsTap = true;
236 }
237
238 if (data.hasAction(SemanticsAction.tap)) {
239 if (!wantsTap) {
240 annotations.add('button');
241 }
242 } else {
243 if (wantsTap) {
244 annotations.add('disabled');
245 }
246 }
247
248 if (data.hasAction(SemanticsAction.longPress)) {
249 annotations.add('long-pressable');
250 }
251
252 final bool isScrollable =
253 data.hasAction(SemanticsAction.scrollLeft) ||
254 data.hasAction(SemanticsAction.scrollRight) ||
255 data.hasAction(SemanticsAction.scrollUp) ||
256 data.hasAction(SemanticsAction.scrollDown);
257
258 final bool isAdjustable =
259 data.hasAction(SemanticsAction.increase) || data.hasAction(SemanticsAction.decrease);
260
261 if (isScrollable) {
262 annotations.add('scrollable');
263 }
264
265 if (isAdjustable) {
266 annotations.add('adjustable');
267 }
268
269 final String message;
270 // Android will avoid pronouncing duplicating tooltip and label.
271 // Therefore, having two identical strings is the same as having a single
272 // string.
273 final bool shouldIgnoreDuplicatedLabel =
274 defaultTargetPlatform == TargetPlatform.android &&
275 data.attributedLabel.string == data.tooltip;
276 final String tooltipAndLabel = <String>[
277 if (data.tooltip.isNotEmpty) data.tooltip,
278 if (data.attributedLabel.string.isNotEmpty && !shouldIgnoreDuplicatedLabel)
279 data.attributedLabel.string,
280 ].join('\n');
281 if (tooltipAndLabel.isEmpty) {
282 message = annotations.join('; ');
283 } else {
284 final String effectiveLabel;
285 if (data.textDirection == null) {
286 effectiveLabel = '${Unicode.FSI}$tooltipAndLabel${Unicode.PDI}';
287 annotations.insert(0, 'MISSING TEXT DIRECTION');
288 } else {
289 effectiveLabel = switch (data.textDirection!) {
290 TextDirection.rtl => '${Unicode.RLI}$tooltipAndLabel${Unicode.PDI}',
291 TextDirection.ltr => tooltipAndLabel,
292 };
293 }
294 if (annotations.isEmpty) {
295 message = effectiveLabel;
296 } else {
297 message = '$effectiveLabel (${annotations.join('; ')})';
298 }
299 }
300
301 return message.trim();
302 }
303
304 void _paintMessage(Canvas canvas, SemanticsNode node) {
305 final String message = getMessage(node);
306 if (message.isEmpty) {
307 return;
308 }
309 final Rect rect = node.rect;
310 canvas.save();
311 canvas.clipRect(rect);
312 final TextPainter textPainter = TextPainter()
313 ..text = TextSpan(style: labelStyle, text: message)
314 ..textDirection = TextDirection
315 .ltr // _getMessage always returns LTR text, even if node.label is RTL
316 ..textAlign = TextAlign.center
317 ..layout(maxWidth: rect.width);
318
319 textPainter.paint(canvas, Alignment.center.inscribe(textPainter.size, rect).topLeft);
320 textPainter.dispose();
321 canvas.restore();
322 }
323
324 int _findDepth(SemanticsNode node) {
325 if (!node.hasChildren || node.mergeAllDescendantsIntoThisNode) {
326 return 1;
327 }
328 int childrenDepth = 0;
329 node.visitChildren((SemanticsNode child) {
330 childrenDepth = math.max(childrenDepth, _findDepth(child));
331 return true;
332 });
333 return childrenDepth + 1;
334 }
335
336 void _paint(Canvas canvas, SemanticsNode node, int rank, int indexInParent, int level) {
337 canvas.save();
338 if (node.transform != null) {
339 canvas.transform(node.transform!.storage);
340 }
341 final Rect rect = node.rect;
342 if (!rect.isEmpty) {
343 final Color lineColor = _colorForNode(indexInParent, level);
344 final Rect innerRect = rect.deflate(rank * 1.0);
345 if (innerRect.isEmpty) {
346 final Paint fill = Paint()
347 ..color = lineColor
348 ..style = PaintingStyle.fill;
349 canvas.drawRect(rect, fill);
350 } else {
351 final Paint fill = Paint()
352 ..color = const Color(0xFFFFFFFF)
353 ..style = PaintingStyle.fill;
354 canvas.drawRect(rect, fill);
355 final Paint line = Paint()
356 ..strokeWidth = rank * 2.0
357 ..color = lineColor
358 ..style = PaintingStyle.stroke;
359 canvas.drawRect(innerRect, line);
360 }
361 _paintMessage(canvas, node);
362 }
363 if (!node.mergeAllDescendantsIntoThisNode) {
364 final int childRank = rank - 1;
365 final int childLevel = level + 1;
366 int childIndex = 0;
367 node.visitChildren((SemanticsNode child) {
368 _paint(canvas, child, childRank, childIndex, childLevel);
369 childIndex += 1;
370 return true;
371 });
372 }
373 canvas.restore();
374 }
375
376 static Color _colorForNode(int index, int level) {
377 return HSLColor.fromAHSL(
378 1.0,
379 // Use custom hash to ensure stable value regardless of Dart changes
380 360.0 * math.Random(_getColorSeed(index, level)).nextDouble(),
381 1.0,
382 0.7,
383 ).toColor();
384 }
385
386 static int _getColorSeed(int level, int index) {
387 // Should be no collision as long as children number < 10000.
388 return level * 10000 + index;
389 }
390}
391
392/// A widget ignores pointer event but still keeps semantics actions.
393class _IgnorePointerWithSemantics extends SingleChildRenderObjectWidget {
394 const _IgnorePointerWithSemantics({super.child});
395
396 @override
397 _RenderIgnorePointerWithSemantics createRenderObject(BuildContext context) {
398 return _RenderIgnorePointerWithSemantics();
399 }
400}
401
402class _RenderIgnorePointerWithSemantics extends RenderProxyBox {
403 _RenderIgnorePointerWithSemantics();
404
405 @override
406 bool hitTest(BoxHitTestResult result, {required Offset position}) => false;
407}
408