背景
Flutter的material
包中本身提供了SnackBar
的实现。使用方式见Displaying SnackBars。
效果如下:
官方SnackBar效果
在底部显示SnackBar
的方式并不符合所有的业务场景,所以我们有时需要另一种实现。
效果如下:
自定义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。
我的公众号,欢迎关注
网友评论