可视化水龙头/手势以进行颤动测试(或颤动驱动程序)

时间:2020-07-03 08:35:27

标签: flutter dart flutter-test

使用flutter_driver / flutter_test时,我们通过执行await tap()之类的操作来模拟用户行为。但是,我想查看仿真器屏幕上的点击位置。可能吗?谢谢!

2 个答案:

答案 0 :(得分:9)

我的想法是(因为Flutter驱动程序和窗口小部件测试不使用真实的拍子)是在Flutter级别上记录拍子,即使用Flutter命中测试。

我将为您提供一个小部件,您可以将其包装在其中,以可视化捕获 全部水龙头。我写了a complete widget for this

演示

这是将小部件包装在默认模板演示应用程序周围的结果:

实施

我们想要做的很简单:反应全部点按事件大小的事件(整个应用是我们的孩子)。
但是,这带来了一些挑战:GestureDetector例如在对它们做出反应后,不会允许点击。因此,如果我们要使用TapGestureRecognizer,那么我们要么无法对在应用程序中点击按钮的点击做出反应,要么就无法点击按钮(只能看到我们的指示)

因此,我们需要使用我们的自己的渲染对象来完成这项工作。当您熟悉-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);
  }
}

代码

当然,我浏览了实现的大部分内容,因为您可以阅读它们:)
由于我概述了所使用的概念,因此代码应该简单明了。

您可以找到 the full source code here

用法

用法很简单:

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>[];

您只需在测试中访问列表即可检查已记录的拍子:)

结束

实现这个过程我很开心,希望它能满足您的期望。
该实现只是一个快速的过渡,但是,我希望它可以为您提供将这个思想提升到一个很好的水平所需的所有概念:)

答案 1 :(得分:0)

这是我对@creativecreatorormaybenot 的修改。

在我最近的案例中,我不仅需要显示 tap 事件,还需要显示 一切(包括拖动、滚动等)。所以我修改如下,效果很好:)

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 https://stackoverflow.com/a/65067655
///
/// 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;
}