本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家釋出
想經濟上支援我 or 想通過視訊看我是怎麼實現的:
概述
在上文,酷炫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
出這些需要的元素,並且還要考慮動畫,以及點選各區域的監聽,實現起來還是有一些難度的,但我們多寫一些有難度的程式碼才能提高水平。
轉載請標明出處:
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…
我們撿重點說,無非是繪製。
繪製的重點,這裡分三塊:
- 靜態繪製。(分兩塊:加減按鈕和數量、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。多謝
github.com/mcxtzhang/A…
我在實現這個控制元件時,覺得難度相對大的地方在於做動畫時,“-”按鈕和數量的旋轉動畫,如何確定正確的座標值。因為將text繪製的居中本身就有一些注意事項在裡面,再涉及到動畫,難免蒙圈。需要多計算,多試驗。
還有就是觀察餓了麼的效果,將hint區域的動畫利用改變RoundRect的寬度去實現。起初沒有想到,也是思考了一會如何去做。這是屬於分析、拆解動畫遇到的問題。
除了繪製以外的重點是:
- 利用
Region
監聽區域點選事件。 - 複用的列表,如何正確顯示UI。
- 動畫次序以及考慮到複用時,在合適的地方取消動畫。
盡情在專案中使用它吧,有問題隨時gayhub給我反饋。
通過sdk工具檢視餓了麼,它其實是用TextView
和ImageView
組合實現的。另外我十分懷疑它沒有封裝成控制元件,因為在列表頁和詳情頁的互動,以及動畫居然略有不同, 在詳情頁,仔細看由0-1時,它右邊的 + 按鈕的動畫居然會閃一下,在列表頁卻沒有,很是不解。
看大神們都有QQ群,
向他們靠齊。
我也建了個QQ搞基交流群:
557266366 。
轉載請標明出處:
gold.xitu.io/post/587220…
本文出自:【張旭童的稀土掘金】(gold.xitu.io/user/56de21…)
程式碼傳送門:喜歡的話,隨手點個star。多謝
github.com/mcxtzhang/A…