聊聊Flutter中的点击空白处隐藏键盘

2,144 阅读4分钟

📖 背景简介

通常我们在Flutter中实现点击空白处隐藏键盘的需求时,有以下两种方法:

方案一

在整个页面外部包裹一个GestureDetector

void hideKeyboard() => FocusManager.instance.primaryFocus?.unfocus();

class SomePage extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
        return GestureDetector(
            onTap: hideKeyboard,
            child: Scaffold(
                body: ..., //something
            ),
        );
    }
}

或者全局为所有子页面都包裹一个GestureDetector

class MyApp extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
        return MaterialApp(
            title: 'Flutter Demo',
            builder: (context, child) => GestureDetector(
                onTap: hideKeyboard,
                child: child,
            ),
            home: ..., //home page
        );
    }
}

😫 但是这种方案有一个缺陷:

如果页面中有其他消费点击事件的子组件(如:Button),那么包裹在当前页面最外面的GestureDetector将无法响应该点击事件。

为了解决这个问题,比较简单粗暴的一种做法是,为所有的点击事件再调用一次hideKeyboard() >_<

(想想就很刺激...)

class SomePage extends StatelessWidget {

    void onTapButton(){
        hideKeyboard();
        ... //do something
    }

    @override
    Widget build(BuildContext context) {
        return GestureDetector(
            onTap: hideKeyboard,
            child: Scaffold(
                body: Column(
                    children: [
                        //点击此按钮的时候,外部GestureDetector的onTap不会响应
                        TextButton(
                            onPressed: onTapButton, //需要再手动调用一次hideKeyboard()
                            child: Text('我是按钮'), 
                        ),
                        ... //something
                    ],
                ),
            ),
        );
    }
}

方案二

针对方案一中的缺陷,我们尝试将包裹在页面外部的GestureDetector换成Listener

class SomePage extends StatelessWidget {

    void onTapButton(){
        ... //do something
    }

    @override
    Widget build(BuildContext context) {
        return Listener(
            onPointerDown: (_) => hideKeyboard(),
            child: Scaffold(
                body: Column(
                    children: [
                        //点击此按钮的时候,外部Listener的onPointerDown也会响应
                        TextButton(
                            onPressed: onTapButton, 
                            child: Text('我是按钮'), 
                        ),
                        ... //something
                    ],
                ),
            ),
        );
    }
}

OK,现在方案一中的问题似乎已经完美解决了。

但是

你有没有发现,如果在输入框聚焦键盘弹起的状态下,再点击输入框区域,

此时已经弹起的键盘会先收下去,然后重新弹出来。

很蛋疼~

💡 解决思路

简单分析可知,解决此需求的关键有两点:

  1. 响应全局点击事件,且不影响已有组件点击事件的分发响应
  2. 获取点击坐标,判断是否命中输入框组件所在区域

如何监听全局点击事件,且不影响已有组件点击事件的分发响应

对于第一点,我从ToolTip组件的源码中获得了灵感

class _TooltipState extends State<Tooltip> withSingleTickerProviderStateMixin {
    ... 
    void _handlePointerEvent(PointerEvent event) {
        ...
        if (event is PointerUpEvent || event is PointerCancelEvent) {
            _hideTooltip();
        } else if (event is PointerDownEvent) {
            _hideTooltip(immediately: true);
        }
    }

    @override
    void initState() {
        super.initState();
        ...
        // Listen to global pointer events so that we can hide a tooltip immediately
        // if some other control is clicked on.
        GestureBinding.instance!.pointerRouter.addGlobalRoute(_handlePointerEvent);
    }

    @override
    void dispose() {
        GestureBinding.instance!.pointerRouter.removeGlobalRoute(_handlePointerEvent);
        ...
        super.dispose();
    }
    ...
}

可以看到,我们可以在GestureBinding.instance!.pointerRouter里注册全局点击事件的回调,

在并且可以从PointerEvent里拿到点击的坐标,

至此我们解决了问题的一大半。

接着往下看,如何拿到输入框组件所在的区域?

如何获取输入框组件所在的区域,判断点击坐标是否命中

这个问题比较简单,我们可以通过输入框组件的BuildContext拿到它的RenderObject

如果这个RenderObjectRenderBox就可以取到它的size

然后通过RenderBox.localToGlobal即可得到输入框组件所在的区域,

Life is short, show me the code.

话不多说,上代码

  void _handlePointerEvent(PointerEvent event) {
    final randerObject = context.findRenderObject();
    if (randerObject is RenderBox) {
      final box = randerObject;
      final target = box.localToGlobal(Offset.zero) & box.size;
      final inSide = target.contains(event.position);
      ...
    }
  }

🌈 组件封装

根据上面的思路,我们把其封装成组件,方便使用。

GlobalTouch

用途:监听全局手势,不影响父子组件原有点击事件的分发响应流程

参数备注
onPanDowninSide表示是否点击在组件内部
onPanUpinSide表示是否点击在组件内部
///监听全局手势,不影响父子组件原有点击事件的分发响应流程
class GlobalTouch extends StatefulWidget {
  final Widget child;
  final Function(PointerEvent event, bool inSide)? onPanDown;
  final Function(PointerEvent event, bool inSide)? onPanUp;
  GlobalTouch({required this.child, this.onPanDown, this.onPanUp});
  @override
  _GlobalTouchState createState() => _GlobalTouchState();
}

class _GlobalTouchState extends State<GlobalTouch> {
  @override
  void initState() {
    super.initState();
    GestureBinding.instance!.pointerRouter.addGlobalRoute(_handlePointerEvent);
  }

  @override
  void dispose() {
    GestureBinding.instance!.pointerRouter
        .removeGlobalRoute(_handlePointerEvent);
    super.dispose();
  }

  void _handlePointerEvent(PointerEvent event) {
    final randerObject = context.findRenderObject();
    if (randerObject is RenderBox) {
      final box = randerObject;
      final target = box.localToGlobal(Offset.zero) & box.size;
      final inSide = target.contains(event.position);
      if (event is PointerUpEvent || event is PointerCancelEvent) {
        widget.onPanUp?.call(event, inSide);
      } else if (event is PointerDownEvent) {
        widget.onPanDown?.call(event, inSide);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return widget.child;
  }
}

AutoHideKeyboard

用途:点击空白处自动隐藏软键盘

模式场景用法
AutoHideKeyBoard.global全局监听点击事件包裹住整个页面
AutoHideKeyBoard.single适合一个页面中只有一个输入框的情况包裹住输入框
AutoHideKeyBoard.multi适合一个页面中有多个输入框的情况包裹住输入框
void hideKeyBoard() => FocusManager.instance.primaryFocus?.unfocus();

enum AutoHideKeyBoardType {
  ///全局监听点击事件
  global,

  ///适合一个页面中只有一个输入框的情况
  single,

  ///适合一个页面中有多个输入框的情况
  multi,
}

///点击空白处自动隐藏键盘
class AutoHideKeyBoard extends StatefulWidget {
  AutoHideKeyBoard._(
    this._type, {
    required this.child,
    this.tag,
    Key? key,
  }) : super(key: key);

  factory AutoHideKeyBoard({
    required Widget child,
    String tag = 'default',
  }) =>
      AutoHideKeyBoard.multi(
        tag: tag,
        child: child,
      );

  ///包裹住整个页面
  ///
  ///此模式有一个缺陷,当点击输入框时会先收起键盘,然后重新唤起焦点
  ///
  ///推荐使用[AutoHideKeyBoard.single]或[AutoHideKeyBoard.multi]
  factory AutoHideKeyBoard.global({required Widget child}) =>
      AutoHideKeyBoard._(
        AutoHideKeyBoardType.global,
        child: child,
      );

  ///包裹住输入框
  ///
  ///适合一个页面中只有一个输入框的情况
  factory AutoHideKeyBoard.single({required Widget child}) =>
      AutoHideKeyBoard._(
        AutoHideKeyBoardType.single,
        child: child,
      );

  ///包裹住输入框
  ///
  ///适合一个页面中有多个输入框的情况
  factory AutoHideKeyBoard.multi(
          {required Widget child, String tag = 'default'}) =>
      AutoHideKeyBoard._(
        AutoHideKeyBoardType.multi,
        tag: tag,
        child: child,
      );

  final AutoHideKeyBoardType _type;
  final Widget child;
  final String? tag;
  static final Map<String, List<BuildContext>> _multiInputContext = {};

  static void setInputContext(String tag, BuildContext context) {
    if (_multiInputContext[tag] == null) {
      _multiInputContext[tag] = [];
    }
    _multiInputContext[tag]!.add(context);
  }

  static void removeInputContext(String tag, BuildContext context) {
    _multiInputContext[tag]!.remove(context);
    if (_multiInputContext[tag]!.isEmpty) {
      _multiInputContext.remove(tag);
    }
  }

  static bool shouldHideKeyboard(
    BuildContext context,
    String tag,
    PointerEvent event,
  ) {
    bool tapInside(BuildContext context, PointerEvent event) {
      final randerObject = context.findRenderObject();
      if (randerObject is RenderBox) {
        final box = randerObject;
        final target = box.localToGlobal(Offset.zero) & box.size;
        return target.contains(event.position);
      }
      return false;
    }

    final _multiInputContexts = _multiInputContext[tag]!;
    return !_multiInputContexts.any((e) => e != context && tapInside(e, event));
  }

  @override
  State<AutoHideKeyBoard> createState() => _AutoHideKeyBoardState();
}

class _AutoHideKeyBoardState extends State<AutoHideKeyBoard> {
  @override
  void initState() {
    super.initState();
    if (widget._type == AutoHideKeyBoardType.multi) {
      AutoHideKeyBoard.setInputContext(widget.tag!, context);
    }
  }

  @override
  void dispose() {
    if (widget._type == AutoHideKeyBoardType.multi) {
      AutoHideKeyBoard.removeInputContext(widget.tag!, context);
    }
    super.dispose();
  }

  @override
  void didUpdateWidget(covariant AutoHideKeyBoard oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget._type == AutoHideKeyBoardType.multi) {
      AutoHideKeyBoard.removeInputContext(oldWidget.tag!, context);
    }
    if (widget._type == AutoHideKeyBoardType.multi) {
      AutoHideKeyBoard.setInputContext(widget.tag!, context);
    }
  }

  @override
  Widget build(BuildContext context) {
    switch (widget._type) {
      case AutoHideKeyBoardType.global:
        return GlobalTouch(
          onPanDown: (_, __) => hideKeyBoard(),
          child: widget.child,
        );
      case AutoHideKeyBoardType.single:
        return GlobalTouch(
          onPanDown: (_, inSide) {
            if (!inSide) hideKeyBoard();
          },
          child: widget.child,
        );
      case AutoHideKeyBoardType.multi:
        return GlobalTouch(
          onPanDown: (event, inSide) {
            if (!inSide &&
                AutoHideKeyBoard.shouldHideKeyboard(
                  context,
                  widget.tag!,
                  event,
                )) {
              hideKeyBoard();
            }
          },
          child: widget.child,
        );
      default:
        return widget.child;
    }
  }
}

🔧 项目地址

更多细节请戳 👉 网页链接

🌍 在线预览

打开网页查看效果 👉 网页链接