| 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(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 | |
| 43 | class _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 | |
| 181 | class _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.
|
| 393 | class _IgnorePointerWithSemantics extends SingleChildRenderObjectWidget {
|
| 394 | const _IgnorePointerWithSemantics({super.child});
|
| 395 |
|
| 396 | @override
|
| 397 | _RenderIgnorePointerWithSemantics createRenderObject(BuildContext context) {
|
| 398 | return _RenderIgnorePointerWithSemantics();
|
| 399 | }
|
| 400 | }
|
| 401 |
|
| 402 | class _RenderIgnorePointerWithSemantics extends RenderProxyBox {
|
| 403 | _RenderIgnorePointerWithSemantics();
|
| 404 |
|
| 405 | @override
|
| 406 | bool hitTest(BoxHitTestResult result, {required Offset position}) => false;
|
| 407 | }
|
| 408 |
|