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<FloatingButton> 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>的对象,更多详细解释可以参考ui.Image 加载探索https://cloud.tencent.com/developer/article/1622733),具体代码如下:

    //通过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; //返回
  }

函数返回了一个Future<ui.Image>的对象,如何把这个对象传进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>执行后返回的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<FloatingButton> 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<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; //返回
  }
}

调用如下:

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

便可以实现最终效果:

实现效果

总结

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

image

有兴趣的可以继续关注。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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