可视化 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,您可以将应用包装到 visualizecapture 所有 次点击。我写了 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;
}