Flutter炫酷的波纹路由动画

  写这个的一切原由都得从我某天切换了酷安App的夜间模式提及,看个Gif,忽略图中其余无关项。 bash

Gif
这种的动画在awesome-Flutter上好像见到过,可是记得只是相似,有一个App的首次引导页跟这个有点像,不过那个是在一个PageView切换的时候的动画。上图的酷安App是原生应用,能够看到我在第三次切换主题的时候滑动了一个横向的相似于Flutter ListView的东西,再次点击切换主题,ListView的状态变化了,因此我怀疑酷安是用StartActivity的方式(过久没碰原生UI了,因此只是猜想)

Flutter端的实现

起初用showOverlay的方式来作一个,效果始终很差,有违和感。后来果断采用自定义路由,最后整个动画的难点全在自定义路由上,若是你对原理不感兴趣,滑动到最后有现成的代码ide

动画分析

新的页面是由点击的控件中心所在的坐标位置呈一个圆形逐渐扩散开来,最后撑满整个屏幕,不会啥绘图工具,用我Mac自带的绘图顶一下 工具

image.png
最中心的点是按钮的中心坐标,图中第二个页面的父布局即为整个圆形,这是整个动画中间的某一时刻 最后会是这个样子
image.png
经过以下代码计算出这个按钮所在的坐标,固然也可使用GlobalObjectKey(value).currentContext来拿到控件的上下文

final RenderBox renderBox = iconContext.findRenderObject();
offset = renderBox.localToGlobal(renderBox.size.center(Offset.zero));
复制代码

也就是说,圆的最大会恰好覆盖住手机屏幕最远的那个角落,若是小于了,则第二个页面显示不完整,大于了则会浪费多余的动画时长,看一下路由圆的直径计算代码,用了比较笨的判断,用简单的勾股定理计算出控件中心的坐标到屏幕最远的距离,路由页面的大小即为该距离的二倍。布局

final RenderBox renderBox = context.findRenderObject();
    offset = renderBox.localToGlobal(renderBox.size.center(Offset.zero));
    if (offset.dx > MediaQuery.of(context).size.width / 2) {
      if (offset.dy > MediaQuery.of(context).size.height / 2) {
        circleRadius = sqrt(pow(offset.dx, 2) + pow(offset.dy, 2)).toDouble();
      } else {
        circleRadius = sqrt(pow(offset.dx, 2) +
                pow(MediaQuery.of(context).size.height - offset.dy, 2))
            .toDouble();
      }
    }
    if (offset.dx <= MediaQuery.of(context).size.width / 2) {
      if (offset.dy > MediaQuery.of(context).size.height / 2) {
        circleRadius = sqrt(
                pow(MediaQuery.of(context).size.width - offset.dx, 2) +
                    pow(offset.dy, 2))
            .toDouble();
      } else {
        circleRadius = sqrt(
                pow(MediaQuery.of(context).size.width - offset.dx, 2) +
                    pow(MediaQuery.of(context).size.height - offset.dy, 2))
            .toDouble();
      }
    }
  }
复制代码

首先咱们实现这个圆的动画,我就不单独写demo了,直接拿个人工具箱作实验,定位到PageRouteBuilder的关键代码动画

transitionsBuilder: (
          BuildContext context,
              Animation<double> animation,
              Animation<double> _,
              Widget child,
            ) {
              return Stack(
                alignment: Alignment.center,
                children: <Widget>[
                  SizedBox(
                    height: routeConfig.circleRadius * 2 * animation.value,
                    width: routeConfig.circleRadius * 2 * animation.value,
                    child: ClipOval(
                      child: Align(
                        alignment: Alignment.center,
                        child: Container(
                          color: Colors.red,
                        ),
                      ),
                    ),
                  ),
                ],
              );
            }
复制代码

这部分的代码比较好理解,routeConfig.circleRadius即为整个圆最大时的半径,路由的新页面收SziedBox的限制,而SizedBox包裹ClipOval控件来实现圆形,这部分的效果以下 ui

111.gif
其中的两个问题, 1.SizedBox长宽都给定的计算出的能包裹手机屏幕的值,最后并无造成那样大的一个圆 2.圆并非从按钮中心扩散开来的 采用Positioned解决这两个问题,起初我考虑的用Transform控件,传入参数Matrix4.identity()..translate(x,y)的方式让整个圆从控件中心展开,但并无解决第一个问题,因而换了Positioned,Positioned能够设置与屏幕的上下左右边距,能够接收负数 观察两个临界状态,动画刚开始与动画结束, 刚开始时:animation.value=0,也就是说第二个页面的大小此时为0,此刻若是想要它在控件中心位置,它应该在哪呢? 咱们上面计算出来了控件中心所在的offset,offset.dx即为控件中心与左屏幕的边距,offset.dy为与上屏幕的边距, 在观察动画结束时:第二个页面的大小为圆的直径,若是不对控件作偏移处理,它会是这个样子
image.png
咱们须要作的就是将此刻的圆心移动到按钮原始中心的位置,有一个这样的图就比较好计算了,此刻圆心须要向上的偏移量即为:圆的半径-起始按钮中心距上屏幕的位置,向左的偏移量即为:圆的半径-起始按钮中心距左屏幕的位置 而整个过程是一个动画,在动画的每一刻的计算方式都如此,因此次时的代码改成

return Stack(
                alignment: Alignment.center,
                children: <Widget>[
                  Positioned(
                    top: routeConfig.offset.dy -
                        routeConfig.circleRadius * animation.value,
                    left: routeConfig.offset.dx -
                        routeConfig.circleRadius * animation.value,
                    child: SizedBox(
                      height: routeConfig.circleRadius * 2 * animation.value,
                      width: routeConfig.circleRadius * 2 * animation.value,
                      child: ClipOval(
                        child: Align(
                          alignment: Alignment.center,
                          child: Container(
                            color: Colors.red,
                          ),
                        ),
                      ),
                    ),
                  ),
                ],
              );
复制代码

传入的是负数是由于初始距离上、左屏幕的距离为0,若是是正数,圆只会愈来愈远离屏幕上与左,看一下此时的效果 this

111.gif
主要的动画已经出来了,我当时的第一想法就是将这个大红色替换成我须要路由的Widget就大功告成了,而后成了下面这个样子
111.gif
这也是当时困扰了我半天的问题,这个问题是因为解决上面两个问题时,及时偏移了整个圆的坐标,才致使圆包含的子页面(咱们想要路由到的页面)坐标也被更改了,而我想要的是路由的页面始终显示到屏幕的位置,既然如此,再使用一个Stack+Positioned的组合,负负得正,上一个Positioned怎么传的值,我就传相反数进去,更改以下:

return Stack(
                alignment: Alignment.center,
                children: <Widget>[
                  Positioned(
                    top: routeConfig.offset.dy -
                        routeConfig.circleRadius * animation.value,
                    left: routeConfig.offset.dx -
                        routeConfig.circleRadius * animation.value,
                    child: SizedBox(
                      height: routeConfig.circleRadius * 2 * animation.value,
                      width: routeConfig.circleRadius * 2 * animation.value,
                      child: ClipOval(
                        child: Stack(
                          children: <Widget>[
                            Positioned(
                              top: routeConfig.circleRadius * animation.value -
                                  routeConfig.offset.dy,
                              left: routeConfig.circleRadius * animation.value -
                                  routeConfig.offset.dx,
                              child: Align(
                                alignment: Alignment.center,
                                child: Container(
                                  width: MediaQuery.of(context).size.width,
                                  height: MediaQuery.of(context).size.height,
                                  child: child,
                                ),
                              ),
                            )
                          ],
                        ),
                      ),
                    ),
                  ),
                ],
              );
复制代码

效果以下: spa

111.gif
**最后再本身把路由时间改一下就好了

附上ripple_router.dart

import 'dart:math';
import 'package:flutter/material.dart';

class RouteConfig {
  Offset offset;
  double circleRadius;
  RouteConfig.fromContext(BuildContext context) {
    final RenderBox renderBox = context.findRenderObject();
    offset = renderBox.localToGlobal(renderBox.size.center(Offset.zero));
    if (offset.dx > MediaQuery.of(context).size.width / 2) {
      if (offset.dy > MediaQuery.of(context).size.height / 2) {
        circleRadius = sqrt(pow(offset.dx, 2) + pow(offset.dy, 2)).toDouble();
      } else {
        circleRadius = sqrt(pow(offset.dx, 2) +
                pow(MediaQuery.of(context).size.height - offset.dy, 2))
            .toDouble();
      }
    }
    if (offset.dx <= MediaQuery.of(context).size.width / 2) {
      if (offset.dy > MediaQuery.of(context).size.height / 2) {
        circleRadius = sqrt(
                pow(MediaQuery.of(context).size.width - offset.dx, 2) +
                    pow(offset.dy, 2))
            .toDouble();
      } else {
        circleRadius = sqrt(
                pow(MediaQuery.of(context).size.width - offset.dx, 2) +
                    pow(MediaQuery.of(context).size.height - offset.dy, 2))
            .toDouble();
      }
    }
  }
}

// double circleRadius
class RippleRoute extends PageRouteBuilder {
  final Widget widget;
  final RouteConfig routeConfig;

  RippleRoute(this.widget, this.routeConfig)
      : super(
          // 设置过分时间
          transitionDuration: Duration(seconds: 1),
          // 构造器
          pageBuilder: (
            // 上下文和动画
            BuildContext context,
            Animation<double> animation,
            Animation<double> _,
          ) {
            return widget;
          },
          opaque: false,
          transitionsBuilder: (
            BuildContext context,
            Animation<double> animation,
            Animation<double> _,
            Widget child,
          ) {
            return Stack(
              alignment: Alignment.center,
              children: <Widget>[
                Positioned(
                  top: routeConfig.offset.dy -
                      routeConfig.circleRadius * animation.value,
                  left: routeConfig.offset.dx -
                      routeConfig.circleRadius * animation.value,
                  child: SizedBox(
                    height: routeConfig.circleRadius * 2 * animation.value,
                    width: routeConfig.circleRadius * 2 * animation.value,
                    child: ClipOval(
                      child: Stack(
                        children: <Widget>[
                          Positioned(
                            top: routeConfig.circleRadius * animation.value -
                                routeConfig.offset.dy,
                            left: routeConfig.circleRadius * animation.value -
                                routeConfig.offset.dx,
                            child: Align(
                              alignment: Alignment.center,
                              child: Container(
                                width: MediaQuery.of(context).size.width,
                                height: MediaQuery.of(context).size.height,
                                child: child,
                              ),
                            ),
                          )
                        ],
                      ),
                    ),
                  ),
                ),
              ],
            );
          },
        );
}


复制代码

###使用方法: 在Flutter里面,可将点击的按钮套上一个Builder,如如下的样式,在跳转逻辑中以下,各类计算都被我封装到了RouteConfig这个类里面了,经过context构造并传入PageRouteBuilder就好了3d

Builder(
      builder: (iconContext) {
        return InkWell(
          child: Icon(
            Icons.***,
          ),
          onTap: () {
          Navigator.of(context).push(
            RippleRoute(
                NewPage(),
                RouteConfig.fromContext(context)),
          );
          },
        );
      },
    );
复制代码

不只是路由相同的页面来切换主题,也能够用于任何的路由场景 code

111.gif

那如何用这个路由切换主题?将你不包含Theme的页面独立出来,再切换主题路由新页面是套上一个新的Theme就好啦 总结了下,是写骚了一点,不过个人确想不到比较官方的写法了哈哈,好几回转gif我就懒得贴表情包了哈哈