? 背景簡介
通常我們在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;
}
}
}
複製程式碼
? 專案地址
更多細節請戳 ? 網頁連結
? 線上預覽
開啟網頁檢視效果 ? 網頁連結