系列指路:
Flutter自绘组件:微信悬浮窗(一)
Flutter自绘组件:微信悬浮窗(二)
Flutter自绘组件:微信悬浮窗(三)
我们在系列第一、二篇文章中实现了悬浮窗的按钮形态,在系列第三篇文章中实现了悬浮窗的列表形态,现在需要做的就是把这两者有逻辑地结合起来。最终实现效果图对比如下:
实现思路
两者之间的切换存在以下的逻辑:
- 当处于边缘按钮形态时,点击边缘按钮则切换至存在遮盖层的悬浮列表部分,且悬浮列表显示位置会根据当前悬浮按钮的位置进行显示,如果悬浮按钮位于屏幕下方且下方的长度不足以显示列表项,则将列表项置于悬浮按钮的上方进行显示。
- 当处于悬浮列表形态,点击遮盖层则会切换至按钮形态。
- 当处于悬浮列表形态时,关闭所有的悬浮列表项悬浮窗组件会隐形。
实现难点
实现难点主要在于悬浮列表的动画控制。我们在系列第三篇实现悬浮列表项的时候,只实现了关闭动画,而没有实现入场动画。在边缘按钮形态切换至悬浮列表形态的时候使存在一个所有列表项从边缘往中心延申的动画,而从悬浮列表形态切换至边缘按钮形态的时候存在一个所有列表项从中心往边缘缩减的动画,如何在实现这些逻辑的连接是难点。
对FloatingItem的改造
我们在系列第三篇文章实现列表项关闭动画的时候是将关闭动画作为一个单独的动画且有列表项自身进行管理,由于我们此时存在了新的要求,所有列表项统一的进场和退场动画,我们可以把退场动画看作是进场动画的一个reverse
,当触发关闭事件的时候就是进行reverse
操作。由于父级状态的切换会触发子项的动画效果,因此所有列表项的进出场动画状态应该由父Widget
即FloatingWindow
来进行管理,我们在FloatingWindow
中使用一个变量isEntering
来管理列表项是否需要进行进场动画。因此我们需要对FloatingItem
的动画进行重写,我们可以把列表项的进场动画和关闭动画看作是同一个动画的不同形态,即forward
和reverse
。我们需要在_FloatingItemState
中定义一个静态共享变量animationControllers
,如下
/// [animationController] 所有列表项的动画控制器列表
static List<AnimationController> animationControllers = [];
这个静态变量记录着所有列表项的动画控制器,这样在父级中就可以通过这个变量来对所有列表项的动画进行控制,达到所有列表项统一进场和出场的动画效果,为了更方便控制。在FloatingItem
中实现了以下静态方法用于控制动画:
/// 全部列表项执行退场动画
static void reverse(){
for(int i = 0; i < _FloatingItemState.animationControllers.length; ++i) {
if(!_FloatingItemState.animationControllers[i].toString().contains('DISPOSED'))
_FloatingItemState.animationControllers[i].reverse();
}
}
/// 全部列表项执行进场动画
static void forward(){
for(int i = 0; i < _FloatingItemState.animationControllers.length; ++i) {
if(!_FloatingItemState.animationControllers[i].toString().contains('DISPOSED'))
_FloatingItemState.animationControllers[i].forward();
}
}
/// 每次更新时释放所有动画资源,清空动画控制器列表
static void resetList(){
for(int i = 0; i < _FloatingItemState.animationControllers.length;++i){
if(!_FloatingItemState.animationControllers[i].toString().contains('DISPOSED')){
_FloatingItemState.animationControllers[i].dispose();
}
}
_FloatingItemState.animationControllers.clear();
_FloatingItemState.animationControllers = [];
}
需要注意的是resetList
方法,这一方法中,每次组件由悬浮按钮和悬浮列表间变换的时候,会生成新的列表项,因此也会申请新动画资源,那么在申请新的动画资源时候,需要对旧的动画资源进行释放,避免造成内存泄漏。
修改后的FloatingItem代码:
/// [FloatingItem]一个单独功能完善的列表项类
class FloatingItem extends StatefulWidget {
FloatingItem({
@required this.top,
@required this.isLeft,
@required this.title,
@required this.imageProvider,
@required this.index,
@required this.left,
@required this.isEntering,
this.width,
Key key
});
/// [index] 列表项的索引值
int index;
/// [top]列表项的y坐标值
double top;
/// [left]列表项的x坐标值
double left;
///[isLeft] 列表项是否在左侧,否则是右侧
bool isLeft;
/// [title] 列表项的文字说明
String title;
///[imageProvider] 列表项Logo的imageProvider
ImageProvider imageProvider;
///[width] 屏幕宽度的 1 / 2
double width;
///[isEntering] 列表项是否触发进场动画
bool isEntering;
@override
_FloatingItemState createState() => _FloatingItemState();
/// 全部列表项执行退场动画
static void reverse(){
for(int i = 0; i < _FloatingItemState.animationControllers.length; ++i) {
if(!_FloatingItemState.animationControllers[i].toString().contains('DISPOSED'))
_FloatingItemState.animationControllers[i].reverse();
}
}
/// 全部列表项执行进场动画
static void forward(){
for(int i = 0; i < _FloatingItemState.animationControllers.length; ++i) {
if(!_FloatingItemState.animationControllers[i].toString().contains('DISPOSED'))
_FloatingItemState.animationControllers[i].forward();
}
}
/// 每次更新时释放所有动画资源,清空动画控制器列表
static void resetList(){
for(int i = 0; i < _FloatingItemState.animationControllers.length;++i){
if(!_FloatingItemState.animationControllers[i].toString().contains('DISPOSED')){
_FloatingItemState.animationControllers[i].dispose();
}
}
_FloatingItemState.animationControllers.clear();
_FloatingItemState.animationControllers = [];
}
}
class _FloatingItemState extends State<FloatingItem> with TickerProviderStateMixin{
/// [isPress] 列表项是否被按下
bool isPress = false;
///[image] 列表项Logo的[ui.Image]对象,用于绘制Logo
ui.Image image;
/// [animationController] 列表关闭动画的控制器
AnimationController animationController;
/// [animationController] 所有列表项的动画控制器列表
static List<AnimationController> animationControllers = [];
/// [animation] 列表项的关闭动画
Animation animation;
@override
void initState() {
// TODO: implement initState
isPress = false;
/// 获取Logo的ui.Image对象
loadImageByProvider(widget.imageProvider).then((value) {
setState(() {
image = value;
});
});
super.initState();
}
@override
Widget build(BuildContext context) {
return Positioned(
left: widget.left,
top: widget.top,
child: GestureDetector(
/// 监听按下事件,在点击区域内则将[isPress]设为true,若在关闭区域内则不做任何操作
onPanDown: (details) {
if (widget.isLeft) {
/// 点击区域内
if (details.globalPosition.dx < widget.width) {
setState(() {
isPress = true;
});
}
}
else{
/// 点击区域内
if(details.globalPosition.dx < widget.width * 2 - 50){
setState(() {
isPress = true;
});
}
}
},
/// 监听抬起事件
onTapUp: (details) async {
/// 通过左右列表项来决定关闭的区域,以及选中区域,触发相应的关闭或选中事件
if(widget.isLeft){
/// 位于关闭区域
if(details.globalPosition.dx >= widget.width && !isPress){
/// 等待关闭动画执行完毕
await animationController.reverse();
/// 通知父级触发关闭事件
ClickNotification(deletedIndex: widget.index).dispatch(context);
}
else{
/// 通知父级触发相应的点击事件
ClickNotification(clickIndex: widget.index).dispatch(context);
}
}
else{
/// 位于关闭区域
if(details.globalPosition.dx >= widget.width * 2 - 50.0 && !isPress){
/// 设置从中间返回至边缘的关闭动画
await animationController.reverse();
/// 通知父级触发关闭事件
ClickNotification(deletedIndex: widget.index).dispatch(context);
}
else{
/// 通知父级触发选中事件
ClickNotification(clickIndex: widget.index).dispatch(context);
}
}
/// 抬起后取消选中
setState(() {
isPress = false;
});
},
onTapCancel: (){
/// 超出范围取消选中
setState(() {
isPress = false;
});
},
child: CustomPaint(
size: new Size(widget.width + 50.0,50.0),
painter: FloatingItemPainter(
title: widget.title,
isLeft: widget.isLeft,
isPress: isPress,
image: image,
)
)
)
);
}
/// 通过ImageProvider获取ui.image
Future<ui.Image> loadImageByProvider(
ImageProvider provider, {
ImageConfiguration config = ImageConfiguration.empty,
}) async {
Completer<ui.Image> completer = Completer<ui.Image>(); //完成的回调
ImageStreamListener listener;
ImageStream stream = provider.resolve(config); //获取图片流
listener = ImageStreamListener((ImageInfo frame, bool sync) {
//监听
final ui.Image image = frame.image;
completer.complete(image); //完成
stream.removeListener(listener); //移除监听
});
stream.addListener(listener); //添加监听
return completer.future; //返回
}
@override
void didUpdateWidget(FloatingItem oldWidget) {
// TODO: implement didUpdateWidget
animationController = new AnimationController(vsync: this,duration: new Duration(milliseconds: 100));
/// 初始化进场动画
if(widget.isLeft){
animation = new Tween<double>(begin: -(widget.width + 50.0),end: 0.0).animate(animationController)
..addListener(() {
setState(() {
widget.left = animation.value;
});
});
}
else{
animation = new Tween<double>(begin: widget.width * 2,end: widget.width -50.0).animate(animationController)
..addListener(() {
setState(() {
widget.left = animation.value;
});
});
}
animationControllers.add(animationController);
/// 执行进场动画
if(animationController.status == AnimationStatus.dismissed && widget.isEntering){
animationController.forward();
}
/// 无需执行进场动画,将列表项置于动画末尾
else{
animationController.forward(from:100.0);
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
// TODO: implement dispose
/// 释放动画资源,避免内存泄漏
if(!animationController.toString().toString().contains('DISPOSED'))
animationController.dispose();
super.dispose();
}
}
FlotingPainter
没有进行修改。需要注意代码中无需进行进场动画的情况为:删除了某一列表项后因此的列表项更新,是不需要进行进场动画的,因此在forward
的时候传入参数from:100.0
,动画总时长也就100ms,因此从100ms开始以为不进行开场动画的同时也把动画的状态置为complete
,这样不会影响退场动画reverse
的执行。
FloatingButton 类的修改
主要是将FloatingButton
类中控制状态变量如isLeft
,isEdge
,top
,left
等替换为FloatingWindowSharedDataWidget
中的共享数据FloatingWindowModel
中的变量。这样方便两种状态的数据共享。此外,点击事件增添了按钮处于边缘的时候点击会触发从按钮形态变化至列表形态的事件的逻辑。
修改完代码如下:
import 'package:floating_window/FloatingWindow/models/ClickNotification.dart';
import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import 'dart:math';
import 'dart:async';
import 'package:floating_window/FloatingWindow/widgets/FloatingWindowSharedDataWidget.dart';
class FloatingButton extends StatefulWidget {
@override
_FloatingButtonState createState() => _FloatingButtonState();
}
class _FloatingButtonState extends State<FloatingButton> with TickerProviderStateMixin{
/// [isPress] 按钮是否被按下
bool isPress = false;
/// [_controller] 返回动画控制器
AnimationController _controller;
/// [_animation] 返回动画
Animation _animation;
@override
Widget build(BuildContext context) {
/// 获取悬浮窗共享数据
var windowModel = FloatingWindowSharedDataWidget.of(context).data;
return Positioned(
left: windowModel.left,
top: windowModel.top,
child: Listener(
/// 按下后设[isPress]为true,绘制选中阴影
onPointerDown: (details){
setState(() {
isPress = true;
});
},
/// 按下后设isPress为false,不绘制阴影
/// 放下后根据当前x坐标与1/2屏幕宽度比较,判断屏幕在屏幕左侧或右侧,设置返回边缘动画
/// 动画结束后设置isLeft的值,根据值绘制左/右边缘按钮
onPointerUp: (e) async{
setState(() {
isPress = false;
});
/// 获取屏幕信息
var pixelDetails = MediaQuery.of(context).size; //获取屏幕信息
/// 点击按钮,触发Widget改变事件
if(windowModel.isLeft && e.position.dx <= 50.0 && windowModel.isEdge){
ClickNotification(changeWidget: true).dispatch(context);
return ;
}
else if(!windowModel.isLeft && e.position.dx >= pixelDetails.width - 50.0 && windowModel.isEdge){
ClickNotification(changeWidget: true).dispatch(context);
return ;
}
/// 触发返回动画
if(e.position.dx <= pixelDetails.width / 2)
{
/// 申请动画资源
_controller = new AnimationController(vsync: this,duration: new Duration(milliseconds: 100)); //0.1s动画
_animation = new Tween(begin: e.position.dx,end: 0.0).animate(_controller)
..addListener(() {setState(() {
/// 更新x坐标
windowModel.left = _animation.value;
});
});
/// 等待动画结束
await _controller.forward();
_controller.dispose();/// 释放动画资源
setState(() {
windowModel.isLeft = true; /// 按钮在屏幕左侧
});
}
else
{
/// 申请动画资源
_controller = new AnimationController(vsync: this,duration: new Duration(milliseconds: 100)); //0.1动画
_animation = new Tween(begin: e.position.dx,end: pixelDetails.width - 50).animate(_controller) //返回右侧坐标需要减去自身宽度及50,因坐标以图形左上角为基点
..addListener(() {
setState(() {
windowModel.left = _animation.value; /// 动画更新x坐标
});
});
await _controller.forward(); /// 等待动画结束
_controller.dispose(); /// 释放动画资源
setState(() {
windowModel.isLeft = false; /// 按钮在屏幕右侧
});
}
setState(() {
windowModel.isEdge = true; /// 按钮返回至边缘,更新按钮状态
});
},
child: GestureDetector(
/// 拖拽更新
onPanUpdate: (details){
var pixelDetails = MediaQuery.of(context).size; /// 获取屏幕信息
/// 拖拽后更新按钮信息,是否处于边缘
if(windowModel.left + details.delta.dx > 0 && windowModel.left + details.delta.dx < pixelDetails.width - 50){
setState(() {
windowModel.isEdge = false;
});
}else{
setState(() {
windowModel.isEdge = true;
});
}
/// 拖拽更新坐标
setState(() {
windowModel.left += details.delta.dx;
windowModel.top += details.delta.dy;
});
},
child: FutureBuilder(
future: loadImageByProvider(AssetImage(windowModel.dataList[0]['imageUrl'])),
builder: (context,snapshot) => CustomPaint(
size: Size(50.0,50.0),
painter: FloatingButtonPainter(isLeft: windowModel.isLeft, isEdge:windowModel.isEdge,
isPress: isPress, buttonImage: snapshot.data),
),
),
),
),
);
}
/// 通过ImageProvider获取ui.image
Future<ui.Image> loadImageByProvider(
ImageProvider provider, {
ImageConfiguration config = ImageConfiguration.empty,
}) async {
Completer<ui.Image> completer = Completer<ui.Image>(); //完成的回调
ImageStreamListener listener;
ImageStream stream = provider.resolve(config); //获取图片流
listener = ImageStreamListener((ImageInfo frame, bool sync) {
//监听
final ui.Image image = frame.image;
completer.complete(image); //完成
stream.removeListener(listener); //移除监听
});
stream.addListener(listener); //添加监听
return completer.future; //返回
}
}
class FloatingButtonPainter extends CustomPainter
{
FloatingButtonPainter({
Key key,
@required this.isLeft,
@required this.isEdge,
@required this.isPress,
@required this.buttonImage
});
/// 按钮是否在屏幕左侧,屏幕宽度 / 2
final bool isLeft;
/// 按钮是否在屏幕边界,左/右边界
final bool isEdge;
/// 按钮是否被按下
final bool isPress;
/// 内按钮图片 ui.image
final ui.Image buttonImage;
@override
void paint(Canvas canvas, Size size) {
// TODO: implement paint
/// 按钮是否在边缘
if(isEdge){
/// 按钮在屏幕左边或右边
if(isLeft)
paintLeftEdgeButton(canvas, size);/// 绘制左边缘按钮
else
paintRightEdgeButton(canvas, size);/// 绘制右边缘按钮
}
else{
paintCenterButton(canvas, size);/// 绘制中心按钮
}
}
///绘制左边界悬浮按钮
void paintLeftEdgeButton(Canvas canvas,Size size)
{
///绘制按钮内层
var paint = Paint()
..isAntiAlias = false
..style = PaintingStyle.fill
..color = Color.fromRGBO(0xF3, 0xF3, 0xF3, 0.9);
//..color = Color.fromRGBO(0xDA,0xDA,0xDA,0.9);
/// path : 按钮内边缘路径
var path = new Path() ..moveTo(size.width / 2 , size.height - 1.5);
path.lineTo(0.0, size.height - 1.5);
path.lineTo(0.0, 1.5);
path.lineTo(size.width / 2 ,1.5);
Rect rect = Rect.fromCircle(center:Offset(size.width / 2,size.height / 2),radius: 23.5);
path.arcTo(rect,pi * 1.5,pi,true);
canvas.drawPath(path, paint);
/// edgePath: 按钮外边缘路径,黑色线条
var edgePath = new Path() ..moveTo(size.width / 2, size.height);
edgePath.lineTo(0.0, size.height);
edgePath.lineTo(0.0, 0.0);
edgePath.lineTo(size.width / 2,0.0);
Rect rect1 = Rect.fromCircle(center:Offset(size.width / 2,size.height / 2),radius: 25);
edgePath.arcTo(rect1,pi * 1.5,pi,true);
paint
..isAntiAlias = true
..strokeWidth = 0.75
..strokeCap = StrokeCap.round
..maskFilter = MaskFilter.blur(BlurStyle.normal,0.25) /// 线条模糊
..style = PaintingStyle.stroke
..color = Color.fromRGBO(0xCF, 0xCF, 0xCF, 1);
canvas.drawPath(edgePath, paint);
/// 按下则画阴影,表示选中
if(isPress) canvas.drawShadow(edgePath, Color.fromRGBO(0xDA, 0xDA, 0xDA, 0.3), 0, false);
if(buttonImage == null)
return ;
/// 绘制中间图标
paint = new Paint();
canvas.save(); /// 剪裁前保存图层
RRect imageRRect = RRect.fromRectAndRadius(Rect.fromLTWH(size.width / 2 - 17.5,size.width / 2 - 17.5, 35, 35),Radius.circular(17.5));
canvas.clipRRect(imageRRect);/// 图片为圆形,圆形剪裁
canvas.drawColor(Colors.white, BlendMode.srcOver); /// 设置填充颜色为白色
Rect srcRect = Rect.fromLTWH(0.0, 0.0, buttonImage.width.toDouble(), buttonImage.height.toDouble());
Rect dstRect = Rect.fromLTWH(size.width / 2 - 17.5, size.height / 2 - 17.5, 35, 35);
canvas.drawImageRect(buttonImage, srcRect, dstRect, paint);
canvas.restore();/// 图片绘制完毕恢复图层
}
/// 绘制右边界按钮
void paintRightEdgeButton(Canvas canvas,Size size){
var paint = Paint()
..isAntiAlias = false
..style = PaintingStyle.fill
..color = Color.fromRGBO(0xF3, 0xF3, 0xF3, 0.9);
var path = Path() ..moveTo(size.width / 2, 1.5);
path.lineTo(size.width,1.5);
path.lineTo(size.width, size.height - 1.5);
path.lineTo(size.width / 2, size.height - 1.5);
Rect rect = Rect.fromCircle(center: Offset(size.width / 2,size.height / 2),radius: 23.5);
path.arcTo(rect, pi * 0.5, pi, true);
canvas.drawPath(path, paint);/// 绘制
/// edgePath: 按钮外边缘路径
var edgePath = Path() ..moveTo(size.width / 2,0.0);
edgePath.lineTo(size.width,0.0);
edgePath.lineTo(size.width, size.height);
edgePath.lineTo(size.width / 2, size.height);
Rect edgeRect = Rect.fromCircle(center: Offset(size.width / 2,size.height / 2),radius: 25);
edgePath.arcTo(edgeRect, pi * 0.5, pi, true);
paint
..isAntiAlias = true
..strokeWidth = 0.75
..strokeCap = StrokeCap.round
..maskFilter = MaskFilter.blur(BlurStyle.normal,0.25)
..style = PaintingStyle.stroke
..color = Color.fromRGBO(0xCF, 0xCF, 0xCF, 1);
canvas.drawPath(edgePath, paint);
/// 如果按下则绘制阴影
if(isPress)
canvas.drawShadow(path, Color.fromRGBO(0xDA, 0xDA, 0xDA, 0.3), 0, false);
/// 防止传入null
if(buttonImage == null)
return ;
/// 绘制中间图标
paint = new Paint();
canvas.save(); /// 剪裁前保存图层
RRect imageRRect = RRect.fromRectAndRadius(Rect.fromLTWH(size.width / 2 - 17.5,size.width / 2 - 17.5, 35, 35),Radius.circular(17.5));
canvas.clipRRect(imageRRect);/// 图片为圆形,圆形剪裁
canvas.drawColor(Colors.white, BlendMode.srcOver); /// 设置填充颜色为白色
Rect srcRect = Rect.fromLTWH(0.0, 0.0, buttonImage.width.toDouble(), buttonImage.height.toDouble());
Rect dstRect = Rect.fromLTWH(size.width / 2 - 17.5, size.height / 2 - 17.5, 35, 35);
canvas.drawImageRect(buttonImage, srcRect, dstRect, paint);
canvas.restore();/// 图片绘制完毕恢复图层
}
/// 绘制中心按钮
void paintCenterButton(Canvas canvas,Size size)
{
/// 绘制按钮内层
var paint = new Paint()
..isAntiAlias = false
..style = PaintingStyle.fill
..color = Color.fromRGBO(0xF3, 0xF3, 0xF3, 0.9);
canvas.drawCircle(Offset(size.width / 2,size.height / 2), 23.5, paint);
/// 绘制按钮外层边线
paint
..isAntiAlias = true
..style = PaintingStyle.stroke
..strokeWidth = 0.75
..maskFilter = MaskFilter.blur(BlurStyle.normal,0.25)
..color = Color.fromRGBO(0xCF, 0xCF, 0xCF, 1);
canvas.drawCircle(Offset(size.width / 2,size.height / 2), 25, paint);
/// 如果按下则绘制阴影
if(isPress){
var circleRect = Rect.fromCircle(center: Offset(size.width / 2,size.height / 2),radius: 25);
var circlePath = new Path() ..moveTo(size.width / 2, size.height / 2);
circlePath.arcTo(circleRect, 0, 2 * 3.14, true);
canvas.drawShadow(circlePath, Color.fromRGBO(0xCF, 0xCF, 0xCF, 0.3), 0.5, false);
}
if(buttonImage == null)
return ;
/// 绘制中间图标
paint = new Paint();
canvas.save(); /// 图片剪裁前保存图层
RRect imageRRect = RRect.fromRectAndRadius(Rect.fromLTWH(size.width / 2 - 17.5,size.width / 2 - 17.5, 35, 35),Radius.circular(35));
canvas.clipRRect(imageRRect);/// 图片为圆形,圆形剪裁
canvas.drawColor(Colors.white, BlendMode.srcOver); /// 设置填充颜色为白色
Rect srcRect = Rect.fromLTWH(0.0, 0.0, buttonImage.width.toDouble(), buttonImage.height.toDouble());
Rect dstRect = Rect.fromLTWH(size.width / 2 - 17.5, size.height / 2 - 17.5, 35, 35);
canvas.drawImageRect(buttonImage, srcRect, dstRect, paint);
canvas.restore();/// 恢复剪裁前的图层
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
// TODO: implement shouldRepaint
return true;
}
}
说明:微信悬浮窗中按钮形态的Logo
是根据每一个列表项的Logo
进行混合绘制,而我实现的悬浮窗中按钮形态的Logo
是取第一个列表项的Logo
进行绘制。如果需要实现混合绘制的效果,可以把所有的列表项的Logo
传入FloatingItemPainter
中,再把Logo
绘制的函数封装为一个函数,自行实现混合绘制原则,读者有兴趣可以自行研究实现,本文不做实现。
其他类的修改
对于其他的类,没有进行太大的修改,例如ClickNotification
增加了changeWidget
变量,用于边缘按钮点击触发悬浮按钮到悬浮列表项形态变化的冒泡通知。FlotingItems
和FloatingItemAnimatedWidget
需要传入isEntering
用于传给子级FloatingItem
是否需要执行进场动画,且FloatingItemAnimatedWidget
增加了需要进行进场动画时的列表项起始位置确定的逻辑处理(需要进场动画时起始位置在屏幕外,而不需要时起始位置为进场动画结束时的位置)。FloatingWindowModel
中增加了get
类型isEmpty
,用于判断列表项数据是否为空(为空则悬浮窗不显示),增加了itemTop
,用于记录打开后列表项*的顶部起始位置。具体所有的代码变化自行查看:
FloatingWindowModel
/// [FloatingWindowModel] 表示悬浮窗共享的数据
class FloatingWindowModel {
FloatingWindowModel({
this.isLeft = true,
this.top = 100.0,
List<Map<String,String>> dataList,
}) : dataList = dataList;
/// [isEmpty] 列表是非为空
get isEmpty => dataList.length == 0;
/// [isLeft]:悬浮窗位于屏幕左侧/右侧
bool isLeft;
/// [isEdge] 悬浮窗是否在边缘
bool isEdge = true;
/// [isButton]
bool isButton = true;
/// [top] 悬浮窗纵坐标
double top;
/// [left] 悬浮窗横坐标
double left = 0.0;
/// [itemTop] 悬浮列表的纵坐标
double itemTop;
/// [dataList] 列表数据
List<Map<String,String>>dataList;
/// 删除的列表项索引
int deleteIndex = -1;
}
ClickNotification
import 'package:flutter/material.dart';
/// [ClickNotification]列表项点击事件通知类
class ClickNotification extends Notification {
ClickNotification({this.deletedIndex = -1,this.clickIndex = -1,this.changeWidget = false});
/// 触发了关闭事件的列表项索引
int deletedIndex = -1;
/// 触发了点击事件的列表项索引
int clickIndex = -1;
/// 是否触发了改变形态的操作
bool changeWidget = false;
}
FloatingItemAnimatedWidget
/// [FloatingItemAnimatedWidget] 列表项进行动画类封装,方便传入平移向上动画
class FloatingItemAnimatedWidget extends AnimatedWidget{
FloatingItemAnimatedWidget({
Key key,
Animation<double> upAnimation,
this.index,
this.isEntering
}):super(key:key,listenable: upAnimation);
/// [index] 列表项索引
final int index;
/// [isEntering] 列表项是否需要执行进场动画
final bool isEntering;
@override
Widget build(BuildContext context) {
// TODO: implement build
/// 获取列表数据
var data = FloatingWindowSharedDataWidget.of(context).data;
/// 监听动画
final Animation<double> animation = listenable;
/// 获取屏幕信息
double width = MediaQuery.of(context).size.width / 2;
double left = 0.0;
if(data.isLeft){
if(isEntering)
left = -(width + 50.0);
else
left = 0.0;
}else{
if(isEntering)
left = (width * 2);
else
left = width -50.0;
}
return FloatingItem(top: animation.value, isLeft: data.isLeft, title: data.dataList[index]['title'],
imageProvider: AssetImage(data.dataList[index]['imageUrl']), index: index,
width: width,left: left,isEntering:isEntering);
}
}
FloatingItems
/// [FloatingItems] 列表
class FloatingItems extends StatefulWidget {
FloatingItems({
Key key,
@required this.isEntering
}):super(key:key);
@override
_FloatingItemsState createState() => _FloatingItemsState();
///[isEntering] 是否具有进场动画
bool isEntering = true;
}
class _FloatingItemsState extends State<FloatingItems> with TickerProviderStateMixin{
/// [_controller] 列表项动画的控制器
AnimationController _controller;
/// 动态生成列表
/// 其中一项触发关闭事件后,索引在该项后的列表项执行向上平移的动画。
List<Widget> getItems(BuildContext context){
/// 释放和申请新的动画资源
if(_controller != null){
_controller.dispose();
_controller = new AnimationController(vsync: this,duration: new Duration(milliseconds: 100));
}
/// widget列表
List<Widget>widgetList = [];
/// 获取共享数据
var data = FloatingWindowSharedDataWidget.of(context).data;
/// 列表数据
var dataList = data.dataList;
/// 确定列表项位置
double top = data.top + 70.0;
if(data.itemTop >= 0){
top = data.itemTop;
}else{
if(data.top + 70.0 * (dataList.length + 1)> MediaQuery.of(context).size.height - 20.0){
top = data.top - 70.0 * (dataList.length + 1);
data.itemTop = top;
}
}
/// 遍历数据生成列表项
for(int i = 0; i < dataList.length; ++i){
/// 在触发关闭事件列表项的索引之后的列表项传入向上平移动画
if(data.deleteIndex != - 1 && i >= data.deleteIndex){
Animation animation;
animation = new Tween<double>(begin: top + (70.0 * (i + 1)),end: top + 70.0 * (i)).animate(_controller);
widgetList.add(FloatingItemAnimatedWidget(upAnimation: animation,index: i,isEntering: widget.isEntering,));
}
/// 在触发关闭事件列表项的索引之前的列表项则位置固定
else{
Animation animation;
animation = new Tween<double>(begin: top + (70.0 * (i)),end: top + 70.0 * (i)).animate(_controller);
widgetList.add(FloatingItemAnimatedWidget(upAnimation: animation,index: i,isEntering: widget.isEntering,));
}
}
/// 重置deletedIndex
if(data.deleteIndex != -1){
data.deleteIndex = -1;
}
/// 执行动画
if(_controller != null)
_controller.forward();
/// 返回列表
return widgetList;
}
@override
void initState() {
// TODO: implement initState
super.initState();
_controller = new AnimationController(vsync: this,duration: new Duration(milliseconds: 100));
}
@override
Widget build(BuildContext context) {
return Stack(children: getItems(context),);
}
}
FloatingWindowSharedDataWidget(无变化)
/// [FloatingWindowSharedDataWidget]悬浮窗数据共享Widget
class FloatingWindowSharedDataWidget extends InheritedWidget{
FloatingWindowSharedDataWidget({
@required this.data,
Widget child
}) : super(child:child);
///[data]悬浮窗共享数据
final FloatingWindowModel data;
/// 静态方法[of]方便直接调用获取共享数据
static FloatingWindowSharedDataWidget of(BuildContext context){
return context.dependOnInheritedWidgetOfExactType<FloatingWindowSharedDataWidget>();
}
@override
bool updateShouldNotify(FloatingWindowSharedDataWidget oldWidget) {
// TODO: implement updateShouldNotify
/// 数据发生变化则发布通知
return oldWidget.data != data && data.deleteIndex != -1;
}
}
FloatingWindow(悬浮窗)的代码
/// [FloatingWindow] 悬浮窗
class FloatingWindow extends StatefulWidget {
@override
_FloatingWindowState createState() => _FloatingWindowState();
}
class _FloatingWindowState extends State<FloatingWindow> {
List<Map<String,String>> ls = [
{'title': "测试以下","imageUrl":"assets/Images/vnote.png"},
{'title': "Flutter自绘组件:微信悬浮窗(三)","imageUrl":"assets/Images/vnote.png"},
{'title': "微信悬浮窗","imageUrl":"assets/Images/vnote.png"}
];
/// 悬浮窗共享数据
FloatingWindowModel windowModel;
/// [isEntering] 列表项是否拥有进场动画
bool isEntering = true;
@override
void initState() {
// TODO: implement initState
super.initState();
windowModel = new FloatingWindowModel(dataList: ls,isLeft: true);
isEntering = true;
}
@override
Widget build(BuildContext context) {
return FloatingWindowSharedDataWidget(
data: windowModel,
child: windowModel.isEmpty ? Container() : Stack(
fit: StackFit.expand,
children: [
/// 列表项遮盖层,增加淡化切换动画
AnimatedSwitcher(
duration: Duration(milliseconds: 100),
child: windowModel.isButton ? Container() : GestureDetector(
onTap: (){
FloatingItem.reverse();
Future.delayed(Duration(milliseconds: 110),(){
setState(() {
windowModel.isButton = true;
windowModel.itemTop = -1.0;
});
});
},
child: Container(
decoration: BoxDecoration(color: Color.fromRGBO(0xEF, 0xEF, 0xEF, 0.9)),
),
),
),
NotificationListener<ClickNotification>(
onNotification: (notification){
/// 列表项关闭事件
if(notification.deletedIndex != -1){
windowModel.deleteIndex = notification.deletedIndex;
setState(() {
FloatingItem.resetList();
windowModel.dataList.removeAt(notification.deletedIndex);
isEntering = false;
});
}
/// 列表点击事件
if(notification.clickIndex != -1){
print(notification.clickIndex);
}
/// 悬浮按钮点击Widget改变事件
if(notification.changeWidget){
setState(() {
/// 释放列表进出场动画资源
FloatingItem.resetList();
windowModel.isButton = false;
isEntering = true;
});
}
return false;
},
child: windowModel.isButton ? FloatingButton():FloatingItems(isEntering: isEntering,),
)
],
),
);
}
}
FloatingWindow
中对悬浮窗和悬浮列表形态切换进行了逻辑联系,且对悬浮列表的进出场动画进行了管理,且使用了AnimatedSwitcher
对遮盖层切换时进行一个淡入淡出的动画效果,使得切换更加流畅。确定列表项是否需要执行进场动画的变量isEntering
自然是由父级FloatingWindow
进行管理,然后按照FloatingWindow
->FloatingItems
->FloatingAnimatedWidget
->FloatingItem
的顺序传递,这一步有点麻烦,但是在最后两个Widget
中都会使用到这个变量,因此也只是多传递了FloatingItems
这一步,也可以把变量存在FloatingWindowModel
中再进行获取,这样可能使代码看起来更为简洁。
总结
这章不需要进行图形的绘制,更多的还是围绕逻辑处理和状态管理这两个来谈。目前微信悬浮窗的实现已经完成了一大部分,只需要在后期中套上一层OverlayEntry
,然后对数据增删逻辑进行处理,下一章应该是本系列的最终章,如果有需要的朋友可以评论或私聊我获取项目完整的代码。之后打算进行两个完整配套开源项目的持续开发,涉及Web
和Flutter
,有兴趣可以继续关注。
创作不易,多多支持。