美文网首页Flutter
Flutter挑战之增大点击范围

Flutter挑战之增大点击范围

作者: 法的空间 | 来源:发表于2021-08-30 10:07 被阅读0次

    theme: cyanosis
    highlight: androidstudio


    前言

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

    调试源码

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

    事件从何而来

    • 首先找到我们经常使用的一个组件 Listener,注册一个事件,打一个断点。
        return Listener(
          onPointerDown: (PointerDownEvent value) {
            showToast('$text:onTap${i++}',
                duration: const Duration(milliseconds: 500));
          },
          child: mockButtonUI(text),
        );
    

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

    • Listener 暴露一些原始的指针事件的回调, 最终处理的类 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 方法中触发了 onPointerDown 回调
    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 分发事件
      void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
        ...省略一部分代码
        for (final HitTestEntry entry in hitTestResult.path) {
          try {
            entry.target.handleEvent(event.transformed(entry.transform), entry);
          } 
    
    • RendererBinding.dispatchEvent 中调用 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 通过一些内部方法,最终_handlePointerDataPacket 来注册的原生回调的地方。
    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 里面的回调实际上是 PlatformDispatcher.onPointerDataPacket ,并且在 _dispatchPointerDataPacket 调用,把引擎传递过来的数据转换成 PointerDataPacket
    
      // Called from the engine, via hooks.dart
      void _dispatchPointerDataPacket(ByteData packet) {
        if (onPointerDataPacket != null) {
          _invoke1<PointerDataPacket>(
            onPointerDataPacket,
            _onPointerDataPacketZone,
            _unpackPointerDataPacket(packet),
          );
        }
      }
    
    • PointerDataPacket 是 通过 _unpackPointerDataPacket 方法把引擎传递的数据转换成为下面的数据结构。
    /// 从原生传递过来的原始指针的一些信息
    /// 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 分发事件,那我们看看 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);
              },
            ));
          }
        }
      }
    
    • GestureBinding._handlePointerEventImmediately, 如果是
      if (event is PointerDownEvent || event is PointerSignalEvent || event is PointerHoverEvent) 成立,就创建一个 HitTestResult,并且调用

    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);
        `}`
      }
    
    • 第一次触发的为 PointerAddedEvent 进入 dispatchEvent, 由于 hitTestResultnull,直接调用 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);
      }
    

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

    • RenderBox.hitTest,我们遇到了处理增大点击范围的一个判断 _size!.contains(position),点击区域必须在自己的大小范围内才会继续取判断 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,这是默认的多孩子的 hitTest ,注意一下,是从 lastChild 开始判断的。不知道你们有没有 zIndex 的概念,Flutter 里面 StackRowColumn 等组件的 children 当中后添加的 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;
      }  
    

    小结

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

    关键点:

    • size 限制
    • children 接受 hitTest 的顺序
    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。

    • StackHitTestWithoutSizeLimit
    • RowHitTestWithoutSizeLimit, ColumnHitTestWithoutSizeLimit, FlexHitTestWithoutSizeLimit
    • SizedBoxHitTestWithoutSizeLimit

    监听点击事件的 widgets

    • GestureDetectorHitTestWithoutSizeLimit
    • RawGestureDetectorHitTestWithoutSizeLimit
    • ListenerHitTestWithoutSizeLimit
    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 全家桶,真香。

    相关文章

      网友评论

        本文标题:Flutter挑战之增大点击范围

        本文链接:https://www.haomeiwen.com/subject/ztbniltx.html