概述
在上文,酷炫Path動畫已經預告了,今天給大家帶來的是利用 純自定義View,實現的仿餓了么加入購物車控件,自帶閃轉騰挪動畫的按鈕。
效果圖如下:
圖1 項目中使用的效果,考慮到了View的回收復用,
并且可以看到在RecyclerView中使用,切換LayoutManager也是沒有問題的,

圖2 Demo效果,測試各種屬性值

注意,本控件非繼承自ViewGroup,而是純自定義View實現。理由如下:
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知識,繪制圓圈(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初始化時就定義好的,執行順序:
代碼如下:
//動畫 + 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的寬度去實現。起初沒有想到,也是思考了一會如何去做。這是屬于分析、拆解動畫遇到的問題。
除了繪制以外的重點是:
盡情在項目中使用它吧,有問題隨時gayhub給我反饋。
通過sdk工具查看餓了么,它其實是用TextView和ImageView組合實現的。另外我十分懷疑它沒有封裝成控件,因為在列表頁和詳情頁的交互,以及動畫居然略有不同, 在詳情頁,仔細看由0-1時,它右邊的 + 按鈕的動畫居然會閃一下,在列表頁卻沒有,很是不解。
好了,本文所述到此結束。
新聞熱點
疑難解答