Bootstrap

【 Flutter 】 超详细 使用showModalBottomSheet 和 AnimationController 实现一个优雅的自定义动态底部弹窗

场景: App在进入二级菜单的时候,需要出现一个底部弹窗以引导用户进行身份核验,本次我打算使用原生的showModalBottomSheet以创建一个底部弹出菜单;再使用AnimationController实现对菜单高度的自定义调整动画。

阅前须知:

  1. 代码是功能实现后改动复现的,可能存在拼写不同或者有些变量不存在的情况,请视情况修改;
  2. 不可转载,欢迎讨论、提议提问、指出错误;
  3. 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('点我触发')
);

如此,就创建了一个包含有按钮的简单弹窗组件

在这里插入图片描述
接下来,我们需要进行第二步,为按钮添加点击事件,并且相关按钮需要有将弹窗拉高的操作;

  1. 在stateFulWidget的类中初始化弹窗高度
…
class _TestPageState extends State {
// 添加下面这句
	double modalHeight = 200;
	@override
	Widget build(BuildContext context){}
  1. 给点击事件添加状态以改变弹窗高度状态
...
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,它的使用方法也很简单

  1. 让你的组件知道页面要执行动画,继承相关方法SingleTickerProviderStateMixin

class _TestPageState extends State with SingleTickerProviderStateMixin

  1. 初始化你的动画

class _TestPageState extends State with SingleTickerProviderStateMixin{
late AnimationController _controller; //加这句
}

  1. 在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('手势解锁的组件 略')),
                    ]),
              );
            },
          );
        });
  }
}

总结

  1. showModalBottomSheet 可以实现底部弹窗,其自己是一个浮动层,不与其他组件使用同一个上下文,需要添加StaetfulBuilder为其添加内部状态
  2. 使用AnimationController时需要初始化动画
  3. 像是浮动层的组件使用动画时,我的处理方式是为全局初始化一个以后实现的方法,在初始化动画时调用,在创建内部状态builder时将方法指向该内部状态改变的方法。
;