Android仿饿了么加入购物车旋转控件自带闪转腾挪动画的按钮效果(实例详解)
概述
在上文,酷炫path动画已经预告了,今天给大家带来的是利用 纯自定义view,实现的仿饿了么加入购物车控件,自带闪转腾挪动画的按钮。
效果图如下:
图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) | 加按钮是否开启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
我们捡重点说,无非是绘制。
绘制的重点,这里分三块:
- 静态绘制。(分两块:加减按钮和数量、hint提示文字和背景)
- 第一层。(加减按钮和数量)以及它的旋转、位移、透明度动画
- 第二层。(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层)或者第一层(加减按钮层)。
绘制第二层时没啥好说的,就是利用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;
依次分析有哪些动画:
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);
减按钮动画
看起来是旋转、位移、透明度。
那么对于背景的圆圈来说,我们只需要位移、透明度。因为它本身是个圆,就不要旋转了。
代码:
//动画 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执行,完毕后执行减按钮(第一层)进入的动画manimadd。
- 数量减少,1-0时,先执行减按钮退出的动画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
我在实现这个控件时,觉得难度相对大的地方在于做动画时,“-”按钮和数量的旋转动画,如何确定正确的坐标值。因为将text绘制的居中本身就有一些注意事项在里面,再涉及到动画,难免蒙圈。需要多计算,多试验。
还有就是观察饿了么的效果,将hint区域的动画利用改变roundrect的宽度去实现。起初没有想到,也是思考了一会如何去做。这是属于分析、拆解动画遇到的问题。
除了绘制以外的重点是:
- 利用region监听区域点击事件。
- 复用的列表,如何正确显示ui。
- 动画次序以及考虑到复用时,在合适的地方取消动画。
尽情在项目中使用它吧,有问题随时gayhub给我反馈。
通过sdk工具查看饿了么,它其实是用textview和imageview组合实现的。另外我十分怀疑它没有封装成控件,因为在列表页和详情页的交互,以及动画居然略有不同, 在详情页,仔细看由0-1时,它右边的 + 按钮的动画居然会闪一下,在列表页却没有,很是不解。
好了,本文所述到此结束。