編輯:關於Android編程
概述
在上文,酷炫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時,它右邊的 + 按鈕的動畫居然會閃一下,在列表頁卻沒有,很是不解。
好了,本文所述到此結束。
Android的界面是有布局和組件協同完成的,布局好比是建築裡的框架,而組件則相當於建築裡的磚瓦。組件按照布局的要求依次排列,就組成了用戶所看見的界面。所有的布局方式都可
本文實例講述了Android實現帶有邊框的ListView和item的方法。分享給大家供大家參考,具體如下:想為ListView和item四周添加邊框有兩種方法:1.貼一
ant 工具:1、為什麼要用到ant這個工具呢?Ant做為一種工具已經廣泛被使用,並且歷史悠久。使用ant的內置命令,可以編譯java源文件(javac),運行java文
昨天偶偶然看見UI 給的一個交互的效果,原圖如下就是下面的loginbutton,於是大概模仿了一下,並沒有做這個UI的全部效果,有興趣的可以完善後面展開的效果這個Vie