QQ小红点(二) DragPointView

传送门

https://github.com/javonleee/DragPointView

前言

之前写了自定义View之QQ小红点(一),还没有看过的可以去大概瞅一眼。我再大概啰嗦一下,上篇文章主要介绍了小红点的实现原理(贝塞尔曲线)以及相关的代码实现。时隔两周,今天我带大家还把之前写的demo封装成一个简单易用,具有一定的程度的可定制的开源控件。为了避免遗落某些点,下面咱们再来回顾一下QQ上的红点效果。

需求分析

下面咱们一起看看QQ上的红点效果是什么样的,这个时候可以把自己当成产品经理,仔细琢磨,仔细研究。最重要的是把细节都抠出来,然后反馈给开发。哈哈,开玩笑啦~

展示

很显然,它需要展示文本并且是数组(对于文字而言,数字文字无异)。它的背景可以是不同的颜色(红色,浅蓝色,甚至其他),在文本长度变化时背景能够自动适应,例如:展示1的时候它是个原,而展示66的时候它是两个半圆夹着矩形(表述不好,自行幻想~)

移位

当我们点住红点不放,到处走走,会发现它是随着手指移动的。看起来好像没什么麻烦的哈,无非就是监听触摸事件,设置新的位置或者translatX/Y值。好,这个时候再回想一下上篇文章demo的实现。你会发现,demo中并没有这个东东,demo中的“动”的效果只是单纯的重绘。OK,这里咱们只是先分析分析,客观先往下看

控件层次

倔强的小红点无论你怎么拖拽,它都是在顶层,并不会被某个东西覆盖或者遮挡。甚至,它竟然能跑到状态栏的位置,自由自在的玩耍,忍了。说的这,我想有些同学可能已经有想法了

恢复回弹

小红点在某个范围内来回拖拽都会有贝塞尔拉伸部分,但是超过阀值之后就再也没有贝塞尔拉伸部分。在某个地方释放触摸事件,控件都会执行冒泡动画并且消失,除了在初始位置的某个小范围内控件释放的话控件会恢复到初始位置(注意此处的小范围与上面提到的“某个范围”不同等)

连带效果

界面上红点分为两类:第一类是单个聊天会话未读消息,拖动消失后不会影响其他红点;第二类是会话未读数总和,拖拽消失后会连带所有会话的红点(顺序执行消失动画后隐藏)

代码实现

自定义View属性定义

    <declare-styleable name="DragPointView">

        <!--最大可拖拽距离-->
        <attr name="maxDragLength" format="dimension"/>
        <!--中心圆形半径-->
        <attr name="centerCircleRadius" format="dimension"/>
        <!--拖拽圆形半径-->
        <attr name="dragCircleRadius" format="dimension"/>
        <!--中心圆形变化最小比例-->
        <attr name="centerMinRatio" format="float"/>
        <!--恢复动画时长-->
        <attr name="recoveryAnimDuration" format="integer"/>
        <!--回弹系数-->
        <attr name="recoveryAnimBounce" format="float"/>
        <!--贝塞尔部分颜色-->
        <attr name="colorStretching" format="color"/>
        <!--标记-->
        <attr name="sign" format="string"/>
        <!--清理标记-->
        <attr name="clearSign" format="string"/>
        <!--是否可拖拽-->
        <attr name="canDrag" format="boolean"/>

    </declare-styleable>

首先,需要展示文字。有两个方案,第一个的方案就是自己实现文字的展示,这个方式需要注意的是文本的居中展示。第二个方案就是直接继承TextView,在TextView基础上实现功能。那么工作量来看,显然我们是直接继承TextView,让TextView帮我们完成文本相关的展示工作。大家可以发现很多有文字的原生控件都是继承于TextView的。还有一个好处就是,我们可以为它设置background,通过shape我们即可以实现我们想要的背景效果

public abstract class AbsDragPointView extends TextView{ ... }
背景效果

接着,咱们处理移位效果。其实很简单,在之前的基础上转变一下思想,将之前dragCircle的变化换成centerCircle的变化。之前demo的实现是centerCircle处于初始位置不变,而dragCenter随着手指移动,这个时候跟咱们的需求就相反了,咱们需要控件本身跟着移动(也就是dragCenter),还有就是此处监听事件的位置,不再以getX为计算数据,而是采用getRaw。getX获取的是事件位置在控件中的x值,而getRaw是基于整个屏幕

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!canDrag || ClearViewHelper.getInstance().isClearSigning(sign)
                || (mRecoveryAnim != null && mRecoveryAnim.isRunning())
                || (mRemoveAnim != null && mRemoveAnim.isRunning())) {
            return super.onTouchEvent(event);
        }
        if (mRecoveryAnim == null || !mRecoveryAnim.isRunning()) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    if(getParent() != null)
                    getParent().requestDisallowInterceptTouchEvent(true);
                    downX = event.getRawX();
                    downY = event.getRawY();
                    isInCircle = true;
                    postInvalidate();
                    break;
                case MotionEvent.ACTION_MOVE:
                    float dx = (int) event.getRawX() - downX;
                    float dy = (int) event.getRawY() - downY;
                    mCenterCircle.x = mWidthHalf - dx;
                    mCenterCircle.y = mHeightHalf - dy;
                    mDistanceCircles = MathUtils.getDistance(mCenterCircle, mDragCircle);
                    mIsDragOut = mIsDragOut ? mIsDragOut : mDistanceCircles > mMaxDragLength;
                    setX(origX + dx);
                    setY(origY + dy);
                    postInvalidate();
                    break;
                case MotionEvent.ACTION_UP:
                case MotionEvent.ACTION_CANCEL:
                    getParent().requestDisallowInterceptTouchEvent(false);
                    upX = getX();
                    upY = getY();
                    upAndCancelEvent();
                    break;
            }
        }
        return true;
    }

控件层次,什么意思呢?咱们的整个布局,最终形成了一个view tree就像多叉树一样。每个控件位于某个层次就决定了它的展示范围,如果我们的控件处于某个父容器中,那么它的最大可显空间也就是父容器的空间。但是,咱们的需求是在全屏幕的任何位置都是可显的。

同样,这里我也想到了两个方案。第一个方案就是设置父容器设置clipChildren属性为true,这个方案缺陷很多,因为我实践过了。主要的问题:1.该控件的直接或者间接关系的所有父容器都需要设置 2.在ListView或者RecyclerView等列表控件中即时设置的clipChildren属性,控件的可显范围也只是其自身及以上的位置 3.无法拖拽到ActionBar,ToolBar以及状态栏位置。可想而知,这个idea pass了。

第二个方案将View添加到Window上,哇塞,没有任何问题。但是实现上可能就要绕一些了,为什么?你想啊,通过什么方式将控件添加到window上呢?

直接把原来的View移除,然后添加?那么什么时候做这个操作呢?答案是:任何触摸事件触发的时候,而且在事件正发生呢,你把人家移走了。那事件怎么继续呢?所以换个方式pass

OK,我说我的实现方式。布局上的控件与window添加的控件是两个控件,并且他们继承于同一个父类AbsDragPointView,该类是个抽象类,定义了两个控件共同的成员变量(其实就是那么自定义View的属性)以及几个抽象方法,这样处理的原因是为了抽象这个两个控件统一规范的行为方式。

public abstract class AbsDragPointView extends TextView{

    protected float mCenterRadius;
    protected float mDragRadius;
    protected float mCenterMinRatio;
    protected float mRecoveryAnimBounce;
    protected int mMaxDragLength;
    protected int colorStretching;
    protected int mRecoveryAnimDuration;
    protected String sign;
    protected String clearSign;
    protected boolean canDrag;

    protected PointViewAnimObject mRemoveAnim;
    protected Interpolator mRecoveryAnimInterpolator;
    protected OnPointDragListener mOnPointDragListener;
    protected AbsDragPointView mNextRemoveView;

    public AbsDragPointView(Context context) {
        super(context);
    }

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

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

    public PointViewAnimObject getRemoveAnim(){
        return mRemoveAnim;
    }

    public AbsDragPointView setRemoveAnim(PointViewAnimObject removeAnim){
        this.mRemoveAnim = removeAnim;
        return this;
    }

    public AbsDragPointView setRemoveAnim(Animator mRemoveAnim) {
        this.mRemoveAnim = new PointViewAnimObject(mRemoveAnim,this);
        return this;
    }

    public AbsDragPointView setRemoveAnim(AnimationDrawable mRemoveAnim) {
        this.mRemoveAnim = new PointViewAnimObject(mRemoveAnim,this);
        return this;
    }

    public OnPointDragListener getOnPointDragListener() {
        return mOnPointDragListener;
    }

    public String getClearSign() {
        return clearSign;
    }

    public AbsDragPointView setClearSign(String clearSign) {
        this.clearSign = clearSign;
        return this;
    }

    public float getCenterRadius() {
        return mCenterRadius;
    }

    public AbsDragPointView setCenterRadius(float mCenterRadius) {
        this.mCenterRadius = mCenterRadius;
        postInvalidate();
        return this;
    }

    public float getDragRadius() {
        return mDragRadius;
    }

    public AbsDragPointView setDragRadius(float mDragRadius) {
        this.mDragRadius = mDragRadius;
        postInvalidate();
        return this;
    }

    public int getMaxDragLength() {
        return mMaxDragLength;
    }

    public AbsDragPointView setMaxDragLength(int mMaxDragLength) {
        this.mMaxDragLength = mMaxDragLength;
        return this;
    }

    public float getCenterMinRatio() {
        return mCenterMinRatio;
    }

    public AbsDragPointView setCenterMinRatio(float mCenterMinRatio) {
        this.mCenterMinRatio = mCenterMinRatio;
        postInvalidate();
        return this;
    }

    public int getRecoveryAnimDuration() {
        return mRecoveryAnimDuration;
    }

    public AbsDragPointView setRecoveryAnimDuration(int mRecoveryAnimDuration) {
        this.mRecoveryAnimDuration = mRecoveryAnimDuration;
        return this;
    }

    public float getRecoveryAnimBounce() {
        return mRecoveryAnimBounce;
    }

    public AbsDragPointView setRecoveryAnimBounce(float mRecoveryAnimBounce) {
        this.mRecoveryAnimBounce = mRecoveryAnimBounce;
        return this;
    }

    public int getColorStretching() {
        return colorStretching;
    }

    public AbsDragPointView setColorStretching(int colorStretching) {
        this.colorStretching = colorStretching;
        postInvalidate();
        return this;
    }

    public String getSign() {
        return sign;
    }

    public AbsDragPointView setSign(String sign) {
        this.sign = sign;
        return this;
    }

    public void setRecoveryAnimInterpolator(Interpolator mRecoveryAnimInterpolator) {
        this.mRecoveryAnimInterpolator = mRecoveryAnimInterpolator;
    }

    public Interpolator getRecoveryAnimInterpolator() {
        return mRecoveryAnimInterpolator;
    }

    public void clearRemoveAnim() {
        this.mRemoveAnim = null;
    }

    public AbsDragPointView setOnPointDragListener(OnPointDragListener onDragListener) {
        this.mOnPointDragListener = onDragListener;
        return this;
    }

    public boolean isCanDrag() {
        return canDrag;
    }

    public AbsDragPointView setCanDrag(boolean canDrag) {
        this.canDrag = canDrag;
        return this;
    }

    public AbsDragPointView getNextRemoveView() {
        return mNextRemoveView;
    }

    public void setNextRemoveView(AbsDragPointView mNextRemoveView) {
        this.mNextRemoveView = mNextRemoveView;
    }

    public abstract void reset();
    public abstract void startRemove();

}

接着咱们定义两个View,DragPointViewDragPointViewWindow。前者是需要在真正在布局中展示的,它的作用也只是展示以及接收咱们需要的自定义属性,也就是说它除了接收属性外,其他的与TextView没有任何差别

public class DragPointView extends AbsDragPointView {

    public static final float DEFAULT_CENTER_MIN_RATIO = 0.5f;
    public static final int DEFAULT_RECOVERY_ANIM_DURATION = 200;

    private DragViewHelper dragViewHelper;

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

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        int flowMaxRadius = Math.min(getMeasuredWidth() / 2, getMeasuredHeight() / 2);
        mCenterRadius = mCenterRadius == 0 ? flowMaxRadius : Math.min(mCenterRadius, flowMaxRadius);
        mDragRadius = mDragRadius == 0 ? flowMaxRadius : Math.min(mDragRadius, flowMaxRadius);
        mMaxDragLength = mMaxDragLength == 0 ? flowMaxRadius * 10 : mMaxDragLength;
    }

    public DragPointView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public DragPointView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray array = context.obtainStyledAttributes(attrs, R.styleable.DragPointView, defStyleAttr, 0);
        mMaxDragLength = array.getDimensionPixelSize(R.styleable.
                DragPointView_maxDragLength, MathUtils.dip2px(context, 0));
        mCenterRadius = array.getDimensionPixelSize(R.styleable.DragPointView_centerCircleRadius, 0);
        mDragRadius = array.getDimensionPixelSize(R.styleable.DragPointView_centerCircleRadius, 0);
        mCenterMinRatio = array.getFloat(R.styleable.DragPointView_centerMinRatio, DEFAULT_CENTER_MIN_RATIO);
        mRecoveryAnimDuration = array.getInt(R.styleable.
                DragPointView_recoveryAnimDuration, DEFAULT_RECOVERY_ANIM_DURATION);
        colorStretching = array.getColor(R.styleable.DragPointView_colorStretching, 0);
        mRecoveryAnimBounce = array.getFloat(R.styleable.DragPointView_recoveryAnimBounce, 0f);
        sign = array.getString(R.styleable.DragPointView_sign);
        clearSign = array.getString(R.styleable.DragPointView_clearSign);
        canDrag = array.getBoolean(R.styleable.DragPointView_canDrag, true);
        init();
    }

    @Override
    public void startRemove() {
        dragViewHelper.startRemove();
    }

    private void init() {
        dragViewHelper = new DragViewHelper(getContext(),this);
    }

    @Override
    public void reset() {

    }

}

控件状态监听接口

public interface OnPointDragListener {
    void onRemoveStart(AbsDragPointView view);

    void onRemoveEnd(AbsDragPointView view);

    void onRecovery(AbsDragPointView view);
}

然后再看DragPointViewWindow,这个同样是继承了AbsDragPointView父类,因此它具有那么需要的自定义View属性变量。并且额外增加了红点的所有逻辑实现,与上篇文章的思想大致一样,只不过将drag与center两个变换了而已。此处通过setX/setY进行移位,当然你也可以通过setTranslateX/Y实现,因为他们俩本质是一样的

`/**

  • Sets the visual x position of this view, in pixels. This is equivalent to setting the
  • {@link #setTranslationX(float) translationX} property to be the difference between
  • the x value passed in and the current {@link #getLeft() left} property.
  • @param x The visual x position of this view, in pixels.
    */
    public void setX(float x) {
    setTranslationX(x - mLeft);
    }`
class DragPointViewWindow extends AbsDragPointView implements ValueAnimator.AnimatorUpdateListener, Animator.AnimatorListener {

    private DragPointView origView;
    private Bitmap origBitmap;
    private Paint mPaint;
    private Path mPath;
    protected int mWidthHalf, mHeightHalf;
    private float mRatioRadius;
    private int mMaxRadiusTrebling;
    private boolean isInCircle;
    private float downX, downY;
    private PointF[] mDragTangentPoint;
    private PointF[] mCenterTangentPoint;
    private PointF mCenterCircle;
    private PointF mCenterCircleCopy;
    private PointF mDragCircle;
    private PointF mDragCircleCopy;
    private double mDistanceCircles;
    private PointF mControlPoint;
    private boolean mIsDragOut;
    private ValueAnimator mRecoveryAnim;
    private float origX, origY, upX, upY;

    public void setOrigBitmap(Bitmap origBitmap) {
        this.origBitmap = origBitmap;
    }

    public String getClearSign() {
        return clearSign;
    }

    public DragPointViewWindow setClearSign(String clearSign) {
        this.clearSign = clearSign;
        return this;
    }

    public DragPointViewWindow setCenterRadius(float mCenterRadius) {
        this.mCenterRadius = mCenterRadius;
        return this;
    }

    public DragPointViewWindow setDragRadius(float mDragRadius) {
        this.mDragRadius = mDragRadius;
        return this;
    }

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

    public DragPointViewWindow(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

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

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mCenterCircle.x = mDragCircle.x = mWidthHalf = getMeasuredWidth() / 2;
        mCenterCircle.y = mDragCircle.y = mHeightHalf = getMeasuredHeight() / 2;
        int flowMaxRadius = Math.min(mWidthHalf, mHeightHalf);
        mMaxRadiusTrebling = flowMaxRadius * 3;
        origX = getX();
        origY = getY();
    }

    private void init() {
        mPath = new Path();
        mPaint = new Paint();
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setTextSize(18f);
        mPaint.setColor(colorStretching);
        mPaint.setAntiAlias(true);
        mPaint.setDither(true);
        mDragTangentPoint = new PointF[2];
        mCenterTangentPoint = new PointF[2];
        mControlPoint = new PointF();
        mCenterCircle = new PointF();
        mCenterCircleCopy = new PointF();
        mDragCircle = new PointF();
        mDragCircleCopy = new PointF();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (getBackground() != null)
            return;
        drawCenterCircle(canvas);
        if (isInCircle) {
            drawBezierLine(canvas);
            drawOriginBitmap(canvas);
        }
    }

    private void drawOriginBitmap(Canvas canvas) {
        if (origBitmap != null && !origBitmap.isRecycled())
            canvas.drawBitmap(origBitmap, 0, 0, mPaint);
    }

    private void drawCenterCircle(Canvas canvas) {
        if (mIsDragOut || !isInCircle) return;
        mPaint.setColor(colorStretching);
        mRatioRadius = Math.min(mCenterRadius, Math.min(mWidthHalf, mHeightHalf));
        if (isInCircle && Math.abs(mCenterMinRatio) < 1.f) {
            mRatioRadius = (float) (Math.max((mMaxDragLength - mDistanceCircles) * 1.f / mMaxDragLength, Math.abs(mCenterMinRatio)) * mCenterRadius);
            mRatioRadius = Math.min(mRatioRadius, Math.min(mWidthHalf, mHeightHalf));
        }
        canvas.drawCircle(mCenterCircle.x, mCenterCircle.y, mRatioRadius, mPaint);
    }

    public void setOrigView(DragPointView origView) {
        this.origView = origView;
    }

    private void drawBezierLine(Canvas canvas) {
        if (mIsDragOut) return;
        mPaint.setColor(colorStretching);
        float dx = mDragCircle.x - mCenterCircle.x;
        float dy = mDragCircle.y - mCenterCircle.y;
        // 控制点
        mControlPoint.set((mDragCircle.x + mCenterCircle.x) / 2,
                (mDragCircle.y + mCenterCircle.y) / 2);
        // 四个切点
        if (dx != 0) {
            float k1 = dy / dx;
            float k2 = -1 / k1;
            mDragTangentPoint = MathUtils.getIntersectionPoints(
                    mDragCircle.x, mDragCircle.y, mDragRadius, (double) k2);
            mCenterTangentPoint = MathUtils.getIntersectionPoints(
                    mCenterCircle.x, mCenterCircle.y, mRatioRadius, (double) k2);
        } else {
            mDragTangentPoint = MathUtils.getIntersectionPoints(
                    mDragCircle.x, mDragCircle.y, mDragRadius, (double) 0);
            mCenterTangentPoint = MathUtils.getIntersectionPoints(
                    mCenterCircle.x, mCenterCircle.y, mRatioRadius, (double) 0);
        }
        // 路径构建
        mPath.reset();
        mPath.moveTo(mCenterTangentPoint[0].x, mCenterTangentPoint[0].y);
        mPath.quadTo(mControlPoint.x, mControlPoint.y, mDragTangentPoint[0].x, mDragTangentPoint[0].y);
        mPath.lineTo(mDragTangentPoint[1].x, mDragTangentPoint[1].y);
        mPath.quadTo(mControlPoint.x, mControlPoint.y,
                mCenterTangentPoint[1].x, mCenterTangentPoint[1].y);
        mPath.close();
        canvas.drawPath(mPath, mPaint);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (!canDrag || ClearViewHelper.getInstance().isClearSigning(sign)
                || (mRecoveryAnim != null && mRecoveryAnim.isRunning())
                || (mRemoveAnim != null && mRemoveAnim.isRunning())) {
            return super.onTouchEvent(event);
        }
        if (mRecoveryAnim == null || !mRecoveryAnim.isRunning()) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    if(getParent() != null)
                    getParent().requestDisallowInterceptTouchEvent(true);
                    downX = event.getRawX();
                    downY = event.getRawY();
                    isInCircle = true;
                    postInvalidate();
                    break;
                case MotionEvent.ACTION_MOVE:
                    float dx = (int) event.getRawX() - downX;
                    float dy = (int) event.getRawY() - downY;
                    mCenterCircle.x = mWidthHalf - dx;
                    mCenterCircle.y = mHeightHalf - dy;
                    mDistanceCircles = MathUtils.getDistance(mCenterCircle, mDragCircle);
                    mIsDragOut = mIsDragOut ? mIsDragOut : mDistanceCircles > mMaxDragLength;
                    setX(origX + dx);
                    setY(origY + dy);
                    postInvalidate();
                    break;
                case MotionEvent.ACTION_UP:
                case MotionEvent.ACTION_CANCEL:
                    getParent().requestDisallowInterceptTouchEvent(false);
                    upX = getX();
                    upY = getY();
                    upAndCancelEvent();
                    break;
            }
        }
        return true;
    }

    private void upAndCancelEvent() {
        if (isInCircle && mDistanceCircles == 0) {
            reset();
            if (mOnPointDragListener != null) {
                mOnPointDragListener.onRecovery(this);
            }
        } else if (!mIsDragOut) {
            mCenterCircleCopy.set(mCenterCircle.x, mCenterCircle.y);
            mDragCircleCopy.set(mDragCircle.x, mDragCircle.y);
            if (mRecoveryAnim == null) {
                mRecoveryAnim = ValueAnimator.ofFloat(1.f, -Math.abs(mRecoveryAnimBounce));
                mRecoveryAnim.setDuration(mRecoveryAnimDuration);
                mRecoveryAnim.addUpdateListener(this);
                mRecoveryAnim.addListener(this);
            }
            if (mRecoveryAnimInterpolator != null)
                mRecoveryAnim.setInterpolator(mRecoveryAnimInterpolator);
            mRecoveryAnim.start();
        } else {
            if (mDistanceCircles <= mMaxRadiusTrebling) {
                reset();
                if (mOnPointDragListener != null) {
                    mOnPointDragListener.onRecovery(this);
                }
            } else if (!TextUtils.isEmpty(clearSign)) {
                ClearViewHelper.getInstance().clearPointViewBySign(origView, clearSign);
            } else {
                startRemove();
            }
        }
    }

    @Override
    public void startRemove() {
        if (mRemoveAnim == null) {
            setVisibility(GONE);
            if (mNextRemoveView != null)
                mNextRemoveView.startRemove();
            if (mOnPointDragListener != null) {
                mOnPointDragListener.onRemoveStart(this);
                mOnPointDragListener.onRemoveEnd(this);
            }
        } else {
            mRemoveAnim.start(mOnPointDragListener);
        }
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        if (mRecoveryAnim != null && mRecoveryAnim.isRunning()) {
            mRecoveryAnim.cancel();
        }
        if (mRemoveAnim != null) {
            mRemoveAnim.cancel();
        }
    }

    @Override
    public void reset() {
        mIsDragOut = false;
        isInCircle = false;
        mDragCircle.x = mCenterCircle.x = mWidthHalf;
        mDragCircle.y = mCenterCircle.y = mHeightHalf;
        mDistanceCircles = 0;
        setTranslationX(0);
        setTranslationY(0);
        origX = getX();
        origY = getY();
        postInvalidate();
    }

    @Override
    public void onAnimationUpdate(ValueAnimator valueAnimator) {
        float value = (float) valueAnimator.getAnimatedValue();
        float dx = (origX - upX);
        float dy = (origY - upY);
        mCenterCircle.x = dx * value + mWidthHalf;
        mCenterCircle.y = dy * value + mHeightHalf;
        setX(upX + dx * (1 - value));
        setY(upY + dy * (1 - value));
        postInvalidate();
    }

    @Override
    public void onAnimationStart(Animator animator) {

    }

    @Override
    public void onAnimationEnd(Animator animator) {
        reset();
        if (mOnPointDragListener != null) {
            mOnPointDragListener.onRecovery(this);
        }
    }

    @Override
    public void onAnimationCancel(Animator animator) {

    }

    @Override
    public void onAnimationRepeat(Animator animator) {

    }
}

这个时候就应该体现这两个View的关联了,很显然,DragPointView是展示在布局中的。而DragPointViewWindow是咱们用来在window上代替DragPointView实现效果的,而且上述的DragPointView实现相信大家也看见了,其实就是一个躯壳。这里我定义了一个helper类用来关联这两个控件的交互逻辑,每个DragPointView都会实例化一个helper类

构造器中将DragPointView存起来,并且设置了setOnTouchListener。大家不知道有没有过这样一个疑问?为什么控件有onTouchEvent方法了,还要这个OnTouchListener干嘛?很简单,其实就是为了对外开放,这样我们可以通过这个监听去接收控件的触摸事件,而不是一味的继承。在监听中,我们在down事件时将DragPointView隐藏起来,接着DragPointViewWindow实例化并且把DragPointView的属性一并进行复制,最后把事件直接传递给DragPointViewWindow

private Context context;
    private FrameLayout container;
    private DragPointView originView;
    private DragPointViewWindow windowView;
    private OnPointDragListener onPointDragListener;
    private Runnable animRunnable;

    private WindowManager windowManager;
    private WindowManager.LayoutParams windowParams;
    private FrameLayout.LayoutParams layoutParams;

    public DragViewHelper(Context context, final DragPointView originView) {
        this.context = context;
        this.originView = originView;
        this.originView.setOnTouchListener(this);
        animRunnable = new Runnable() {
            @Override
            public void run() {
                windowView.startRemove();
            }
        };
    }

    public void addViewToWindow() {
        if (windowManager == null) {
            windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
        }
        if (windowView == null) {
            createWindowView();
        }
        if (windowParams == null ||
                layoutParams == null) {
            initParams();
        }
        if (container == null) {
            container = new FrameLayout(context);
            container.setClipChildren(false);
            container.setClipToPadding(false);
            windowView.setLayoutParams(layoutParams);
            container.addView(windowView, layoutParams);
        }
        int[] ps = new int[2];
        originView.getLocationInWindow(ps);
        layoutParams.setMargins(ps[0], ps[1], 0, 0);
        layoutParams.width = originView.getWidth();
        layoutParams.height = originView.getHeight();
        windowView.setOrigView(originView);
        originView.setDrawingCacheEnabled(true);
        Bitmap bitmap = Bitmap.createBitmap(originView.getDrawingCache());
        originView.setDrawingCacheEnabled(false);
        windowView.setOrigBitmap(bitmap);
        onPointDragListener = originView.getOnPointDragListener();
        windowView.setVisibility(View.VISIBLE);
        if(container.getParent() != null)
            windowManager.removeView(container);
        windowManager.addView(container, windowParams);
        originView.setVisibility(View.INVISIBLE);
    }

    private void createWindowView() {
        windowView = new DragPointViewWindow(context);
        windowView.setCanDrag(originView.isCanDrag());
        windowView.setCenterMinRatio(originView.getCenterMinRatio());
        windowView.setCenterRadius(originView.getCenterRadius());
        windowView.setColorStretching(originView.getColorStretching());
        windowView.setDragRadius(originView.getDragRadius());
        windowView.setClearSign(originView.getClearSign());
        windowView.setSign(originView.getSign());
        windowView.setMaxDragLength(originView.getMaxDragLength());
        windowView.setRecoveryAnimBounce(originView.getRecoveryAnimBounce());
        windowView.setRecoveryAnimDuration(originView.getRecoveryAnimDuration());
        windowView.setRecoveryAnimInterpolator(originView.getRecoveryAnimInterpolator());
        if (originView.getRemoveAnim() != null)
            windowView.setRemoveAnim(originView.getRemoveAnim().setView(windowView));
        windowView.setOnPointDragListener(this);
    }

当然,我们还需要接收DragPointView的状态监听,在相应状态产生的时候做相应的动作

    @Override
    public void onRemoveStart(AbsDragPointView view) {
        if (onPointDragListener != null) {
            onPointDragListener.onRemoveStart(originView);
        }
    }

    @Override
    public void onRemoveEnd(AbsDragPointView view) {
        if (windowManager != null && container != null) {
            windowManager.removeView(container);
        }
        if (onPointDragListener != null) {
            onPointDragListener.onRemoveEnd(originView);
        }
        if (originView != null) {
            originView.setVisibility(View.GONE);
        }
    }

    @Override
    public void onRecovery(AbsDragPointView view) {
        if (windowManager != null && container != null) {
            windowManager.removeView(container);
        }
        if (originView != null) {
            originView.setVisibility(View.VISIBLE);
        }
        if (onPointDragListener != null) {
            onPointDragListener.onRecovery(originView);
        }
    }

在写Helper的时候有个小问题,就是windowParams.type = WindowManager.LayoutParams.TYPE_TOAST;为什么用TYPE_TOAST而不是其他的呢?这里涉及到android 6.0 SYSTEM_ALERT_WINDOW 权限验证的问题,使用TYPE_TOAST可以巧妙避开校验

OK,最后来说说连带效果的实现。首先,上述中我定义的两个属性:sign与clearSign。sign作为某个控件的特殊标记,标记所属类别。而clearSign标记当自身清除时候要连带清除哪个类别的控件。这里我同样写了clear helper类来实现

public class ClearViewHelper {

    private void ClearViewHelper(){}

    public static ClearViewHelper getInstance(){
        return ClearViewHelperHolder.clearViewHelper;
    }

    private SparseArray<Boolean> clearSigning = new SparseArray<>();

    public void clearPointViewBySign(AbsDragPointView dragPointView, String clearSign) {
        List<AbsDragPointView> list = new ArrayList<>();
        list.add(dragPointView);
        getAllPointViewVisible(dragPointView.getRootView(), list, clearSign);
        if (list.contains(dragPointView))
            list.remove(dragPointView);
        list.add(0, dragPointView);
        for (int i = 0; i < list.size() - 1; i++) {
            list.get(i).setNextRemoveView(list.get(i + 1));
        }
        clearSigning.put(clearSign.hashCode(), true);
        list.get(0).startRemove();
    }

    public void clearSignOver(String clearSign) {
        if (TextUtils.isEmpty(clearSign)) return;
        clearSigning.put(clearSign.hashCode(), false);
    }

    public boolean isClearSigning(String clearSign) {
        if (TextUtils.isEmpty(clearSign)) return false;
        Boolean clear = clearSigning.get(clearSign.hashCode());
        return clear == null ? false : clear.booleanValue();
    }

    private void getAllPointViewVisible(View view, List<AbsDragPointView> list, String clearSign) {
        if (view instanceof ViewGroup) {
            for (int i = 0; i < ((ViewGroup) view).getChildCount(); i++) {
                View child = ((ViewGroup) view).getChildAt(i);
                getAllPointViewVisible(child, list, clearSign);
            }
        } else if (view instanceof AbsDragPointView) {
            AbsDragPointView v = (AbsDragPointView) view;
            if (v.getVisibility() == View.VISIBLE
                    && clearSign.equals(v.getSign())
                    && !list.contains(view))
                list.add((AbsDragPointView) view);
        }
    }

    private static class ClearViewHelperHolder{
        public static ClearViewHelper clearViewHelper = new ClearViewHelper();
    }

}

ClearViewHelper 是个单例实现,在DragPointViewWindow中UP/CANCEL事件产生后,调用upAndCancelEvent()方法

    private void upAndCancelEvent() {
        if (isInCircle && mDistanceCircles == 0) {
            reset();
            if (mOnPointDragListener != null) {
                mOnPointDragListener.onRecovery(this);
            }
        } else if (!mIsDragOut) {
            mCenterCircleCopy.set(mCenterCircle.x, mCenterCircle.y);
            mDragCircleCopy.set(mDragCircle.x, mDragCircle.y);
            if (mRecoveryAnim == null) {
                mRecoveryAnim = ValueAnimator.ofFloat(1.f, -Math.abs(mRecoveryAnimBounce));
                mRecoveryAnim.setDuration(mRecoveryAnimDuration);
                mRecoveryAnim.addUpdateListener(this);
                mRecoveryAnim.addListener(this);
            }
            if (mRecoveryAnimInterpolator != null)
                mRecoveryAnim.setInterpolator(mRecoveryAnimInterpolator);
            mRecoveryAnim.start();
        } else {
            if (mDistanceCircles <= mMaxRadiusTrebling) {
                reset();
                if (mOnPointDragListener != null) {
                    mOnPointDragListener.onRecovery(this);
                }
            } else if (!TextUtils.isEmpty(clearSign)) {
                ClearViewHelper.getInstance().clearPointViewBySign(origView, clearSign);
            } else {
                startRemove();
            }
        }
    }

在clearPointViewBySign方法中首先调用getAllPointViewVisible方法找到view tree中所有可见的并且是执行sign的DrawPointView,并将自身放于首位,依次执行消除动画list.get(0).startRemove();

    @Override
    public void startRemove() {
        if (mRemoveAnim == null) {
            setVisibility(GONE);
            if (mNextRemoveView != null)
                mNextRemoveView.startRemove();
            if (mOnPointDragListener != null) {
                mOnPointDragListener.onRemoveStart(this);
                mOnPointDragListener.onRemoveEnd(this);
            }
        } else {
            mRemoveAnim.start(mOnPointDragListener);
        }
    }

mRemoveAnim是一个PointViewAnimObject对象,PointViewAnimObject是用于该控件的消除动画执行,内部封装了star,cancel,isRunning等方法,支持Animator与AnimationDrawable动画

public class PointViewAnimObject {

    private Object object;
    private AbsDragPointView view;
    private Drawable background;

    public PointViewAnimObject setView(AbsDragPointView view) {
        this.view = view;
        return this;
    }

    public PointViewAnimObject(Object object, AbsDragPointView view) {
        this.object = object;
        this.view = view;
    }

    public void start(OnPointDragListener removeListener) {
        if (object == null)
            throw new RuntimeException("remove anim is null.");
        if (removeListener != null)
            removeListener.onRemoveStart(view);
        view.setPivotX(view.getWidth() / 2);
        view.setPivotY(view.getHeight() / 2);
        if (object instanceof AnimationDrawable) {
            background = view.getBackground();
            start((AnimationDrawable) object, removeListener);
        } else if (object instanceof Animator) {
            start((Animator) object, removeListener);
        } else if (object instanceof Animation) {
            start((Animation) object, removeListener);
        }
    }

    private void start(AnimationDrawable object, final OnPointDragListener removeListener) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
            int duration = 0;
            for (int i = 0; i < object.getNumberOfFrames(); i++) {
                duration += object.getDuration(i);
            }
            view.postDelayed(new Runnable() {
                @Override
                public void run() {
                    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                        view.setBackground(background);
                    }
                    end(removeListener);
                }
            }, duration + 5);
            view.setText("");
            int drawableL = (view.getWidth() + view.getHeight()) / 2;
            ViewGroup.LayoutParams lp = view.getLayoutParams();
            lp.height = lp.width = drawableL;
            view.setLayoutParams(lp);
            view.setBackground(object);
            if (object.isRunning())
                object.stop();
            object.start();
        } else {
            end(removeListener);
        }
    }

    private void start(Animator object, final OnPointDragListener removeListener) {
        view.setVisibility(View.VISIBLE);
        Animator copy = object.clone();
        copy.setTarget(view);
        copy.removeAllListeners();
        copy.addListener(new AnimatorListenerAdapter() {

            @Override
            public void onAnimationEnd(Animator animation) {
                animation.removeListener(this);
                end(removeListener);
            }
        });
        copy.start();
    }

    private void start(Animation object, final OnPointDragListener removeListener) {
        long duration = object.getDuration();
        object.cancel();
        view.startAnimation(object);
        view.postDelayed(new Runnable() {
            @Override
            public void run() {
                view.clearAnimation();
                end(removeListener);
            }
        }, duration);
    }

    private void end(OnPointDragListener listener) {
        view.setVisibility(View.INVISIBLE);
        view.reset();
        if (listener != null)
            listener.onRemoveEnd(view);
        AbsDragPointView nextRemoveView = view.getNextRemoveView();
        if (nextRemoveView != null) {
            view.setNextRemoveView(null);
            nextRemoveView.startRemove();
        } else {
            ClearViewHelper.getInstance().clearSignOver(view.getSign());
        }
    }

    public void cancel() {
        if (object == null)
            throw new RuntimeException("remove anim is null.");
        if (object instanceof AnimationDrawable) {
            ((AnimationDrawable) object).stop();
        } else if (object instanceof Animator) {
            ((Animator) object).cancel();
        } else if (object instanceof Animation) {
            ((Animation) object).cancel();
        }
    }

    public boolean isRunning() {
        if (object == null)
            return false;
        if (object instanceof AnimationDrawable) {
            return ((AnimationDrawable) object).isRunning();
        } else if (object instanceof Animator) {
            return ((Animator) object).isRunning();
        } else if (object instanceof Animation) {
            if (((Animation) object).hasStarted()) {
                return !((Animation) object).hasEnded();
            } else {
                return false;
            }
        }
        return false;
    }
}

效果图

这里写图片描述
这里写图片描述
这里写图片描述

总结

最后,做下总结。实现该控件主要思想就是两个控件,其中一个用于布局中的展示,与TextView无异。另一个用于往Window上添加,复制了布局中DragPointView的所有属性以及将DragPointView的事件传递给自己实现控件的真实逻辑。

传送门:https://github.com/javonleee/DragPointView 欢迎star,fork~

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,431评论 25 707
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,351评论 0 17
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • 这几天因流感盛行,导致了许多同学上课瞌睡、生病、不注意听讲等现象。我们应多喝水,锻炼我们的身体,增强抵抗力赶走病魔!
    前_773a阅读 145评论 0 0
  • 明月几时有,把酒问青天,不知天上宫阙,今夕是何年…… 女导演许鞍华的新作《明月几时有》,让人惊艳动容。 强大的演出...
    81fa047defa3阅读 270评论 0 0