聊聊Flutter中的點選空白處隱藏鍵盤

乂乂又又發表於2021-08-31

? 背景簡介

通常我們在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;
    }
  }
}
複製程式碼

? 專案地址

更多細節請戳 ? 網頁連結

? 線上預覽

開啟網頁檢視效果 ? 網頁連結

相關文章