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