Flutter 实现底部扩散模糊动画:页面交互
作为一个炫(pin)酷(ru)的页面,页面中的交互也非常的重要。在本篇,我将进一步说明页面内各个位置的交互细节,从而带着各位做一个不将就的强迫症~
效果图:
完整demo及组件已上传至项目,走过路过留个star~
- ripple_backdrop_animate_route
- OpenJMU/lib/pages/home/AddButtonPage.dart
- OpenJMU/lib/pages/MainPage.dart
交互要素
页面中的交互主要包含三个触发位置:
- 点击空白的模糊处,页面会执行退出和退出动画;
- 点击页面上的返回或关闭按钮,页面会执行退出和退出动画;
- 元素渐显并带有其他效果。
接下来将逐点说明如何实现。
实现过程
拦截返回操作
我们知道在Flutter中,页面要返回时,会执行Navigator.maybePop
的方法,使页面返回。为了拦截路由pop
,Flutter提供了WillPopScope
来拦截返回行为,我们只需要注册onWillPop
方法,就可以在pop
前执行代码。
bool _popping = false;
Future<bool> willPop() async {
/// 等待返回动画的执行
await backDropFilterAnimate(context, false);
/// 判断_popping从而避免重复触发pop
if (!_popping) {
_popping = true;
await Future.delayed(Duration(milliseconds: _animateDuration), () {
Navigator.of(context).pop();
});
}
return null;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: WillPopScope(
/// 绑定willPop方法
onWIllPop: willPop,
child: wrapper(
context,
child: widget.child,
),
),
);
}
复制代码
如此我们就轻松愉快地拦截了路由~
退出动画
思考退出动画和跳转动画的关系,我们立马就可以想到,跳转和退出的动画是相反的,也就是说,逆向执行跳转的动画,就能得到一个退出动画。
这时我们来回顾一下上一期的跳转动画:
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();
}
复制代码
要想以相反的方向执行动画,我们加入一个参数bool forward
:
void backDropFilterAnimate(BuildContext context, bool forward)
使用forward
来控制begin
和end
,达到执行的效果。同时对forward
进行判断,如果为false
则尝试暂停动画:
void backDropFilterAnimate(BuildContext context, bool forward) {
/.../
if (!forward) _backDropFilterController?.stop();
_backDropFilterAnimation = Tween(
/// 三元运算赋值
begin: forward ? 0.0 : _backdropFilterSize,
end: forward ? pythagoreanTheorem(s.width, s.height) * 2 : 0.0,
).animate(_backDropFilterCurve)
..addListener(() {
setState(() {
_backdropFilterSize = _backDropFilterAnimation.value;
});
});
/.../
}
复制代码
看到这里可能会有小伙伴问了,AnimateController
明明提供了reverse
方法用于反向,为什么还要使用一个bool
来控制动画执行方向呢?
原因在于当使用reverse
时,控制器会将begin
和end
对调来执行动画,但当我们执行退出动画时,圆形不一定已经完全覆盖,所以通过使用forward
来判断方向,可以使未完全覆盖的动画从停止处反向执行,不会造成闪烁的情况。
至此,跳转和退出动画已经完美完成。
"X" & 空白处返回
根据效果图,在页面的底部,会提供一个带有旋转动画返回按钮,点击可以返回。
由于我的页面时点击加号触发的,所以这里我引入了bottomHeight
,用来确定加号的位置。从效果图可以看到我的底部导航栏,它的高度我们假设是60.0
,那按钮的位置如何定义呢?
final double bottomHeight = 60.0;
/.../
Widget popButton() {
return SizedBox(
/// 此处假设为60.0
width: widget.bottomHeight,
height: widget.bottomHeight,
child: Center(
/// 套手势监听,并设定监听行为
child: GestureDetector(
behavior: HitTestBehavior.opaque,
child: Icon(
Icons.add,
color: Colors.grey
),
onTap: willPop,
),
),
);
}
复制代码
将它放入布局中:
Stack(
/.../
children: <Widget>[
Positioned(
/// 将按钮控件固定在视图底部中央
left: 0.0,
right: 0.0,
bottom: 0.0,
child: popButton(),
),
],
)
复制代码
按钮定位完成,这时我们开始设计动画。按钮一共需要两组动画,一组是旋转,一组是淡入淡出。
/// 初始化按钮旋转的角度
final double bottomButtonRotateDegree = 45.0;
/// 旋转动画相关
Animation<double> _popButtonAnimation;
AnimationController _popButtonController;
/// 淡入淡出相关
Animation<double> _popButtonOpacityAnimation;
AnimationController _popButtonOpacityController;
void popButtonAnimate(context, bool forward) {
/// 与背景相同,判断正反执行
if (!forward) {
_popButtonController?.stop();
_popButtonOpacityController?.stop();
}
/// 转换按钮实际旋转角度
final double rotateDegree =
widget.bottomButtonRotateDegree * (math.pi / 180);
///
_popButtonOpacityController = _popButtonController = AnimationController(
duration: Duration(milliseconds: _animateDuration),
vsync: this,
);
Animation _popButtonCurve = CurvedAnimation(
parent: _popButtonController,
curve: Curves.easeInOut,
);
_popButtonAnimation = Tween(
begin: forward ? 0.0 : _popButtonRotateAngle,
end: forward ? rotateDegree : 0.0,
).animate(_popButtonCurve)
..addListener(() {
setState(() {
_popButtonRotateAngle = _popButtonAnimation.value;
});
});
/// 设定透明度最小值为0.01,防止背景显示错误
_popButtonOpacityAnimation = Tween(
begin: forward ? 0.01 : _popButtonOpacity,
end: forward ? 1.0 : 0.01,
).animate(_popButtonCurve)
..addListener(() {
setState(() {
_popButtonOpacity = _popButtonOpacityAnimation.value;
});
});
_popButtonController.forward();
_popButtonOpacityController.forward();
}
复制代码
按钮动画构建完成,我们将它放到背景动画中一起执行:
Future backDropFilterAnimate(BuildContext context, bool forward) async {
/.../
/// 使用相同的forward控制方向
popButtonAnimate(context, forward);
/.../
}
复制代码
至此,按钮的动画会跟着背景一起联动了,十分完美~
但,别着急结束,我们还有内容的动画定制没有完成,如果不需要如效果图一般的元素动画,可以出门右转~
操作项动画
从效果图我们可以看到,两个操作项是依次淡入出现,并且带有一定的垂直位移。这时问题出现了:我的操作项数量不确定,难道每一个操作项我都要专门写一个动画吗?
答案是:对了一半。为什么这么说?我们确实需要写操作项的动画,但我们不需要重复地去写每一个操作项,只需要通过封装操作项的内容,将动画所有相关内容也组成数个List
,问题就简单了很多。
以效果图为例,我有两个操作项,先进行声明。
List<String> itemTitles = ["动态", "扫一扫"];
List<String> itemIcons = ["subscriptedAccount", "scan"];
List<Color> itemColors = [Colors.orange, Colors.teal];
List<Function> itemOnTap = [...];
复制代码
将操作项所有的信息存储在四个数组中。接下来我们创建两组动画共8个数组的相关变量。
/// 操作项垂直偏移量
List<double> _itemOffset;
/// 操作项偏移动画
List<Animation<double>> _itemAnimations;
/// 操作项偏移动画曲线
List<CurvedAnimation> _itemCurveAnimations;
/// 操作项偏移动画控制器
List<AnimationController> _itemAnimateControllers;
/// 操作项透明度
List<double> _itemOpacity;
/// 操作项透明度动画
List<Animation<double>> _itemOpacityAnimations;
/// 操作项透明度动画曲线
List<CurvedAnimation> _itemOpacityCurveAnimations;
/// 操作项透明度动画控制器
List<AnimationController> _itemOpacityAnimateControllers;
复制代码
那么,该怎么初始化动画呢?
void initItemsAnimation() {
/// 根据操作项内容,初始化动画相关变量
_itemOffset = <double>[for (int i=0; i<itemTitles.length; i++) 0.0];
_itemAnimations = List<Animation<double>>(itemTitles.length);
_itemCurveAnimations = List<CurvedAnimation>(itemTitles.length);
_itemAnimateControllers = List<AnimationController>(itemTitles.length);
_itemOpacity = <double>[for (int i=0; i<itemTitles.length; i++) 0.01];
_itemOpacityAnimations = List<Animation<double>>(itemTitles.length);
_itemOpacityCurveAnimations = List<CurvedAnimation>(itemTitles.length); _itemOpacityAnimateControllers = List<AnimationController>(itemTitles.length);
/// 遍历操作性,初始化每一个动画内容
for (int i = 0; i < itemTitles.length; i++) {
/// 垂直偏移动画的设定
_itemAnimateControllers[i] = AnimationController(
duration: Duration(milliseconds: _animateDuration),
vsync: this,
);
_itemCurveAnimations[i] = CurvedAnimation(
parent: _itemAnimateControllers[i],
curve: Curves.ease,
);
/// 垂直偏移量设置为20
_itemAnimations[i] = Tween(
begin: -20.0,
end: 0.0,
).animate(_itemCurveAnimations[i]) ..addListener(() {
setState(() {
_itemOffset[i] = _itemAnimations[i].value;
});
});
/// 透明度动画的设定
_itemOpacityAnimateControllers[i] = AnimationController(
duration: Duration(milliseconds: _animateDuration),
vsync: this,
);
_itemOpacityCurveAnimations[i] = CurvedAnimation(
parent: _itemOpacityAnimateControllers[i],
curve: Curves.linear,
);
_itemOpacityAnimations[i] = Tween(
begin: 0.01,
end: 1.0,
).animate(_itemOpacityCurveAnimations[i])
..addListener(() {
setState(() {
_itemOpacity[i] = _itemOpacityAnimations[i].value;
});
});
}
}
/// 操作项动画的执行
void itemsAnimate(bool forward) {
for (int i = 0; i < _itemAnimateControllers.length; i++) {
/// 每个操作项依次增加延时,形成连续效果
Future.delayed(Duration(milliseconds: 50 * i), () {
if (forward) {
_itemAnimateControllers[i]?.forward();
_itemOpacityAnimateControllers[i]?.forward();
} else {
_itemAnimateControllers[i]?.reverse();
_itemOpacityAnimateControllers[i]?.reverse();
}
});
}
}
复制代码
创建操作项的widget
,将动画值进行绑定:
Widget item(BuildContext context, int index) {
return Stack(
overflow: Overflow.visible,
children: <Widget>[
Positioned(
left: 0.0, right: 0.0,
/// 绑定垂直偏移
top: _itemOffset[index],
child: Opacity(
/// 绑定透明度
opacity: _itemOpacity[index],
child: ...
),
),
],
);
}
复制代码
最后将动画初始化放进initState
,动画执行添加至跳转动画。
@override
void initState() {
initItemsAnimation();
/.../
}
Future backDropFilterAnimate(BuildContext context, bool forward) async {
/.../
if (forward) {
/// 以跳转动画二分之一的延时执行,效果更佳
Future.delayed(
Duration(milliseconds: _animateDuration ~/ 2),
() { itemsAnimate(true); },
);
} else {
itemsAnimate(false);
}
}
复制代码
一切就绪,保存就可以看到精美的动画效果了~
结语
这个动画个人耗时大约2小时,在思路非常清晰的情况下,将动画效果实现不是一件难事,这样的动画其实相对不难,接下来可能会有内容揭开、位置自定义等花式的需求,让我们拭目以待~
作者:AlexV525
链接:https://juejin.im/post/5d5f7d69f265da03e71afd53
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。