Flutter自繪組件:微信懸浮窗(三)

系列指路:

Flutter自繪組件:微信懸浮窗(一)
Flutter自繪組件:微信懸浮窗(二)
Flutter自繪組件:微信懸浮窗(四)

上兩講中講解了微信懸浮窗按鈕形態的實現,在本章中講解如何實現懸浮窗列表形態。廢話不多說,先上效果對比圖。

效果對比

image

image

實現難點

這部分的難點主要有以下:

  1. 列表的每一項均是不規則的圖形。
  2. 該項存在多個動畫,如關閉時從屏幕中間返回至屏幕邊緣的動畫,關閉某項后該項往下的所有項向上平移的動畫,以及出現時由屏幕邊緣伸展至屏幕中間的動畫。
  3. 列表中存在動畫的銜接,如某列表項關閉是會有從中間返回至屏幕邊緣的消失動畫,且在消失之后,該列表項下面的列表項會產生一個往上移動的動畫效果,如何做到這兩個動畫的無縫鏈接?

實現思路

列表項非規則圖形,依舊按照按鈕形態的方法,使用CustomPainterCustomPaint進行自定義圖形的繪制。多個動畫,根據觸發的條件和環境不同,選擇直接使用AnimationController進行管理或編寫一個AnimatedWidget的子類,在父組件中進行管理。至于動畫銜接部分,核心是狀態管理。不同的列表項同屬一個Widget,當其中一個列表項關閉完成后通知父組件列表,然后父組件再控制該列表項下的所有列表項進行一個自下而上的平移動畫,直至到達關閉的列表項原位置。

這個組件的關鍵詞列表動畫,可能很多人已經想到了十分簡單的實現方法,就是使用AnimatedList組件,它其內包含了增、刪、插入時動畫的接口,實現起來十分方便,但在本次中為了更深入了解狀態管理和培養邏輯思維,并沒有使用到這個組件,而是通過InheritedWidgetNotification的方法,完成了狀態的傳遞,從而實現動畫的銜接。在下一篇文章中會使用AnimatedList重寫,讀者可以把兩種實現進行一個對比,加深理解。

使用到的新類

AnimationWidget:鏈接 :《Flutter實戰》--動畫結構

NotificationNotificationListener: 鏈接:《Flutter實戰》--Notification

InheritedWidget : 鏈接《Flutter實戰 》--數據共享

列表項圖解及繪制代碼

圖解對比如下:

image

image

在設計的時候我把列表項的寬度設為屏幕的寬度的一般再加上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,如下圖:

image

對于關閉動畫,則是對橫坐標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中實現了。而列表動畫是,列表項關閉后,索引在其后的其他列表項向上平移填充的動畫,示意圖如下:

image

已知單個列表項的關閉動畫是由自身管理實現的,那么單個列表項關閉后引起的列表動畫由誰進行管理呢? 自然是由列表進行管理。每個列表項除了原始的第一個列表項都可能會發生向上平移的動畫,因此我們需要對單個的列表項再進行一層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中只需要傳入索引值就可以在共享數據中提取到相應列表項的數據。FloatingWindowSharedDataWidgetFloatingWindowModel的代碼及注釋如下:

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即動畫列表來實現。

創作不易,點贊支持

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,197評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,415評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,104評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,884評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,647評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,130評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,208評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,366評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,887評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,737評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,939評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,478評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,174評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,586評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,827評論 1 283
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,608評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,914評論 2 372