使用Animation实现SnackBar

背景

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增加paddingmargin以及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;

动画创建完成,剩下的就是将fadetranslate应用在Widget上,此时我们需要使用Transform.translateOpacity来对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的偏移量
    );
  }
}

SnackBarAppSnackBarWidgetState补充完整即可实现动画效果。代码如下:

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

我的公众号,欢迎关注