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

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

功能实现

在上一篇文章中,实现了FloatingButtonPainter,对不同形态的按钮进行了绘制。这次主要是实际运用起来,让它实现“动”起来的效果。“动”细分下有按钮间形态变化的逻辑按钮的拖拽事件按钮按下时引起的重绘,及按钮拖拽后释放的动画效果。要实现这些功能复杂的功能,先新建一个StatefulWidget作为自绘组件的父级,命名为FloatingButton,这个类中会有一些需要用到的变量,具体如下:

class FloatingButton extends StatefulWidget {

  FloatingButton({
    @required this.imageProvider
  });
  final ImageProvider imageProvider; //按钮中心logo
  @override
  _FloatingButtonState createState() => _FloatingButtonState();

}

class _FloatingButtonState extends State with TickerProviderStateMixin {

  double _left = 0.0; //按钮在屏幕上的x坐标
  double _top = 100.0;    //按钮在屏幕上的y坐标

  bool isLeft = true;    //按钮是否在按钮左侧
  bool isEdge = true;    //按钮是否处于边缘
  bool isPress = false;    //按钮是否被按下

  AnimationController _controller;
  Animation _animation;    // 松开后按钮返回屏幕边缘的动画
  
  @override
  Widget build(BuildContext context) {
    // TODO: implement build
    return Container();
  }
}

补坑Image

在上次文章中有讲到Canvas中的Image是属于ui库中的一个私有类,只能通过监听ImageProvider的图片流来获取一个Future的对象,更多详细解释可以参考ui.Image 加载探索 (https://cloud.tencent.com/developer/article/1622733),具体代码如下:

    //通过ImageProvider获取ui.image
  Future loadImageByProvider(
      ImageProvider provider, {
        ImageConfiguration config = ImageConfiguration.empty,
      }) async {
    Completer completer = Completer(); //完成的回调
    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; //返回
  }

函数返回了一个Future的对象,如何把这个对象传进FloatingButtonPainter呢?Future对象,我们很自然想到了异步更新UI的FutureBuilder组件。大概看一下FutureBuilder的构造函数:

FutureBuilder({
  this.future,
  this.initialData,
  @required this.builder,
})

future : 一个异步耗时的Future对象
builder : Widget构建器。构建签名如下:
Function (BuildContext context, AsyncSnapshot snapshot)
主要讲一下snapshot,它包含了当前异步任务的状态和结果,因此通过它获取函数返回的Future执行后返回的ui.Image对象,具体代码如下:

FutureBuilder(
              future:  loadImageByProvider(widget.imageProvider),
              builder: (context,snapshot) => CustomPaint(
                size: Size(50,50),//绘制区域50x50
                painter: FloatingButtonPainter(isLeft: isLeft, isEdge: isEdge, isPress: isPress, buttonImage: snapshot.data),
              ),//CustomPaint
            ),//FutureBuilder

CustomPaint在上篇文章中说到是配合CustomPainter使用实现自定义图形绘制。

取色补充

上篇文章绘制各个部分的颜色选取都是根据微信悬浮窗的截图然后通过在线取色网站进行取色。


image

按钮的拖拽事件和变化逻辑

按钮的变化其实是和拖拽事件相关的,因为坐标是按钮形态变化的标准,当x坐标为0的时候便是左边缘按钮,为屏幕宽度减去自身宽度的时候便是右边缘按钮,处于两者之间的时候就是中心按钮的形态,而拖拽改变了组件的坐标。拖拽事件需要注意的是,当拖拽位置从边缘到中间或者从中间到边缘的时候,会触发按钮从边缘按钮到中心按钮或中心按钮到边缘按钮的形态变化

拖拽事件可以使用到的组件有DraggableGestureDetector,这里使用我较为熟悉的后者,具体代码如下:

    GestureDetector(
            //拖拽更新事件
            onPanUpdate: (details){
              var pixelDetails = MediaQuery.of(context).size; //获取屏幕信息
              
              //拖拽后更新按钮信息,是否处于边缘
              if(_left + details.delta.dx > 0 && _left + details.delta.dx < pixelDetails.width - 50{
                setState(() {
                  isEdge = false;
                });
              }else{
                setState(() {
                  isEdge = true;
                });
              }
              //拖拽更新坐标
              setState(() {
                _left += details.delta.dx;
                _top += details.delta.dy;
              });
              
            },
            
            child: FutureBuilder(
              future:  loadImageByProvider(widget.imageProvider),
              builder: (context,snapshot) => CustomPaint(
                size: Size(50,50),
                painter: FloatingButtonPainter(isLeft: isLeft, isEdge: isEdge, isPress: isPress, buttonImage: snapshot.data),
              ),//CustomPaint
            ),//FutureBuilder
          ),//GestureDetector

按钮按下时引起的重回及释放时的动画效果

按下和释放这两个手势在GestureDetector中也可以进行监听,但是这两个手势会与拖拽手势发生竞争与冲突,导致按下与释放两个手势失效或者需要静止的情况下才能触发按下与释放两个手势事件,这明显是不符合我们的要求。手势冲突可以通过Listener直接识别原始指针事件来解决。就是在GestureDetector外面套一层Listener,在Listener中监听原始的按下释放指针事件。在按下是需要设置isPress为true,释放的时候为false。且在释放的时候是会存在一个从屏幕中间返回到屏幕边缘的动画,这个过程的逻辑为:释放时,根据按钮当前位置,以屏幕宽度中线为准线,位于屏幕左侧的触发从当前位置返回左边缘的动画,且当动画结束时,按钮从中心按钮变化为左边缘按钮。右侧同理。 示意图如下:

image

具体代码为:

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; //获取屏幕信息
            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(() {
                  _left = _animation.value; //更新x坐标
                });
                });
              await _controller.forward(); //等待动画结束
              _controller.dispose();//释放动画资源
              setState(() {
                isLeft = true;  //按钮在屏幕左侧
              });
            }
            else
            {
              print(pixelDetails.width);
              _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(() {
                    _left = _animation.value; //动画更新x坐标
                  });
                });
              await _controller.forward(); //等待动画结束
              _controller.dispose(); //释放动画资源
              setState(() {
                isLeft = false; //按钮在屏幕左侧
              });
            }

            setState(() {
              isEdge = true; //按钮返回至边缘
            });
          },

          child: GestureDetector(
            ....省略代码
          ),//GestureDetector
        ),//Listener

完整代码

FloatingButton完整代码

import 'dart:ui' as ui;
import 'dart:async';
import 'package:flutter/material.dart';


class FloatingButton extends StatefulWidget {

  FloatingButton({
    @required this.imageProvider
  });
  final ImageProvider imageProvider;
  @override
  _FloatingButtonState createState() => _FloatingButtonState();

}

class _FloatingButtonState extends State with TickerProviderStateMixin{

  double _left = 0.0;  //按钮在屏幕上的x坐标
  double _top = 100.0;  //按钮在屏幕上的y坐标

  bool isLeft = true;    //按钮是否在按钮左侧
  bool isEdge = true;    //按钮是否处于边缘
  bool isPress = false;   //按钮是否被按下

  AnimationController _controller;
  Animation _animation; // 松开后按钮返回屏幕边缘的动画

  @override
  Widget build(BuildContext context) {
    return Positioned(
        left: _left,
        top: _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; //获取屏幕信息
            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(() {
                  _left = _animation.value; //更新x坐标
                });
                });
              await _controller.forward(); //等待动画结束
              _controller.dispose();//释放动画资源
              setState(() {
                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(() {
                    _left = _animation.value; //动画更新x坐标
                  });
                });
              await _controller.forward(); //等待动画结束
              _controller.dispose(); //释放动画资源
              setState(() {
                isLeft = false; //按钮在屏幕左侧
              });
            }

            setState(() {
              isEdge = true; //按钮返回至边缘
            });
          },
          child: GestureDetector(
            //拖拽更新
            onPanUpdate: (details){
              var pixelDetails = MediaQuery.of(context).size; //获取屏幕信息
              //拖拽后更新按钮信息,是否处于边缘
              if(_left + details.delta.dx > 0 && _left + details.delta.dx < pixelDetails.width - 50){
                setState(() {
                  isEdge = false;
                });
              }else{
                setState(() {
                  isEdge = true;
                });
              }
              //拖拽更新坐标
              setState(() {
                _left += details.delta.dx;
                _top += details.delta.dy;
              });
            },
            child: FutureBuilder(
              future:  loadImageByProvider(widget.imageProvider),
              builder: (context,snapshot) => CustomPaint(
                size: Size(50.0,50.0),
                painter: FloatingButtonPainter(isLeft: isLeft, isEdge: isEdge, isPress: isPress, buttonImage: snapshot.data),
              ),
            ),
          ),
        ),
      );
  }

  //通过ImageProvider获取ui.image
  Future loadImageByProvider(
      ImageProvider provider, {
        ImageConfiguration config = ImageConfiguration.empty,
      }) async {
    Completer completer = Completer(); //完成的回调
    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; //返回
  }
}

调用如下:

FloatingButton(imageProvider: AssetImage('assets/Images/vnote.png')

使用网络图片则替换相应的ImageProvider

main.dart代码:

void main(){
  runApp(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: [
            FloatingButton(imageProvider: AssetImage('assets/Images/vnote.png'),)
          ],
        )
      )
    );
  }
}

便可以实现最终效果:

实现效果

总结

对于自绘组件,我们先要把各种形态绘制出来,再思考各种形态之间存在的逻辑关系和事件处理。对于需要实现的功能使用已有的组件去实现会大大增加开发的效率。目前已经实现了悬浮窗点击前的悬浮按钮效果,下篇文章开始着手点击后遮盖层效果和列表效果的实现,如下图所示:

image

有兴趣的可以继续关注。

你可能感兴趣的:(Flutter自绘组件:微信悬浮窗(二))