Flutter自绘组件:微信悬浮窗(四)

系列指路:
Flutter自绘组件:微信悬浮窗(一)
Flutter自绘组件:微信悬浮窗(二)
Flutter自绘组件:微信悬浮窗(三)

我们在系列第一、二篇文章中实现了悬浮窗的按钮形态,在系列第三篇文章中实现了悬浮窗的列表形态,现在需要做的就是把这两者有逻辑地结合起来。最终实现效果图对比如下:

Screenrecording_20200912_203536 00_00_00-00_00_30.gif

Screenrecording_20200913_123059 00_00_00-00_00_30.gif

实现思路

两者之间的切换存在以下的逻辑:

  1. 当处于边缘按钮形态时,点击边缘按钮则切换至存在遮盖层的悬浮列表部分,且悬浮列表显示位置会根据当前悬浮按钮的位置进行显示,如果悬浮按钮位于屏幕下方且下方的长度不足以显示列表项,则将列表项置于悬浮按钮的上方进行显示。
  2. 当处于悬浮列表形态,点击遮盖层则会切换至按钮形态。
  3. 当处于悬浮列表形态时,关闭所有的悬浮列表项悬浮窗组件会隐形。

实现难点

实现难点主要在于悬浮列表的动画控制。我们在系列第三篇实现悬浮列表项的时候,只实现了关闭动画,而没有实现入场动画。在边缘按钮形态切换至悬浮列表形态的时候使存在一个所有列表项从边缘往中心延申的动画,而从悬浮列表形态切换至边缘按钮形态的时候存在一个所有列表项从中心往边缘缩减的动画,如何在实现这些逻辑的连接是难点。

对FloatingItem的改造

我们在系列第三篇文章实现列表项关闭动画的时候是将关闭动画作为一个单独的动画且有列表项自身进行管理,由于我们此时存在了新的要求,所有列表项统一的进场和退场动画,我们可以把退场动画看作是进场动画的一个reverse,当触发关闭事件的时候就是进行reverse操作。由于父级状态的切换会触发子项的动画效果,因此所有列表项的进出场动画状态应该由父WidgetFloatingWindow来进行管理,我们在FloatingWindow中使用一个变量isEntering来管理列表项是否需要进行进场动画。因此我们需要对FloatingItem的动画进行重写,我们可以把列表项的进场动画和关闭动画看作是同一个动画的不同形态,即forwardreverse。我们需要在_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,isEdgetop,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变量,用于边缘按钮点击触发悬浮按钮悬浮列表项形态变化的冒泡通知。FlotingItemsFloatingItemAnimatedWidget需要传入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,然后对数据增删逻辑进行处理,下一章应该是本系列的最终章,如果有需要的朋友可以评论或私聊我获取项目完整的代码。之后打算进行两个完整配套开源项目的持续开发,涉及WebFlutter,有兴趣可以继续关注。
创作不易,多多支持。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,056评论 5 474
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,842评论 2 378
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,938评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,296评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,292评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,413评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,824评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,493评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,686评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,502评论 2 318
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,553评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,281评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,820评论 3 305
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,873评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,109评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,699评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,257评论 2 341

推荐阅读更多精彩内容