semantics_debugger.dart 12.5 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
Hixie's avatar
Hixie committed
2 3 4 5 6
// 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;

7
import 'package:flutter/foundation.dart';
Hixie's avatar
Hixie committed
8
import 'package:flutter/rendering.dart';
9
import 'package:flutter/scheduler.dart';
Hixie's avatar
Hixie committed
10 11

import 'basic.dart';
12
import 'binding.dart';
Hixie's avatar
Hixie committed
13 14
import 'framework.dart';
import 'gesture_detector.dart';
15
import 'view.dart';
Hixie's avatar
Hixie committed
16

17
/// A widget that visualizes the semantics for the child.
18 19 20
///
/// This widget is useful for understand how an app presents itself to
/// accessibility technology.
21
class SemanticsDebugger extends StatefulWidget {
22 23 24
  /// Creates a widget that visualizes the semantics for the child.
  ///
  /// The [child] argument must not be null.
25 26 27
  ///
  /// [labelStyle] dictates the [TextStyle] used for the semantics labels.
  const SemanticsDebugger({
28
    super.key,
29
    required this.child,
30 31 32 33 34
    this.labelStyle = const TextStyle(
      color: Color(0xFF000000),
      fontSize: 10.0,
      height: 0.8,
    ),
35
  });
36

37
  /// The widget below this widget in the tree.
38
  ///
39
  /// {@macro flutter.widgets.ProxyWidget.child}
Hixie's avatar
Hixie committed
40
  final Widget child;
41

42 43 44
  /// The [TextStyle] to use when rendering semantics labels.
  final TextStyle labelStyle;

45
  @override
46
  State<SemanticsDebugger> createState() => _SemanticsDebuggerState();
Hixie's avatar
Hixie committed
47 48
}

49
class _SemanticsDebuggerState extends State<SemanticsDebugger> with WidgetsBindingObserver {
50
  late _SemanticsClient _client;
51

52
  @override
Hixie's avatar
Hixie committed
53 54
  void initState() {
    super.initState();
55 56 57 58
    // 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.
59
    _client = _SemanticsClient(WidgetsBinding.instance.pipelineOwner)
60
      ..addListener(_update);
61
    WidgetsBinding.instance.addObserver(this);
Hixie's avatar
Hixie committed
62
  }
63 64

  @override
Hixie's avatar
Hixie committed
65
  void dispose() {
66 67 68
    _client
      ..removeListener(_update)
      ..dispose();
69
    WidgetsBinding.instance.removeObserver(this);
Hixie's avatar
Hixie committed
70 71
    super.dispose();
  }
72

73 74 75 76 77 78 79
  @override
  void didChangeMetrics() {
    setState(() {
      // The root transform may have changed, we have to repaint.
    });
  }

Hixie's avatar
Hixie committed
80
  void _update() {
81
    SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
82 83
      // 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
84
      // this, we call setState() in a post-frame callback.
85 86 87 88 89 90 91 92
      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.
        });
      }
Hixie's avatar
Hixie committed
93 94
    });
  }
95

96
  Offset? _lastPointerDownLocation;
Hixie's avatar
Hixie committed
97 98
  void _handlePointerDown(PointerDownEvent event) {
    setState(() {
99
      _lastPointerDownLocation = event.position * View.of(context).devicePixelRatio;
Hixie's avatar
Hixie committed
100
    });
101 102
    // TODO(ianh): Use a gesture recognizer so that we can reset the
    // _lastPointerDownLocation when none of the other gesture recognizers win.
Hixie's avatar
Hixie committed
103
  }
104

Hixie's avatar
Hixie committed
105 106
  void _handleTap() {
    assert(_lastPointerDownLocation != null);
107
    _performAction(_lastPointerDownLocation!, SemanticsAction.tap);
Hixie's avatar
Hixie committed
108 109 110 111
    setState(() {
      _lastPointerDownLocation = null;
    });
  }
112

Hixie's avatar
Hixie committed
113 114
  void _handleLongPress() {
    assert(_lastPointerDownLocation != null);
115
    _performAction(_lastPointerDownLocation!, SemanticsAction.longPress);
Hixie's avatar
Hixie committed
116 117 118 119
    setState(() {
      _lastPointerDownLocation = null;
    });
  }
120

121
  void _handlePanEnd(DragEndDetails details) {
122 123
    final double vx = details.velocity.pixelsPerSecond.dx;
    final double vy = details.velocity.pixelsPerSecond.dy;
124
    if (vx.abs() == vy.abs()) {
125
      return;
126
    }
127 128
    if (vx.abs() > vy.abs()) {
      if (vx.sign < 0) {
129 130
        _performAction(_lastPointerDownLocation!, SemanticsAction.decrease);
        _performAction(_lastPointerDownLocation!, SemanticsAction.scrollLeft);
131
      } else {
132 133
        _performAction(_lastPointerDownLocation!, SemanticsAction.increase);
        _performAction(_lastPointerDownLocation!, SemanticsAction.scrollRight);
134 135
      }
    } else {
136
      if (vy.sign < 0) {
137
        _performAction(_lastPointerDownLocation!, SemanticsAction.scrollUp);
138
      } else {
139
        _performAction(_lastPointerDownLocation!, SemanticsAction.scrollDown);
140
      }
141
    }
Hixie's avatar
Hixie committed
142 143 144 145
    setState(() {
      _lastPointerDownLocation = null;
    });
  }
146

147
  void _performAction(Offset position, SemanticsAction action) {
148 149 150 151 152
    _pipelineOwner.semanticsOwner?.performActionAt(position, action);
  }

  // TODO(abarth): This shouldn't be a static. We should get the pipeline owner
  // from [context] somehow.
153
  PipelineOwner get _pipelineOwner => WidgetsBinding.instance.pipelineOwner;
154

155
  @override
Hixie's avatar
Hixie committed
156
  Widget build(BuildContext context) {
157 158
    return CustomPaint(
      foregroundPainter: _SemanticsDebuggerPainter(
159 160
        _pipelineOwner,
        _client.generation,
161
        _lastPointerDownLocation, // in physical pixels
162
        View.of(context).devicePixelRatio,
163
        widget.labelStyle,
164
      ),
165
      child: GestureDetector(
Hixie's avatar
Hixie committed
166 167 168 169 170
        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...
171
        child: Listener(
Hixie's avatar
Hixie committed
172 173
          onPointerDown: _handlePointerDown,
          behavior: HitTestBehavior.opaque,
174
          child: _IgnorePointerWithSemantics(
175 176 177 178
            child: widget.child,
          ),
        ),
      ),
Hixie's avatar
Hixie committed
179 180 181 182
    );
  }
}

183 184
class _SemanticsClient extends ChangeNotifier {
  _SemanticsClient(PipelineOwner pipelineOwner) {
185
    _semanticsHandle = pipelineOwner.ensureSemantics(
186
      listener: _didUpdateSemantics,
187
    );
Hixie's avatar
Hixie committed
188 189
  }

190
  SemanticsHandle? _semanticsHandle;
191 192 193

  @override
  void dispose() {
194
    _semanticsHandle!.dispose();
195
    _semanticsHandle = null;
196 197
    super.dispose();
  }
Hixie's avatar
Hixie committed
198 199 200

  int generation = 0;

201
  void _didUpdateSemantics() {
Hixie's avatar
Hixie committed
202
    generation += 1;
203
    notifyListeners();
Hixie's avatar
Hixie committed
204
  }
205 206
}

Hixie's avatar
Hixie committed
207
class _SemanticsDebuggerPainter extends CustomPainter {
208
  const _SemanticsDebuggerPainter(this.owner, this.generation, this.pointerPosition, this.devicePixelRatio, this.labelStyle);
209

210
  final PipelineOwner owner;
Hixie's avatar
Hixie committed
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 218 219
    return owner.semanticsOwner?.rootSemanticsNode;
  }

220
  @override
Hixie's avatar
Hixie committed
221
  void paint(Canvas canvas, Size size) {
222
    final SemanticsNode? rootNode = _rootSemanticsNode;
223 224
    canvas.save();
    canvas.scale(1.0 / devicePixelRatio, 1.0 / devicePixelRatio);
225
    if (rootNode != null) {
226
      _paint(canvas, rootNode, _findDepth(rootNode));
227
    }
Hixie's avatar
Hixie committed
228
    if (pointerPosition != null) {
229
      final Paint paint = Paint();
Hixie's avatar
Hixie committed
230
      paint.color = const Color(0x7F0090FF);
231
      canvas.drawCircle(pointerPosition!, 10.0 * devicePixelRatio, paint);
Hixie's avatar
Hixie committed
232
    }
233
    canvas.restore();
Hixie's avatar
Hixie committed
234
  }
235 236

  @override
Hixie's avatar
Hixie committed
237
  bool shouldRepaint(_SemanticsDebuggerPainter oldDelegate) {
238 239
    return owner != oldDelegate.owner
        || generation != oldDelegate.generation
Hixie's avatar
Hixie committed
240 241
        || pointerPosition != oldDelegate.pointerPosition;
  }
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258

  @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)) {
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 273 274 275 276 277 278 279

    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);

280
    if (isScrollable) {
281
      annotations.add('scrollable');
282
    }
283

284
    if (isAdjustable) {
285
      annotations.add('adjustable');
286
    }
287

288
    final String message;
289 290 291 292
    // Android will avoid pronouncing duplicating tooltip and label.
    // Therefore, having two identical strings is the same as having a single
    // string.
    final bool shouldIgnoreDuplicatedLabel = defaultTargetPlatform == TargetPlatform.android && data.attributedLabel.string == data.tooltip;
293 294 295
    final String tooltipAndLabel = <String>[
      if (data.tooltip.isNotEmpty)
        data.tooltip,
296
      if (data.attributedLabel.string.isNotEmpty && !shouldIgnoreDuplicatedLabel)
297 298 299
        data.attributedLabel.string,
    ].join('\n');
    if (tooltipAndLabel.isEmpty) {
300 301
      message = annotations.join('; ');
    } else {
302
      final String effectivelabel;
303
      if (data.textDirection == null) {
304
        effectivelabel = '${Unicode.FSI}$tooltipAndLabel${Unicode.PDI}';
305 306
        annotations.insert(0, 'MISSING TEXT DIRECTION');
      } 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 322 323 324 325
      }
    }

    return message.trim();
  }

  void _paintMessage(Canvas canvas, SemanticsNode node) {
    final String message = getMessage(node);
326
    if (message.isEmpty) {
327
      return;
328
    }
329 330 331 332 333
    final Rect rect = node.rect;
    canvas.save();
    canvas.clipRect(rect);
    final TextPainter textPainter = TextPainter()
      ..text = TextSpan(
334
        style: labelStyle,
335 336 337 338 339 340 341
        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);
342
    textPainter.dispose();
343 344 345 346
    canvas.restore();
  }

  int _findDepth(SemanticsNode node) {
347
    if (!node.hasChildren || node.mergeAllDescendantsIntoThisNode) {
348
      return 1;
349
    }
350 351 352 353 354 355 356 357 358 359
    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();
360
    if (node.transform != null) {
361
      canvas.transform(node.transform!.storage);
362
    }
363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393
    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();
  }
Hixie's avatar
Hixie committed
394
}
395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413

/// A widget ignores pointer event but still keeps semantics actions.
class _IgnorePointerWithSemantics extends SingleChildRenderObjectWidget {
  const _IgnorePointerWithSemantics({
    super.child,
  });

  @override
  _RenderIgnorePointerWithSemantics createRenderObject(BuildContext context) {
    return _RenderIgnorePointerWithSemantics();
  }
}

class _RenderIgnorePointerWithSemantics extends RenderProxyBox {
  _RenderIgnorePointerWithSemantics();

  @override
  bool hitTest(BoxHitTestResult result, { required Offset position }) => false;
}