semantics_debugger.dart 11.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
// 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;
6
import 'dart:ui' show SemanticsFlag;
Hixie's avatar
Hixie committed
7

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

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

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 29
    Key? key,
    required this.child,
30 31 32 33 34 35 36 37
    this.labelStyle = const TextStyle(
      color: Color(0xFF000000),
      fontSize: 10.0,
      height: 0.8,
    ),
  }) : assert(child != null),
       assert(labelStyle != null),
       super(key: key);
38

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

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

47
  @override
48
  _SemanticsDebuggerState createState() => _SemanticsDebuggerState();
Hixie's avatar
Hixie committed
49 50
}

51
class _SemanticsDebuggerState extends State<SemanticsDebugger> with WidgetsBindingObserver {
52
  late _SemanticsClient _client;
53

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

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

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

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

98
  Offset? _lastPointerDownLocation;
Hixie's avatar
Hixie committed
99 100
  void _handlePointerDown(PointerDownEvent event) {
    setState(() {
101
      _lastPointerDownLocation = event.position * WidgetsBinding.instance!.window.devicePixelRatio;
Hixie's avatar
Hixie committed
102
    });
103 104
    // 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
105
  }
106

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

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

123
  void _handlePanEnd(DragEndDetails details) {
124 125
    final double vx = details.velocity.pixelsPerSecond.dx;
    final double vy = details.velocity.pixelsPerSecond.dy;
126 127 128 129
    if (vx.abs() == vy.abs())
      return;
    if (vx.abs() > vy.abs()) {
      if (vx.sign < 0) {
130 131
        _performAction(_lastPointerDownLocation!, SemanticsAction.decrease);
        _performAction(_lastPointerDownLocation!, SemanticsAction.scrollLeft);
132
      } else {
133 134
        _performAction(_lastPointerDownLocation!, SemanticsAction.increase);
        _performAction(_lastPointerDownLocation!, SemanticsAction.scrollRight);
135 136 137
      }
    } else {
      if (vy.sign < 0)
138
        _performAction(_lastPointerDownLocation!, SemanticsAction.scrollUp);
139
      else
140
        _performAction(_lastPointerDownLocation!, SemanticsAction.scrollDown);
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
        WidgetsBinding.instance!.window.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: IgnorePointer(
Hixie's avatar
Hixie committed
175
            ignoringSemantics: false,
176 177 178 179
            child: widget.child,
          ),
        ),
      ),
Hixie's avatar
Hixie committed
180 181 182 183
    );
  }
}

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

191
  SemanticsHandle? _semanticsHandle;
192 193 194

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

  int generation = 0;

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

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

211
  final PipelineOwner owner;
Hixie's avatar
Hixie committed
212
  final int generation;
213
  final Offset? pointerPosition; // in physical pixels
214
  final double devicePixelRatio;
215
  final TextStyle labelStyle;
216

217
  SemanticsNode? get _rootSemanticsNode {
218 219 220
    return owner.semanticsOwner?.rootSemanticsNode;
  }

221
  @override
Hixie's avatar
Hixie committed
222
  void paint(Canvas canvas, Size size) {
223
    final SemanticsNode? rootNode = _rootSemanticsNode;
224 225
    canvas.save();
    canvas.scale(1.0 / devicePixelRatio, 1.0 / devicePixelRatio);
226 227
    if (rootNode != null)
      _paint(canvas, rootNode, _findDepth(rootNode));
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 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283

  @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)) {
      if (!wantsTap)
        annotations.add('button');
    } else {
      if (wantsTap)
        annotations.add('disabled');
    }

    if (data.hasAction(SemanticsAction.longPress))
      annotations.add('long-pressable');

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

    if (isScrollable)
      annotations.add('scrollable');

    if (isAdjustable)
      annotations.add('adjustable');

    assert(data.label != null);
284
    final String message;
285 286 287
    if (data.label.isEmpty) {
      message = annotations.join('; ');
    } else {
288
      final String label;
289 290 291 292
      if (data.textDirection == null) {
        label = '${Unicode.FSI}${data.label}${Unicode.PDI}';
        annotations.insert(0, 'MISSING TEXT DIRECTION');
      } else {
293
        switch (data.textDirection!) {
294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320
          case TextDirection.rtl:
            label = '${Unicode.RLI}${data.label}${Unicode.PDF}';
            break;
          case TextDirection.ltr:
            label = data.label;
            break;
        }
      }
      if (annotations.isEmpty) {
        message = label;
      } else {
        message = '$label (${annotations.join('; ')})';
      }
    }

    return message.trim();
  }

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

  int _findDepth(SemanticsNode node) {
    if (!node.hasChildren || node.mergeAllDescendantsIntoThisNode)
      return 1;
    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();
    if (node.transform != null)
346
      canvas.transform(node.transform!.storage);
347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377
    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
378
}