前期指路:
Flutter自绘组件:微信悬浮窗(一)
Flutter自绘组件:微信悬浮窗(二)
上两讲中讲解了微信悬浮窗按钮形态的实现,在本章中讲解如何实现悬浮窗列表形态。废话不多说,先上效果对比图。
效果对比
imageimage
实现难点
这部分的难点主要有以下:
- 列表的每一项均是不规则的图形。
- 该项存在多个动画,如关闭时从屏幕中间返回至屏幕边缘的动画,关闭某项后该项往下的所有项向上平移的动画,以及出现时由屏幕边缘伸展至屏幕中间的动画。
- 列表中存在动画的衔接,如某列表项关闭是会有从中间返回至屏幕边缘的消失动画,且在消失之后,该列表项下面的列表项会产生一个往上移动的动画效果,如何做到这两个动画的无缝链接?
实现思路
列表项非规则图形,依旧按照按钮形态的方法,使用CustomPainter
和CustomPaint
进行自定义图形的绘制。多个动画,根据触发的条件和环境不同,选择直接使用AnimationController
进行管理或编写一个AnimatedWidget
的子类,在父组件中进行管理。至于动画衔接部分,核心是状态管理。不同的列表项同属一个Widget
,当其中一个列表项关闭完成后通知父组件列表,然后父组件再控制该列表项下的所有列表项进行一个自下而上的平移动画,直至到达关闭的列表项原位置。
这个组件的关键词列表
和动画
,可能很多人已经想到了十分简单的实现方法,就是使用AnimatedList
组件,它其内包含了增、删、插入时动画的接口,实现起来十分方便,但在本次中为了更深入了解状态管理和培养逻辑思维,并没有使用到这个组件,而是通过InheritedWidget
和Notification
的方法,完成了状态的传递,从而实现动画的衔接。在下一篇文章中会使用AnimatedList
重写,读者可以把两种实现进行一个对比,加深理解。
使用到的新类
AnimationWidget
:链接 :《Flutter实战》--动画结构
Notification
和NotificationListener
: 链接:《Flutter实战》--Notification
InheritedWidget
: 链接《Flutter实战 》--数据共享
列表项图解及绘制代码
图解对比如下:
imageimage
在设计的时候我把列表项的宽度设为屏幕的宽度的一般再加上50.0,左右列表项在中间的内容部分的布局是完全一样的,只是在外层部分有所不同,在绘制的时候,我分别把列表项的背景部分(背景阴影,外边缘,以及内层)、Logo部分、文字部分、交叉部分分别封装成了一个函数,避免了重复代码的编写,需要注意的是绘制Logo的Image
对象的获取,在上一章中有讲到,此处不再详述。其他详情看代码及注释:
/// [FloatingItemPainter]:画笔类,绘制列表项
class FloatingItemPainter extends CustomPainter{
FloatingItemPainter({
@required this.title,
@required this.isLeft,
@required this.isPress,
@required this.image
});
/// [isLeft] 列表项在左侧/右侧
bool isLeft = true;
/// [isPress] 列表项是否被选中,选中则绘制阴影
bool isPress;
/// [title] 绘制列表项内容
String title;
/// [image] 列表项图标
ui.Image image;
@override
void paint(Canvas canvas, Size size) {
// TODO: implement paint
if(size.width < 50.0){
return ;
}
else{
if(isLeft){
paintLeftItem(canvas, size);
if(image != null)//防止传入null引起崩溃
paintLogo(canvas, size);
paintParagraph(canvas, size);
paintCross(canvas, size);
}else{
paintRightItem(canvas, size);
paintParagraph(canvas, size);
paintCross(canvas, size);
if(image != null)
paintLogo(canvas, size);
}
}
}
/// 通过传入[Canvas]对象和[Size]对象绘制左侧列表项外边缘,阴影以及内层
void paintLeftItem(Canvas canvas,Size size){
/// 外边缘路径
Path edgePath = new Path() ..moveTo(size.width - 25.0, 0.0);
edgePath.lineTo(0.0, 0.0);
edgePath.lineTo(0.0, size.height);
edgePath.lineTo(size.width - 25.0, size.height);
edgePath.arcTo(Rect.fromCircle(center: Offset(size.width - 25.0,size.height / 2),radius: 25), pi * 1.5, pi, true);
/// 绘制背景阴影
canvas.drawShadow(edgePath, Color.fromRGBO(0xDA, 0xDA, 0xDA, 0.3), 3, true);
var paint = new Paint()
..style = PaintingStyle.fill
..color = Colors.white;
/// 通过填充去除列表项内部多余的阴影
canvas.drawPath(edgePath, paint);
paint = new Paint()
..isAntiAlias = true // 抗锯齿
..style = PaintingStyle.stroke
..color = Color.fromRGBO(0xCF, 0xCF, 0xCF, 1)
..strokeWidth = 0.75
..maskFilter = MaskFilter.blur(BlurStyle.solid, 0.25); //边缘模糊
/// 绘制列表项外边缘
canvas.drawPath(edgePath, paint);
/// [innerPath] 内层路径
Path innerPath = new Path() ..moveTo(size.width - 25.0, 1.5);
innerPath.lineTo(0.0, 1.5);
innerPath.lineTo(0.0, size.height - 1.5);
innerPath.lineTo(size.width - 25.0, size.height - 1.5);
innerPath.arcTo(Rect.fromCircle(center: Offset(size.width - 25.0,size.height / 2),radius: 23.5), pi * 1.5, pi, true);
paint = new Paint()
..isAntiAlias = false
..style = PaintingStyle.fill
..color = Color.fromRGBO(0xF3, 0xF3, 0xF3, 1);
/// 绘制列表项内层
canvas.drawPath(innerPath, paint);
/// 绘制选中阴影
if(isPress)
canvas.drawShadow(edgePath, Color.fromRGBO(0xDA, 0xDA, 0xDA, 0.3), 0, true);
}
/// 通过传入[Canvas]对象和[Size]对象绘制左侧列表项外边缘,阴影以及内层
void paintRightItem(Canvas canvas,Size size){
/// 外边缘路径
Path edgePath = new Path() ..moveTo(25.0, 0.0);
edgePath.lineTo(size.width, 0.0);
edgePath.lineTo(size.width, size.height);
edgePath.lineTo(25.0, size.height);
edgePath.arcTo(Rect.fromCircle(center: Offset(25.0,size.height / 2),radius: 25), pi * 0.5, pi, true);
/// 绘制列表项背景阴影
canvas.drawShadow(edgePath, Color.fromRGBO(0xDA, 0xDA, 0xDA, 0.3), 3, true);
var paint = new Paint()
..style = PaintingStyle.fill
..color = Colors.white;
/// 通过填充白色去除列表项内部多余阴影
canvas.drawPath(edgePath, paint);
paint = new Paint()
..isAntiAlias = true
..style = PaintingStyle.stroke
..color = Color.fromRGBO(0xCF, 0xCF, 0xCF, 1)
..strokeWidth = 0.75
..maskFilter = MaskFilter.blur(BlurStyle.solid, 0.25); //边缘模糊
/// 绘制列表项外边缘
canvas.drawPath(edgePath, paint);
/// 列表项内层路径
Path innerPath = new Path() ..moveTo(25.0, 1.5);
innerPath.lineTo(size.width, 1.5);
innerPath.lineTo(size.width, size.height - 1.5);
innerPath.lineTo(25.0, size.height - 1.5);
innerPath.arcTo(Rect.fromCircle(center: Offset(25.0,25.0),radius: 23.5), pi * 0.5, pi, true);
paint = new Paint()
..isAntiAlias = false
..style = PaintingStyle.fill
..color = Color.fromRGBO(0xF3, 0xF3, 0xF3, 1);
/// 绘制列表项内层
canvas.drawPath(innerPath, paint);
/// 条件绘制选中阴影
if(isPress)
canvas.drawShadow(edgePath, Color.fromRGBO(0xDA, 0xDA, 0xDA, 0.3), 0, false);
}
/// 通过传入[Canvas]对象和[Size]对象以及[image]绘制列表项Logo
void paintLogo(Canvas canvas,Size size){
//绘制中间图标
var paint = new Paint();
canvas.save(); //剪裁前保存图层
RRect imageRRect = RRect.fromRectAndRadius(Rect.fromLTWH(25.0 - 17.5,25.0- 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, image.width.toDouble(), image.height.toDouble());
Rect dstRect = Rect.fromLTWH(25.0 - 17.5, 25.0 - 17.5, 35, 35);
canvas.drawImageRect(image, srcRect, dstRect, paint);
canvas.restore();//图片绘制完毕恢复图层
}
/// 通过传入[Canvas]对象和[Size]对象以及[title]绘制列表项的文字说明部分
void paintParagraph(Canvas canvas,Size size){
ui.ParagraphBuilder pb = ui.ParagraphBuilder(ui.ParagraphStyle(
textAlign: TextAlign.left,//左对齐
fontWeight: FontWeight.w500,
fontSize: 14.0, //字体大小
fontStyle: FontStyle.normal,
maxLines: 1, //行数限制
ellipsis: "…" //省略显示
));
pb.pushStyle(ui.TextStyle(color: Color.fromRGBO(61, 61, 61, 1),)); //字体颜色
double pcLength = size.width - 100.0; //限制绘制字符串宽度
ui.ParagraphConstraints pc = ui.ParagraphConstraints(width: pcLength);
pb.addText(title);
ui.Paragraph paragraph = pb.build() ..layout(pc);
Offset startOffset = Offset(50.0,18.0); // 字符串显示位置
/// 绘制字符串
canvas.drawParagraph(paragraph, startOffset);
}
/// 通过传入[Canvas]对象和[Size]对象绘制列表项末尾的交叉部分,
void paintCross(Canvas canvas,Size size){
/// ‘x’ 路径
Path crossPath = new Path()
..moveTo(size.width - 28.5, 21.5);
crossPath.lineTo(size.width - 21.5,28.5);
crossPath.moveTo(size.width - 28.5, 28.5);
crossPath.lineTo(size.width - 21.5, 21.5);
var paint = new Paint()
..isAntiAlias = true
..color = Color.fromRGBO(61, 61, 61, 1)
..style = PaintingStyle.stroke
..strokeWidth = 0.75
..maskFilter = MaskFilter.blur(BlurStyle.normal, 0.25); // 线段模糊
/// 绘制交叉路径
canvas.drawPath(crossPath, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
// TODO: implement shouldRepaint
return (true && image != null);
}
}
列表项的实现代码
实现完列表项的绘制代码FloatingItemPainter
类,你还需要一个画布CustomPaint
和事件逻辑。一个完整列表项类除了绘制代码外还需要补充绘制区域的定位,列表项手势方法的捕捉(关闭和点击事件,关闭动画的逻辑处理。对于定位,纵坐标是根据传进来的top
值决定的,对于列表项的Letf
值则是根据列表项位于左侧 / 右侧的,左侧很好理解就为0。而右侧的坐标,由于列表项的长度为width + 50.0
,因此列表项位于右侧时,横坐标为width - 50.0
,如下图:
对于关闭动画,则是对横坐标Left
取动画值来实现由中间收缩回边缘的动画效果。
对于事件的捕捉,需要确定当前列表项的点击区域和关闭区域。在事件处理的时候需要考虑较为极端的情况,就是把UI使用者不当正常人来看。正常的点击包括按下和抬起两个事件,但如果存在按下后拖拽出区域的情况呢?这时即使抬起后列表项还是处于选中的状态,还需要监听一个onTapCancel
的事件,当拖拽离开列表项监听区域时将列表项设为未选中状态。
FloatingItem
类的具体代码及解析如下:
/// [FloatingItem]一个单独功能完善的列表项类
class FloatingItem extends StatefulWidget {
FloatingItem({
@required this.top,
@required this.isLeft,
@required this.title,
@required this.imageProvider,
@required this.index,
this.left,
Key key
});
/// [index] 列表项的索引值
int index;
/// [top]列表项的y坐标值
double top;
/// [left]列表项的x坐标值
double left;
///[isLeft] 列表项是否在左侧,否则是右侧
bool isLeft;
/// [title] 列表项的文字说明
String title;
///[imageProvider] 列表项Logo的imageProvider
ImageProvider imageProvider;
@override
_FloatingItemState createState() => _FloatingItemState();
}
class _FloatingItemState extends State<FloatingItem> with TickerProviderStateMixin{
/// [isPress] 列表项是否被按下
bool isPress = false;
///[image] 列表项Logo的[ui.Image]对象,用于绘制Logo
ui.Image image;
/// [animationController] 列表关闭动画的控制器
AnimationController animationController;
/// [animation] 列表项的关闭动画
Animation animation;
/// [width] 屏幕宽度的一半,用于确定列表项的宽度
double width;
@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) {
if(width == null)
width = MediaQuery.of(context).size.width / 2 ;
if(widget.left == null)
widget.left = widget.isLeft ? 0.0 : width - 50.0;
return Positioned(
left: widget.left,
top: widget.top,
child: GestureDetector(
/// 监听按下事件,在点击区域内则将[isPress]设为true,若在关闭区域内则不做任何操作
onPanDown: (details) {
if (widget.isLeft) {
/// 点击区域内
if (details.globalPosition.dx < width) {
setState(() {
isPress = true;
});
}
}
else{
/// 点击区域内
if(details.globalPosition.dx < width * 2 - 50){
setState(() {
isPress = true;
});
}
}
},
/// 监听抬起事件
onTapUp: (details) async {
/// 通过左右列表项来决定关闭的区域,以及选中区域,触发相应的关闭或选中事件
if(widget.isLeft){
/// 位于关闭区域
if(details.globalPosition.dx >= width && !isPress){
/// 设置从中间返回至边缘的关闭动画
animationController = new AnimationController(vsync: this,duration: new Duration(milliseconds: 100));
animation = new Tween<double>(begin: 0.0,end: -(width + 50.0)).animate(animationController)
..addListener(() {
setState(() {
widget.left = animation.value;
});
});
/// 等待关闭动画结束后通知父级已关闭
await animationController.forward();
/// 销毁动画资源
animationController.dispose();
/// 通知父级触发关闭事件
ClickNotification(deletedIndex: widget.index).dispatch(context);
}
else{
/// 通知父级触发相应的点击事件
ClickNotification(clickIndex: widget.index).dispatch(context);
}
}
else{
/// 位于关闭区域
if(details.globalPosition.dx >= width * 2 - 50.0 && !isPress){
/// 设置从中间返回至边缘的关闭动画
animationController = new AnimationController(vsync: this,duration: new Duration(milliseconds: 100));
animation = new Tween<double>(begin: width - 50.0,end: width * 2).animate(animationController)
..addListener(() {
setState(() {
widget.left = animation.value;
});
});
/// 等待执行完毕
await animationController.forward();
/// 销毁动画资源
animationController.dispose();
/// 通知父级触发关闭事件
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(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; //返回
}
}
对于ClickNotification
类,看一下代码:
import 'package:flutter/material.dart';
/// [ClickNotification]列表项点击事件通知类
class ClickNotification extends Notification {
ClickNotification({this.deletedIndex,this.clickIndex});
/// 触发了关闭事件的列表项索引
int deletedIndex = -1;
/// 触发了点击事件的列表项索引
int clickIndex = -1;
}
它继承自Notification
,自定义了一个通知用于处理列表项点击或关闭时整个列表发生的变化。单个列表项在执行完关闭动画后分发通知,通知父级进行一个列表项上移填补被删除列表项位置的的动画。
列表动画
单个列表项的关闭动画,我们已经在FlotingItem
中实现了。而列表动画是,列表项关闭后,索引在其后的其他列表项向上平移填充的动画,示意图如下:
已知单个列表项的关闭动画是由自身管理实现的,那么单个列表项关闭后引起的列表动画由谁进行管理呢? 自然是由列表进行管理。每个列表项除了原始的第一个列表项都可能会发生向上平移的动画,因此我们需要对单个的列表项再进行一层AnimatedWidget
的加装,方便动画的传入与管理,具体代码如下:
FloatingItemAnimatedWidget:
/// [FloatingItemAnimatedWidget] 列表项进行动画类封装,方便传入平移向上动画
class FloatingItemAnimatedWidget extends AnimatedWidget{
FloatingItemAnimatedWidget({
Key key,
Animation<double> animation,
this.index,
}):super(key:key,listenable: animation);
/// [index] 列表项索引
final int index;
@override
Widget build(BuildContext context) {
// TODO: implement build
/// 获取列表数据
var data = FloatingWindowSharedDataWidget.of(context).data;
final Animation<double> animation = listenable;
return FloatingItem(top: animation.value, isLeft: data.isLeft, title: data.dataList[index]['title'],
imageProvider: AssetImage(data.dataList[index]['imageUrl']), index: index);
}
}
代码中引用到了一个新类FloatingWindowSharedDataWidget
,它是一个InheritedWidget
,共享了FloatingWindowModel
类型的数据,FloatingWindowModel
中包括了悬浮窗用到的一些数据,例如判断列表在左侧或右侧的isLeft
,列表的数据dataList
等,避免了父组件向子组件传数据时大量参数的编写,一定程度上增强了可维护性,例如FloatingItemAnimatedWidget
中只需要传入索引值就可以在共享数据中提取到相应列表项的数据。FloatingWindowSharedDataWidget
和FloatingWindowModel
的代码及注释如下:
FloatingWindowSharedDataWidget
/// [FloatingWindowSharedDataWidget]悬浮窗数据共享Widget
class FloatingWindowSharedDataWidget extends InheritedWidget{
FloatingWindowSharedDataWidget({
@required this.data,
Widget child
}) : super(child:child);
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;
}
}
FloatingWindowModel
/// [FloatingWindowModel] 表示悬浮窗共享的数据
class FloatingWindowModel {
FloatingWindowModel({
this.isLeft = true,
this.top = 100.0,
List<Map<String,String>> datatList,
}) : dataList = datatList;
/// [isLeft]:悬浮窗位于屏幕左侧/右侧
bool isLeft;
/// [top] 悬浮窗纵坐标
double top;
/// [dataList] 列表数据
List<Map<String,String>>dataList;
/// 删除的列表项索引
int deleteIndex = -1;
}
列表的实现
上述已经实现了单个列表项并进行了动画的封装,现在只需要实现列表,监听列表项的点击和关闭事件并执行相应的操作。为了方便,我们实现了一个作为列表的FloatingItems
类然后实现了一个悬浮窗类TestWindow
来对列表的操作进行监听和管理,在以后的文章中还会继续完善TestWindow
类和FloatingWindowModel
类,把前两节的实现的FloatingButton
加进去并实现联动。目前的具体实现代码和注释如下:
FloatingItems
/// [FloatingItems] 列表
class FloatingItems extends StatefulWidget {
@override
_FloatingItemsState createState() => _FloatingItemsState();
}
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;
/// 遍历数据生成列表项
for(int i = 0; i < dataList.length; ++i){
/// 在触发关闭事件列表项的索引之后的列表项传入向上平移动画
if(data.deleteIndex != - 1 && i >= data.deleteIndex){
Animation animation;
animation = new Tween<double>(begin: data.top + (70.0 * (i + 1)),end: data.top + 70.0 * i).animate(_controller);
widgetList.add(FloatingItemAnimatedWidget(animation: animation,index: i));
}
/// 在触发关闭事件列表项的索引之前的列表项则位置固定
else{
Animation animation;
animation = new Tween<double>(begin: data.top + (70.0 * i),end: data.top + 70.0 * i).animate(_controller);
widgetList.add(FloatingItemAnimatedWidget(animation: animation,index: i,));
}
}
/// 执行动画
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),);
}
}
TestWindow
/// [TestWindow] 悬浮窗
class TestWindow extends StatefulWidget {
@override
_TestWindowState createState() => _TestWindowState();
}
class _TestWindowState extends State<TestWindow> {
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;
@override
void initState() {
// TODO: implement initState
super.initState();
windowModel = new FloatingWindowModel(datatList: ls,isLeft: true);
}
@override
Widget build(BuildContext context) {
return FloatingWindowSharedDataWidget(
data: windowModel,
child:Stack(
fit: StackFit.expand, /// 未定义长宽的子类填充屏幕
children:[
/// 遮盖层
Container(
decoration:
BoxDecoration(color: Color.fromRGBO(0xEF, 0xEF, 0xEF, 0.9))
),
/// 监听点击与关闭事件
NotificationListener<ClickNotification>(
onNotification: (notification) {
/// 关闭事件
if(notification.deletedIndex != - 1) {
windowModel.deleteIndex = notification.deletedIndex;
setState(() {
windowModel.dataList.removeAt(windowModel.deleteIndex);
});
}
if(notification.clickIndex != -1){
/// 执行点击事件
print(notification.clickIndex);
}
/// 禁止冒泡
return false;
},
child: FloatingItems(),),
])
);
}
}
main代码
void main(){
runApp(MultiProvider(providers: [
ChangeNotifierProvider<ClosingItemProvider>(
create: (_) => ClosingItemProvider(),
)
],
child: new MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: new ThemeData(
primarySwatch: Colors.blue
),
home: new Scaffold(
appBar: new AppBar(title: Text('Flutter Demo')),
body: Stack(
children: [
/// 用于测试遮盖层是否生效
Positioned(
left: 250,
top: 250,
child: Container(width: 50,height: 100,color: Colors.red,),
),
TestWindow()
],
)
)
);
}
}
总结
对于列表项的编写,难度就在于状态的管理上和动画的管理上,绘制上来来去去还是那几个函数。组件存在多个复杂动画,每个动画由谁进行管理,如何触发,状态量如何传递,都是需要认真思考才能解决的,本篇文章采用了一个比较“原始”的方式进行实现,但能使对状态的管理和动画的管理有更深入的理解,在下篇文章中采用更为简单的方式进行实现,通过AnimatedList
即动画列表来实现。
创作不易,点赞支持
网友评论