【Flutter】应用中弹窗与软键盘互动/交互的几种方式

3,097 阅读5分钟

Flutter的弹窗与软键盘交互

前言

开发过 Flutter 应用的同学可能对软键盘多多少少都被坑过,但是大多数使用场景是页面中的软键盘弹起之后遮挡布局的问题,其实也好解决。

主流的方案是加滚动布局,或者设置 Scaffold 的 resizeToAvoidBottomInset 属性,但是如果是弹窗呢?如果不做处理是什么情况?

上图:

image.png

如果做了适配之后应该是这样的:

image.png

那么弹窗与软键盘的适配怎么做呢?

一、软键盘开关监听

Flutter 中有一些软键盘的插件,可以监听和获取到不同平台下软键盘的弹出与收起的状态,我们可以通过监听软键盘的弹出与收起,给 Dialog 的布局设置不同的 Padding 即可。

比如以我用的 flutter_keyboard_visibility 插件为例:

class VerifyPaymentPasswordView extends StatefulWidget {
  VerifyPaymentPasswordView({super.key, this.confirmAction});

  VoidCallback? confirmAction;

  @override
  State<VerifyPaymentPasswordView> createState() => _VerifyPaymentPasswordViewState();
}

class _VerifyPaymentPasswordViewState extends State<VerifyPaymentPasswordView> {
  final TextEditingController _controller = TextEditingController();
  final FocusNode _node = FocusNode();
  bool isKeyboardVisible = false;
  StreamSubscription<bool>? subscription;

  void _updateKeyboardVisibility(bool isVisible) {
    setState(() {
      isKeyboardVisible = isVisible;
    });
  }

  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      _node.requestFocus();
    });

    var keyboardVisibilityController = KeyboardVisibilityController();
    subscription = keyboardVisibilityController.onChange.listen((bool visible) {
      _updateKeyboardVisibility(visible);
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: isKeyboardVisible?MediaQuery.of(context).viewInsets:EdgeInsets.zero,

      ...
    );
  }

效果:

监听软键盘方案 00_00_00-00_00_30.gif

如果是底部的弹窗呢?

class ReviewDialog extends StatefulWidget {
  const ReviewDialog({Key? key}) : super(key: key);

  @override
  State<ReviewDialog> createState() => _ReviewDialogState();
}

class _ReviewDialogState extends State<ReviewDialog> {
  final _focusNode = FocusNode();
  bool isKeyboardVisible = false;
  StreamSubscription<bool>? subscription;
    
  void _updateKeyboardVisibility(bool isVisible) {
    setState(() {
      isKeyboardVisible = isVisible;
    });
  }

  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      _focusNode.requestFocus();
    });

    var keyboardVisibilityController = KeyboardVisibilityController();
   subscription = keyboardVisibilityController.onChange.listen((bool visible) {
      Log.d('Keyboard visibility update. Is visible: $visible');
      _updateKeyboardVisibility(visible);
    });

    super.initState();
  }
    
  @override
  void dispose() {
    subscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.only(
        bottom: isKeyboardVisible ? MediaQuery.of(context).viewInsets.bottom : 0,
      ),
      child: Container(
        height: 100,
        width: double.infinity,
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(10),
        ),
        alignment: Alignment.center,
        child: TextField(focusNode: _focusNode),
      ),
    );
  }
}

只设置底部的 padding 即可,只是具体弹出Dialog的时候,对齐方式的不同而已,具体的交互是一样的效果。

评论弹窗-软键盘方案 00_00_00-00_00_30.gif

看代码或效果图(GIF掉帧不明显)能看出,这种效果是可以完成我们要的效果了,但是突然的上下跳动相对来说比较突兀。

二、监听 EdgeInsets 值变化

能不能监听软键盘弹起的过程中整个页面中 EdgeInsets 的变化呢?让我们的布局跟随这个状态不停的刷新不就可以实现类似动画的效果了吗?

如何监听页面中 EdgeInsets 的变化呢?

class ReviewDialog extends StatefulWidget {
  const ReviewDialog({Key? key}) : super(key: key);

  @override
  State<ReviewDialog> createState() => _ReviewDialogState();
}

class _ReviewDialogState extends State<ReviewDialog> with WidgetsBindingObserver {
  final _focusNode = FocusNode();
  EdgeInsets viewInsets = EdgeInsets.zero;

  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      _focusNode.requestFocus();
    });

    // 注册 WidgetsBindingObserver
    WidgetsBinding.instance.addObserver(this);
    super.initState();
  }

  @override
  void dispose() {
    // 移除 WidgetsBindingObserver
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeMetrics() {
    super.didChangeMetrics();
    // 获取最新的 viewInsets 值
    final newInsets = WidgetsBinding.instance.window.viewInsets;
    setState(() {
      viewInsets = EdgeInsets.fromViewPadding(newInsets, WidgetsBinding.instance.window.devicePixelRatio);
    });
  }

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.only(
        bottom:  viewInsets.bottom, 
      ),
      child: Container(
        height: 100,
        width: double.infinity,
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(10),
        ),
        alignment: Alignment.center,
        child: TextField(focusNode: _focusNode),
      ),
    );
  }
}

这里就不重复贴居中的弹窗布局了,是一样的监听方案。

居中弹窗效果:

使用监听viewInsets的方案 00_00_00-00_00_30.gif

底部弹窗效果:

评论弹窗-监听viewInsets方案 00_00_00-00_00_30.gif

效果比软键盘弹起与收起的监听的效果要稍好。

三、直接用AnimatedPadding或Padding

监听的方式还是有点麻烦,有没有更方便的方式呢?

其实直接使用 AnimatedPadding 设置一个目标的 Padding 值与动画执行时间,让弹窗按自己的方式做动画,这是网上推荐比较多的方案。

怎么实现呢?

class VerifyPaymentPasswordView extends StatefulWidget {
  VerifyPaymentPasswordView({super.key, this.confirmAction});

  VoidCallback? confirmAction;

  @override
  State<VerifyPaymentPasswordView> createState() => _VerifyPaymentPasswordViewState();
}

class _VerifyPaymentPasswordViewState extends State<VerifyPaymentPasswordView> {
  final TextEditingController _controller = TextEditingController();
  final FocusNode _node = FocusNode();

  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      _node.requestFocus();
    });

    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedPadding(
      padding: MediaQuery.of(context).viewInsets,
      ...
    );
  }

效果同样能实现,但是由于带有动画的原因,顺滑度与响应速度上还是比不上上面的两种方案。

使用AnimPadding的效果 00_00_00-00_00_30.gif

这里抛出一个疑问,为什么 AnimatedPadding 的 padding 可以直接使用一个 MediaQuery.of(context).viewInsets 值?有谁给他设置状态了吗?

其实并没有,只是在软键盘弹起的过程中,页面的 viewInsets 发生了改变,会触发 build 方法重新构建视图而已,所以每次 build 之后就可以拿到最新的 viewInsets 值,就能间接的实现设置状态的效果,也就能动啦。

t01cbf1390968094a9f.jpg

本质还是动画,其实是模拟动画,个人感觉不是那么的跟手,部分低端机型甚至会出现弹窗与软键盘重合效果,

那我直接用 Padding 不行吗? 还减少了重复 build ,更快速的响应。

class ReviewDialog extends StatefulWidget {
  const ReviewDialog({Key? key}) : super(key: key);

  @override
  State<ReviewDialog> createState() => _ReviewDialogState();
}

class _ReviewDialogState extends State<ReviewDialog> {
  final _focusNode = FocusNode();
  EdgeInsets viewInsets = EdgeInsets.zero;

  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
      _focusNode.requestFocus();
    });

    super.initState();
  }


  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.only(
        bottom: MediaQuery.of(context).viewInsets.bottom,
      ),
      child: Container(
        height: 100,
        width: double.infinity,
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(10),
        ),
        alignment: Alignment.center,
        child: TextField(focusNode: _focusNode),
      ),
    );
  }
}

效果:

测试机型 Andriod 12 :

直接用即可 00_00_00-00_00_30.gif

测试机型 Android 7.1 :

click_02.gif

总结

其实如果只是做对应的弹窗效果的话,确实直接用 MediaQuery.of(context).viewInsets 相关的对象即可实现。

看效果图也可以看出,使用 Padding 方案 Android 11 以上会更显流畅,Android 11 以下的效果其实也还不错。当然如果你想让 Android 11 以下的效果更加平滑,其实也可以使用 AnimatedPadding 方案。

在我之前的 Android 文章中弹窗的与软键盘的处理【传送门】中,我也是根据 Android 11 版本做的兼容,个人感觉使用 Padding 方案已经足够,并且 iOS 设备都是支持的。

除此之外软键盘的状态监听与 viewInsets 的值监听在一些特殊的场景下是有作用的,比如当软键盘关闭的时候做出一些移除焦点,清空内容等等的特殊处理,我们可以灵活的应用这一特性。

由于 GIF 录制出来有压缩效果不明显,并且我的全部测试机都是百元级的低端机,在 Debug 模式下效果不好可能有卡顿掉帧情况,如果用 Release/Profile 模式会有更好的效果,有兴趣大家可以自行测试。

那么本期内容就到这里,如讲的不到位或错漏的地方,希望同学们可以评论区指出,如果有更多更好更方便的方式也欢迎大家评论区交流。

本文的代码已经全部贴出,部分没贴出的代码可以在前文中找到,我的 Flutter Demo 项目正在整理中,后期开源了会更新文章链接。

如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力啦!

Ok,这一期就此完结。