semantics_debugger.dart 11.1 KB
Newer Older
Hixie's avatar
Hixie committed
1 2 3 4 5
// Copyright 2015 The Chromium Authors. All rights reserved.
// 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;
7
import 'dart:ui' as ui show window;
Hixie's avatar
Hixie committed
8

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

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

18
/// A widget that visualizes the semantics for the child.
19 20 21
///
/// This widget is useful for understand how an app presents itself to
/// accessibility technology.
22
class SemanticsDebugger extends StatefulWidget {
23 24 25
  /// Creates a widget that visualizes the semantics for the child.
  ///
  /// The [child] argument must not be null.
Hixie's avatar
Hixie committed
26
  const SemanticsDebugger({ Key key, this.child }) : super(key: key);
27

28
  /// The widget below this widget in the tree.
29 30
  ///
  /// {@macro flutter.widgets.child}
Hixie's avatar
Hixie committed
31
  final Widget child;
32

33
  @override
34
  _SemanticsDebuggerState createState() => _SemanticsDebuggerState();
Hixie's avatar
Hixie committed
35 36
}

37
class _SemanticsDebuggerState extends State<SemanticsDebugger> with WidgetsBindingObserver {
38 39
  _SemanticsClient _client;

40
  @override
Hixie's avatar
Hixie committed
41 42
  void initState() {
    super.initState();
43 44 45 46
    // 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.
47
    _client = _SemanticsClient(WidgetsBinding.instance.pipelineOwner)
48
      ..addListener(_update);
49
    WidgetsBinding.instance.addObserver(this);
Hixie's avatar
Hixie committed
50
  }
51 52

  @override
Hixie's avatar
Hixie committed
53
  void dispose() {
54 55 56
    _client
      ..removeListener(_update)
      ..dispose();
57
    WidgetsBinding.instance.removeObserver(this);
Hixie's avatar
Hixie committed
58 59
    super.dispose();
  }
60

61 62 63 64 65 66 67
  @override
  void didChangeMetrics() {
    setState(() {
      // The root transform may have changed, we have to repaint.
    });
  }

Hixie's avatar
Hixie committed
68
  void _update() {
69
    SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) {
70 71 72 73 74 75 76
      // 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
      // this, we call setState() in a post-frame callback. THIS PATTERN SHOULD
      // NOT BE COPIED. Calling setState() in a post-frame callback is a bad
      // idea as it will not schedule a frame and your app may be lagging behind
      // by one frame. We manually call scheduleFrame() to force a frame and
      // ensure that the semantic information are always painted on the screen.
77 78 79 80 81 82 83
      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.
        });
84
        SchedulerBinding.instance.scheduleFrame();
85
      }
Hixie's avatar
Hixie committed
86 87
    });
  }
88

89
  Offset _lastPointerDownLocation;
Hixie's avatar
Hixie committed
90 91
  void _handlePointerDown(PointerDownEvent event) {
    setState(() {
92
      _lastPointerDownLocation = event.position * ui.window.devicePixelRatio;
Hixie's avatar
Hixie committed
93
    });
94 95
    // 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
96
  }
97

Hixie's avatar
Hixie committed
98 99
  void _handleTap() {
    assert(_lastPointerDownLocation != null);
100
    _performAction(_lastPointerDownLocation, SemanticsAction.tap);
Hixie's avatar
Hixie committed
101 102 103 104
    setState(() {
      _lastPointerDownLocation = null;
    });
  }
105

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

114
  void _handlePanEnd(DragEndDetails details) {
115 116
    final double vx = details.velocity.pixelsPerSecond.dx;
    final double vy = details.velocity.pixelsPerSecond.dy;
117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132
    if (vx.abs() == vy.abs())
      return;
    if (vx.abs() > vy.abs()) {
      if (vx.sign < 0) {
        _performAction(_lastPointerDownLocation, SemanticsAction.decrease);
        _performAction(_lastPointerDownLocation, SemanticsAction.scrollLeft);
      } else {
        _performAction(_lastPointerDownLocation, SemanticsAction.increase);
        _performAction(_lastPointerDownLocation, SemanticsAction.scrollRight);
      }
    } else {
      if (vy.sign < 0)
        _performAction(_lastPointerDownLocation, SemanticsAction.scrollUp);
      else
        _performAction(_lastPointerDownLocation, SemanticsAction.scrollDown);
    }
Hixie's avatar
Hixie committed
133 134 135 136
    setState(() {
      _lastPointerDownLocation = null;
    });
  }
137

138
  void _performAction(Offset position, SemanticsAction action) {
139 140 141 142 143 144 145
    _pipelineOwner.semanticsOwner?.performActionAt(position, action);
  }

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

146
  @override
Hixie's avatar
Hixie committed
147
  Widget build(BuildContext context) {
148 149
    return CustomPaint(
      foregroundPainter: _SemanticsDebuggerPainter(
150 151
        _pipelineOwner,
        _client.generation,
152 153
        _lastPointerDownLocation, // in physical pixels
        ui.window.devicePixelRatio,
154
      ),
155
      child: GestureDetector(
Hixie's avatar
Hixie committed
156 157 158 159 160
        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...
161
        child: Listener(
Hixie's avatar
Hixie committed
162 163
          onPointerDown: _handlePointerDown,
          behavior: HitTestBehavior.opaque,
164
          child: IgnorePointer(
Hixie's avatar
Hixie committed
165
            ignoringSemantics: false,
166 167 168 169
            child: widget.child,
          ),
        ),
      ),
Hixie's avatar
Hixie committed
170 171 172 173
    );
  }
}

174 175
class _SemanticsClient extends ChangeNotifier {
  _SemanticsClient(PipelineOwner pipelineOwner) {
176 177 178
    _semanticsHandle = pipelineOwner.ensureSemantics(
      listener: _didUpdateSemantics
    );
Hixie's avatar
Hixie committed
179 180
  }

181
  SemanticsHandle _semanticsHandle;
182 183 184

  @override
  void dispose() {
185 186
    _semanticsHandle.dispose();
    _semanticsHandle = null;
187 188
    super.dispose();
  }
Hixie's avatar
Hixie committed
189 190 191

  int generation = 0;

192
  void _didUpdateSemantics() {
Hixie's avatar
Hixie committed
193
    generation += 1;
194
    notifyListeners();
Hixie's avatar
Hixie committed
195
  }
196 197 198
}

String _getMessage(SemanticsNode node) {
199 200
  final SemanticsData data = node.getSemanticsData();
  final List<String> annotations = <String>[];
Hixie's avatar
Hixie committed
201

202
  bool wantsTap = false;
203 204
  if (data.hasFlag(SemanticsFlag.hasCheckedState)) {
    annotations.add(data.hasFlag(SemanticsFlag.isChecked) ? 'checked' : 'unchecked');
205
    wantsTap = true;
Hixie's avatar
Hixie committed
206 207
  }

208 209 210 211 212 213
  if (data.hasAction(SemanticsAction.tap)) {
    if (!wantsTap)
      annotations.add('button');
  } else {
    if (wantsTap)
      annotations.add('disabled');
Hixie's avatar
Hixie committed
214
  }
215

216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232
  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');

Ian Hickson's avatar
Ian Hickson committed
233
  assert(data.label != null);
234
  String message;
Ian Hickson's avatar
Ian Hickson committed
235 236
  if (data.label.isEmpty) {
    message = annotations.join('; ');
237
  } else {
Ian Hickson's avatar
Ian Hickson committed
238 239 240 241
    String label;
    if (data.textDirection == null) {
      label = '${Unicode.FSI}${data.label}${Unicode.PDI}';
      annotations.insert(0, 'MISSING TEXT DIRECTION');
Hixie's avatar
Hixie committed
242
    } else {
Ian Hickson's avatar
Ian Hickson committed
243 244 245 246 247 248 249 250 251 252 253 254 255
      switch (data.textDirection) {
        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('; ')})';
Hixie's avatar
Hixie committed
256 257
    }
  }
258 259 260 261

  return message.trim();
}

262 263
const TextStyle _messageStyle = TextStyle(
  color: Color(0xFF000000),
264 265 266 267 268
  fontSize: 10.0,
  height: 0.8
);

void _paintMessage(Canvas canvas, SemanticsNode node) {
269
  final String message = _getMessage(node);
270 271 272 273 274
  if (message.isEmpty)
    return;
  final Rect rect = node.rect;
  canvas.save();
  canvas.clipRect(rect);
275 276
  final TextPainter textPainter = TextPainter()
    ..text = TextSpan(
Ian Hickson's avatar
Ian Hickson committed
277 278 279 280
      style: _messageStyle,
      text: message,
    )
    ..textDirection = TextDirection.ltr // _getMessage always returns LTR text, even if node.label is RTL
281
    ..textAlign = TextAlign.center
282 283
    ..layout(maxWidth: rect.width);

284
  textPainter.paint(canvas, Alignment.center.inscribe(textPainter.size, rect).topLeft);
285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302
  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)
    canvas.transform(node.transform.storage);
303
  final Rect rect = node.rect;
304
  if (!rect.isEmpty) {
305
    final Color lineColor = Color(0xFF000000 + math.Random(node.id).nextInt(0xFFFFFF));
306
    final Rect innerRect = rect.deflate(rank * 1.0);
307
    if (innerRect.isEmpty) {
308
      final Paint fill = Paint()
309 310 311 312
       ..color = lineColor
       ..style = PaintingStyle.fill;
      canvas.drawRect(rect, fill);
    } else {
313
      final Paint fill = Paint()
314 315 316
       ..color = const Color(0xFFFFFFFF)
       ..style = PaintingStyle.fill;
      canvas.drawRect(rect, fill);
317
      final Paint line = Paint()
318 319 320 321 322 323 324 325 326 327 328 329 330 331 332
       ..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
333 334 335
}

class _SemanticsDebuggerPainter extends CustomPainter {
336
  const _SemanticsDebuggerPainter(this.owner, this.generation, this.pointerPosition, this.devicePixelRatio);
337

338
  final PipelineOwner owner;
Hixie's avatar
Hixie committed
339
  final int generation;
340 341
  final Offset pointerPosition; // in physical pixels
  final double devicePixelRatio;
342

343 344 345 346
  SemanticsNode get _rootSemanticsNode {
    return owner.semanticsOwner?.rootSemanticsNode;
  }

347
  @override
Hixie's avatar
Hixie committed
348
  void paint(Canvas canvas, Size size) {
349
    final SemanticsNode rootNode = _rootSemanticsNode;
350 351
    canvas.save();
    canvas.scale(1.0 / devicePixelRatio, 1.0 / devicePixelRatio);
352 353
    if (rootNode != null)
      _paint(canvas, rootNode, _findDepth(rootNode));
Hixie's avatar
Hixie committed
354
    if (pointerPosition != null) {
355
      final Paint paint = Paint();
Hixie's avatar
Hixie committed
356
      paint.color = const Color(0x7F0090FF);
357
      canvas.drawCircle(pointerPosition, 10.0 * devicePixelRatio, paint);
Hixie's avatar
Hixie committed
358
    }
359
    canvas.restore();
Hixie's avatar
Hixie committed
360
  }
361 362

  @override
Hixie's avatar
Hixie committed
363
  bool shouldRepaint(_SemanticsDebuggerPainter oldDelegate) {
364 365
    return owner != oldDelegate.owner
        || generation != oldDelegate.generation
Hixie's avatar
Hixie committed
366 367 368
        || pointerPosition != oldDelegate.pointerPosition;
  }
}