flutter 实现点击区域放大的一种方式

669 阅读4分钟

场景 测试说这个左上角的返回按钮太难按了。

解决:1 给返回键增加padding,配合HitTestBehavior.translucent增大响应区域。

问题 增加padding,可能会影响到周围的组件的位置。与UI不符

期待的最佳解决方案:在不改变组件尺寸的情况下,增大响应区域。

觉得写的不好的可以看这篇文章 参考文章:juejin.cn/post/700203…

知识点:

1 在Stack组件中,通过Position摆放组件。不会影响Stack的大小

2 Flutter组件事件是从上往下,也就是父->子的方式传递。只有父组件接受了事件,子组件才有机会处理事件。

//关键代码 
//判断自己是否命中
if (_size!.contains(position)) {
 //校验children是否命中
 if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
   result.add(BoxHitTestEntry(this, position));
   return true;
 }
}

所以解决思路是:1 通过在Stack中通过Position来增大响应区域。像这样

var defaultWidth = 100.0;
var defaultHeight = 100.0;
Stack(
  children: [
    SizedBox(
      width: defaultWidth,
      height: defaultHeight,
    ),
    Positioned(child: Container(
      width: defaultWidth * 2,
      height: defaultHeight * 2,
      color: Colors.yellow,
    ))
  ],
);

这样写 Stack 的size最终是 100 * 100,内部Container的尺寸200*200. 当前情况下,点击 100 * 100的区域 SizedBox 是可以响应事件的。但是点击 150 * 150 虽然点击的在Container范围之内,但是不在Stack范围内。也就是说Container的父节点没有机会处理事件,他的子节点也就没机会处理事件。

2 解决点击 150 * 150 Stack不能处理事件。 只需要重写 RenderStack(RenderStack 是Stack 对应的RenderObject)的hitTest。自己不进行是否包含坐标点的判断,直接交给子节点去判断。这样 200 * 200的Container 就有机会处理 100 * 100 以外的事件。

@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
   //自己直接返回true(RenderStack) 直接交给子节点 子节点命中就处理事件
  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;

我的封装代码 具体使用方式 参考上面的链接。


import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

class AddClickAreaGestureDetector extends StatelessWidget {

  final double width;
  final double height;
  final Widget child;
  final VoidCallback onTap;
  final double wScale;
  final double hScale;

  const AddClickAreaGestureDetector({super.key,required this.width,
    required this.height,required this.child,required this.onTap, this.wScale = 4,
    this.hScale = 4
  });

  @override
  Widget build(BuildContext context) {
    var defaultWidth = 100.0;
    var defaultHeight = 100.0;
    Stack(
      children: [
        SizedBox(
          width: defaultWidth,
          height: defaultHeight,
        ),
        Positioned(child: Container(
          width: defaultWidth * 2,
          height: defaultHeight * 2,
          color: Colors.yellow,
        ))
      ],
    );

    var clickWidth = width * wScale;
    var clickHeight = height * hScale;
    return _AddClickAreaStack(
      clipBehavior: Clip.none,
      children: [
        //设置占据位置
        SizedBox(width: width,height: height),
        Positioned(
            left: -  (clickWidth - width) / 2 ,
            top: -  (clickHeight - height) / 2,
            child: GestureDetector(
              behavior: HitTestBehavior.translucent,
              onTap: (){
                debugPrint("======onTap===");
                onTap.call();
              },
              child: Container(
                alignment: Alignment.center,
                width: clickWidth,
                height: clickHeight,
                child: child,
              ),
            )
        )
      ],
    );
  }

}

class _AddClickAreaStack extends Stack {
  const _AddClickAreaStack(
      {required super.children,super.clipBehavior,super.fit});

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


class _AddClickAreaRenderStack extends RenderStack with RenderBoxHitTestWithoutSizeLimit {
  _AddClickAreaRenderStack({
    required super.alignment,
    super.textDirection,
    required super.fit,
    super.clipBehavior
  });
}

//不校验自己是否命中的Column
class ColumnHitTestWithoutSizeLimit extends Column
    with FlexHitTestWithoutSizeLimitmixin{
  ColumnHitTestWithoutSizeLimit({
    super.key,
    super.mainAxisAlignment,
    super.mainAxisSize,
    super.crossAxisAlignment,
    super.textDirection,
    super.verticalDirection,
    super.textBaseline, // NO DEFAULT: we don't know what the text's baseline should be
    super.children,
  });
}


//不校验自己是否命中的Row
class RowHitTestWithoutSizeLimit extends Row
    with FlexHitTestWithoutSizeLimitmixin {
  RowHitTestWithoutSizeLimit({
    super.key,
    super.mainAxisAlignment,
    super.mainAxisSize,
    super.crossAxisAlignment,
    super.textDirection,
    super.verticalDirection,
    super.textBaseline, // NO DEFAULT: we don't know what the text's baseline should be
    super.children,
  });
}

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({
    super.children,
    super.direction,
    super.mainAxisSize,
    super.mainAxisAlignment,
    super.crossAxisAlignment,
    super.textDirection,
    super.verticalDirection,
    super.textBaseline,
    super.clipBehavior,
  });

}


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);
}


mixin RenderBoxChildrenHitTestWithoutSizeLimit on RenderFlex{

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

  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;
  }
}