Flutter之使用Overlay创建全局Toast并静态调用

Toast在Android上是最常用的提示组件了,它的优势在于静态调用、全局显示,可以在任意你想要的地方调用他而丝毫不影响界面的布局,调用简单程度与Logger的调用不相上下。
然而在Flutter中并没有给我们提供Toast的接口,想要实现Toast的效果有两种途径,一种是接Android/iOS原生工程,第二种是不依托于使用Flutter来实现。
本篇选用第二种方案来实现,接原生代码一方面要求双端开发工作量和门槛都较大,而且不利于以后的样式扩展,二是纯Flutter实现的Toast确实效果非常好,自定义样式也非常的方便。使用Flutter相对于RN来说,Flutter的渲染引擎是非常强大的,基本上能用Flutter实现的效果都不建议接原生,而RN则没有自己的渲染引擎,性能的限制造成RN需要频繁的接入原生模块,这也是我倾心Flutter的原因。

效果图

本篇要用的核心组件是Overlay,这个组件提供了动态的在Flutter的渲染树上插入布局的特性,从而让我们有了在包括路由在内的所有组件的上层插入toast的可能性。

创建Flutter工程

本品系列的Flutter博客都会以创建纯净的Flutter工程开篇,创建工程后,放一个Button在布局中,便于触发Toast调用。
代码:略。

使用Overlay插入Toast布局

因为我们要实现全局的静态调用,所以这里先创建一个工具类,并在这个类中创建静态方法show:

class Toast {
    
    static show(BuildContext context, String msg) {
        //这里实现toast的弹出逻辑
    }

}

这是一种很常见的静态调用方式,是需要在你的某个回调中调用Toast.show(context, "你的消息提示");即可完成toast的显示,而不用考虑布局嵌套问题。

下面我们就在show方法中向布局中插入一个toast:

class Toast {
  static show(BuildContext context, String msg) {
    var overlayState = Overlay.of(context);
    OverlayEntry overlayEntry;
    overlayEntry = new OverlayEntry(builder: (context) {
      return buildToastLayout(msg);
    });
    overlayState.insert(overlayEntry);
  }

  static LayoutBuilder buildToastLayout(String msg) {
    return LayoutBuilder(builder: (context, constraints) {
      return IgnorePointer(
        ignoring: true,
        child: Container(
          child: Material(
            color: Colors.white.withOpacity(0),
            child: Container(
              child: Container(
                child: Text(
                  "${msg}",
                  style: TextStyle(color: Colors.white),
                ),
                decoration: BoxDecoration(
                  color: Colors.black.withOpacity(0.6),
                  borderRadius: BorderRadius.all(
                    Radius.circular(5),
                  ),
                ),
                padding: EdgeInsets.symmetric(vertical: 10, horizontal: 10),
              ),
              margin: EdgeInsets.only(
                bottom: constraints.biggest.height * 0.15,
                left: constraints.biggest.width * 0.2,
                right: constraints.biggest.width * 0.2,
              ),
            ),
          ),
          alignment: Alignment.bottomCenter,
        ),
      );
    });
  }
}

在show方法中使用Overlay插入了一个OverlayEntry,而OverlayEntry负责构建布局,buildToastLayout方法这是一个正常的布局构建方法,通过这个方法我们构建了一个Toast样式的ToastView,并通过OverlayEntry插入到了整个布局的最上层。
这时候通过调用Toast.show方法就能在界面上看到一个Toast样式的提示了。
但是,这个ToastView是不会消失的,它会一直呆在界面上,这显然不是我们想要的。

让Toast自动消失

我们继续改造这个Toast,让它能够自动消失。
创建一个叫做ToastView的类,便于控制每次插入的ToastView:

class ToastView {
  OverlayEntry overlayEntry;
  OverlayState overlayState;
  bool dismissed = false;

  _show() async {
    overlayState.insert(overlayEntry);
    await Future.delayed(Duration(milliseconds: 3500));
    this.dismiss();
  }

  dismiss() async {
    if (dismissed) {
      return;
    }
    this.dismissed = true;
    overlayEntry?.remove();
  }
}

这样,就把ToastView的显示和消失的控制封装起来了。然后在Toast的show方法中对他进行调用

class Toast {
  static show(BuildContext context, String msg) {
    var overlayState = Overlay.of(context);
    OverlayEntry overlayEntry;
    overlayEntry = new OverlayEntry(builder: (context) {
      return buildToastLayout(msg);
    });
    var toastView = ToastView();
    toastView.overlayState = overlayState;
    toastView.overlayEntry = overlayEntry;
    toastView._show();
  }
  ...
}

通过上面的方法,已经实现了Toast的全局静态调用,并插入全局布局,并在显示3.5秒后自动消失的Toast,但是这个toast好像怪怪的,没错,他没有动画,下面来给这个toast增加动画。

给Toast增加动画

这个Toast的动画算是Flutter的高级应用了,它涉及到了缩放,位移,自定义差值器,AnimatedBuilder等特性,本篇的核心在介绍Overlay的使用和ToastView的封装,关于动画的使用如果在这里讲就发散的太多了,篇幅限制以后单独来讲动画吧,这里以你对动画系统了解的前提来讲解。

class Toast {
  static show(BuildContext context, String msg) {
    var overlayState = Overlay.of(context);
    var controllerShowAnim = new AnimationController(
      vsync: overlayState,
      duration: Duration(milliseconds: 250),
    );
    var controllerShowOffset = new AnimationController(
      vsync: overlayState,
      duration: Duration(milliseconds: 350),
    );
    var controllerHide = new AnimationController(
      vsync: overlayState,
      duration: Duration(milliseconds: 250),
    );
    var opacityAnim1 =
        new Tween(begin: 0.0, end: 1.0).animate(controllerShowAnim);
    var controllerCurvedShowOffset = new CurvedAnimation(
        parent: controllerShowOffset, curve: _BounceOutCurve._());
    var offsetAnim =
        new Tween(begin: 30.0, end: 0.0).animate(controllerCurvedShowOffset);
    var opacityAnim2 = new Tween(begin: 1.0, end: 0.0).animate(controllerHide);

    OverlayEntry overlayEntry;
    overlayEntry = new OverlayEntry(builder: (context) {
      return ToastWidget(
        opacityAnim1: opacityAnim1,
        opacityAnim2: opacityAnim2,
        offsetAnim: offsetAnim,
        child: buildToastLayout(msg),
      );
    });
    var toastView = ToastView();
    toastView.overlayEntry = overlayEntry;
    toastView.controllerShowAnim = controllerShowAnim;
    toastView.controllerShowOffset = controllerShowOffset;
    toastView.controllerHide = controllerHide;
    toastView.overlayState = overlayState;
    preToast = toastView;
    toastView._show();
  }
  ...
}

class ToastView {
  OverlayEntry overlayEntry;
  AnimationController controllerShowAnim;
  AnimationController controllerShowOffset;
  AnimationController controllerHide;
  OverlayState overlayState;
  bool dismissed = false;

  _show() async {
    overlayState.insert(overlayEntry);
    controllerShowAnim.forward();
    controllerShowOffset.forward();
    await Future.delayed(Duration(milliseconds: 3500));
    this.dismiss();
  }

  dismiss() async {
    if (dismissed) {
      return;
    }
    this.dismissed = true;
    controllerHide.forward();
    await Future.delayed(Duration(milliseconds: 250));
    overlayEntry?.remove();
  }
}

class ToastWidget extends StatelessWidget {
  final Widget child;
  final Animation<double> opacityAnim1;
  final Animation<double> opacityAnim2;
  final Animation<double> offsetAnim;

  ToastWidget(
      {this.child, this.offsetAnim, this.opacityAnim1, this.opacityAnim2});

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: opacityAnim1,
      child: child,
      builder: (context, child_to_build) {
        return Opacity(
          opacity: opacityAnim1.value,
          child: AnimatedBuilder(
            animation: offsetAnim,
            builder: (context, _) {
              return Transform.translate(
                offset: Offset(0, offsetAnim.value),
                child: AnimatedBuilder(
                  animation: opacityAnim2,
                  builder: (context, _) {
                    return Opacity(
                      opacity: opacityAnim2.value,
                      child: child_to_build,
                    );
                  },
                ),
              );
            },
          ),
        );
      },
    );
  }
}

class _BounceOutCurve extends Curve {
  const _BounceOutCurve._();

  @override
  double transform(double t) {
    t -= 1.0;
    return t * t * ((2 + 1) * t + 2) + 1.0;
  }
}

这是段非常长的代码,本来是不想往上面贴这么多代码的,但是动画这块儿讲的话篇幅又太长,不贴代码的话讲起来又太空洞,只能贴了,大概说一下。
上面代码分为四段:
第一段,在show方法中创建3个动画,Toast显示的位移和渐显动画,Toast消失的渐隐动画,然后把这三个动画的controller交给ToastView来控制动画播放。
第二段,在ToastView中接收三个动画controller,并在show和dismiss方法中控制动画的播放。
第三段,创建一个自定义Widget,并使用三个AnimatedBuilder来实现动画,并在show方法中把Toast的布局包裹起来。
第四段,定义了一个动画差值器,Flutter中提供了很多动画差值器,但是并没有我们需要的,所以这里定义一个弹跳一次后回弹的动画差值器用来控制ToastView的偏移动画效果。

到目前为止,这个Toast已经满足了最基本的样式,全局调用,动画弹出,延迟3.5秒后自动渐隐消失。

防止连续调用造成toast堆叠

但是还存在一个问题,因为Toast的样式的半透明的黑色,如果连续调用多次的话,会有多个Toast同时弹出,并堆叠在一起,会显得非常的黑。

下面再做一个处理,在show之前,判断是否已经有一个Toast在显示了,如果有,即刻把它dismiss了。

  static ToastView preToast;

  static show(BuildContext context, String msg) {
    preToast?.dismiss();
    preToast = null;
    ...
    preToast = toastView;
    toastView._show();
  }
  ...
}

这样就可以了,?.操作符和kotlin的效果是一样的,空指针安全,很舒服。


更多干货移步我的个人博客 https://www.nightfarmer.top/

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,992评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,212评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,535评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,197评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,310评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,383评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,409评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,191评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,621评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,910评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,084评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,763评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,403评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,083评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,318评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,946评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,967评论 2 351

推荐阅读更多精彩内容