阅读 834

Flutter如何为初始路由添加动画?页面中单个元素又如何随路由动起来?

  今天给大家将两个关于路由的骚操作,虽然说项目里不太会用到,但是看看涨涨姿势总是好的。
  我想大家应该都知道,Flutter在push/pop路由的时候,都是可以自定义动画的,路由动画在Flutter里面写起来非常的灵活,一般来说在push的时候带上一个自定义的Route就可以了:

Navigator.of(context).push(
    PageRouteBuilder(
        pageBuilder: (context, _, __) {
            return ProductDetailPage(
                product: product,
            );
        },
        transitionDuration:
            const Duration(milliseconds: 500),
        transitionsBuilder:
            (_, animation, __, child) {
                return FadeTransition(
                    opacity: animation,
                    child: FadeTransition(
                        opacity:
                            Tween(begin: 0.5, end: 1.0).animate(animation),
                                child: child,
                            ),
                        );
            }),
        );
复制代码

  transitionDuration定义路由动画时间,transitionsBuilder中可以自定义路由动画,旋转、位移、缩放等等都可以。

  那么,你有没有想过下面这两个问题呢?

  • push/pop路由的时候可以带动画,可是初始页面该怎么给它添加路由动画呢(Flutter为了能快速打开App,初始路由是默认不带任何transition变换的)?
  • 我想在新页面中做这么一个效果:打开页面图片动画进入,关闭页面图片反向退出,除了自己手动控制,有没有其他的方法呢?

  其实,这两个问题都是属于路由范畴

  问题一同样可以使用PageRouteBuilder来解决,只是写法有些不同。一般来说我们都是直接在MaterialApp中设置home属性,配置初始页面,这样写明显是不行的啦,home属性接受的是一个Widget,而不是一个路由。莫急,很快就教你们一个不一样的设置初始页面的写法。

MaterialApp(
    home: MyHomePage(),
)
复制代码

  问题二完全可以在页面打开的时候播放一个动画A,然后监听页面关闭,关闭时倒着播放动画A。但是这么做明显不太优雅,我们换个角度来思考,从路由的角度来说,你的图片进入/退出动画不就是随着路由动画来进行的吗?它们完全可以使用同一个controller,那么你只需定义好动画,将其和路由controller绑定,什么时候开始动画,什么时候结束,都不需要你来手动控制。

如何为初始路由添加动画

  同样也是在MaterialApp中设置,但是不是设置home属性,而是onGenerateRoute属性,它和routes属性很像,也是用来配置路由的:

MaterialApp(
    onGenerateRoute: (settings) {
            if (settings.isInitialRoute) {
              return createInitialRoute();
            }
          },
)

Route<dynamic> createInitialRoute() {
    return PageRouteBuilder(
        transitionDuration: const Duration(seconds: 1),
        pageBuilder: (BuildContext context, _, __) {
          return MyHomePage(title: 'Flutter YMUI');
        },
        transitionsBuilder: (_, animation, __, child) {
          return RotationTransition(
            turns: Tween(begin: 0.0, end: 1.0).animate(animation),
            child: ScaleTransition(
              scale: Tween(begin: 0.0, end: 1.0).animate(animation),
              child: child,
            ),
          );
        });
  }
复制代码

  onGenerateRoute属性会告诉我们一个RouteSettings值,这个值有两个重要的api:

  • settings.isInitialRoute判断是否是初始路由;
  • settings.name返回路由名(和routes属性中的路由名一样,是一个字符串,用于和push/pop时的name配对)

  因此,我们根据settings.isInitialRoute判断是否是初始路由,如果是,那么就替换成我们的自定义PageRouteBuilder,这个时候是不是就很熟悉啦,我们可以肆意添加路由动画了。运行一下看下效果:

  PS:关于homeroutesonGenerateRouteonUnknownRoute属性的优先级:

  Navigator会按照home---->routes---->onGenerateRoute---->onUnknownRoute的顺序去寻找路由:

  • home,也就是初始路由,路径为:/
  • routes,也就是我们一般定义路由映射的地方,它的优先级会比onGenerateRoute,如果两者定义的路由又重复,肯定是先找routes中的;
  • onGenerateRoute优先级最低,用来处理homeroutes都没有处理的路由,所以一般返回非空值;
  • onUnknownRoute如果说某个路由上面三个都没处理,那么就会由onUnknownRoute来处理这个路由。

页面元素动画如何和路由动画绑定

  我们先看一下效果,理解一下我们的需求:

  其实很简单,就是图片和文字从页面底部进入和退出,你完全监听页面的打开和退出,手动执行动画,只是这样有点儿麻烦。获取路由动画需要用到ModalRoute这个类中的ModalRoute.of(context).animation方法。
  我们先看下相关类的继承关系:PageRouteBuilder<T> ----> PageRoute<T> ----> ModalRoute<T> ----> TransitionRoute<T> ----> OverlayRoute<T> ----> Route<T>,我们找源码可以发现控制路由动画的controller是存在于TransitionRoute中的,所以它的子类都是可以获取到这个属性的,再仔细看源码就可以发现,子类中的ModalRoute有一个.of(context)的工厂函数,可以获取到Route实例,那么绑定就可以这么写了(就是将controller赋给你自定义的图片变换动画的parent):

Animation<double> controller;
Animation<Offset> imageTranslation;

void _buildAniamtion(){
    controller = ModalRoute.of(context).controller;
    imageTranslation = Tween(
        begin: Offset(0.0, 2.0),
        end: Offset(0.0, 0.0),
      ).animate(
        CurvedAnimation(
          parent: controller,
          curve: Interval(0.0, 0.67, curve: Curves.fastOutSlowIn),
        ),
      );
 }
复制代码

  其实这么写最终运行的时候,效果也是OK的,但是你会发现有一个警告:

info: The member 'controller' can only be used within instance members of subclasses of 'package:flutter/src/widgets/routes.dart'.

  也就是说,Flutter是不建议你在页面中获取这个controller的,因为这个属性有@protected注解,虽说最后运行效果是没问题的,但是有个警告总是不太好的。源码注释终有一句话,说是controller控制的动画是通过animation暴露出来的,所以,我们不妨来看看这个animation。我们很容易就能找到animationsecondaryAnimation成员变量,是不是看起来很熟悉?创建路由时的pageBuilder就给了我们两个动画值,就是这两个动画啦。所以ModalRoute.of(context).animation获取到的animation就是PageRouteBuilder构建时,pageBuilder属性中包含的animation

/// animation 对应 ModalRoute.of(context).animation
/// secondaryAnimation 对应 ModalRoute.of(context).secondaryAnimation
 pageBuilder: (BuildContext context, Animation<double> animation,
    Animation<double> secondaryAnimation) {
            return TestPage();
    },
复制代码

  所以,我们可以这么定义我们的动画,将每一个animation都和路由的animation绑定起来就可以了(AnimationController是继承自Animation<double>的,所以这里将controlleranimation赋给你自定义的图片变换动画的parent是一个效果,这就是为什么最终运行效果是一样的了):

  Animation<double> navAnimation;
  Animation<Offset> imageTranslation;
  Animation<Offset> textTranslation;
  Animation<double> imageOpacity;
  Animation<double> textOpacity;

  void _buildAniamtion(){
    if (navAnimation == null) {
      navAnimation = ModalRoute.of(context).animation;
      imageTranslation = Tween(
        begin: Offset(0.0, 2.0),
        end: Offset(0.0, 0.0),
      ).animate(
        CurvedAnimation(
          parent: navAnimation,
          curve: Interval(0.0, 0.67, curve: Curves.fastOutSlowIn),
        ),
      );
      imageOpacity = Tween(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(
          parent: navAnimation,
          curve: Interval(0.0, 0.67, curve: Curves.easeIn),
        ),
      );
      textTranslation = Tween(
        begin: Offset(0.0, 1.0),
        end: Offset(0.0, 0.0),
      ).animate(
        CurvedAnimation(
          parent: navAnimation,
          curve: Interval(0.34, 0.84, curve: Curves.ease),
        ),
      );
      textOpacity = Tween(begin: 0.0, end: 1.0).animate(
        CurvedAnimation(
          parent: navAnimation,
          curve: Interval(0.34, 0.84, curve: Curves.linear),
        ),
      );
    }
  }
复制代码

  接下来就剩最后一个问题了,什么时候来进行这个绑定操作呢?一般来说,动画的初始化我们会选择在initState()中进行,但是如果我们将_buildAniamtion()方法放入initState()中执行的话,会报如下的错误:

The following assertion was thrown building Builder:

  inheritFromWidgetOfExactType(_ModalScopeStatus) or inheritFromElement() was called before _TestPageState.initState() completed.
  When an inherited widget changes, for example if the value of Theme.of() changes, its dependent widgets are rebuilt. If the dependent widget's reference to the inherited widget is in a constructor or an initState() method, then the rebuilt dependent widget will not reflect the changes in the inherited widget.
  Typically references to to inherited widgets should occur in widget build() methods. Alternatively, initialization based on inherited widgets can be placed in the didChangeDependencies method, which is called after initState and whenever the dependencies change thereafter.

  划报错重点:最后一段中:can be placed in the didChangeDependencies method,嗯,写的很清楚了,我们在didChangeDependencies()中初始化动画就可以了:

@override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _buildAniamtion();
  }
复制代码

  didChangeDependencies()是紧接着initState()后面执行的,源码注释中有一句:

It is safe to call [BuildContext.inheritFromWidgetOfExactType] from this method.

  两者的区别我也说不清楚,反正如果你在initState()中执行某些方法报错,不妨试试放到didChangeDependencies()中去。
  补充:评论有人说将操作放置到 addPostFrameCallback((timeStamp){ }),也就是第一帧绘制完成之后,听起来很有道理对不对??但是跟布局渲染顺序是矛盾的,因为第一帧绘制完成也就意味着build()方法走完了,但是我们的布局初始化的时候肯定是需要用到动画value的,比如下面这样:

FractionalTranslation(
    translation: imageTranslation.value,
        hild: HeaderImage(),
    ),
复制代码

  而初始化布局的时候,我们的自定义的图片aniamtion还没有初始化和绑定好呢,imageTranslation还没有值,会报错的。如果非要这么写,那么就需要修改一下布局了,我们可以先给imageTranslation赋一个默认值,然后在addPostFrameCallback((timeStamp){ })监听中再去修改这个值,然后再刷新状态:

FractionalTranslation(
    translation:
        imageTranslation == null ? 0.0 : imageTranslation.value,
    child: HeaderImage(),
),

WidgetsBinding.instance.addPostFrameCallback((callback) {
      _buildAniamtion();
      setState(() { });
    });
复制代码

  TestPage.dart完整代码如下:

class TestPage extends StatefulWidget {
  @override
  _TestPageState createState() => _TestPageState();
}

class _TestPageState extends State<TestPage> {
  Animation<double> controller;
  Animation<Offset> imageTranslation;
  Animation<Offset> textTranslation;
  Animation<double> imageOpacity;
  Animation<double> textOpacity;

  @override
  void initState() {
    // TODO: implement initState
    super.initState();
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
      _buildAniamtion();  // 此处代码省略,见上面
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: AnimatedBuilder(
        animation: controller,
        builder: (BuildContext context, Widget child) {
          return Column(
            children: <Widget>[
              FractionalTranslation(
                translation: imageTranslation.value,
                child: HeaderImage(),
              ),
              Expanded(
                child: FractionalTranslation(
                  translation: textTranslation.value,
                  child: AppText(),
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

class AppText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.only(left: 12.0, right: 12.0, top: 44.0),
      child: Text(
        "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras non lorem non justo congue feugiat ut a enim. Ut et sem nec lacus aliquet gravida. Mauris viverra lectus nec vulputate placerat. Nullam sit amet blandit massa, volutpat blandit arcu. Vivamus eu tellus tincidunt, vestibulum neque eu, sagittis neque. Phasellus vitae rutrum magna, eu finibus mi. Suspendisse eget laoreet metus. In mattis dui vitae vestibulum molestie. Curabitur bibendum ut purus in faucibus.",
        style: Theme.of(context).textTheme.body2,
      ),
    );
  }
}

class HeaderImage extends StatelessWidget {
  const HeaderImage({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ClipRRect(
      borderRadius: BorderRadius.circular(4.0),
      child: Image.asset(
        "images/food01.jpeg",
        height: 300.0,
        fit: BoxFit.cover,
      ),
    );
  }
}

复制代码

  PS:如何判断当前页面是否是初始页面?如何获取当前页面路由名?
  ModalRoute.of(context).settings可以拿到当前路由的基础配置信息,RouteSettings这个类一开始的时候就提到过啦,可以取到布尔值isInitialRoute和路由名。

关于ModalRoute的更多属性,可以看下这个:Flutter当前路由属性详解