Flutter bottomSheet 输入框 键盘遮挡解决:2种新思路
相信各位朋友做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. 意思就是被系统用户界面完全遮挡的部分,而这系统界面,一般也就是键盘。
因此,相信通过这,我们就明白了以下两点:
- 我们可以通过
MediaQuery.of(context).viewInsets.bottom
来获取键盘的高度 - 我们也可以通过它,来控制我们输入框显示的位置
使用 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());
此时,会有一点问题,如图所示:
可以看到,它并没有按照我们的预期工作,此时我们有两种办法解决这个问题:
-
显示弹窗的时候,增加scrollControlled,
showSheet(context, _buildWidthColumn(), scrollControlled: true);
。效果如图: -
显示弹窗的时候,在 _buildWidthColumn() 外层包裹一层 ScrollView,
showSheet(context, SingleChildScrollView(child: _buildWidthColumn()));
,其中 ScrollView 使用 SingleChildScrollView 或者ListView 或者其他的ScrollView都是可以的。效果如图:
我们可以发现,虽然都解决了输入框被遮挡的问题,但其最终效果是不一样的。这就需要我们在实际工作中根据需求去选择了。
上面我们在显示弹窗的时候,其实不使用 AnimatedPadding,使用 Container, Padding等组件也是可以的。这种方法就不再过多介绍了。
使用 Transform.translate 来实现
上一种方法的两种方式,各有优缺点:
- 第一种,它将整个 bottomSheet 顶上去了
- 第二种,输入框虽然紧贴着键盘,内容的上半部分变得不可见,这在某些情况下,也不满足要求
因此,我们现在使用一种更加灵活的方式。相信在大部分情况下,这种更容易满足需求,效果图:
在实际体验中,会比效果图更流畅。
源码稍微有点长,如下:
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
的方式虽然确实还不错,但依然有无法满足需求的时候。比如,当我们的内容已经撑满了屏幕,这个时候,如果再向上平移,顶部就会有一部分内容被挡住。
因此,此时我们可以采用另外一种方式,其实和前一种核心思路差不多。效果图如下:
源码如下:
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发生变化时会被调用。有兴趣的朋友可以去通过源码详细了解了解。
给个关注呗,让我们一起探索互联网的新技术!
转载自:https://juejin.cn/post/7088587843198533669