本篇文章已受權微信公衆號 guolin_blog (郭霖)獨家發佈 javascript
想經濟上支持我 or 想經過視頻看我是怎麼實現的:java
edu.csdn.net/course/deta…android
在上文,酷炫Path動畫已經預告了,今天給你們帶來的是利用 純自定義View,實現的仿餓了麼加入購物車控件,自帶閃轉騰挪動畫的按鈕。
效果圖以下:git
圖1 項目中使用的效果,考慮到了View
的回收複用,
而且能夠看到在RecyclerView
中使用,切換LayoutManager
也是沒有問題的,
github
圖2 Demo效果,測試各類屬性值
數據庫
注意,本控件非繼承自ViewGroup
,而是純自定義View實現。理由以下:canvas
draw
,用到什麼draw
什麼,沒有其餘的額外工做,也間接提升性能。View
難度更高,更有實(裝)踐(B)的意義1 減小布局層次,很好理解,ViewGroup
內嵌套幾個TextView
、ImageV這裏寫代碼片
iew也能夠實現這個效果,然而這會使佈局層次多了一級,而且內部要嵌套多個控件,層級越多,控件越多,繪製的就越慢,在列表中對性能的影響更大。微信
2 別小看了「小小」的TextView
和的ImageView
,其實它們有不少的屬性和特性在本例中是沒必要要的,舉個例子,查看源碼,TextView
有一萬多行,ondraw()
方法有一百多行, ImageView
有1588行,這麼多行代碼都是咱們須要的嗎?直接使用這些現成的控件嵌套實現,其實性能不如咱們用到什麼draw
什麼。惟一的好處可能就是比較簡單了。(其實TextView的性能是不高的)app
3 純自定義View
,draw
出這些須要的元素,而且還要考慮動畫,以及點擊各區域的監聽,實現起來仍是有一些難度的,但咱們多寫一些有難度的代碼才能提升水平。ide
轉載請標明出處:
gold.xitu.io/post/587220…
本文出自:【張旭童的稀土掘金】(gold.xitu.io/user/56de21…)
代碼傳送門:喜歡的話,隨手點個star。多謝
github.com/mcxtzhang/A…
伸手黨福利:講解實現前,先看一下如何使用 以及支持的屬性等。
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。多謝
github.com/mcxtzhang/A…
咱們撿重點說,無非是繪製。
繪製的重點,這裏分三塊:
除了繪製之外的重點是:
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。多謝
github.com/mcxtzhang/A…
我在實現這個控件時,以爲難度相對大的地方在於作動畫時,「-」按鈕和數量的旋轉動畫,如何肯定正確的座標值。由於將text繪製的居中自己就有一些注意事項在裏面,再涉及到動畫,不免蒙圈。須要多計算,多試驗。
還有就是觀察餓了麼的效果,將hint區域的動畫利用改變RoundRect的寬度去實現。起初沒有想到,也是思考了一會如何去作。這是屬於分析、拆解動畫遇到的問題。
除了繪製之外的重點是:
Region
監聽區域點擊事件。盡情在項目中使用它吧,有問題隨時gayhub給我反饋。
經過sdk工具查看餓了麼,它實際上是用TextView
和ImageView
組合實現的。另外我十分懷疑它沒有封裝成控件,由於在列表頁和詳情頁的交互,以及動畫竟然略有不一樣, 在詳情頁,仔細看由0-1時,它右邊的 + 按鈕的動畫竟然會閃一下,在列表頁卻沒有,非常不解。
看大神們都有QQ羣,
向他們靠齊。
我也建了個QQ搞基交流羣:
557266366 。
轉載請標明出處:
gold.xitu.io/post/587220…
本文出自:【張旭童的稀土掘金】(gold.xitu.io/user/56de21…)
代碼傳送門:喜歡的話,隨手點個star。多謝
github.com/mcxtzhang/A…