背景
Flutter的material
包中本身提供了SnackBar
的实现。使用方式见Displaying SnackBars。
效果如下:
在底部显示SnackBar
的方式并不符合所有的业务场景,所以我们有时需要另一种实现。
效果如下:

需要直接使用的,请查看flutter_snackbar。
实现
对效果进行拆解,整个SnackBarWidget分为两部分:
- 动画提示部分
-
内容部分
刨除布局,另外从演示效果中还可以看出,提示内容是动态变化的。
实现布局
而动画提示部分能够覆盖在内容部分之上,此处应该使用Stack
来实现布局的覆盖,并且布局顺序应如下:
Stack( childern:[ Container(),// 先布局内容部分,保证内容部分不会覆盖提示部分 SnackBarAnimation() // 然后布局动画提示部分 ] )简单实现如下:
class SnackBarApp extends StatelessWidget { GlobalKey<SnackBarWidgetState> _globalKey = GlobalKey(); int count = 0; @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData(primarySwatch: Colors.blue), home: Scaffold( appBar: AppBar( title: Text("SnackBar"), ), body: SnackBarWidget( text: Text("内容不变时使用text属性"), content: Center(child: Text("这是内容部分"))), )); } } class SnackBarWidget extends StatefulWidget { final Text text; final Widget content; const SnackBarWidget({Key key, this.text, this.content}) : super(key: key); @override State<StatefulWidget> createState() { return SnackBarWidgetState(); } } class SnackBarWidgetState extends State<SnackBarWidget> { @override Widget build(BuildContext context) { return Stack( children: <Widget>[ SizedBox.expand(child: widget.content), SnackBarAnimation(child: widget.text) ], ); } } class SnackBarAnimation extends StatelessWidget { final Widget child; const SnackBarAnimation({Key key, this.child}) : super(key: key); @override Widget build(BuildContext context) { return child; } }但是此时的效果如下:
如果需要实现最开始的效果图中的效果,则需要增加一定的修饰。首先为SnackBarWidget
增加padding
、margin
以及decoration
属性,其构造函数修改如下:
class SnackBarWidget extends StatefulWidget { SnackBarWidget( {Key key, this.text, this.content, this.padding, this.margin, this.duration, this.decoration}) : super(key: key); }然后在构建Widget时为
SnackBarAnimation
增加内外边距以及Decoration:
class SnackBarWidgetState extends State<SnackBarWidget> { @override Widget build(BuildContext context) { return Stack( alignment: AlignmentDirectional.topCenter, // 顶部居中 fit: StackFit.loose, // 如果child没有指定位置,则采用使用child自身的大小 children: <Widget>[ SizedBox.expand(child: widget.content), SnackBarAnimation( child: Container( child: widget.text, padding: widget.padding, margin: widget.padding, decoration: widget.decoration)) ], ); } }然后在调用处传入对应的内外边距以及
Decoration
即可:
// SnackBarApp SnackBarWidget( // 内容不变时使用text属性 text: Text("内容不变时使用text属性"), // 用于显示内容,默认是填充空白区域的 content: Center(child: Text("这是内容部分")), decoration: ShapeDecoration( shape: RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(20))), color: Colors.blue.withOpacity(0.8)), padding: EdgeInsets.fromLTRB(20.0, 10.0, 20.0, 10.0), margin: EdgeInsets.only(top: 10.0), )此时效果如下:

实现动画
布局已经完成,最后需要实现动画部分。
在该Widget中需要交错执行两个动画,淡入动画及位移动画。关于交叉动画,具体请查阅交错动画。
首先实现淡入动画:
Animation<double> fade = Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation( parent: controller, curve: Interval(0.0, 0.3, // 前30%的时间用于执行淡入动画 curve: Curves.ease)));
此处Tween<double>(begin: 0.0, end: 1.0)
表示动画的值在0.0~1.0之前变化,可以通过fade.value
来获取动画执行过程中对应的当前值,用来更新Widget的Opacity。
controller
为调用SnackAnimation
处传入的AnimationController
。
然后实现位移动画:
Animation<double> translate = Tween<double>(begin: -deltaY, end: 0).animate(CurvedAnimation( parent: controller, curve: Interval(0.0, 0.15, curve: Curves.ease))); // 前15%的时间用于执行平移动画
deltaY
值,该值是位移动画对应的具体位移值。此处begin=-deltaY
,表示初始化时将Widget完全隐藏(将Widget平移到可视区域外,对用户不可见)。想要得到
deltaY
的值,则需要计算Widget的整体高度/在屏幕的位置。要实现这一功能,我们需要通过BuildContext
中的size
属性来获取Widget的宽高,而要获取到Widget对应的BuildContext
,则需要一个GlobalKey
,故应该将SnackBarAnimation
构造函数中的key
标记为@required
。修改如下:
SnackBarAnimation( {@required GlobalKey key, @required this.controller, this.child}) : assert(key != null), // 由于需要通过BuildContext来获取Widget的高度,此处的key为必须的 assert(controller != null), super(key: key);获取控件的高度实现如下:
double deltaY = (key as GlobalKey).currentContext.size.height;动画创建完成,剩下的就是将
fade
和translate
应用在Widget上,此时我们需要使用Transform.translate和Opacity来对Widget的位移和不透明度进行改变。代码如下:
class SnackBarAnimation extends StatelessWidget { SnackBarAnimation( {@required GlobalKey key, @required this.controller, this.child}) : assert(key != null), // 由于需要通过BuildContext来获取Widget的高度,此处的key为必须的 assert(controller != null), super(key: key); @override Widget build(BuildContext context) { return AnimatedBuilder( builder: _buildAnimation, animation: controller, child: child, ); } Widget _buildAnimation(BuildContext context, Widget child) { return Transform.translate( child: Opacity( child: child, opacity: fade != null ? fade.value : 0), // 此处使用fade.value不断取值来刷新child的opacity offset: Offset( 0, translate != null ? translate.value : 0), // 此处使用translate.value不断取值来刷新child的偏移量 ); } }然后再通过
AnimationController
来控制动画的执行和取消即可。完整代码如下:
class SnackBarAnimation extends StatelessWidget { final AnimationController controller; final Container child; Animation<double> fade; Animation<double> translate; SnackBarAnimation( {@required GlobalKey key, @required this.controller, this.child}) : assert(key != null), // 由于需要通过BuildContext来获取Widget的高度,此处的key为必须的 assert(controller != null), super(key: key); @override Widget build(BuildContext context) { return AnimatedBuilder( builder: _buildAnimation, animation: controller, child: child, ); } // 开始播放动画 Future<Null> playAnimation() async { // 此处通过key去获取Widget的Size属性 double deltaY = (key as GlobalKey).currentContext.size.height; // 该值为位移动画需要的位移值 // 如果fade动画不存在,则创建一个新的fade动画 fade = fade ?? Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation( parent: controller, curve: Interval(0.0, 0.3, // 持续时间为总持续时间的30% curve: Curves.ease))); translate = translate ?? Tween<double>(begin: -deltaY, end: 0).animate(CurvedAnimation( parent: controller, curve: Interval(0.0, 0.15, curve: Curves.ease))); // 前15%的时间用于执行平移动画 try { await controller.forward().orCancel; await controller.reverse().orCancel; } on TickerCanceled {} } Future<Null> reverseAnimation() async { try { await controller.reverse().orCancel; } on TickerCanceled {} } Widget _buildAnimation(BuildContext context, Widget child) { return Transform.translate( child: Opacity( child: child, opacity: fade != null ? fade.value : 0), // 此处使用fade.value不断取值来刷新child的opacity offset: Offset( 0, translate != null ? translate.value : 0), // 此处使用translate.value不断取值来刷新child的偏移量 ); } }将
SnackBarApp
和SnackBarWidgetState
补充完整即可实现动画效果。代码如下:
class SnackBarApp extends StatelessWidget { GlobalKey<SnackBarWidgetState> _globalKey = GlobalKey(); @override Widget build(BuildContext context) { return MaterialApp( theme: ThemeData(primarySwatch: Colors.blue), home: Scaffold( appBar: AppBar(title: Text("SnackBar"), actions: <Widget>[ InkWell( child: Padding( child: Center( child: Text("显示"), ), padding: EdgeInsets.only(left: 10, right: 10), ), onTap: () { _globalKey.currentState.show(); }, ), Padding( child: InkWell( child: Padding( child: Center( child: Text("隐藏"), ), padding: EdgeInsets.only(left: 10, right: 10), ), onTap: () { _globalKey.currentState.dismiss(); }, ), padding: EdgeInsets.only(right: 10), ) ]), body: SnackBarWidget( key: _globalKey, text: Text("内容不变时使用text属性"), // 用于显示内容,默认是填充空白区域的 content: Center(child: Text("这是内容部分")), decoration: ShapeDecoration( shape: RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(20))), color: Colors.blue.withOpacity(0.8)), padding: EdgeInsets.fromLTRB(20.0, 10.0, 20.0, 10.0), margin: EdgeInsets.only(top: 10.0), ))); } } class SnackBarWidgetState extends State<SnackBarWidget> with TickerProviderStateMixin { final GlobalKey _snackKey = GlobalKey(); AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( duration: widget.duration ?? Duration(milliseconds: 1400), vsync: this); } @override void dispose() { // 此时进行资源回收 _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Stack( alignment: AlignmentDirectional.topCenter, // 顶部居中 fit: StackFit.loose, // 如果child没有指定位置,则采用使用child自身的大小 children: <Widget>[ SizedBox.expand(child: widget.content), SnackBarAnimation( key: _snackKey, controller: _controller, child: Container( child: widget.text, padding: widget.padding, margin: widget.padding, decoration: widget.decoration)) ], ); } /// 显示SnackBar /// [message] 要更新的提示内容 void show([String message]) { // if (_textKey != null && _textKey.currentState != null) { // _textKey.currentState.update(message); // } (_snackKey.currentWidget as SnackBarAnimation).playAnimation(); } /// 隐藏SnackBar void dismiss() { if (_controller.isDismissed) return; (_snackKey.currentWidget as SnackBarAnimation).reverseAnimation(); } }此时,效果如下:

动态改变提示内容
现在布局、动画已实现,只需要再实现动态改变提示内容功能就大功告成了。
但是之前的提示就是一个简单的Text
控件,而Text
本身继承自StatelessWidget
,内容是无法动态修改的,应该怎么实现呢?
此时就需要将Text
包装为一个StatefulWidget
,具体实现如下:
/// 能够动态更新内容的[Text] class DynamicText extends StatefulWidget { DynamicText({Key key}) : super(key: key); @override State<StatefulWidget> createState() { return DynamicTextState(); } } class DynamicTextState extends State<DynamicText> { String _message; @override Widget build(BuildContext context) { return Text(_message); } void update([String message]) { if (message == _message) return; // 如果文案相同,则不刷新Text setState(() { _message = message; }); } }
SnackBarWidget
中的Text
替换为DynamicText
,然后通过GlobalKey<DynamicTextState>.update()
就能够修改提示内容。而为了能够随时修改
Text
的样式,我们最好从外部传入一个Text
,以便于能够使用Text
控件的所有属性。此处的实现可以参考ListView.builder中的itemBuilder。实现如下:
/// 用于动态构建[Text],以实现动态改变[SnackBarWidget]中内容的目的 typedef TextBuilder = Text Function(String message);此时只需要在使用
SnackBarWidget
中如下使用即可实现动态改变提示内容。当然,此前需要为SnackBarWidget
添加textBuilder
属性来替代text
属性:
SnackBarWidget( // 绑定GlobalKey,用于调用显示/隐藏方法 key: _globalKey, //textBuilder用于动态构建Text,用于显示变化的内容。优先级高于'text'属性 textBuilder: (String message) { return Text(message ?? "", style: TextStyle(color: Colors.white, fontSize: 16.0)); }, // 内容不变时使用text属性 text: Text("内容不变时使用text属性"), // 设定背景decoration decoration: ShapeDecoration( shape: RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(20))), color: Colors.blue.withOpacity(0.8)), // 用于显示内容,默认是填充空白区域的 content: Center(child: Text("这是内容部分")))
至此为止,整个SnackBarWidget
的定义过程完成,可以将控件引用到自己的布局中使用了!
关于vsync
:
在创建AnimationController
时需要传入一个vsync
参数,其作用是防止超出屏幕的动画消耗系统资源,可以通过为StatefulWidget
添加SingleTickerProviderStateMixin来将其转换为TickerProvider
类型。
原文请查看:AnimationController中关于vsync
的说明。
完整实现请查看flutter_snackbar。
发表评论