可视化 taps/gestures 以进行 flutter-test(或 flutter-driver)
Visualize taps/gestures for flutter-test (or flutter-driver)
当使用 flutter_driver
/ flutter_test
时,我们通过 await tap()
等方式模拟用户行为。但是,我想查看在模拟器屏幕上点击的位置。可能吗?谢谢!
我对此的想法(因为 Flutter Driver 和 widget 测试不使用真正的点击)是记录 Flutter-level 上的点击,即使用 Flutter 命中测试。
我将向您展示一个 widget,您可以将应用包装到 visualize 和 capture 所有 次点击。我写了 a complete widget for this.
示范[=70=]
这是围绕默认模板演示应用包装小部件时的结果:
实施
我们想要做的很简单:反应到所有我们小部件大小的点击事件(整个应用程序是我们的child).
然而,它带来了一点挑战:GestureDetector
例如在对他们做出反应后,不会让点击通过。因此,如果我们要使用 TapGestureRecognizer
,我们要么不会对点击我们应用程序中的按钮做出反应 ,要么 我们将无法点击按钮(只会看到我们的指示)。
因此,我们需要使用我们的自己的渲染器object来完成这项工作。当您熟悉时,这不是一项艰巨的任务 - RenderProxyBox
只是我们需要的抽象:)
捕捉命中事件
通过覆盖 hitTest
,我们可以确保我们始终记录命中:
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!size.contains(position)) return false;
// We always want to add a hit test entry for ourselves as we want to react
// to each and every hit event.
result.add(BoxHitTestEntry(this, position));
return hitTestChildren(result, position: position);
}
现在,我们可以使用 handleEvent
来记录命中事件并将它们可视化:
@override
void handleEvent(PointerEvent event, covariant HitTestEntry entry) {
// We do not want to interfere in the gesture arena, which is why we are not
// using regular tap recognizers. Instead, we handle it ourselves and always
// react to the hit events (ignoring the gesture arena).
if (event is PointerDownEvent) {
// Record the global position.
recordTap(event.position);
// Visualize local position.
visualizeTap(event.localPosition);
}
}
可视化
我会为你省去细节(最后的完整代码):我决定为每个记录的命中创建一个 AnimationController
并将其与本地位置一起存储。
由于我们使用的是 RenderProxyBox
,我们可以在动画控制器触发时调用 markNeedsPaint
,然后为所有记录的点击绘制一个圆圈:
@override
void paint(PaintingContext context, Offset offset) {
context.paintChild(child!, offset);
final canvas = context.canvas;
for (final tap in _recordedTaps) {
drawTap(canvas, tap);
}
}
代码
当然,我浏览了大部分实现,因为您可以通读它们:)
代码应该是 straight-forward 因为我概述了我使用的概念。
你可以找到the full source code here.
用法
用法是straight-forward:
TapRecorder(
child: YourApp(),
)
即使在我的示例实现中,您也可以配置点击圆颜色、大小、持续时间等:
/// These are the parameters for the visualization of the recorded taps.
const _tapRadius = 15.0,
_tapDuration = Duration(milliseconds: 420),
_tapColor = Colors.white,
_shadowColor = Colors.black,
_shadowElevation = 2.0;
如果您愿意,可以将它们设为小部件参数。
测试
希望可视化部分不负众望。
如果你想更进一步,我确保水龙头是全局存储的:
/// List of the taps recorded by [TapRecorder].
///
/// This is only a make-shift solution of course. This will only be viable
/// when using a single [TapRecorder] because it is saved as a top-level
/// variable.
@visibleForTesting
final recordedTaps = <Offset>[];
您可以简单地在测试中访问列表来检查记录的点击:)
结束
我在实现这个过程中获得了很多乐趣,希望它能满足您的期望。
实现只是一个快速的 make-shift,但是,我希望它能为您提供将这个想法提升到良好水平所需的所有概念:)
这是我对@creativecreatorormaybenot 的修改。
在我最近的案例中,我不仅需要显示 tap 事件,还需要显示 everything(包括拖动、滚动等) ).于是我修改如下,效果不错:)
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// These are the parameters for the visualization of the recorded taps.
final _kTapRadius = 15.0, _kTapColor = Colors.grey[300]!, _kShadowColor = Colors.black, _kShadowElevation = 3.0;
const _kRemainAfterPointerUp = Duration(milliseconds: 100);
/// NOTE: refer to this answer for why use hitTest/handleEvent/etc
///
/// Widget that visualizes gestures.
///
/// It does not matter to this widget whether the child accepts the hit events.
/// Everything hitting the rect of the child will be recorded.
class GestureVisualizer extends SingleChildRenderObjectWidget {
const GestureVisualizer({Key? key, required Widget child}) : super(child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderGestureVisualizer();
}
}
class _RenderGestureVisualizer extends RenderProxyBox {
/// key: pointer id, value: the info
final _recordedPointerInfoMap = <int, _RecordedPointerInfo>{};
@override
void detach() {
_recordedPointerInfoMap.clear();
super.detach();
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!size.contains(position)) return false;
// We always want to add a hit test entry for ourselves as we want to react
// to each and every hit event.
result.add(BoxHitTestEntry(this, position));
return hitTestChildren(result, position: position);
}
@override
void handleEvent(PointerEvent event, covariant HitTestEntry entry) {
// We do not want to interfere in the gesture arena, which is why we are not
// using regular tap recognizers. Instead, we handle it ourselves and always
// react to the hit events (ignoring the gesture arena).
// by experiment, sometimes we see PointerHoverEvent with pointer=0 strangely...
if (event.pointer == 0) {
return;
}
if (event is PointerUpEvent || event is PointerCancelEvent) {
Future.delayed(_kRemainAfterPointerUp, () {
_recordedPointerInfoMap.remove(event.pointer);
markNeedsPaint();
});
} else {
_recordedPointerInfoMap[event.pointer] = _RecordedPointerInfo(event.localPosition);
markNeedsPaint();
}
}
@override
void paint(PaintingContext context, Offset offset) {
context.paintChild(child!, offset);
final canvas = context.canvas;
for (final info in _recordedPointerInfoMap.values) {
final path = Path()..addOval(Rect.fromCircle(center: info.localPosition, radius: _kTapRadius));
canvas.drawShadow(path, _kShadowColor, _kShadowElevation, true);
canvas.drawPath(path, Paint()..color = _kTapColor);
}
}
}
class _RecordedPointerInfo {
_RecordedPointerInfo(this.localPosition);
final Offset localPosition;
}
当使用 flutter_driver
/ flutter_test
时,我们通过 await tap()
等方式模拟用户行为。但是,我想查看在模拟器屏幕上点击的位置。可能吗?谢谢!
我对此的想法(因为 Flutter Driver 和 widget 测试不使用真正的点击)是记录 Flutter-level 上的点击,即使用 Flutter 命中测试。
我将向您展示一个 widget,您可以将应用包装到 visualize 和 capture 所有 次点击。我写了 a complete widget for this.
示范[=70=]
这是围绕默认模板演示应用包装小部件时的结果:
实施
我们想要做的很简单:反应到所有我们小部件大小的点击事件(整个应用程序是我们的child).
然而,它带来了一点挑战:GestureDetector
例如在对他们做出反应后,不会让点击通过。因此,如果我们要使用 TapGestureRecognizer
,我们要么不会对点击我们应用程序中的按钮做出反应 ,要么 我们将无法点击按钮(只会看到我们的指示)。
因此,我们需要使用我们的自己的渲染器object来完成这项工作。当您熟悉时,这不是一项艰巨的任务 - RenderProxyBox
只是我们需要的抽象:)
捕捉命中事件
通过覆盖 hitTest
,我们可以确保我们始终记录命中:
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!size.contains(position)) return false;
// We always want to add a hit test entry for ourselves as we want to react
// to each and every hit event.
result.add(BoxHitTestEntry(this, position));
return hitTestChildren(result, position: position);
}
现在,我们可以使用 handleEvent
来记录命中事件并将它们可视化:
@override
void handleEvent(PointerEvent event, covariant HitTestEntry entry) {
// We do not want to interfere in the gesture arena, which is why we are not
// using regular tap recognizers. Instead, we handle it ourselves and always
// react to the hit events (ignoring the gesture arena).
if (event is PointerDownEvent) {
// Record the global position.
recordTap(event.position);
// Visualize local position.
visualizeTap(event.localPosition);
}
}
可视化
我会为你省去细节(最后的完整代码):我决定为每个记录的命中创建一个 AnimationController
并将其与本地位置一起存储。
由于我们使用的是 RenderProxyBox
,我们可以在动画控制器触发时调用 markNeedsPaint
,然后为所有记录的点击绘制一个圆圈:
@override
void paint(PaintingContext context, Offset offset) {
context.paintChild(child!, offset);
final canvas = context.canvas;
for (final tap in _recordedTaps) {
drawTap(canvas, tap);
}
}
代码
当然,我浏览了大部分实现,因为您可以通读它们:)
代码应该是 straight-forward 因为我概述了我使用的概念。
你可以找到the full source code here.
用法
用法是straight-forward:
TapRecorder(
child: YourApp(),
)
即使在我的示例实现中,您也可以配置点击圆颜色、大小、持续时间等:
/// These are the parameters for the visualization of the recorded taps.
const _tapRadius = 15.0,
_tapDuration = Duration(milliseconds: 420),
_tapColor = Colors.white,
_shadowColor = Colors.black,
_shadowElevation = 2.0;
如果您愿意,可以将它们设为小部件参数。
测试
希望可视化部分不负众望。
如果你想更进一步,我确保水龙头是全局存储的:
/// List of the taps recorded by [TapRecorder].
///
/// This is only a make-shift solution of course. This will only be viable
/// when using a single [TapRecorder] because it is saved as a top-level
/// variable.
@visibleForTesting
final recordedTaps = <Offset>[];
您可以简单地在测试中访问列表来检查记录的点击:)
结束
我在实现这个过程中获得了很多乐趣,希望它能满足您的期望。
实现只是一个快速的 make-shift,但是,我希望它能为您提供将这个想法提升到良好水平所需的所有概念:)
这是我对@creativecreatorormaybenot 的修改。
在我最近的案例中,我不仅需要显示 tap 事件,还需要显示 everything(包括拖动、滚动等) ).于是我修改如下,效果不错:)
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
/// These are the parameters for the visualization of the recorded taps.
final _kTapRadius = 15.0, _kTapColor = Colors.grey[300]!, _kShadowColor = Colors.black, _kShadowElevation = 3.0;
const _kRemainAfterPointerUp = Duration(milliseconds: 100);
/// NOTE: refer to this answer for why use hitTest/handleEvent/etc
///
/// Widget that visualizes gestures.
///
/// It does not matter to this widget whether the child accepts the hit events.
/// Everything hitting the rect of the child will be recorded.
class GestureVisualizer extends SingleChildRenderObjectWidget {
const GestureVisualizer({Key? key, required Widget child}) : super(child: child);
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderGestureVisualizer();
}
}
class _RenderGestureVisualizer extends RenderProxyBox {
/// key: pointer id, value: the info
final _recordedPointerInfoMap = <int, _RecordedPointerInfo>{};
@override
void detach() {
_recordedPointerInfoMap.clear();
super.detach();
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
if (!size.contains(position)) return false;
// We always want to add a hit test entry for ourselves as we want to react
// to each and every hit event.
result.add(BoxHitTestEntry(this, position));
return hitTestChildren(result, position: position);
}
@override
void handleEvent(PointerEvent event, covariant HitTestEntry entry) {
// We do not want to interfere in the gesture arena, which is why we are not
// using regular tap recognizers. Instead, we handle it ourselves and always
// react to the hit events (ignoring the gesture arena).
// by experiment, sometimes we see PointerHoverEvent with pointer=0 strangely...
if (event.pointer == 0) {
return;
}
if (event is PointerUpEvent || event is PointerCancelEvent) {
Future.delayed(_kRemainAfterPointerUp, () {
_recordedPointerInfoMap.remove(event.pointer);
markNeedsPaint();
});
} else {
_recordedPointerInfoMap[event.pointer] = _RecordedPointerInfo(event.localPosition);
markNeedsPaint();
}
}
@override
void paint(PaintingContext context, Offset offset) {
context.paintChild(child!, offset);
final canvas = context.canvas;
for (final info in _recordedPointerInfoMap.values) {
final path = Path()..addOval(Rect.fromCircle(center: info.localPosition, radius: _kTapRadius));
canvas.drawShadow(path, _kShadowColor, _kShadowElevation, true);
canvas.drawPath(path, Paint()..color = _kTapColor);
}
}
}
class _RecordedPointerInfo {
_RecordedPointerInfo(this.localPosition);
final Offset localPosition;
}