mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
379 lines
12 KiB
Dart
379 lines
12 KiB
Dart
// Copyright 2014 The Flutter 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;
|
|
import 'dart:ui' show SemanticsFlag;
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/scheduler.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
|
|
import 'basic.dart';
|
|
import 'binding.dart';
|
|
import 'framework.dart';
|
|
import 'gesture_detector.dart';
|
|
|
|
/// A widget that visualizes the semantics for the child.
|
|
///
|
|
/// This widget is useful for understand how an app presents itself to
|
|
/// accessibility technology.
|
|
class SemanticsDebugger extends StatefulWidget {
|
|
/// Creates a widget that visualizes the semantics for the child.
|
|
///
|
|
/// The [child] argument must not be null.
|
|
///
|
|
/// [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);
|
|
|
|
/// The widget below this widget in the tree.
|
|
///
|
|
/// {@macro flutter.widgets.ProxyWidget.child}
|
|
final Widget child;
|
|
|
|
/// The [TextStyle] to use when rendering semantics labels.
|
|
final TextStyle labelStyle;
|
|
|
|
@override
|
|
_SemanticsDebuggerState createState() => _SemanticsDebuggerState();
|
|
}
|
|
|
|
class _SemanticsDebuggerState extends State<SemanticsDebugger> with WidgetsBindingObserver {
|
|
late _SemanticsClient _client;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// 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.
|
|
_client = _SemanticsClient(WidgetsBinding.instance!.pipelineOwner)
|
|
..addListener(_update);
|
|
WidgetsBinding.instance!.addObserver(this);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_client
|
|
..removeListener(_update)
|
|
..dispose();
|
|
WidgetsBinding.instance!.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeMetrics() {
|
|
setState(() {
|
|
// The root transform may have changed, we have to repaint.
|
|
});
|
|
}
|
|
|
|
void _update() {
|
|
SchedulerBinding.instance!.addPostFrameCallback((Duration timeStamp) {
|
|
// 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.
|
|
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.
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
Offset? _lastPointerDownLocation;
|
|
void _handlePointerDown(PointerDownEvent event) {
|
|
setState(() {
|
|
_lastPointerDownLocation = event.position * WidgetsBinding.instance!.window.devicePixelRatio;
|
|
});
|
|
// TODO(ianh): Use a gesture recognizer so that we can reset the
|
|
// _lastPointerDownLocation when none of the other gesture recognizers win.
|
|
}
|
|
|
|
void _handleTap() {
|
|
assert(_lastPointerDownLocation != null);
|
|
_performAction(_lastPointerDownLocation!, SemanticsAction.tap);
|
|
setState(() {
|
|
_lastPointerDownLocation = null;
|
|
});
|
|
}
|
|
|
|
void _handleLongPress() {
|
|
assert(_lastPointerDownLocation != null);
|
|
_performAction(_lastPointerDownLocation!, SemanticsAction.longPress);
|
|
setState(() {
|
|
_lastPointerDownLocation = null;
|
|
});
|
|
}
|
|
|
|
void _handlePanEnd(DragEndDetails details) {
|
|
final double vx = details.velocity.pixelsPerSecond.dx;
|
|
final double vy = details.velocity.pixelsPerSecond.dy;
|
|
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);
|
|
}
|
|
setState(() {
|
|
_lastPointerDownLocation = null;
|
|
});
|
|
}
|
|
|
|
void _performAction(Offset position, SemanticsAction action) {
|
|
_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;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return CustomPaint(
|
|
foregroundPainter: _SemanticsDebuggerPainter(
|
|
_pipelineOwner,
|
|
_client.generation,
|
|
_lastPointerDownLocation, // in physical pixels
|
|
WidgetsBinding.instance!.window.devicePixelRatio,
|
|
widget.labelStyle,
|
|
),
|
|
child: GestureDetector(
|
|
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...
|
|
child: Listener(
|
|
onPointerDown: _handlePointerDown,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: IgnorePointer(
|
|
ignoringSemantics: false,
|
|
child: widget.child,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SemanticsClient extends ChangeNotifier {
|
|
_SemanticsClient(PipelineOwner pipelineOwner) {
|
|
_semanticsHandle = pipelineOwner.ensureSemantics(
|
|
listener: _didUpdateSemantics
|
|
);
|
|
}
|
|
|
|
SemanticsHandle? _semanticsHandle;
|
|
|
|
@override
|
|
void dispose() {
|
|
_semanticsHandle!.dispose();
|
|
_semanticsHandle = null;
|
|
super.dispose();
|
|
}
|
|
|
|
int generation = 0;
|
|
|
|
void _didUpdateSemantics() {
|
|
generation += 1;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
class _SemanticsDebuggerPainter extends CustomPainter {
|
|
const _SemanticsDebuggerPainter(this.owner, this.generation, this.pointerPosition, this.devicePixelRatio, this.labelStyle);
|
|
|
|
final PipelineOwner owner;
|
|
final int generation;
|
|
final Offset? pointerPosition; // in physical pixels
|
|
final double devicePixelRatio;
|
|
final TextStyle labelStyle;
|
|
|
|
SemanticsNode? get _rootSemanticsNode {
|
|
return owner.semanticsOwner?.rootSemanticsNode;
|
|
}
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final SemanticsNode? rootNode = _rootSemanticsNode;
|
|
canvas.save();
|
|
canvas.scale(1.0 / devicePixelRatio, 1.0 / devicePixelRatio);
|
|
if (rootNode != null)
|
|
_paint(canvas, rootNode, _findDepth(rootNode));
|
|
if (pointerPosition != null) {
|
|
final Paint paint = Paint();
|
|
paint.color = const Color(0x7F0090FF);
|
|
canvas.drawCircle(pointerPosition!, 10.0 * devicePixelRatio, paint);
|
|
}
|
|
canvas.restore();
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_SemanticsDebuggerPainter oldDelegate) {
|
|
return owner != oldDelegate.owner
|
|
|| generation != oldDelegate.generation
|
|
|| pointerPosition != oldDelegate.pointerPosition;
|
|
}
|
|
|
|
@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);
|
|
final String message;
|
|
if (data.label.isEmpty) {
|
|
message = annotations.join('; ');
|
|
} else {
|
|
final 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(
|
|
style: labelStyle,
|
|
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();
|
|
}
|
|
}
|