📖 背景简介
通常我们在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,现在方案一中的问题似乎已经完美解决了。
但是
你有没有发现,如果在输入框聚焦键盘弹起的状态下,再点击输入框区域,
此时已经弹起的键盘会先收下去,然后重新弹出来。
很蛋疼~
💡 解决思路
简单分析可知,解决此需求的关键有两点:
- 响应全局点击事件,且不影响已有组件点击事件的分发响应
- 获取点击坐标,判断是否命中输入框组件所在区域
如何监听全局点击事件,且不影响已有组件点击事件的分发响应
对于第一点,我从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
,
如果这个RenderObject
是RenderBox
就可以取到它的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
用途:监听全局手势,不影响父子组件原有点击事件的分发响应流程
参数 | 备注 |
---|---|
onPanDown | inSide表示是否点击在组件内部 |
onPanUp | inSide表示是否点击在组件内部 |
///监听全局手势,不影响父子组件原有点击事件的分发响应流程
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;
}
}
}
🔧 项目地址
更多细节请戳 👉 网页链接
🌍 在线预览
打开网页查看效果 👉 网页链接