写这个的一切原由都得从我某天切换了酷安App的夜间模式提及,看个Gif,忽略图中其余无关项。 bash
起初用showOverlay的方式来作一个,效果始终很差,有违和感。后来果断采用自定义路由,最后整个动画的难点全在自定义路由上,若是你对原理不感兴趣,滑动到最后有现成的代码ide
新的页面是由点击的控件中心所在的坐标位置呈一个圆形逐渐扩散开来,最后撑满整个屏幕,不会啥绘图工具,用我Mac自带的绘图顶一下 工具
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
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
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
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
那如何用这个路由切换主题?将你不包含Theme的页面独立出来,再切换主题路由新页面是套上一个新的Theme就好啦 总结了下,是写骚了一点,不过个人确想不到比较官方的写法了哈哈,好几回转gif我就懒得贴表情包了哈哈