Flutter

Flutter挑战之增大点击范围

2021-08-30  本文已影响0人  法的空间

theme: cyanosis
highlight: androidstudio


前言

我在 Flutter 重识 NestedScrollView (juejin.cn) 中留下 增大点击范围 的挑战,时间已经过了一个星期,不知道大家思考的怎么样了?今天说了一下对于 增大点击范围 我个人的的思路。

调试源码

首先我们先顺一顺,Flutter 中手势相关事件这些东西是从何而来的。

事件从何而来

    return Listener(
      onPointerDown: (PointerDownEvent value) {
        showToast('$text:onTap${i++}',
            duration: const Duration(milliseconds: 500));
      },
      child: mockButtonUI(text),
    );

我们可以看到整个 call stack 信息,我们反推回去。

  const Listener({
    Key? key,
    this.onPointerDown,
    this.onPointerMove,
    this.onPointerUp,
    this.onPointerHover,
    this.onPointerCancel,
    this.onPointerSignal,
    this.behavior = HitTestBehavior.deferToChild,
    Widget? child,
  }) 
  
  ...省略部分代码
  
  @override
  RenderPointerListener createRenderObject(BuildContext context) {
    return RenderPointerListener(
      onPointerDown: onPointerDown,
      onPointerMove: onPointerMove,
      onPointerUp: onPointerUp,
      onPointerHover: onPointerHover,
      onPointerCancel: onPointerCancel,
      onPointerSignal: onPointerSignal,
      behavior: behavior,
    );
  }
  
class RenderPointerListener extends RenderProxyBoxWithHitTestBehavior {
  /// Creates a render object that forwards pointer events to callbacks.
  ///
  /// The [behavior] argument defaults to [HitTestBehavior.deferToChild].
  RenderPointerListener({
    this.onPointerDown,
    this.onPointerMove,
    this.onPointerUp,
    this.onPointerHover,
    this.onPointerCancel,
    this.onPointerSignal,
    HitTestBehavior behavior = HitTestBehavior.deferToChild,
    RenderBox? child,
  }) : super(behavior: behavior, child: child);
  // 省略一些代码 
  ...
  
  @override
  Size computeSizeForNoChild(BoxConstraints constraints) {
    return constraints.biggest;
  }

  @override
  void handleEvent(PointerEvent event, HitTestEntry entry) {
    assert(debugHandleEvent(event, entry));
    if (event is PointerDownEvent)
      return onPointerDown?.call(event);
    if (event is PointerMoveEvent)
      return onPointerMove?.call(event);
    if (event is PointerUpEvent)
      return onPointerUp?.call(event);
    if (event is PointerHoverEvent)
      return onPointerHover?.call(event);
    if (event is PointerCancelEvent)
      return onPointerCancel?.call(event);
    if (event is PointerSignalEvent)
      return onPointerSignal?.call(event);
  }
}
  void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
    ...省略一部分代码
    for (final HitTestEntry entry in hitTestResult.path) {
      try {
        entry.target.handleEvent(event.transformed(entry.transform), entry);
      } 

  @override // from GestureBinding
  void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
    if (hitTestResult != null ||
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      assert(event.position != null);
      _mouseTracker!.updateWithEvent(event, () => hitTestResult ?? renderView.hitTestMouseTrackers(event.position));
    }
    super.dispatchEvent(event, hitTestResult);
  }

graph TD
GestureBinding._handlePointerEventImmediately --> GestureBinding.handlePointerEvent --> GestureBinding._flushPointerEventQueue --> GestureBinding._handlePointerDataPacket
mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
  @override
  void initInstances() {
    super.initInstances();
    _instance = this;
    window.onPointerDataPacket = _handlePointerDataPacket;
  }


  // Called from the engine, via hooks.dart
  void _dispatchPointerDataPacket(ByteData packet) {
    if (onPointerDataPacket != null) {
      _invoke1<PointerDataPacket>(
        onPointerDataPacket,
        _onPointerDataPacketZone,
        _unpackPointerDataPacket(packet),
      );
    }
  }
/// 从原生传递过来的原始指针的一些信息
/// A sequence of reports about the state of pointers.
class PointerDataPacket {
  /// Creates a packet of pointer data reports.
  const PointerDataPacket({ this.data = const <PointerData>[] }) : assert(data != null);

  /// Data about the individual pointers in this packet.
  ///
  /// This list might contain multiple pieces of data about the same pointer.
  final List<PointerData> data;
}

/// 原始指针包含的一些信息
/// Information about the state of a pointer.
class PointerData {
  /// Creates an object that represents the state of a pointer.
  const PointerData({
    this.embedderId = 0,
    this.timeStamp = Duration.zero,
    this.change = PointerChange.cancel,
    this.kind = PointerDeviceKind.touch,
    this.signalKind,
    this.device = 0,
    this.pointerIdentifier = 0,
    this.physicalX = 0.0,
    this.physicalY = 0.0,
    this.physicalDeltaX = 0.0,
    this.physicalDeltaY = 0.0,
    this.buttons = 0,
    this.obscured = false,
    this.synthesized = false,
    this.pressure = 0.0,
    this.pressureMin = 0.0,
    this.pressureMax = 0.0,
    this.distance = 0.0,
    this.distanceMax = 0.0,
    this.size = 0.0,
    this.radiusMajor = 0.0,
    this.radiusMinor = 0.0,
    this.radiusMin = 0.0,
    this.radiusMax = 0.0,
    this.orientation = 0.0,
    this.tilt = 0.0,
    this.platformData = 0,
    this.scrollDeltaX = 0.0,
    this.scrollDeltaY = 0.0,
  });

https://github.com/flutter/flutter/blob/stable/bin/cache/pkg/sky_engine/lib/ui/hooks.dart

@pragma('vm:entry-point')
// ignore: unused_element
void _dispatchPointerDataPacket(ByteData packet) {
  PlatformDispatcher.instance._dispatchPointerDataPacket(packet);
}

HitTest

从上面的流程,我们能知道点击事件是从哪里来的,那么 Flutter 又是怎么知道我是点击的哪个位置呢?还记得我在前面有留提示,GestureBinding.dispatchEvent 方法中的对 hitTestResult 分发事件,那我们看看 hitTestResult 又是从何而来的呢?

找到 https://github.com/flutter/flutter/blob/stable/packages/flutter/lib/src/gestures/binding.dart
GestureBinding.dispatchEvent 方法。可以看到 hitTestResult 是作为参数传递进来的,那我们再向上找。

  @override // from HitTestDispatcher
  void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
    assert(!locked);
    // No hit test information implies that this is a [PointerHoverEvent],
    // [PointerAddedEvent], or [PointerRemovedEvent]. These events are specially
    // routed here; other events will be routed through the `handleEvent` below.
    if (hitTestResult == null) {
      assert(event is PointerAddedEvent || event is PointerRemovedEvent);
      try {
        pointerRouter.route(event);
      } catch (exception, stack) {
        FlutterError.reportError(FlutterErrorDetailsForPointerEventDispatcher(
          exception: exception,
          stack: stack,
          library: 'gesture library',
          context: ErrorDescription('while dispatching a non-hit-tested pointer event'),
          event: event,
          hitTestEntry: null,
          informationCollector: () sync* {
            yield DiagnosticsProperty<PointerEvent>('Event', event, style: DiagnosticsTreeStyle.errorProperty);
          },
        ));
      }
      return;
    }
    for (final HitTestEntry entry in hitTestResult.path) {
      try {
        entry.target.handleEvent(event.transformed(entry.transform), entry);
      } catch (exception, stack) {
        FlutterError.reportError(FlutterErrorDetailsForPointerEventDispatcher(
          exception: exception,
          stack: stack,
          library: 'gesture library',
          context: ErrorDescription('while dispatching a pointer event'),
          event: event,
          hitTestEntry: entry,
          informationCollector: () sync* {
            yield DiagnosticsProperty<PointerEvent>('Event', event, style: DiagnosticsTreeStyle.errorProperty);
            yield DiagnosticsProperty<HitTestTarget>('Target', entry.target, style: DiagnosticsTreeStyle.errorProperty);
          },
        ));
      }
    }
  }

hitTest(hitTestResult, event.position) 方法。

  void _handlePointerEventImmediately(PointerEvent event) {
    HitTestResult? hitTestResult;
    if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) {
      assert(!_hitTests.containsKey(event.pointer));
      hitTestResult = HitTestResult();
      // 由于是根,所以直接把自己加进 HitTestResult 当中
      hitTest(hitTestResult, event.position);
      // 保存
      if (event is PointerDownEvent) {
        _hitTests[event.pointer] = hitTestResult;
      }
      assert(() {
        if (debugPrintHitTestResults)
          debugPrint('$event: $hitTestResult');
        return true;
      }());
    }
    // up 或者 cancel 的时候移除掉
    else if (event is PointerUpEvent || event is PointerCancelEvent) {
      hitTestResult = _hitTests.remove(event.pointer);
    } else if (event.down) {
      // Because events that occur with the pointer down (like
      // [PointerMoveEvent]s) should be dispatched to the same place that their
      // initial PointerDownEvent was, we want to re-use the path we found when
      // the pointer went down, rather than do hit detection each time we get
      // such an event.
      hitTestResult = _hitTests[event.pointer];
    }
    assert(() {
      if (debugPrintMouseHoverEvents && event is PointerHoverEvent)
        debugPrint('$event');
      return true;
    }());
    if (hitTestResult != null ||
        // 第一次触发的为 PointerAddedEvent 进入 dispatchEvent
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      assert(event.position != null);
      // 分发
      dispatchEvent(event, hitTestResult);
    `}`
  }
  @override // from GestureBinding
  void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
    if (hitTestResult != null ||
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      assert(event.position != null);
      _mouseTracker!.updateWithEvent(event, () => hitTestResult ?? renderView.hitTestMouseTrackers(event.position));
    }
    super.dispatchEvent(event, hitTestResult);
  }

之后将从父节点一个一个向下去调用 hitTesthitTestChildren 方法

  bool hitTest(BoxHitTestResult result, { required Offset position }) {
    ... 省略部分代码
    if (_size!.contains(position)) {
      if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
        result.add(BoxHitTestEntry(this, position));
        return true;
      }
    }
    return false;
  }

  @override
  bool hitTestChildren(BoxHitTestResult result, { required Offset position }) {
    return defaultHitTestChildren(result, position: position);
  }
  
  bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) {
    // The x, y parameters have the top left of the node's box as the origin.
    ChildType? child = lastChild;
    while (child != null) {
      final ParentDataType childParentData = child.parentData! as ParentDataType;
      final bool isHit = result.addWithPaintOffset(
        offset: childParentData.offset,
        position: position,
        hitTest: (BoxHitTestResult result, Offset? transformed) {
          assert(transformed == position - childParentData.offset);
          return child!.hitTest(result, position: transformed!);
        },
      );
      if (isHit)
        return true;
      child = childParentData.previousSibling;
    }
    return false;
  }  

小结

  1. 引擎通知 Flutter GestureBinding
  2. GestureBinding 通过 hitTest 方法确定哪些 RenderOject 通过了 hitTest 测试, 并且加入 BoxHitTestResult

关键点:

  1. BoxHitTestResult 中的结果进行事件分发
  2. 通过 GestureDetectorRawGestureDetector等组件对 Listener 获取的事件监听进行转换,转换成我们更容易接受的各种事件。

解决

A,B 两个按钮都跟附近的组件紧挨着。就是说如果要增大点击区域,必然需要考虑它们个附近的组件。

伪代码,大致的结构是这样的。我们怎么样才能让 ButtonAButtonB 的点击区域扩大呢?

    Row(children: <Widget>[
      Text(''),
      Column(children: <Widget>[
        Row(children: <Widget>[
          ButtonA(),
          Text(''),
          ButtonB(),
        ],),
        Text(''),
      ],),
      Text(''),
    ],)

如果扩大点击范围为下图的话,你的第一反应是什么?

  1. 我的第一反应是利用 stack 绘制出一个看不见的区域来接收 hitTest。但是其实很早就有听到过说 stack 中溢出的部分是不会接收到 hitTest的,想想也是,溢出的部分已经超出 size 了。
    return Stack(
      clipBehavior: Clip.none,
      children: <Widget>[
        mockButtonUI(text),
        Positioned(
          left: -16,
          right: -16,
          top: -16,
          bottom: -16,
          child: GestureDetector(
            behavior: HitTestBehavior.translucent,
            onTap: () {
              showToast('$text:onTap${i++}',
                  duration: const Duration(milliseconds: 500));
            },
            // 使用看不见的颜色来占位来接收 hitTest
            child: const ColoredBox(
              color: Color(0x00100000),
            ),
          ),
        ),
      ],
    );

RenderBoxHitTestWithoutSizeLimit

我们先来创建一个 mixin 用来解除 hitTest 关于 size 的限制。

mixin RenderBoxHitTestWithoutSizeLimit on RenderBox {
  @override
  bool hitTest(BoxHitTestResult result, {required Offset position}) {
    assert(() {
      if (!hasSize) {
        if (debugNeedsLayout) {
          throw FlutterError.fromParts(<DiagnosticsNode>[
            ErrorSummary(
                'Cannot hit test a render box that has never been laid out.'),
            describeForError(
                'The hitTest() method was called on this RenderBox'),
            ErrorDescription(
                "Unfortunately, this object's geometry is not known at this time, "
                'probably because it has never been laid out. '
                'This means it cannot be accurately hit-tested.'),
            ErrorHint('If you are trying '
                'to perform a hit test during the layout phase itself, make sure '
                "you only hit test nodes that have completed layout (e.g. the node's "
                'children, after their layout() method has been called).'),
          ]);
        }
        throw FlutterError.fromParts(<DiagnosticsNode>[
          ErrorSummary('Cannot hit test a render box with no size.'),
          describeForError('The hitTest() method was called on this RenderBox'),
          ErrorDescription(
              'Although this node is not marked as needing layout, '
              'its size is not set.'),
          ErrorHint('A RenderBox object must have an '
              'explicit size before it can be hit-tested. Make sure '
              'that the RenderBox in question sets its size during layout.'),
        ]);
      }
      return true;
    }());

    if (contains(position)) {
      if (hitTestChildren(result, position: position) ||
          hitTestSelf(position)) {
        result.add(BoxHitTestEntry(this, position));
        return true;
      }
    }

    return false;
  }
  // 永远为 true
  bool contains(Offset position) => true;
  // size.contains(position);
}

StackHitTestWithoutSizeLimit

复制 Stack 的源码,为 RenderStack 混入 RenderBoxHitTestWithoutSizeLimit

class StackHitTestWithoutSizeLimit extends Stack {
  /// Creates a stack layout widget.
  ///
  /// By default, the non-positioned children of the stack are aligned by their
  /// top left corners.
  StackHitTestWithoutSizeLimit({
    Key? key,
    AlignmentDirectional alignment = AlignmentDirectional.topStart,
    TextDirection? textDirection,
    StackFit fit = StackFit.loose,
    Clip clipBehavior = Clip.hardEdge,
    List<Widget> children = const <Widget>[],
  }) : super(
          key: key,
          children: children,
          alignment: alignment,
          textDirection: textDirection,
          fit: fit,
          clipBehavior: clipBehavior,
        );
  bool _debugCheckHasDirectionality(BuildContext context) {
    if (alignment is AlignmentDirectional && textDirection == null) {
      assert(
        debugCheckHasDirectionality(context,
            why: 'to resolve the \'alignment\' argument',
            hint: alignment == AlignmentDirectional.topStart
                ? 'The default value for \'alignment\' is AlignmentDirectional.topStart, which requires a text direction.'
                : null,
            alternative:
                'Instead of providing a Directionality widget, another solution would be passing a non-directional \'alignment\', or an explicit \'textDirection\', to the $runtimeType.'),
      );
    }
    return true;
  }

  @override
  RenderStack createRenderObject(BuildContext context) {
    assert(_debugCheckHasDirectionality(context));
    return RenderStackHitTestWithoutSizeLimit(
      alignment: alignment,
      textDirection: textDirection ?? Directionality.of(context),
      fit: fit,
      clipBehavior: clipBehavior,
    );
  }
}

class RenderStackHitTestWithoutSizeLimit extends RenderStack
    with RenderBoxHitTestWithoutSizeLimit {
  RenderStackHitTestWithoutSizeLimit({
    List<RenderBox>? children,
    AlignmentGeometry alignment = AlignmentDirectional.topStart,
    TextDirection? textDirection,
    StackFit fit = StackFit.loose,
    Clip clipBehavior = Clip.hardEdge,
  }) : super(
          alignment: alignment,
          children: children,
          textDirection: textDirection,
          fit: fit,
          clipBehavior: clipBehavior,
        );
}

RowHitTestWithoutSizeLimit,ColumnHitTestWithoutSizeLimit

    Row(children: <Widget>[
      Text(''),
      Column(children: <Widget>[
        Row(children: <Widget>[
          ButtonA(),
          Text(''),
          ButtonB(),
        ],),
        Text(''),
      ],),
      Text(''),
    ],)

由于 Stack 溢出的部分已经达到 RowColumn 的中其他 child 的区域了,所以我们对 RowColumn 也需要进行特殊的处理。

class RowHitTestWithoutSizeLimit extends Row
    with FlexHitTestWithoutSizeLimitmixin {
  RowHitTestWithoutSizeLimit({
    Key? key,
    MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
    MainAxisSize mainAxisSize = MainAxisSize.max,
    CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
    TextDirection? textDirection,
    VerticalDirection verticalDirection = VerticalDirection.down,
    TextBaseline?
        textBaseline, // NO DEFAULT: we don't know what the text's baseline should be
    List<Widget> children = const <Widget>[],
  }) : super(
          children: children,
          key: key,
          mainAxisAlignment: mainAxisAlignment,
          mainAxisSize: mainAxisSize,
          crossAxisAlignment: crossAxisAlignment,
          textDirection: textDirection,
          verticalDirection: verticalDirection,
          textBaseline: textBaseline,
        );
}

mixin FlexHitTestWithoutSizeLimitmixin on Flex {
  @override
  RenderFlex createRenderObject(BuildContext context) {
    return RenderFlexHitTestWithoutSizeLimit(
      direction: direction,
      mainAxisAlignment: mainAxisAlignment,
      mainAxisSize: mainAxisSize,
      crossAxisAlignment: crossAxisAlignment,
      textDirection: getEffectiveTextDirection(context),
      verticalDirection: verticalDirection,
      textBaseline: textBaseline,
      clipBehavior: clipBehavior,
    );
  }
}

class RenderFlexHitTestWithoutSizeLimit extends RenderFlex
    with
        RenderBoxHitTestWithoutSizeLimit,
        RenderBoxChildrenHitTestWithoutSizeLimit {
  RenderFlexHitTestWithoutSizeLimit({
    List<RenderBox>? children,
    Axis direction = Axis.horizontal,
    MainAxisSize mainAxisSize = MainAxisSize.max,
    MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
    CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
    TextDirection? textDirection,
    VerticalDirection verticalDirection = VerticalDirection.down,
    TextBaseline? textBaseline,
    Clip clipBehavior = Clip.none,
  }) : super(
          children: children,
          direction: direction,
          mainAxisSize: mainAxisSize,
          mainAxisAlignment: mainAxisAlignment,
          crossAxisAlignment: crossAxisAlignment,
          textDirection: textDirection,
          verticalDirection: verticalDirection,
          textBaseline: textBaseline,
          clipBehavior: clipBehavior,
        );

  @override
  bool hitTestChildren(BoxHitTestResult result, {required Offset position}) {
    return hitTestChildrenWithoutSizeLimit(
      result,
      position: position,
      children: getChildrenAsList().reversed,
    );
  }
}

由于 children 默认是反序接受 hitTest ,我们需要让 RenderBoxHitTestWithoutSizeLimit 优先接受 hitTest

mixin RenderBoxChildrenHitTestWithoutSizeLimit {
  bool hitTestChildrenWithoutSizeLimit(
    BoxHitTestResult result, {
    required Offset position,
    required Iterable<RenderBox> children,
  }) {
    final List<RenderBox> normal = <RenderBox>[];
    for (final RenderBox child in children) {
      if ((child is RenderBoxHitTestWithoutSizeLimit) &&
          childIsHit(result, child, position: position)) {
        return true;
      } else {
        normal.insert(0, child);
      }
    }

    for (final RenderBox child in normal) {
      if (childIsHit(result, child, position: position)) {
        return true;
      }
    }

    return false;
  }

  bool childIsHit(BoxHitTestResult result, RenderBox child,
      {required Offset position}) {
    final ContainerParentDataMixin<RenderBox> childParentData =
        child.parentData as ContainerParentDataMixin<RenderBox>;
    final Offset offset = (childParentData as BoxParentData).offset;
    final bool isHit = result.addWithPaintOffset(
      offset: offset,
      position: position,
      hitTest: (BoxHitTestResult result, Offset transformed) {
        assert(transformed == position - offset);
        return child.hitTest(result, position: transformed);
      },
    );
    return isHit;
  }
}

我们将写好的新组件替换掉之前的,就可以达到增大点击范围的效果了。

    RowHitTestWithoutSizeLimit(children: <Widget>[
      Text(''),
      ColumnHitTestWithoutSizeLimit(children: <Widget>[
        RowHitTestWithoutSizeLimit(children: <Widget>[
          ButtonA(),
          Text(''),
          ButtonB(),
        ],),
        Text(''),
      ],),
      Text(''),
    ],)
    
    Widget ButtonA()
    {
  return StackHitTestWithoutSizeLimit(
      clipBehavior: Clip.none,
      children: <Widget>[
        mockButtonUI(text),
        Positioned(
          left: -16,
          right: -16,
          top: -16,
          bottom: -16,
          child: GestureDetector(
            behavior: HitTestBehavior.translucent,
            onTap: () {
              showToast('$text:onTap${i++}',
                  duration: const Duration(milliseconds: 500));
            },
            // 使用看不见的颜色来占位来接收 hitTest
            child: const ColoredBox(
              color: Color(0x00100000),
            ),
          ),
        ),
      ],
    );
     
    }
    

extra_hittest_area | Flutter Package (flutter-io.cn)

为了方便大家使用,我将常用的组件封装了一下供大家使用。

Parent widgets

跟官方的 widgets 一样,使用它们来保证,当额外 hitTest 区域超出了父 widget的大小的时候,一样能接收到 hitTest。

监听点击事件的 widgets

parameter description default
extraHitTestArea 额外增加的 hitTest 区域 EdgeInsets.zero
debugHitTestAreaColor 用于 debug 的 hitTest 区域背景色 null

你可以设置 ExtraHitTestBase.debugGlobalHitTestAreaColor 来替代在每个监听 widget 中单独设置 debugHitTestAreaColor

实现其他的 HitTestWithoutSizeLimit

如果这个 package 没有你需要的 widgets , 你可以使用下面的类自己实现。

RenderBoxHitTestWithoutSizeLimit, RenderBoxChildrenHitTestWithoutSizeLimit

结语

这次我们尝试解决了实际开发中遇到的一个问题,重要的是理解了 Flutter 中手势事件的由来。至于从引擎传递过来的 rawevent 怎么转换成 TaponLongPressScale 等我们熟悉的事件,可以再开一篇了。

FlutterChallenges qq 群 321954965 喜欢折腾自己的童鞋欢迎加群,欢迎大家提供新的挑战或者解决挑战

Flutter,爱糖果,欢迎加入[Flutter Candies]
最最后放上 Flutter Candies 全家桶,真香。

上一篇下一篇

猜你喜欢

热点阅读