系列指路:
Flutter自繪組件:微信懸浮窗(一)
Flutter自繪組件:微信懸浮窗(二)
Flutter自繪組件:微信懸浮窗(四)
上兩講中講解了微信懸浮窗按鈕形態的實現,在本章中講解如何實現懸浮窗列表形態。廢話不多說,先上效果對比圖。
效果對比
實現難點
這部分的難點主要有以下:
- 列表的每一項均是不規則的圖形。
- 該項存在多個動畫,如關閉時從屏幕中間返回至屏幕邊緣的動畫,關閉某項后該項往下的所有項向上平移的動畫,以及出現時由屏幕邊緣伸展至屏幕中間的動畫。
- 列表中存在動畫的銜接,如某列表項關閉是會有從中間返回至屏幕邊緣的消失動畫,且在消失之后,該列表項下面的列表項會產生一個往上移動的動畫效果,如何做到這兩個動畫的無縫鏈接?
實現思路
列表項非規則圖形,依舊按照按鈕形態的方法,使用CustomPainter
和CustomPaint
進行自定義圖形的繪制。多個動畫,根據觸發的條件和環境不同,選擇直接使用AnimationController
進行管理或編寫一個AnimatedWidget
的子類,在父組件中進行管理。至于動畫銜接部分,核心是狀態管理。不同的列表項同屬一個Widget
,當其中一個列表項關閉完成后通知父組件列表,然后父組件再控制該列表項下的所有列表項進行一個自下而上的平移動畫,直至到達關閉的列表項原位置。
這個組件的關鍵詞列表
和動畫
,可能很多人已經想到了十分簡單的實現方法,就是使用AnimatedList
組件,它其內包含了增、刪、插入時動畫的接口,實現起來十分方便,但在本次中為了更深入了解狀態管理和培養邏輯思維,并沒有使用到這個組件,而是通過InheritedWidget
和Notification
的方法,完成了狀態的傳遞,從而實現動畫的銜接。在下一篇文章中會使用AnimatedList
重寫,讀者可以把兩種實現進行一個對比,加深理解。
使用到的新類
AnimationWidget
:鏈接 :《Flutter實戰》--動畫結構
Notification
和NotificationListener
: 鏈接:《Flutter實戰》--Notification
InheritedWidget
: 鏈接《Flutter實戰 》--數據共享
列表項圖解及繪制代碼
圖解對比如下:
在設計的時候我把列表項的寬度設為屏幕的寬度的一般再加上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
即動畫列表來實現。
創作不易,點贊支持