转载请标明出处:
http://blog.csdn.net/zxt0601/article/details/54235736
本文出自:【张旭童的博客】(http://blog.csdn.net/zxt0601)
代码传送门:喜欢的话,随手点个star。多谢
https://github.com/mcxtzhang/AnimShopButton
在上文,酷炫Path动画已经预告了,今天给大家带来的是利用 纯自定义View,实现的仿饿了么加入购物车控件,自带闪转腾挪动画的按钮。 
效果图如下:
图1 项目中使用的效果,考虑到了View的回收复用, 
并且可以看到在RecyclerView中使用,切换LayoutManager也是没有问题的, 

图2 Demo效果,测试各种属性值 

注意,本控件非继承自ViewGroup,而是纯自定义View实现。理由如下:
draw,用到什么draw什么,没有其他的额外工作,也间接提高性能。View难度更高,更有实(装)践(B)的意义1 减少布局层次,很好理解,ViewGroup内嵌套几个TextView、ImageV这里写代码片iew也可以实现这个效果,然而这会使布局层次多了一级,并且内部要嵌套多个控件,层级越多,控件越多,绘制的就越慢,在列表中对性能的影响更大。
2 别小看了“小小”的TextView和的ImageView,其实它们有很多的属性和特性在本例中是不必要的,举个例子,查看源码,TextView有一万多行,ondraw()方法有一百多行,  ImageView有1588行,这么多行代码都是我们需要的吗?直接使用这些现成的控件嵌套实现,其实性能不如我们用到什么draw什么。唯一的好处可能就是比较简单了。(其实TextView的性能是不高的)
3 纯自定义View,draw出这些需要的元素,并且还要考虑动画,以及点击各区域的监听,实现起来还是有一些难度的,但我们多写一些有难度的代码才能提高水平。
伸手党福利:讲解实现前,先看一下如何使用 以及支持的属性等。
xml:
    <!--使用默认UI属性-->
    <com.mcxtzhang.lib.AnimShopButton
        android:id="@+id/btn1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:maxCount="3"/>
    <!--设置了两圆间距-->
    <com.mcxtzhang.lib.AnimShopButton
        android:id="@+id/btn2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:count="3"
        app:gapBetweenCircle="90dp"
        app:maxCount="99"/>
    <!--仿饿了么-->
    <com.mcxtzhang.lib.AnimShopButton
        android:id="@+id/btnEle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:addEnableBgColor="#3190E8"
        app:addEnableFgColor="#ffffff"
        app:hintBgColor="#3190E8"
        app:hintBgRoundValue="15dp"
        app:hintFgColor="#ffffff"
        app:maxCount="99"/>
注意: 
加减点击后,具体的操作,要根据业务的不同来编写了,设计到实际的购物车可能还有写数据库操作,或者请求接口等,要操作成功后才执行动画、或者修改count,这一块代码每个人写法可能不同。 
使用时,可以重写onDelClick()和onAddClick()方法,并在合适的时机回调onCountAddSuccess()和onCountDelSuccess()以执行动画。
效果图如图2.
| name | format | description | 中文解释 | 
|---|---|---|---|
| isAddFillMode | boolean | Plus button is opened Fill mode default is stroke (false) | 加按钮是否开启fill模式 默认是stroke(false) | 
| addEnableBgColor | color | The background color of the plus button | 加按钮的背景色 | 
| addEnableFgColor | color | The foreground color of the plus button | 加按钮的前景色 | 
| addDisableBgColor | color | The background color when the button is not available | 加按钮不可用时的背景色 | 
| addDisableFgColor | color | The foreground color when the button is not available | 加按钮不可用时的前景色 | 
| isDelFillMode | boolean | Plus button is opened Fill mode default is stroke (false) | 减按钮是否开启fill模式 默认是stroke(false) | 
| delEnableBgColor | color | The background color of the minus button | 减按钮的背景色 | 
| delEnableFgColor | color | The foreground color of the minus button | 减按钮的前景色 | 
| delDisableBgColor | color | The background color when the button is not available | 减按钮不可用时的背景色 | 
| delDisableFgColor | color | The foreground color when the button is not available | 减按钮不可用时的前景色 | 
| radius | dimension | The radius of the circle | 圆的半径 | 
| circleStrokeWidth | dimension | The width of the circle | 圆圈的宽度 | 
| lineWidth | dimension | The width of the line (+ - sign) | 线(+ - 符号)的宽度 | 
| gapBetweenCircle | dimension | The spacing between two circles | 两个圆之间的间距 | 
| numTextSize | dimension | The textSize of draws the number | 绘制数量的textSize | 
| maxCount | integer | max count | 最大数量 | 
| count | integer | current count | 当前数量 | 
| hintText | string | The hint text when number is 0 | 数量为0时,hint文字 | 
| hintBgColor | color | The hint background when number is 0 | 数量为0时,hint背景色 | 
| hintFgColor | color | The hint foreground when number is 0 | 数量为0时,hint前景色 | 
| hingTextSize | dimension | The hint text size when number is 0 | 数量为0时,hint文字大小 | 
| hintBgRoundValue | dimension | The background fillet value when number is 0 | 数量为0时,hint背景圆角值 | 
这么多属性够你用了吧。
下面看重点的实现吧,Let’s Go!.
关于自定义View的基础,这里不再赘述。 
如果阅读时有不明白的,建议下载源码边看边读,或者学习自定义View基础知识后再阅读本文。 
代码传送门:喜欢的话,随手点个star。多谢 
https://github.com/mcxtzhang/AnimShopButton
我们捡重点说,无非是绘制。 
绘制的重点,这里分三块:
除了绘制以外的重点是:
View去实现这么一个“组合控件效果”,则点击事件的监听需要自己处理。静态绘制就是最基本的自定义View知识,绘制圆圈(Circle)、线段(Line)、数字(Text)以及圆角矩形(RoundRect),值得注意的是, 
要考虑到 避免overDraw和动画的需求, 
我们要绘制的两层应该是互斥关系。
剥离掉动画代码,大致如下(基本都是draw代码,可以快速阅读):
@Override
    protected void onDraw(Canvas canvas) {
        if (isHintMode) {
            //hint 展开
            //背景
            mHintPaint.setColor(mHintBgColor);
            RectF rectF = new RectF(mLeft, mTop
                    , mWidth - mCircleWidth, mHeight - mCircleWidth);
            canvas.drawRoundRect(rectF, mHintBgRoundValue, mHintBgRoundValue, mHintPaint);
            //前景文字
            mHintPaint.setColor(mHintFgColor);
            // 计算Baseline绘制的起点X轴坐标
            int baseX = (int) (mWidth / 2 - mHintPaint.measureText(mHintText) / 2);
            // 计算Baseline绘制的Y坐标
            int baseY = (int) ((mHeight / 2) - ((mHintPaint.descent() + mHintPaint.ascent()) / 2));
            canvas.drawText(mHintText, baseX, baseY, mHintPaint);
        } else {
            //左边
            //背景 圆
            if (mCount > 0) {
                mDelPaint.setColor(mDelEnableBgColor);
            } else {
                mDelPaint.setColor(mDelDisableBgColor);
            }
            mDelPaint.setStrokeWidth(mCircleWidth);
            mDelPath.reset();
            mDelPath.addCircle(mLeft + mRadius, mTop + mRadius, mRadius, Path.Direction.CW);
            mDelRegion.setPath(mDelPath, new Region(mLeft, mTop, mWidth - getPaddingRight(), mHeight - getPaddingBottom()));
            canvas.drawPath(mDelPath, mDelPaint);
            //前景 -
            if (mCount > 0) {
                mDelPaint.setColor(mDelEnableFgColor);
            } else {
                mDelPaint.setColor(mDelDisableFgColor);
            }
            mDelPaint.setStrokeWidth(mLineWidth);
            canvas.drawLine(-mRadius / 2, 0,
                    +mRadius / 2, 0,
                    mDelPaint);
            //数量
            //是没有动画的普通写法,x left, y baseLine
            canvas.drawText(mCount + "", mLeft + mRadius * 2, mTop + mRadius - (mFontMetrics.top + mFontMetrics.bottom) / 2, mTextPaint);
            //右边
            //背景 圆
            if (mCount < mMaxCount) {
                mAddPaint.setColor(mAddEnableBgColor);
            } else {
                mAddPaint.setColor(mAddDisableBgColor);
            }
            mAddPaint.setStrokeWidth(mCircleWidth);
            float left = mLeft + mRadius * 2 + mGapBetweenCircle;
            mAddPath.reset();
            mAddPath.addCircle(left + mRadius, mTop + mRadius, mRadius, Path.Direction.CW);
            mAddRegion.setPath(mAddPath, new Region(mLeft, mTop, mWidth - getPaddingRight(), mHeight - getPaddingBottom()));
            canvas.drawPath(mAddPath, mAddPaint);
            //前景 +
            if (mCount < mMaxCount) {
                mAddPaint.setColor(mAddEnableFgColor);
            } else {
                mAddPaint.setColor(mAddDisableFgColor);
            }
            mAddPaint.setStrokeWidth(mLineWidth);
            canvas.drawLine(left + mRadius / 2, mTop + mRadius, left + mRadius / 2 + mRadius, mTop + mRadius, mAddPaint);
            canvas.drawLine(left + mRadius, mTop + mRadius / 2, left + mRadius, mTop + mRadius / 2 + mRadius, mAddPaint);
        }
    }
根据isHintMode 布尔值变量,区分是绘制第二层(Hint层)或者第一层(加减按钮层)。
绘制第二层时没啥好说的,就是利用canvas.drawRoundRect,绘制圆角矩形,然后canvas.drawText绘制hint。 
(如果圆角的值足够大,矩形的宽度足够小,就变成了圆形。)
绘制第一层时,要根据当前的数量选择不同的颜色,注意在绘制加减按钮的圆圈时,我们是用Path绘制的,这是因为我们还需要用Path构建Region类,这个类就是我们监听点击区域的重点。
在讲解动画之前,我们先说说如何监听点击的区域,因为本控件的动画是和加减数量息息相关的,而数量的加减是由点击相应”+ - 按钮”区域触发的。 
所以我们的监听按钮的点击事件,其实就是监听相应的”+ - 按钮”区域。
上一节中,我们在绘制”+ - 按钮”区域时,通过Path,构建了两个Region类,Region类有个contains(int x, int y)方法如下,通过传入对应触摸的x、y坐标,就可知道知否点击了相应区域。
    /**
     * Return true if the region contains the specified point
     */
    public native boolean contains(int x, int y);
知道了这一点,再写这部分代码就相当简单了:
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                //hint模式
                if (isHintMode) {
                    onAddClick();
                    return true;
                } else {
                    if (mAddRegion.contains((int) event.getX(), (int) event.getY())) {
                        onAddClick();
                        return true;
                    } else if (mDelRegion.contains((int) event.getX(), (int) event.getY())) {
                        onDelClick();
                        return true;
                    }
                }
                break;
            case MotionEvent.ACTION_MOVE:
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                break;
        }
        return super.onTouchEvent(event);
    }
hint模式时,我们可以认为控件所有范围都是“+”的有效区域。
而在非hint模式时,根据上一节构建的mAddRegion和mDelRegion去判断。
判断确认点击后,具体的操作,要根据业务的不同来编写了,设计到实际的购物车可能还有写数据库操作,或者请求接口等,要操作成功后才执行动画、或者修改count,这一块代码每个人写法可能不同。 
使用时,可以重写onDelClick()和onAddClick()方法,并在合适的时机回调onCountAddSuccess()和onCountDelSuccess()以执行动画。
本文如下编写:
    protected void onDelClick() {
        if (mCount > 0) {
            mCount--;
            onCountDelSuccess();
        }
    }
    protected void onAddClick() {
        if (mCount < mMaxCount) {
            mCount++;
            onCountAddSuccess();
        } else {
        }
    }
    /**
     * 数量增加成功后,使用者回调
     */
    public void onCountAddSuccess() {
        if (mCount == 1) {
            cancelAllAnim();
            mAnimReduceHint.start();
        } else {
            mAnimFraction = 0;
            invalidate();
        }
    }
    /**
     * 数量减少成功后,使用者回调
     */
    public void onCountDelSuccess() {
        if (mCount == 0) {
            cancelAllAnim();
            mAniDel.start();
        } else {
            mAnimFraction = 0;
            invalidate();
        }
    }
这里会用到两个变量:
    //动画的基准值 动画:减 0~1, 加 1~0 
    // 普通状态下是0
    protected float mAnimFraction;
    //提示语收缩动画 0-1 展开1-0
    //普通模式时,应该是1, 只在 isHintMode true 才有效
    protected float mAnimExpandHintFraction;
依次分析有哪些动画:
主要是圆角矩形的展开、收缩。  
固定right、bottom,当展开时,不断减少矩形的左起点left坐标值,则整个矩形宽度变大,呈现展开。收缩时相反。 
代码:
            //背景
            mHintPaint.setColor(mHintBgColor);
            RectF rectF = new RectF(mLeft + (mWidth - mRadius * 2) * mAnimExpandHintFraction, mTop
                    , mWidth - mCircleWidth, mHeight - mCircleWidth);
            canvas.drawRoundRect(rectF, mHintBgRoundValue, mHintBgRoundValue, mHintPaint);
看起来是旋转、位移、透明度。 
那么对于背景的圆圈来说,我们只需要位移、透明度。因为它本身是个圆,就不要旋转了。 
代码:
            //动画 mAnimFraction :减 0~1, 加 1~0 ,
            //动画位移Max,
            float animOffsetMax = (mRadius * 2 +mGapBetweenCircle);
            //透明度动画的基准
            int animAlphaMax = 255;
            int animRotateMax = 360;
            //左边
            //背景 圆
            mDelPaint.setAlpha((int) (animAlphaMax * (1 - mAnimFraction)));
            mDelPath.reset();
            //改变圆心的X坐标,实现位移
            mDelPath.addCircle(animOffsetMax * mAnimFraction + mLeft + mRadius, mTop + mRadius, mRadius, Path.Direction.CW);
            canvas.drawPath(mDelPath, mDelPaint);
对于前景的“-”号来说,旋转、位移、透明度都需要做。 
这里我们利用canvas.translate() canvas.rotate  做旋转和位移动画,别忘了 canvas.save()和 canvas.restore()恢复画布的状态。(透明度在上面已经设置过了。)
            //前景 -
            //旋转动画
            canvas.save();
            canvas.translate(animOffsetMax * mAnimFraction + mLeft + mRadius, mTop + mRadius);
            canvas.rotate((int) (animRotateMax * (1 - mAnimFraction)));
            canvas.drawLine(-mRadius / 2, 0,
                    +mRadius / 2, 0,
                    mDelPaint);
            canvas.restore();
看起来也是旋转、位移、透明度。同样是利用canvas.translate() canvas.rotate  做旋转和位移动画。
            //数量
            canvas.save();
            //平移动画
            canvas.translate(mAnimFraction * (mGapBetweenCircle / 2 - mTextPaint.measureText(mCount + "") / 2 + mRadius), 0);
            //旋转动画,旋转中心点,x 是绘图中心,y 是控件中心
            canvas.rotate(360 * mAnimFraction,
                mGapBetweenCircle / 2 + mLeft + mRadius * 2 ,
                    mTop + mRadius);
            //透明度动画
            mTextPaint.setAlpha((int) (255 * (1 - mAnimFraction)));
            //是没有动画的普通写法,x left, y baseLine
            canvas.drawText(mCount + "",  mGapBetweenCircle / 2 - mTextPaint.measureText(mCount + "") / 2 + mLeft + mRadius * 2, mTop + mRadius - (mFontMetrics.top + mFontMetrics.bottom) / 2, mTextPaint);
            canvas.restore();
动画是在View初始化时就定义好的,执行顺序:
mAnimReduceHint执行,完毕后执行减按钮(第一层)进入的动画mAnimAdd。mAniDel,再伸展Hint动画mAnimExpandHint,完毕后,显示hint文字。代码如下:
        //动画 +
        mAnimAdd = ValueAnimator.ofFloat(1, 0);
        mAnimAdd.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mAnimFraction = (float) animation.getAnimatedValue();
                invalidate();
            }
        });
        mAnimAdd.setDuration(350);
        //提示语收缩动画 0-1
        mAnimReduceHint = ValueAnimator.ofFloat(0, 1);
        mAnimReduceHint.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mAnimExpandHintFraction = (float) animation.getAnimatedValue();
                invalidate();
            }
        });
        mAnimReduceHint.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                if (mCount == 1) {
                    //然后底色也不显示了
                    isHintMode = false;
                }
                if (mCount == 1) {
                    Log.d(TAG, "现在还是1 开始收缩动画");
                    if (mAnimAdd != null && !mAnimAdd.isRunning()) {
                        mAnimAdd.start();
                    }
                }
            }
            @Override
            public void onAnimationStart(Animator animation) {
                if (mCount == 1) {
                    //先不显示文字了
                    isShowHintText = false;
                }
            }
        });
        mAnimReduceHint.setDuration(350);
        //动画 -
        mAniDel = ValueAnimator.ofFloat(0, 1);
        mAniDel.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mAnimFraction = (float) animation.getAnimatedValue();
                invalidate();
            }
        });
        //1-0的动画
        mAniDel.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                if (mCount == 0) {
                    Log.d(TAG, "现在还是0onAnimationEnd() called with: animation = [" + animation + "]");
                    if (mAnimExpandHint != null && !mAnimExpandHint.isRunning()) {
                        mAnimExpandHint.start();
                    }
                }
            }
        });
        mAniDel.setDuration(350);
        //提示语展开动画
        //分析这个动画,最初是个圆。 就是left 不断减小
        mAnimExpandHint = ValueAnimator.ofFloat(1, 0);
        mAnimExpandHint.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mAnimExpandHintFraction = (float) animation.getAnimatedValue();
                invalidate();
            }
        });
        mAnimExpandHint.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                if (mCount == 0) {
                    isShowHintText = true;
                }
            }
            @Override
            public void onAnimationStart(Animator animation) {
                if (mCount == 0) {
                    isHintMode = true;
                }
            }
        });
        mAnimExpandHint.setDuration(350);
因为我们的购物车控件肯定会用在列表中,不管你用ListView还是RecyclerView,都会涉及到复用的问题。 
复用给我们带来一个麻烦的地方就是,我们要处理好一些属性状态值,否则UI上会有问题。
可以从两处下手处理:
列表复用时,依然会回调onMeasure()方法,所以在这里初始化一些UI显示的参数。 
这里顺带将适配wrap_content 的代码也一同贴上:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int wMode = MeasureSpec.getMode(widthMeasureSpec);
        int wSize = MeasureSpec.getSize(widthMeasureSpec);
        int hMode = MeasureSpec.getMode(heightMeasureSpec);
        int hSize = MeasureSpec.getSize(heightMeasureSpec);
        switch (wMode) {
            case MeasureSpec.EXACTLY:
                break;
            case MeasureSpec.AT_MOST:
                //不超过父控件给的范围内,自由发挥
                int computeSize = (int) (getPaddingLeft() + mRadius * 2 +mGapBetweenCircle + mRadius * 2 + getPaddingRight() + mCircleWidth * 2);
                wSize = computeSize < wSize ? computeSize : wSize;
                break;
            case MeasureSpec.UNSPECIFIED:
                //自由发挥
                computeSize = (int) (getPaddingLeft() + mRadius * 2 + mGapBetweenCircle + mRadius * 2 + getPaddingRight() + mCircleWidth * 2);
                wSize = computeSize;
                break;
        }
        switch (hMode) {
            case MeasureSpec.EXACTLY:
                break;
            case MeasureSpec.AT_MOST:
                int computeSize = (int) (getPaddingTop() + mRadius * 2 + getPaddingBottom() + mCircleWidth * 2);
                hSize = computeSize < hSize ? computeSize : hSize;
                break;
            case MeasureSpec.UNSPECIFIED:
                computeSize = (int) (getPaddingTop() + mRadius * 2 + getPaddingBottom() + mCircleWidth * 2);
                hSize = computeSize;
                break;
        }
        setMeasuredDimension(wSize, hSize);
        //复用时会走这里,所以初始化一些UI显示的参数
        mAnimFraction = 0;
        initHintSettings();
    }
    /**
     * 根据当前count数量 初始化 hint提示语相关变量
     */
    private void initHintSettings() {
        if (mCount == 0) {
            isHintMode = true;
            isShowHintText = true;
            mAnimExpandHintFraction = 0;
        } else {
            isHintMode = false;
            isShowHintText = false;
            mAnimExpandHintFraction = 1;
        }
    }
一般在onBindViewHolder()或者getView()时,都会对本控件重新设置count值,count改变时,当然也是需要根据count进行属性值的调整。 
且此时如果View正在做动画,应该停止这些动画。
    /**
     * 设置当前数量
     * @param count
     * @return
     */
    public AnimShopButton setCount(int count) {
        mCount = count;
        //先暂停所有动画
        if (mAnimAdd != null && mAnimAdd.isRunning()) {
            mAnimAdd.cancel();
        }
        if (mAniDel != null && mAniDel.isRunning()) {
            mAniDel.cancel();
        }
        //复用机制的处理
        if (mCount == 0) {
            // 0 不显示 数字和-号
            mAnimFraction = 1;
        } else {
            mAnimFraction = 0;
        }
        initHintSettings();
        return this;
    }
代码传送门:喜欢的话,随手点个star。多谢 
https://github.com/mcxtzhang/AnimShopButton
我在实现这个控件时,觉得难度相对大的地方在于做动画时,“-”按钮和数量的旋转动画,如何确定正确的坐标值。因为将text绘制的居中本身就有一些注意事项在里面,再涉及到动画,难免蒙圈。需要多计算,多试验。
还有就是观察饿了么的效果,将hint区域的动画利用改变RoundRect的宽度去实现。起初没有想到,也是思考了一会如何去做。这是属于分析、拆解动画遇到的问题。
除了绘制以外的重点是:
Region监听区域点击事件。尽情在项目中使用它吧,有问题随时gayhub给我反馈。
通过sdk工具查看饿了么,它其实是用TextView和ImageView组合实现的。另外我十分怀疑它没有封装成控件,因为在列表页和详情页的交互,以及动画居然略有不同, 在详情页,仔细看由0-1时,它右边的 + 按钮的动画居然会闪一下,在列表页却没有,很是不解。
看大神们都有QQ群, 
向他们靠齐。 
我也建了个QQ搞基交流群: 
557266366 。
转载请标明出处:
http://blog.csdn.net/zxt0601/article/details/54235736
本文出自:【张旭童的博客】(http://blog.csdn.net/zxt0601)
代码传送门:喜欢的话,随手点个star。多谢
https://github.com/mcxtzhang/AnimShopButton
原文:http://blog.csdn.net/zxt0601/article/details/54235736