场景: App在进入二级菜单的时候,需要出现一个底部弹窗以引导用户进行身份核验,本次我打算使用原生的showModalBottomSheet以创建一个底部弹出菜单;再使用AnimationController实现对菜单高度的自定义调整动画。
阅前须知:
- 代码是功能实现后改动复现的,可能存在拼写不同或者有些变量不存在的情况,请视情况修改;
- 不可转载,欢迎讨论、提议提问、指出错误;
- enjoy coding ~
首先看一下实现效果
Flutter的原生组件showModalBottomSheet是有【展开】和【关闭】的两种动画模式,那么我们想要实现图上的效果,对组件进行自定义的展开高度 并为这段区间进行补间动画的操作,并且确保该弹窗对其他引用具有普适性,我们应该如何操作?
注意: 此方法不仅适用于showModalBottomSheet,且对于所有具备内部状态的组件 例如 overlayEntry 等都适用
第一步 添加组件
首先 我们将组件引入到项目中,showModalBottomSheet与所有浮动层的组件类似,需要函数触发加入到你的上下文Context中,如果是这样加入到上下文的话就很明显了,所有Navigator操作是对它适用的,例如Navigator.pop(context)
// 定义方法
_showBottomModal1() {
//在弹窗容器中,我想要定义一个可复用的组件innerContainer,用来生成一个拥有可自定义
//【图标】【名称】【点击方法】的按钮;
Container innerContainer(IconData icon, String title, Function() action) {
return Container(
width: 110,
padding: const EdgeInsets.all(10),
decoration: const BoxDecoration(
color: Colors.white,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 5)],
borderRadius: BorderRadiusDirectional.all(Radius.circular(5)),
),
child: GestureDetector(
onTap: action,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Icon(icon, color: const Color(0xFF000057)), Text(title)],
),
),
);
}
// 这里是showModalBottomSheet的本体,对其成员shape进行操作,可以将其改编为一个圆角弹窗
return showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: const BorderRadius.all(const Radius.circular(5))),
builder: (BuildContext context) {
return Container(
padding: const EdgeInsets.only(top: 20),
height: 200,
child:
Column(mainAxisAlignment: MainAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'选择校验方式',
style: TextStyle(fontSize: 16, color: Color(0xff666666)),
),
GestureDetector(
onTap: () {},
child: const Icon(Icons.close)
)
],
),
),
const Divider(height: 30),
// 在此处调用方法 填充innerContainer
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
innerContainer(Icons.fingerprint_rounded, '指纹校验', () {}),
innerContainer(Icons.password, '密码校验', () {}),
innerContainer(Icons.timeline, '手势校验', () {})
])
]),
);
});
}
// *******************使用任意方法触发 写在你的buil中*********************
GestureDetector(
onTap: () {
_showBottomModal('/on_trajectory');
},
child: Text('点我触发')
);
如此,就创建了一个包含有按钮的简单弹窗组件
接下来,我们需要进行第二步,为按钮添加点击事件,并且相关按钮需要有将弹窗拉高的操作;
- 在stateFulWidget的类中初始化弹窗高度
…
class _TestPageState extends State {
// 添加下面这句
double modalHeight = 200;
@override
Widget build(BuildContext context){
…
}
…
- 给点击事件添加状态以改变弹窗高度状态
...
GestureDetector(){
onTap:(){
setState((){
modalHeight = 400;
});
}
}
...
这样操作了之后会发现 无论你怎么点击,弹窗的高度是不会变化的,但是当你下滑关闭弹窗再打开,弹窗的高度才如愿变化到了400,这是怎么回事呢?
其实 这种脱离原页面组件上下文的浮动层,类似于一个沙盒,属于状态自治,不参与其他组件的状态变化推动刷新,这也就意味着,不做特定操作,是不可以因为其他绑定了值的状态变化而刷新组件的,当然,在这种浮动层中的状态改变也不会影响父组件的变动,归根结底,它们并不共用一个上下文
好在,Flutter给这类组件提供了一个方法 StatefulBuilder ,旨在为组件创建内部状态管理的能力。如此这般,我们将组件修改一下
...
return showModalBottomSheet{
context: context,
builder:(BuildContext context){
return StatefulBuilder(
builder: (context, setStateButtomSheet){
return Container(
...
)
}
)
}
}
...
只要这样包裹着,就可以实现内部状态管理,接下来,我们只需要操作builider提供的 setStateBottomSheet方法就可以对状态进行操作
对任意一个按钮的方法添加状态改变
...
innerContainer(Icons.timeline,'手势校验',(){
setStateBottomSheet((){
modalHeight = 400;
});
})
...
这时候,就可以操作弹窗进行状态变化了;但是,你可以发现组件的高度是从200直接变化到400的,其间并没有补间动画,不够优雅。
Next:introduce AnimationController
对于这种线性动画,我打算直接使用AnimationController,它的使用方法也很简单
- 让你的组件知道页面要执行动画,继承相关方法SingleTickerProviderStateMixin
class _TestPageState extends State with SingleTickerProviderStateMixin
- 初始化你的动画
class _TestPageState extends State with SingleTickerProviderStateMixin{
late AnimationController _controller; //加这句
}
- 在initState中初始化动画
@override
void initState() {
...
super.initState();
_controller = AnimationController(
vsync: this, // 指向SingleTickerProviderStateMixin
duration: const Duration(milliseconds: 2500), //设定区间,当然也可以在补间动画中自定义
lowerBound: 200, //范围的最小值
upperBound: 600) //范围的最大值
…addListener(() {
setState(() {
modalHeight = _controller.value; // 通过controller的值来改变modalHeight
});
});
}
在方法中创建补间动画
innerContainer(Icons.timeline, '手势校验', () {
_controller.animateTo(600.0,
duration: const Duration(
milliseconds: 200));
}
理论上就实现了动画的操作,但是你会发现动画并没有如愿播放,但是打印_controller.value却发现值是变化的,这是因为之前提到过,浮动层状态自治,你外部的动画操作是改不了内部状态的,解决方案是在全局中初始化一个方法
late Function setAnimationState;
让初始化的动画调用这个方法
…addListener(() {
setAnimationState(() {
modalHeight = _controller.value;
});
});
在build的时候将改变状态的方法赋给setAnimationState,以使得动画调用的方法指向改变内部状态的方法
...
return showModalBottomSheet(
context: context,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (context, setStateBottomSheet) {
setAnimationState = setStateBottomSheet;
return Container()
});
)
....
这时 你会发现动画成功运行了,接下来,我们添加一些小细节,比如改变弹窗高度的时候,退出X按钮变为<- 返回上级的按钮 等等。
贴上showModal的方法代码
import 'package:flutter/material.dart';
class TestPage extends StatefulWidget {
const TestPage({Key? key}) : super(key: key);
@override
State<TestPage> createState() => _TestPageState();
}
class _TestPageState extends State<TestPage>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Function setAnimationState;
String checkType = '';
double modalHeight = 200;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('测试弹窗'),
),
body: Center(
child: GestureDetector(
onTap: () {
_showBottomModal();
},
child: Text('打开弹窗'),
),
),
);
}
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this, // the SingleTickerProviderStateMixin
duration: const Duration(milliseconds: 2500),
lowerBound: 200,
upperBound: 600)
..addListener(() {
setAnimationState(() {
modalHeight = _controller.value;
});
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
_showBottomModal() {
Container innerContainer(IconData icon, String title, Function() action) {
return Container(
width: 110,
padding: const EdgeInsets.all(10),
decoration: const BoxDecoration(
color: Colors.white,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 5)],
borderRadius: BorderRadiusDirectional.all(Radius.circular(5)),
),
child: GestureDetector(
onTap: action,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Icon(icon, color: const Color(0xFF000057)), Text(title)],
),
),
);
}
return showModalBottomSheet(
enableDrag: false,
context: context,
shape: const RoundedRectangleBorder(
borderRadius: const BorderRadius.all(const Radius.circular(5))),
builder: (BuildContext context) {
return StatefulBuilder(
builder: (context, setStateBottomSheet) {
String password = '';
setAnimationState = setStateBottomSheet;
return Container(
padding: const EdgeInsets.only(top: 20),
height: modalHeight,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'选择校验方式',
style: TextStyle(
fontSize: 16, color: Color(0xff666666)),
),
GestureDetector(
onTap: () {
modalHeight == 200
? Navigator.pop(context)
: _controller.animateTo(200.0,
duration:
const Duration(milliseconds: 200));
},
child: modalHeight == 200
? const Icon(Icons.close)
: const Icon(Icons.arrow_back),
)
],
),
),
const Divider(height: 30),
modalHeight == 200
? Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
innerContainer(
Icons.fingerprint_rounded, '指纹校验', () {
// 校验指纹的方法略
}),
innerContainer(Icons.password, '密码校验', () {
_controller.animateTo(600.0,
duration:
const Duration(milliseconds: 200));
setAnimationState(() {
checkType = 'passwordCheck';
});
}),
innerContainer(Icons.timeline, '手势校验', () {
print(checkType);
_controller.animateTo(600.0,
duration:
const Duration(milliseconds: 200));
setAnimationState(() {
checkType = 'gestureCheck';
});
})
])
: checkType == 'passwordCheck'
? Row(
children: [
Expanded(
child: Padding(
padding:
const EdgeInsets.only(left: 20),
child: TextFormField(
//输入内容,隐藏
obscureText: true,
autofocus: true,
decoration: InputDecoration(
contentPadding:
EdgeInsets.symmetric(
horizontal: 20,
vertical: 5),
hintText: '请输入密码',
),
onChanged: (v) {
setAnimationState(() {
password = v;
});
},
),
),
),
Container(
width: 30,
margin: EdgeInsets.symmetric(
horizontal: 20),
child: GestureDetector(
onTap: () {
if (password == '***用户的密码') {
Navigator.pop(context);
Navigator.pushNamed(
context, '***跳转的页面');
}
},
child: Icon(Icons.done),
))
],
)
: Expanded(child: Text('手势解锁的组件 略')),
]),
);
},
);
});
}
}
总结
- showModalBottomSheet 可以实现底部弹窗,其自己是一个浮动层,不与其他组件使用同一个上下文,需要添加StaetfulBuilder为其添加内部状态
- 使用AnimationController时需要初始化动画
- 像是浮动层的组件使用动画时,我的处理方式是为全局初始化一个以后实现的方法,在初始化动画时调用,在创建内部状态builder时将方法指向该内部状态改变的方法。