Flutter bottomSheet 输入框 键盘遮挡解决:2种新思路

6,715 阅读5分钟

相信各位朋友做flutter开发的时候,在处理bottom sheet中输入框的时候,多少会有点不能满足需求。今天就来介绍三种思路,各有优劣,朋友们在工作中可以参考参考

网上普遍的解决方案:AnimatedPadding

这其实和 AnimatedPadding 并没有什么关系,其核心知识点还是利用了 MediaQuery.of(context).viewInsets.bottom 关于 viewInsets 这个属性,源码中的注释是这样说的

The parts of the display that are completely obscured by system UI, typically by the device's keyboard. 意思就是被系统用户界面完全遮挡的部分,而这系统界面,一般也就是键盘。

因此,相信通过这,我们就明白了以下两点:

  1. 我们可以通过 MediaQuery.of(context).viewInsets.bottom 来获取键盘的高度
  2. 我们也可以通过它,来控制我们输入框显示的位置

使用 AnimatedPadding 的源码如下

Future<T?> showSheet<T>(
  BuildContext context,
  Widget body, {
  bool scrollControlled = false,
}) {
  return showModalBottomSheet(
      context: context,
      elevation: 0,
      backgroundColor: Colors.transparent,
      barrierColor: Colors.black.withOpacity(0.25),
      isScrollControlled: scrollControlled,
      builder: (ctx) {
        return AnimatedPadding(
          padding: EdgeInsets.only(
            // 下面这一行是重点
            bottom: MediaQuery.of(context).viewInsets.bottom,
          ),
          duration: Duration.zero,
          child: Container(
            padding: const EdgeInsets.all(20),
            decoration: const BoxDecoration(
              borderRadius: BorderRadius.only(topLeft: Radius.circular(16), topRight: Radius.circular(16)),
              color: Colors.white,
            ),
            child: body,
          ),
        );
      });
}

// 弹窗内容
Widget _buildWidthColumn() {
  return Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      Container(
        height: 160,
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(8),
          color: Colors.lightGreen,
        ),
      ),
      const TextField(),
      Container(
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(8),
          color: Colors.teal,
        ),
        height: 160,
        margin: const EdgeInsets.only(top: 20),
      )
    ],
  );
}

显示弹窗:showSheet(context, _buildWidthColumn());

此时,会有一点问题,如图所示:

3.gif

可以看到,它并没有按照我们的预期工作,此时我们有两种办法解决这个问题:

  1. 显示弹窗的时候,增加scrollControlled,showSheet(context, _buildWidthColumn(), scrollControlled: true);。效果如图:2.gif

  2. 显示弹窗的时候,在 _buildWidthColumn() 外层包裹一层 ScrollView,showSheet(context, SingleChildScrollView(child: _buildWidthColumn()));,其中 ScrollView 使用 SingleChildScrollView 或者ListView 或者其他的ScrollView都是可以的。效果如图:1.gif

我们可以发现,虽然都解决了输入框被遮挡的问题,但其最终效果是不一样的。这就需要我们在实际工作中根据需求去选择了。

上面我们在显示弹窗的时候,其实不使用 AnimatedPadding,使用 Container, Padding等组件也是可以的。这种方法就不再过多介绍了。

使用 Transform.translate 来实现

上一种方法的两种方式,各有优缺点:

  1. 第一种,它将整个 bottomSheet 顶上去了
  2. 第二种,输入框虽然紧贴着键盘,内容的上半部分变得不可见,这在某些情况下,也不满足要求

因此,我们现在使用一种更加灵活的方式。相信在大部分情况下,这种更容易满足需求,效果图: 6.gif

在实际体验中,会比效果图更流畅。

源码稍微有点长,如下:

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
  final GlobalKey _tbKey = GlobalKey();
  double translate = 0;
  double _tbOffsetToBottom = 0;

  @override
  void initState() {
    WidgetsBinding.instance?.addObserver(this);
    super.initState();
  }

  @override
  void dispose() {
    WidgetsBinding.instance?.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeMetrics() {
    if (_tbKey.currentContext == null) return;
    // 获取输入框的位置
    final ro = _tbKey.currentContext!.findRenderObject();
    if (ro == null) return;

    // 此处我们除了要去掉键盘的高度外,为了让输入框可见,还需要减去输入框的高度,
    // 否则键盘出来之后,会刚好盖住输入框
    // 如果UI需要在输入框下增加额外的空隙,我们在多减一部分即可。
    final inputHeight = ro.paintBounds.height;
    final transDelta =_tbOffsetToBottom - MediaQuery.of(context).viewInsets.bottom - inputHeight;
    translate = transDelta > 0 ? 0 : transDelta;
    setState(() {});

    super.didChangeMetrics();
  }

  void _show() {
    // 500ms之后,获取输入框的位置。(时间长短不论,只要弹窗内容完全渲染完成就行)
    // 如果输入框的位置有变化,也应该及时更新。但在键盘出现时不更新
    Future.delayed(const Duration(milliseconds: 500), () {
      final ro = _tbKey.currentContext?.findRenderObject() as RenderBox?;
      assert(ro != null, 'The renderBox of text field cannot be null');
      // 此处需要除以设备像素比,因为前面获取到的 dy 是以像素为单位的
      _tbOffsetToBottom = ro!.localToGlobal(Offset.zero).dy / MediaQuery.of(context).devicePixelRatio;
    });
    
    showModalBottomSheet(
        context: context,
        elevation: 0,
        backgroundColor: Colors.transparent,
        barrierColor: Colors.black.withOpacity(0.25),
        builder: (ctx) {
          return Transform.translate(
            offset: Offset(0, translate),
            child: Container(
              padding: const EdgeInsets.all(20),
              decoration: const BoxDecoration(
                borderRadius: BorderRadius.only(topLeft: Radius.circular(16), topRight: Radius.circular(16)),
                color: Colors.white,
              ),
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Container(
                    height: 160,
                    decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(8),
                      color: Colors.lightGreen,
                    ),
                  ),
                  // 此处需要为输入框加入一个全局的 Key,用于获取它的位置
                  TextField(key: _tbKey),
                  Container(
                    decoration: BoxDecoration(
                      borderRadius: BorderRadius.circular(8),
                      color: Colors.teal,
                    ),
                    height: 160,
                    margin: const EdgeInsets.only(top: 20),
                  )
                ],
              ),
            ),
          );
        });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      resizeToAvoidBottomInset: false,
      body: Center(
        child: ElevatedButton(
          onPressed: _show,
          child: const Text('显示底部弹窗'),
        ),
      ),
    );
  }
}

在输入框下方填充额外内容,来使输入框可见

上面使用 Transform.translate 的方式虽然确实还不错,但依然有无法满足需求的时候。比如,当我们的内容已经撑满了屏幕,这个时候,如果再向上平移,顶部就会有一部分内容被挡住。

因此,此时我们可以采用另外一种方式,其实和前一种核心思路差不多。效果图如下: 7.gif

源码如下:

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> with WidgetsBindingObserver {
  double _virtualHeight = 0;
  final double _virtualBoxBottomContentHeight = 160;
  final _sheetPadding = const EdgeInsets.all(20);

  @override
  void initState() {
    WidgetsBinding.instance?.addObserver(this);
    super.initState();
  }

  @override
  void dispose() {
    WidgetsBinding.instance?.removeObserver(this);
    super.dispose();
  }

  @override
  void didChangeMetrics() {
    // 此处我们用键盘高度减去虚拟框(virtualBox)下面内容的高度,在减去sheet的下方内边距。
    // 即得到虚拟框的高度
    final transDelta = MediaQuery.of(context).viewInsets.bottom - _virtualBoxBottomContentHeight - _sheetPadding.bottom;
    _virtualHeight = transDelta <= 0 ? 0 : transDelta;
    setState(() {});

    super.didChangeMetrics();
  }

  void _show() {
    showModalBottomSheet(
        context: context,
        elevation: 0,
        backgroundColor: Colors.transparent,
        barrierColor: Colors.black.withOpacity(0.25),
        isScrollControlled: true,
        constraints: BoxConstraints(
          maxHeight: MediaQuery.of(context).size.height - MediaQuery.of(context).viewPadding.top,
        ),
        builder: (ctx) {
          return Container(
            padding: _sheetPadding,
            decoration: const BoxDecoration(
              borderRadius: BorderRadius.only(topLeft: Radius.circular(16), topRight: Radius.circular(16)),
              color: Colors.white,
            ),
            child: Column(
              children: [
                // 此处加了一个ListView,用于演示较为复杂的场景
                Expanded(child: ListView.builder(itemBuilder: (ctx, i) => Text('item_$i'), itemCount: 50)),
                Container(
                  height: 160,
                  decoration: BoxDecoration(borderRadius: BorderRadius.circular(8), color: Colors.lightGreen),
                ),
                const TextField(),
                const SizedBox(height: 16),
                // 虚拟的高度,用户填充被键盘遮挡的部分
                SizedBox(height: _virtualHeight),
                Container(
                  decoration: BoxDecoration(borderRadius: BorderRadius.circular(8), color: Colors.teal),
                  height: _virtualBoxBottomContentHeight,
                )
              ],
            ),
          );
        });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(widget.title)),
      resizeToAvoidBottomInset: false,
      body: Center(
        child: ElevatedButton(
          onPressed: _show,
          child: const Text('显示底部弹窗'),
        ),
      ),
    );
  }
}

这种思路下,弹窗的内容未被平移到状态栏下方。也不会出现第一种思路那种被顶或被压缩的情况。

因此,这也算是在某些场景下的一种解决问题的方案吧

总结一下

前面共提到了三种思路用于解决 bottomSheet 键盘被遮挡的问题。 其中,第一、三种思路会导致弹窗内容重新布局;而第二种则不会。

第一种方案,个人觉得并不那么舒服。相对而言,还是觉得第二种方案比较好,当然,它的性能也是这里面最好的。

不过,这终归还得根据实际需求来,对吧?

其实后两种方案,均利用了 WidgetsBindingObserver 下的 didChangeMetrics 方法,该方法在系统UI发生变化时会被调用。有兴趣的朋友可以去通过源码详细了解了解。

给个关注呗,让我们一起探索互联网的新技术!