Flutter 实现底部扩散模糊动画(一)跳转页面

5,348 阅读7分钟

相关文章

背景

  一直以来,项目组的小伙伴对于某安的设计和交互十分喜爱,从首页布局到用户页样式到加号扩散动画,都想用到项目里来。鉴于他们强烈的热爱,已经实现了部分布局看齐。最近,终于轮到了要实现点击底部加号后出现扩散动画,并出现几项操作项的动画的时候了。

简介

  阅读这篇文章前,你需要对Flutter有一定的了解,包括生命周期、高斯模糊、动画、MediaQuery等相关知识,当然,所有内容都可以通过搜索找到~

  效果图:   

  交互过程主要分为以下三步:

  • 点击加号,从加号位置以圆形扩散高斯模糊效果;
  • 操作项依次出现,并附带一定的动画效果;
  • 点击"X"或空白处或系统返回键,背景以圆形收缩至加号位置。


  完整demo及组件已上传至项目,走过路过留个star~

前置条件

  想要实现效果,首先有几点前置条件需要明确:

  • 路由需要做成透明路由,否则高斯模糊无法作用在上一个路由之上;
  • 根据生命周期,动画的执行必须要在第一次build后立即执行,而不能在initStatedidChangeDependencies里执行,否则会存在context为空或触发时机错误的问题;
  • 关闭动画必须要在pop()前执行,否则widget已经被取消挂载(this.mounted == false)

实现过程

  下面是具体的实现过程,将配合上述条件进行说明。

透明跳转路由

  网上有非常多的透明路由实例,包括法法路由里也包含了透明路由,此处不再赘述,直接贴上代码。

class TransparentRoute extends PageRoute<void> {
    TransparentRoute({
        @required this.builder,
        RouteSettings settings,
    })  : assert(builder != null),
                super(settings: settings, fullscreenDialog: false);

    final WidgetBuilder builder;

    @override
    bool get opaque => false;
    @override
    Color get barrierColor => null;
    @override
    String get barrierLabel => null;
    @override
    bool get maintainState => true;
    @override
    /// 这里时长设置为0,是因为我们的布局一开始
    /// 并不包含任何内容,所以直接砍掉跳转时间。
    Duration get transitionDuration => Duration.zero;

    @override
    Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
        final result = builder(context);
        return Semantics(
            scopesRoute: true,
            explicitChildNodes: true,
            child: result,
        );
    }
}

  构建完成后,直接push就OK。

Navigator.of(context).push(TransparentRoute(
    builder: (context) => AddingButtonPage(),
));

扩散动画

  在widget中实现运行动画,首先需要加入TickerProviderStateMixin,并且声明一个controller和动画(Animation)本身。

class _DemoPageState extends State<DemoPage>
    with TickerProviderStateMixin {
/.../
    Animation<double> _backDropFilterAnimation;
    AnimationController _backDropFilterController;

  在随后的功能中,我们首先对controller进行初始化,设定一个动画时长。

_backDropFilterController = AnimationController(
    duration: Duration(milliseconds: 300),
    vsync: this,
);

  这时我们开始思考扩散大小的问题:以底部为中心,半径逐渐放大的圆,当半径达到多少时能完全覆盖可视范围呢?

答案:√ (width² + (height * 2 + padding.top)²) / 2
根号(二倍高的平方加宽的平方)的一半

  是不是一个非常熟悉的公式?没错,它就是“勾股定理”~

  贴上以dart:math简单实现的勾股定理:

import 'dart:math' as math;

double pythagoreanTheorem(double short, double long) {
    return math.sqrt(math.pow(short, 2) + math.pow(long, 2));
}

  这里利用一张图片说明半径的问题。

  为了让模糊控件能完整的覆盖视图区域,扩散的圆的半径必须大于以视图长的两倍和宽及其顶点连接而成的斜边的长度,而不能只是视图的高度。padding.top是状态栏的高度,也要加入到高度中。

  所以,我们就确定了圆形的终止半径,且起始半径为0。这个时候可以写出第一个Tween了,用于确定圆形半径的变化范围。MediaQuery用于获取视图长短边。顺便定义一个曲线,实现曲线过渡效果。Flutter的Curves里内置了许多曲线,在这我选用了Curves.easeInOut

/// 视野区域的大小(Size)
final MediaQueryData m = MediaQuery.of(context);
final Size s = m.size;
final double r = pythagoreanTheorem(s.width, s.height * 2 + m.padding.top) / 2;

/// 动画曲线
Animation _backDropFilterCurve = CurvedAnimation(
    parent: _backDropFilterController,
    curve: Curves.easeInOut,
);

/// 放大动画的设定档
Animation<double> _backDropFilterAnimation = Tween(
    begin: 0.0, end: r * 2
).animate(_backDropFilterCurve);

  此处终止值是两倍半径的原因是圆形的绘制是以圆形的外正方形大小来进行的绘制的,所以此处大小需要设置为两倍半径,以达到真正的半径效果。

  一个动画的设定档完成了,要想让动画动起来,需要把动画执行的值和一个变量绑定,并且执行动画。所以我们给这个动画加上监听后执行setState以更新大小,并且执行动画。

/// 保存半径的变量
double _backdropFilterSize = 0.0;

/// 监听动画执行
_backDropFilterAnimation.addListener(() {
    setState(() {
        _backdropFilterSize = _backDropFilterAnimation.value;
    });
});

/// 正向执行动画
_backDropFilterController.forward();

  至此,放大动画已经完成了设定,接下来我们创建布局与该动画进行绑定。

高斯模糊布局

  刚刚在设定动画时我们已经知道,圆形的最终大小是远远超过视图可视大小的,在Flutter中想要实现这样的相对布局或绝对布局,我们需要用到Stack。这时需要注意,Stack的溢出属性(overflow)需要设置为显示,否则圆形只能扩大到视图最大宽度。

Stack(
    overflow: Overflow.visible,
    children: <Widget>[],
);

  我们开始来考虑高斯模糊的区域大小。已知圆形的半径为对角线长度,那么以此设定的区域应该是多大呢?

  再次拿出一张图来看看我们的扩散圆形相对于视图应该处于什么位置:

  Positioned使用的是绝对布局,在此处,它的参考系是视图区域。那么我们可以很轻易的判断顶部和横向的溢出,用于计算大小。

final MediaQueryData m = MediaQuery.of(context);
final Size s = m.size;
final double r = pythagoreanTheorem(s.width, s.height * 2 + m.padding.top) / 2;

/// 顶部溢出大小
final double topOverflow = r - s.height;
/// 横向溢出大小
final double horizontalOverflow = r - s.width;

return Stack(
    overflow: Overflow.visible,
    children: <Widget>[
        Positioned(
            left: - horizontalOverflow,
            right: - horizontalOverflow,
            top: - topOverflow,
            bottom: - r,
/.../

  以此设定范围,就是圆形扩大到最大半径时外正方形的大小。

  在Flutter中实现高斯模糊非常简单,只需要使用BackdropFilter即可,通常来说需要在外包裹ClipRect用来解决模糊区域的问题,而我们的需求是圆形,所以在这里应该使用ClipRRect

import 'dart:ui' as ui;

Stack(
    overflow: Overflow.visible,
    children: <Widget>[
        Positioned(
            left: - horizontalOverflow,
            right: - horizontalOverflow,
            top: - topOverflow,
            bottom: - r,
            child: SizedBox(
                /// 高宽与变量绑定
                width: _backdropFilterSize,
                height: _backdropFilterSize,
                /// 使用圆角ClipRRect达到圆形效果
                child: ClipRRect(
                    /// 圆角的大小,使用最大值则所有时候都为圆形
                    borderRadius: BorderRadius.circular(r * 2),
                    child: BackdropFilter(
                        /// XY用于设定模糊程度
                        filter: ui.ImageFilter.blur(sigmaX: 20.0, sigmaY: 20.0),
                        /// 使用空格占位,否则模糊背景不显示
                        child: Text(" "),
                    ),
                ),
            ),
        ),
    ],
);

  将高斯模糊控件放入布局中,我们便完成了圆形的定位。

设定可放置内容的区域

  实现了背景模糊,接下来就是将内容放置在布局中合理的大小区域。

  我们的圆形上半部分位于可视区域,所以我们在背景中,使用Align,利用溢出大小和已知的可视区域大小,便可以确定内容放置的位置。

Stack(
    overflow: Overflow.visible,
    children: <Widget>[
        Positioned(...),
        Align(
            /// 区域相对顶部居中对齐,在可视区域附近
            alignment: Alignment.topCenter,
            child: Container(
                /// 推出顶部溢出部分,使得区域顶部对齐视图顶部
                margin: EdgeInsets.only(top: topOverflow),
                /// 将可视区域大小设定为控件大小
                width: s.width,
                height: s.height,
                /// 设置constraint,防止子控件发生意料之外的溢出
                constraints: BoxConstraints(
                    maxWidth: s.width,
                    maxHeight: s.height,
                ),
                child: child ?? SizedBox(),
            ),
        );
    ],
);

  至此,我们可以很方便地在模糊区域内放置内容了,不需要使用时再去设置布局。

整体执行

  动画部分完成,我们将动画部分封装起来,加入到首次完成build后执行。

import 'package:flutter/scheduler.dart';

class _AddingButtonPageState extends State<AddingButtonPage> with TickerProviderStateMixin {
    @override
    void initState() {
        /// 使用scheduler,将动画加入到build后进行
        SchedulerBinding.instance.addPostFrameCallback((_) => backDropFilterAnimate(context));
        super.initState();
    }
    
    
    void backDropFilterAnimate(BuildContext context) async {
        final Size s = MediaQuery.of(context).size;

        _backDropFilterController = AnimationController(
            duration: Duration(milliseconds: _animateDuration),
            vsync: this,
        );
        Animation _backDropFilterCurve = CurvedAnimation(
            parent: _backDropFilterController,
            curve: Curves.easeInOut,
        );
        _backDropFilterAnimation = Tween(
            begin: 0.0,
            end: pythagoreanTheorem(s.width, s.height) * 2,
        ).animate(_backDropFilterCurve)
            ..addListener(() {
                setState(() {
                    _backdropFilterSize = _backDropFilterAnimation.value;
                });
            });
        _backDropFilterController.forward();
    }
    
/.../

  至此,一个底部扩散模糊动画跳转页面的动画就这样轻松如意的完成啦~

结语

  根据几个月的潜水经验,大多数人觉得Flutter制作动画困难是因为看不懂Animation的各种属性和操作,甚至文档都生涩难懂,可其实真正写出来后,动画部分也只有少量代码,很容易就可以理解其中的含义。

  最后欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果 (QQ群:181398081)