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 | |
5 | import 'dart:math' as math; |
6 | |
7 | import 'package:flutter/foundation.dart'; |
8 | import 'package:flutter/rendering.dart'; |
9 | import 'package:flutter/scheduler.dart'; |
10 | |
11 | import 'basic.dart'; |
12 | import 'binding.dart'; |
13 | import 'framework.dart'; |
14 | import 'gesture_detector.dart'; |
15 | import '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. |
21 | class 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 | |
47 | class _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 | |
183 | class _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 | |
207 | class _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.
|
397 | class _IgnorePointerWithSemantics extends SingleChildRenderObjectWidget {
|
398 | const _IgnorePointerWithSemantics({
|
399 | super.child,
|
400 | });
|
401 |
|
402 | @override
|
403 | _RenderIgnorePointerWithSemantics createRenderObject(BuildContext context) {
|
404 | return _RenderIgnorePointerWithSemantics();
|
405 | }
|
406 | }
|
407 |
|
408 | class _RenderIgnorePointerWithSemantics extends RenderProxyBox {
|
409 | _RenderIgnorePointerWithSemantics();
|
410 |
|
411 | @override
|
412 | bool hitTest(BoxHitTestResult result, { required Offset position }) => false;
|
413 | }
|
414 |
|