Flutter挑戰(zhàn)之增大點擊范圍


theme: cyanosis
highlight: androidstudio


前言

我在 Flutter 重識 NestedScrollView (juejin.cn) 中留下 增大點擊范圍 的挑戰(zhàn),時間已經(jīng)過了一個星期,不知道大家思考的怎么樣了?今天說了一下對于 增大點擊范圍 我個人的的思路。

調(diào)試源碼

首先我們先順一順,Flutter 中手勢相關(guān)事件這些東西是從何而來的。

事件從何而來

  • 首先找到我們經(jīng)常使用的一個組件 Listener,注冊一個事件,打一個斷點。
    return Listener(
      onPointerDown: (PointerDownEvent value) {
        showToast('$text:onTap${i++}',
            duration: const Duration(milliseconds: 500));
      },
      child: mockButtonUI(text),
    );

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

  • Listener 暴露一些原始的指針事件的回調(diào), 最終處理的類 RenderPointerListener 。
  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,
    );
  }
  
  • RenderPointerListener.handleEvent 方法中觸發(fā)了 onPointerDown 回調(diào)
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);
  }
}
  • GestureBinding.dispatchEvent 方法中的對 hitTestResult 分發(fā)事件
  void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
    ...省略一部分代碼
    for (final HitTestEntry entry in hitTestResult.path) {
      try {
        entry.target.handleEvent(event.transformed(entry.transform), entry);
      } 
  • RendererBinding.dispatchEvent 中調(diào)用 super.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);
  }

  • 再次回到中 GestureBinding 通過一些內(nèi)部方法,最終_handlePointerDataPacket 來注冊的原生回調(diào)的地方。
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;
  }

  • window 里面的回調(diào)實際上是 PlatformDispatcher.onPointerDataPacket ,并且在 _dispatchPointerDataPacket 調(diào)用,把引擎?zhèn)鬟f過來的數(shù)據(jù)轉(zhuǎn)換成 PointerDataPacket

  // Called from the engine, via hooks.dart
  void _dispatchPointerDataPacket(ByteData packet) {
    if (onPointerDataPacket != null) {
      _invoke1<PointerDataPacket>(
        onPointerDataPacket,
        _onPointerDataPacketZone,
        _unpackPointerDataPacket(packet),
      );
    }
  }
  • PointerDataPacket 是 通過 _unpackPointerDataPacket 方法把引擎?zhèn)鬟f的數(shù)據(jù)轉(zhuǎn)換成為下面的數(shù)據(jù)結(jié)構(gòu)。
/// 從原生傳遞過來的原始指針的一些信息
/// 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,
  });
  • 最后來到 hooks.dart

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 分發(fā)事件,那我們看看 hitTestResult 又是從何而來的呢?

找到 https://github.com/flutter/flutter/blob/stable/packages/flutter/lib/src/gestures/binding.dart
GestureBinding.dispatchEvent 方法??梢钥吹?hitTestResult 是作為參數(shù)傳遞進(jìn)來的,那我們再向上找。

  @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);
          },
        ));
      }
    }
  }
  • GestureBinding._handlePointerEventImmediately, 如果是
    if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) 成立,就創(chuàng)建一個 HitTestResult,并且調(diào)用

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();
      // 由于是根,所以直接把自己加進(jìn) HitTestResult 當(dāng)中
      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 ||
        // 第一次觸發(fā)的為 PointerAddedEvent 進(jìn)入 dispatchEvent
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      assert(event.position != null);
      // 分發(fā)
      dispatchEvent(event, hitTestResult);
    `}`
  }
  • 第一次觸發(fā)的為 PointerAddedEvent 進(jìn)入 dispatchEvent, 由于 hitTestResultnull,直接調(diào)用 renderView.hitTestMouseTrackers(event.position).
  @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);
  }

之后將從父節(jié)點一個一個向下去調(diào)用 hitTesthitTestChildren 方法

  • RenderBox.hitTest,我們遇到了處理增大點擊范圍的一個判斷 _size!.contains(position),點擊區(qū)域必須在自己的大小范圍內(nèi)才會繼續(xù)取判斷 hitTestChildrenhitTestSelf。 那是不是如果我們把這個判斷去掉,這樣 child 或者 children 就能接受 hitTest 測試了呢?
  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;
  }

  • RenderBoxContainerDefaultsMixin.hitTestChildren,這是默認(rèn)的多孩子的 hitTest ,注意一下,是從 lastChild 開始判斷的。不知道你們有沒有 zIndex 的概念,F(xiàn)lutter 里面 Stack,RowColumn 等組件的 children 當(dāng)中后添加的 child 會先接受 hitTest 測試,給人的感覺就是 lastChild 是在最上層。
  @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;
  }  

小結(jié)

  1. 引擎通知 Flutter GestureBinding
  2. GestureBinding 通過 hitTest 方法確定哪些 RenderOject 通過了 hitTest 測試, 并且加入 BoxHitTestResult。

關(guān)鍵點:

  • size 限制
  • children 接受 hitTest 的順序
  1. BoxHitTestResult 中的結(jié)果進(jìn)行事件分發(fā)
  2. 通過 GestureDetector,RawGestureDetector等組件對 Listener 獲取的事件監(jiān)聽進(jìn)行轉(zhuǎn)換,轉(zhuǎn)換成我們更容易接受的各種事件。

解決

A,B 兩個按鈕都跟附近的組件緊挨著。就是說如果要增大點擊區(qū)域,必然需要考慮它們個附近的組件。

偽代碼,大致的結(jié)構(gòu)是這樣的。我們怎么樣才能讓 ButtonAButtonB 的點擊區(qū)域擴(kuò)大呢?

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

如果擴(kuò)大點擊范圍為下圖的話,你的第一反應(yīng)是什么?

  1. 我的第一反應(yīng)是利用 stack 繪制出一個看不見的區(qū)域來接收 hitTest。但是其實很早就有聽到過說 stack 中溢出的部分是不會接收到 hitTest的,想想也是,溢出的部分已經(jīng)超出 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

我們先來創(chuàng)建一個 mixin 用來解除 hitTest 關(guān)于 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;
  }
  // 永遠(yuǎn)為 true
  bool contains(Offset position) => true;
  // size.contains(position);
}

StackHitTestWithoutSizeLimit

復(fù)制 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 溢出的部分已經(jīng)達(dá)到 RowColumn 的中其他 child 的區(qū)域了,所以我們對 RowColumn 也需要進(jìn)行特殊的處理。

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 默認(rèn)是反序接受 hitTest ,我們需要讓 RenderBoxHitTestWithoutSizeLimit 優(yōu)先接受 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;
  }
}

我們將寫好的新組件替換掉之前的,就可以達(dá)到增大點擊范圍的效果了。

    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 一樣,使用它們來保證,當(dāng)額外 hitTest 區(qū)域超出了父 widget的大小的時候,一樣能接收到 hitTest。

  • StackHitTestWithoutSizeLimit
  • RowHitTestWithoutSizeLimit, ColumnHitTestWithoutSizeLimit, FlexHitTestWithoutSizeLimit
  • SizedBoxHitTestWithoutSizeLimit

監(jiān)聽點擊事件的 widgets

  • GestureDetectorHitTestWithoutSizeLimit
  • RawGestureDetectorHitTestWithoutSizeLimit
  • ListenerHitTestWithoutSizeLimit
parameter description default
extraHitTestArea 額外增加的 hitTest 區(qū)域 EdgeInsets.zero
debugHitTestAreaColor 用于 debug 的 hitTest 區(qū)域背景色 null

你可以設(shè)置 ExtraHitTestBase.debugGlobalHitTestAreaColor 來替代在每個監(jiān)聽 widget 中單獨設(shè)置 debugHitTestAreaColor

實現(xiàn)其他的 HitTestWithoutSizeLimit

如果這個 package 沒有你需要的 widgets , 你可以使用下面的類自己實現(xiàn)。

RenderBoxHitTestWithoutSizeLimit, RenderBoxChildrenHitTestWithoutSizeLimit

結(jié)語

這次我們嘗試解決了實際開發(fā)中遇到的一個問題,重要的是理解了 Flutter 中手勢事件的由來。至于從引擎?zhèn)鬟f過來的 rawevent 怎么轉(zhuǎn)換成 TaponLongPress,Scale 等我們熟悉的事件,可以再開一篇了。

FlutterChallenges qq 群 321954965 喜歡折騰自己的童鞋歡迎加群,歡迎大家提供新的挑戰(zhàn)或者解決挑戰(zhàn)
。

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容