仿饿了么增加购物车旋转控件 - 自带闪转腾挪动画 的button
本篇文章已授权微信公众号 guolin_blog (郭霖)独家公布
转载请标明出处:
http://blog.csdn.net/zxt0601/article/details/54235736
本文出自:【张旭童的博客】(http://blog.csdn.net/zxt0601)
代码传送门:喜欢的话,随手点个star。多谢
https://github.com/mcxtzhang/AnimShopButton
想经济上支持我 or 想通过视频看我是怎么实现的:
http://edu.csdn.net/course/detail/3898
概述
在上文,酷炫Path动画已经预告了,今天给大家带来的是利用 纯自己定义View。实现的仿饿了么添加购物车控件,自带闪转腾挪动画的button。
效果图例如以下:
图1 项目中使用的效果,考虑到了View
的回收复用,
而且能够看到在RecyclerView
中使用,切换LayoutManager
也是没有问题的,
图2 Demo效果,測试各种属性值
注意。本控件非继承自ViewGroup
,而是纯自己定义View实现。理由例如以下:
- 1 降低布局层级。从而提高性能
- 2 文字和图形纯
draw
,用到什么draw
什么。没有其它的额外工作。也间接提高性能。 - 3 纯自己定义
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) | 加button是否开启fill模式 默认是stroke(false) |
addEnableBgColor | color | The background color of the plus button | 加button的背景色 |
addEnableFgColor | color | The foreground color of the plus button | 加button的前景色 |
addDisableBgColor | color | The background color when the button is not available | 加button不可用时的背景色 |
addDisableFgColor | color | The foreground color when the button is not available | 加button不可用时的前景色 |
isDelFillMode | boolean | Plus button is opened Fill mode default is stroke (false) | 减button是否开启fill模式 默认是stroke(false) |
delEnableBgColor | color | The background color of the minus button | 减button的背景色 |
delEnableFgColor | color | The foreground color of the minus button | 减button的前景色 |
delDisableBgColor | color | The background color when the button is not available | 减button不可用时的背景色 |
delDisableFgColor | color | The foreground color when the button is not available | 减button不可用时的前景色 |
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
我们捡重点说。无非是绘制。
绘制的重点。这里分三块:
- 静态绘制。
(分两块:加减button和数量、hint提示文字和背景)
- 第一层。(加减button和数量)以及它的旋转、位移、透明度动画
- 第二层。(hint区域)以及它的伸展收缩动画
除了绘制以外的重点是:
- 由于採用了全然的自己定义
View
去实现这么一个“组合控件效果”。则点击事件的监听须要自己处理。 - 在回收复用的列表中使用时,列表滑动。怎样正确显示UI。
静态绘制
静态绘制就是最主要的自己定义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层)或者第一层(加减button层)。
绘制第二层时没啥好说的,就是利用canvas.drawRoundRect
,绘制圆角矩形。然后canvas.drawText
绘制hint。
(假设圆角的值足够大,矩形的宽度足够小。就变成了圆形。)
绘制第一层时。要依据当前的数量选择不同的颜色,注意在绘制加减button的圆圈时。我们是用Path
绘制的,这是由于我们还须要用Path
构建Region
类。这个类就是我们监听点击区域的重点。
点击事件的监听
在解说动画之前。我们先说说怎样监听点击的区域。由于本控件的动画是和加减数量息息相关的。而数量的加减是由点击对应”+ - button”区域触发的。
所以我们的监听button的点击事件。事实上就是监听对应的”+ - button”区域。
上一节中,我们在绘制”+ - button”区域时,通过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;
依次分析有哪些动画:
Hint动画
主要是圆角矩形的展开、收缩。
固定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);
减button动画
看起来是旋转、位移、透明度。
那么对于背景的圆圈来说,我们仅仅须要位移、透明度。由于它本身是个圆,就不要旋转了。
代码:
//动画 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初始化时就定义好的。运行顺序:
- 数量添加。0-1时,先收缩Hint(第二层)
mAnimReduceHint
运行,完成后运行减button(第一层)进入的动画mAnimAdd
。 - 数量降低,1-0时。先运行减button退出的动画
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
列表复用时,依旧会回调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;
}
}
在改变count时
一般在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
想经济上支持我 or 想通过视频看我是怎么实现的:
http://edu.csdn.net/course/detail/3898
我在实现这个控件时。觉得难度相对大的地方在于做动画时,“-”button和数量的旋转动画,怎样确定正确的坐标值。由于将text绘制的居中本身就有一些注意事项在里面,再涉及到动画。难免蒙圈。
须要多计算。多试验。
还有就是观察饿了么的效果,将hint区域的动画利用改变RoundRect的宽度去实现。起初没有想到,也是思考了一会怎样去做。
这是属于分析、拆解动画遇到的问题。
除了绘制以外的重点是:
- 利用
Region
监听区域点击事件。 - 复用的列表,怎样正确显示UI。
- 动画次序以及考虑到复用时。在合适的地方取消动画。
尽情在项目中使用它吧,有问题随时gayhub给我反馈。
通过sdk工具查看饿了么。它事实上是用TextView
和ImageView
组合实现的。另外我十分怀疑它没有封装成控件,由于在列表页和详情页的交互。以及动画竟然略有不同, 在详情页,细致看由0-1时,它右边的 + button的动画竟然会闪一下,在列表页却没有,非常是不解。
看大神们都有QQ群,
向他们靠齐。
我也建了个QQ搞基交流群:
557266366 。
转载请标明出处:
http://blog.csdn.net/zxt0601/article/details/54235736
本文出自:【张旭童的博客】(http://blog.csdn.net/zxt0601)
代码传送门:喜欢的话,随手点个star。多谢
https://github.com/mcxtzhang/AnimShopButton