semantics_debugger.dart 11.8 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 28 29 30 31 32 33 34 35 36 37
  ///
  /// [labelStyle] dictates the [TextStyle] used for the semantics labels.
  const SemanticsDebugger({
    Key key,
    @required this.child,
    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.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 53
  _SemanticsClient _client;

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 86 87 88 89 90
      // 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.
91 92 93 94 95 96 97
      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.
        });
98
        SchedulerBinding.instance.scheduleFrame();
99
      }
Hixie's avatar
Hixie committed
100 101
    });
  }
102

103
  Offset _lastPointerDownLocation;
Hixie's avatar
Hixie committed
104 105
  void _handlePointerDown(PointerDownEvent event) {
    setState(() {
106
      _lastPointerDownLocation = event.position * WidgetsBinding.instance.window.devicePixelRatio;
Hixie's avatar
Hixie committed
107
    });
108 109
    // 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
110
  }
111

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

Hixie's avatar
Hixie committed
120 121
  void _handleLongPress() {
    assert(_lastPointerDownLocation != null);
122
    _performAction(_lastPointerDownLocation, SemanticsAction.longPress);
Hixie's avatar
Hixie committed
123 124 125 126
    setState(() {
      _lastPointerDownLocation = null;
    });
  }
127

128
  void _handlePanEnd(DragEndDetails details) {
129 130
    final double vx = details.velocity.pixelsPerSecond.dx;
    final double vy = details.velocity.pixelsPerSecond.dy;
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146
    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
147 148 149 150
    setState(() {
      _lastPointerDownLocation = null;
    });
  }
151

152
  void _performAction(Offset position, SemanticsAction action) {
153 154 155 156 157 158 159
    _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;

160
  @override
Hixie's avatar
Hixie committed
161
  Widget build(BuildContext context) {
162 163
    return CustomPaint(
      foregroundPainter: _SemanticsDebuggerPainter(
164 165
        _pipelineOwner,
        _client.generation,
166
        _lastPointerDownLocation, // in physical pixels
167
        WidgetsBinding.instance.window.devicePixelRatio,
168
        widget.labelStyle,
169
      ),
170
      child: GestureDetector(
Hixie's avatar
Hixie committed
171 172 173 174 175
        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...
176
        child: Listener(
Hixie's avatar
Hixie committed
177 178
          onPointerDown: _handlePointerDown,
          behavior: HitTestBehavior.opaque,
179
          child: IgnorePointer(
Hixie's avatar
Hixie committed
180
            ignoringSemantics: false,
181 182 183 184
            child: widget.child,
          ),
        ),
      ),
Hixie's avatar
Hixie committed
185 186 187 188
    );
  }
}

189 190
class _SemanticsClient extends ChangeNotifier {
  _SemanticsClient(PipelineOwner pipelineOwner) {
191 192 193
    _semanticsHandle = pipelineOwner.ensureSemantics(
      listener: _didUpdateSemantics
    );
Hixie's avatar
Hixie committed
194 195
  }

196
  SemanticsHandle _semanticsHandle;
197 198 199

  @override
  void dispose() {
200 201
    _semanticsHandle.dispose();
    _semanticsHandle = null;
202 203
    super.dispose();
  }
Hixie's avatar
Hixie committed
204 205 206

  int generation = 0;

207
  void _didUpdateSemantics() {
Hixie's avatar
Hixie committed
208
    generation += 1;
209
    notifyListeners();
Hixie's avatar
Hixie committed
210
  }
211 212
}

Hixie's avatar
Hixie committed
213
class _SemanticsDebuggerPainter extends CustomPainter {
214
  const _SemanticsDebuggerPainter(this.owner, this.generation, this.pointerPosition, this.devicePixelRatio, this.labelStyle);
215

216
  final PipelineOwner owner;
Hixie's avatar
Hixie committed
217
  final int generation;
218 219
  final Offset pointerPosition; // in physical pixels
  final double devicePixelRatio;
220
  final TextStyle labelStyle;
221

222 223 224 225
  SemanticsNode get _rootSemanticsNode {
    return owner.semanticsOwner?.rootSemanticsNode;
  }

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

  @override
Hixie's avatar
Hixie committed
242
  bool shouldRepaint(_SemanticsDebuggerPainter oldDelegate) {
243 244
    return owner != oldDelegate.owner
        || generation != oldDelegate.generation
Hixie's avatar
Hixie committed
245 246
        || pointerPosition != oldDelegate.pointerPosition;
  }
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 284 285 286 287 288 289 290 291 292 293 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 321 322 323 324 325

  @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);
    String message;
    if (data.label.isEmpty) {
      message = annotations.join('; ');
    } else {
      String label;
      if (data.textDirection == null) {
        label = '${Unicode.FSI}${data.label}${Unicode.PDI}';
        annotations.insert(0, 'MISSING TEXT DIRECTION');
      } else {
        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('; ')})';
      }
    }

    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(
326
        style: labelStyle,
327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 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 378 379 380 381 382
        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)
      canvas.transform(node.transform.storage);
    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
383
}