UI--学习模仿QQ未读提醒拖拽删除

《代码里的世界》UI篇

用文字札记描绘自己 android学习之路

转载请保留出处 by Qiao
http://blog.csdn.net/qiaoidea/article/details/46608385

【导航】

  • 弹出式对话框各种方案 从仿QQ消息提示框来谈弹出式对话框的实现方式 (Dialog,PopupWind,自定义View,Activity,FragmentDialog)
  • Dialog源码解析 从源码上看Dialog与DialogFragment
  • 仿IOS ActionSheet 两种方式实现ActionSheet底部弹出菜单效果
  • QQ未读提醒拖拽删除 触摸事件监听与控件位置变换

1.概述

  作为一款优秀的社交聊天软件,QQ始终保持着优秀的交互与设计,同时引领不少新时尚与标准规范,而它同时也有一些人性化的设计颇值得为人称道。今天要提到的是QQ消息未读拖拽清除(一键退朝”,“一键清除未读”,“一键下班”)的功能。具体细节参考 知乎:一键消除红点功能是怎么想出来的?当然,得益于诸位大大的各种尝试,小弟也稍加模仿修改了一个类似的Demo.这里展示下我们最后实现的各种样式及效果图:
  
  UI--学习模仿QQ未读提醒拖拽删除_第1张图片
  
  (注:部分设计思想借鉴 chenupt的博文,不过其部分细节和实用性不够友善,拖动区域小,移除效果不随手指移动等原因,特作修缮并加一个人理解稍作补充。)
  示例源码 已更新上传,包涵了滑动切换fragment和底部tab变色效果。具体实现方案鸿神已经提过,有兴趣的点个赞我后边再另开一篇仔细啰嗦下。


2.设计实现

  具体原理分析请看前边贴的知乎原文,实现的话稍作讲解下吧。
  先贴老图:
  UI--学习模仿QQ未读提醒拖拽删除_第2张图片
  1.以未读消息原图中心为原点,计算p1~p4四点坐标,根据未读红点和手势拖动点间的距离来判断红点的消除与回弹。
  2.初始化消除动画view,关联到指定未读view并绑定拖动事件,根据手势位置更新图片坐标。
  3.判断是否超出最大距离,并根据连接/断开状态来处理手势释放事件,看是否需要消除view。
  4.在上述各状态时绑定相应事件。
  
  貌似很多同学更关注如何使用,因此从本篇开始优先讲引入与使用。

2.1如何使用

  • 导入 示例源码

    1.直接导入tipsview至项目作为库/(或直接引入到自己项目)
    (1)Android Studio 在项目 build.gradle 中配置

    compile project(“:tipsview”)

    (2)eclipse 直接 add library(或在 project.properties 配置)

    android.library.reference.1=../tipsview

  • 使用

    1.将TipsView添加至layout.xml 布局最顶层

<code.qiao.com.tipsview.TipsView
        android:id="@+id/tip"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

或者

rootView.addView(tipview, LayoutParams.MATCH_PARENT,LayoutParams.MATCH_PARENT);

2.关联至指定可拖动view,并实现拖动响应事件

 tipsView.attach(targetView, new TipsView.Listener(){
                @Override
                public void onStart() {
                    targetView.setVisibility(View.INVISIBLE);
                }

                @Override
                public void onComplete() {

                }

                @Override
                public void onCancel() {
                    targetView.setVisibility(View.VISIBLE);
                }
            });

如果是添加在listView等有触摸事件处理作为子view的地方,记得在onStart()方法中调用

//当requestDisallowInterceptTouchEvent 参数为true的时候 它不会拦截其子控件的 触摸事件
listView.requestDisallowInterceptTouchEvent(true);

方法说明

    //缺省方法
    attach(final View attachView, Listener listener)

    attach(final View attachView, final Func copyViewCreator, final Listener listener) 

其中
+ View attachView 为点击拖动目标view,比如显示消息未读的view
+ Func copyViewCreator 点击拖动时候显示的View,缺省方法默认显示被拖动view本身,当然可以返回其他view,比如选中弹出另外一个view样式。

重写invoke()方法返回拖动显示的view

         new TipsView.Func() {
                @Override
                public View invoke() {
                    return null;//返回要显示view
                }
            }
  • Listener listener 点击拖动开始,完成(即消除),取消事件接口
new TipsView.Listener(){
                @Override
                public void onStart() {
                          //开始拖动
                }

                @Override
                public void onComplete() {
                         //拖动并移除后
                }

                @Override
                public void onCancel() {
                       //拖动取消
                }
            });

实现上述接口便可以达到类似QQ拖动清除效果。

2.2 详细实现

  整个View大概用了不到300行代码,原理除了计算和利用贝塞尔曲线绘制拖拽效果外,并无什么特别复杂的逻辑。这里从源码层面简要走一遍。

1.全局变量参数

  定义记录各个位置的坐标点 ,初始化画笔,半径和view ,并设置状态值

public class TipsView extends FrameLayout {
    //默认半径
    public static final float DEFAULT_RADIUS = 20; 

    //画笔与路径
    private Paint paint;

    //当前拖动位置
    float x = 0;
    float y = 0;

    //贝塞尔曲线的操作点
    float anchorX = 0;

    //相对于view起点
    float startX = 500;
    float startY = 100;

    //相对于屏幕的起点位置
    float thisX = 0;
    float thisY = 0;

    float radius = DEFAULT_RADIUS;//当前半径

    boolean isTrigger, isTouch;//是否触发消失动画,是否手势触摸状态

    ImageView exploredImageView;//带有消失动画效果的imageview
    View tipImageView; //可拖动的View

    //。。。

2.初始化

  因为是显示在最顶层,用于展示拖动View,所以初始化时候设置背景透明,并设置画笔和添加带有消失动画效果的imageview(默认不可见)。

     public TipsView(Context context) {
        super(context);
        init();
    }

    public TipsView(Context context, AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public TipsView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        //透明背景
        setBackgroundColor(Color.TRANSPARENT);
        path = new Path();//轨迹

        //初始化画笔
        paint = new Paint();
        paint.setAntiAlias(true);
        paint.setStyle(Paint.Style.FILL_AND_STROKE);
        paint.setStrokeWidth(2);
        paint.setColor(0xffed5050);

        //添加消失效果的View
        LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        exploredImageView = new ImageView(getContext());
        exploredImageView.setLayoutParams(params);
        exploredImageView.setImageResource(R.drawable.tips_bubble);
        exploredImageView.setVisibility(View.INVISIBLE);
        addView(exploredImageView);
    }

3.计算和绘制

private void calculate() {
        //计算两点(拖动位置和起点)距离,得到当前圆点半径
        float distance = (float) Math.sqrt(Math.pow(y - startY, 2) + Math.pow(x - startX, 2));
        radius = -distance / 15 + DEFAULT_RADIUS;

        if (radius < 7) { //当半径小于指定值时候触发消除动画
            isTrigger = true;
        } else {
            isTrigger = false;
        }

        /**
        *近似地将两个圆起始半径设置相等,简单地求与圆心线垂直的线和圆的相交的四个点
        */
        float offsetX = (float) (radius * Math.sin(Math.atan((y - startY) / (x - startX))));
        float offsetY = (float) (radius * Math.cos(Math.atan((y - startY) / (x - startX))));

        float x1 = startX - offsetX;
        float y1 = startY + offsetY;

        float x2 = x - offsetX;
        float y2 = y + offsetY;

        float x3 = x + offsetX;
        float y3 = y - offsetY;

        float x4 = startX + offsetX;
        float y4 = startY - offsetY;

        //计算轨迹
        path.reset();
        path.moveTo(x1, y1);
        path.quadTo(anchorX, anchorY, x2, y2); //贝塞尔曲线
        path.lineTo(x3, y3); //直线
        path.quadTo(anchorX, anchorY, x4, y4); 
        path.lineTo(x1, y1);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        calculate();//计算轨迹
        if (isTrigger || !isTouch || tipImageView == null) { //断开或手势释放或展示view为空均只绘制透明图层
            canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.OVERLAY);
        } else { //绘制透明图层后,接着绘制轨迹,并在起点和当前手势位置绘制两个圆
            canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.OVERLAY);
            canvas.drawPath(path, paint); 
            canvas.drawCircle(startX, startY, radius, paint);
            canvas.drawCircle(x, y, radius, paint);
        }
        super.onDraw(canvas);
    }

4.关联到目标view(可拖动view)和绑定事件

    //缺省方法,默认使用目标view作为可拖动view
    public void attach(final View attachView, Listener listener) {
        attach(attachView, 
        new Func() { //返回拖动时候显示的view
            @Override
            public View invoke() {
                Bitmap bm = view2Bitmap(attachView);
                ImageView iv = new ImageView(getContext());
                iv.setImageBitmap(bm);
                return iv;
            }
        }, listener);
    }

    //目标view,拖动时候展示的view(可定制)和监听事件
    public void attach(final View attachView, final Func copyViewCreator, final Listener listener) {
        attachView.setOnTouchListener(new OnTouchListener() {

            protected void init() { //初始化
                /**
                *获取目标view在屏幕中位置 和 可展示区域(TipView)在屏幕位置
                *从而得到目标在展示区域的位置startX 和startY
                */
                int[] attachLocation = new int[2];
                attachView.getLocationOnScreen(attachLocation);
                int[] thisLocation = new int[2];
                TipsView.this.getLocationOnScreen(thisLocation);

                //得到目标view在展示区域中的位置
                startX = attachLocation[0] - thisLocation[0] + attachView.getWidth() / 2;
                startY = attachLocation[1] - thisLocation[1] + attachView.getHeight() / 2;

                //当前手势位置即起点
                x = startX;
                y = startY;

                tipImageView = copyViewCreator.invoke(); //得到拖动时候要展示的View

                //添加到展示区域中
                tipImageView.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
                TipsView.this.addView(tipImageView);
                tipImageView.measure(0,0);//由于刚添加并没有测量和绘制,会导致娶不到高宽,影响展示view(中心与起点对齐),所以先进行一次测量

                //绘制展示的view位置中心与起点重合
                tipImageView.setX(startX - tipImageView.getMeasuredWidth() / 2);
                tipImageView.setY(startY - tipImageView.getMeasuredHeight() / 2);

                if (listener != null) {
                    listener.onStart(); //触发开始拖动事件
                }
            }

            protected void destory() {
                TipsView.this.removeView(tipImageView); //移除拖动展示的view
            }

            @Override
            public boolean onTouch(View v, MotionEvent event) { //触摸事件监听
                if (event.getAction() == MotionEvent.ACTION_DOWN) { //按下
                    init(); //初始化
                    isTouch = true;
                    int[] location = new int[2]; 
                    TipsView.this.getLocationOnScreen(location);
                    //相对于屏幕的起点位置
                    thisX = location[0];
                    thisY = location[1];

                    invalidate(); //重绘
                    return true;
                }
                //如果isTouch为false,不处理触摸事件
                if (!isTouch)
                    return false;
                if (event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL) { //手势移开或取消
                    isTouch = false; 
                    destory(); //移除展示view

                    if (isTrigger) {  //触发了消失动画
                        postDelayed(new Runnable() {
                            @Override
                            public void run() {
                                isTrigger = false;
                                if (listener != null) {
                                    listener.onComplete();//调用移除完成事件
                                }
                            }
                        }, 1000);

                        //在手势释放处显示移除效果的View
                        exploredImageView.setX(x - exploredImageView.getWidth() / 2);
                        exploredImageView.setY(y - exploredImageView.getHeight() / 2);
                        exploredImageView.setVisibility(View.VISIBLE);

                        //取得移除效果view的帧动画背景,播放消失动画
                        exploredImageView.setImageResource(R.drawable.tips_bubble);
                        ((AnimationDrawable) exploredImageView.getDrawable()).stop();
                        ((AnimationDrawable) exploredImageView.getDrawable()).start();
                    } else {
                        if (listener != null) {
                            listener.onCancel();
                        }
                    }
                }

                /**
                * 获取当前位置和起点的中间点为贝塞尔曲线的操作点
                */
                anchorX = (event.getRawX() - thisX + startX) / 2;
                anchorY = (event.getRawY() - thisY + startY) / 2;
                x = event.getRawX() - thisX;
                y = event.getRawY() - thisY;

                //在当前位置绘制展示view
                tipImageView.setX(x - tipImageView.getWidth() / 2);
                tipImageView.setY(y - tipImageView.getHeight() / 2);

                invalidate();
                return true;
            }
        });
    }

5.返回展示View的接口 和 拖动监听事件接口

     public interface Func<Tresult> {
        Tresult invoke(); //返回类型为Tresult的对象实例 
    }

    public static interface Listener {
        void onStart();

        void onComplete();

        void onCancel();
    }

  通过位置记录和绘制view,根据手势时间来计算和改变view的状态。其中有三个view,分别对应:

  • attachView 目标view,该view可被拖动(比如未读消息数view)
  • tipImageView 拖动时候显示的view,比如点击未读红点拖动和显示的却是蓝色气泡等(默认显示原目标view)
  • exploredImageView 移除时候负责显示带动画效果的view

    以上就是我们的TipsView简易包装。


3.综述总结

  如果单单只是在目标view的父view区域拖动该未读图标,大可不必在根布局里添加一个顶层view。但是如果我们想要实现目标view全屏任意位置拖动,就需要额外模拟一个填充View,隐藏掉原来的view,达到全屏幕拖动的效果。
  需要说明的一点,在有些时候,比如listview或者scrollView等布局下,我们会发现无法拖动目标view,这是因为这些父容器有拦截触摸滑动事件。所以我们必须在onStart()监听事件接口中设置

//当requestDisallowInterceptTouchEvent 参数为true的时候 它不会拦截其子控件的 触摸事件
requestDisallowInterceptTouchEvent(true);

实现目标view的触摸事件有效。

  注: 请在布局根视图添加TipView,并保持在最顶层。他将决定你的拖动范围。
  注: 请在布局根视图添加TipView,并保持在最顶层。他将决定你的拖动范围。

  最后,附上代码下载地址:
  示例demo源码下载地址 (资源上传较慢,如果不可用,试试这里)


你可能感兴趣的:(Android,进阶,UI积累)