讀前思考:
為什么要自定義View?
答:當(dāng)Android SDK中提供的系統(tǒng)UI控件無法滿足業(yè)務(wù)需求時,這時候就需要我們使用自定義 View 來進(jìn)行繪制了。
如何實現(xiàn)自定義View?
答:兩種方式。其一為繼承系統(tǒng)提供的成熟控件(比如LinearLayout,RelativeLayout,ImageView等)。其二為直接繼承自系統(tǒng)View或者ViewGroup,并自繪現(xiàn)實內(nèi)容。
1.了解自定義View的方法
主要重寫兩個方法:
(1) onMeasure():用于測量,你的控件占多大的地方由這個方法指定;
(2) onDarw():用于繪制,你的控件呈現(xiàn)給用戶長什么樣子由這個方法決定;
1.1 onMeasure( )
onMeasure( )方法中有兩個參數(shù),widthMeasureSpec 和 heightMeasureSpec,可以通過如下代碼獲取模式和大小
//獲取高度模式
int height_mode = MeasureSpec.getMode(heightMeasureSpec);
//獲取寬度模式
int with_mode = MeasureSpec.getMode(widthMeasureSpec);
//獲取高度尺寸
int height_size = MeasureSpec.getSize(heightMeasureSpec);
//獲取寬度尺寸
int width_size = MeasureSpec.getSize(widthMeasureSpec);
復(fù)制代碼測量模式的話,有下面三種:
UNSPECIFIED:任意大小,想要多大就多大,盡可能大,一般我們不會遇到,如 ListView,RecyclerView,ScrollView 測量子 View 的時候給的就是 UNSPECIFIED ,一般開發(fā)中不需要關(guān)注它;
EXACTLY:一個確定的值,比如在布局中你是這樣寫的 layout_width="100dp","match_parent","fill_parent";
AT_MOST:包裹內(nèi)容,比如在布局中你是這樣寫的 layout_width="wrap_content"。
1.2 onDraw( )
onDarw( ) 方法中有個參數(shù) Canvas,Canvas 就是我們要在上面繪制的畫布,我們可以使用我們的畫筆在上面進(jìn)行繪制,最后呈現(xiàn)給用戶。
1.3 坐標(biāo)系
在Android坐標(biāo)系中,以屏幕左上角作為原點,這個原點向右是X軸的正軸,向下是Y軸正軸。

除了Android坐標(biāo)系,還存在View坐標(biāo)系,View坐標(biāo)系內(nèi)部關(guān)系如圖所示。

View獲取自身高度
由上圖可算出View的高度:
- width = getRight() - getLeft();
- height = getBottom() - getTop();
View的源碼當(dāng)中提供了getWidth()和getHeight()方法用來獲取View的寬度和高度,其內(nèi)部方法和上文所示是相同的,我們可以直接調(diào)用來獲取View得寬高。
View自身的坐標(biāo)
通過如下方法可以獲取View到其父控件的距離。
- getTop();獲取View到其父布局頂邊的距離。
- getLeft();獲取View到其父布局左邊的距離。
- getBottom();獲取View到其父布局底邊的距離。
- getRight();獲取View到其父布局右邊的距離。
2.自定義View流程
2.1 創(chuàng)建類并繼承View
創(chuàng)建一個類,并繼承View,本示例創(chuàng)建一個名為CustomView的類,需要實現(xiàn)其構(gòu)造方法,為了在XML布局中使用自定義View的屬性,至少需要提供一個參數(shù)包含Context和AttributeSet的構(gòu)造方法,如下所示:
public class CustomView extends View {
public CustomView(Context context) {
this(context,null);
}
public CustomView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
init(context, attrs);
}
}
2.2 提供自定義屬性
為了像系統(tǒng)提供的組件那樣,可以在XML布局中設(shè)置視圖組件的屬性,需要提供自定義View的屬性設(shè)置,在res/values路徑下新建一個attrs.xml文件,并在其中編輯屬性名和格式,常用的格式有string:字符串,boolean:布爾值,color:顏色值, dimension:尺寸值,enum:枚舉值,flags:位,float:浮點值,fraction:百分?jǐn)?shù),integer整數(shù)值,reference:引用資源ID。示例如下:
<resources>
<declare-styleable name="CustomView">
<attr name="textContent" format="string|reference" />
<attr name="textSize" format="dimension|reference" />
<attr name="textColor" format="color|reference" />
<attr name="circleColor" format="color|reference" />
</declare-styleable>
</resources>
在XML布局中使用自定義屬性,需要提供命名空間,命名空間的格式如:xmlns:[別名]="[schemas.android.com/apk/res/pa… name],還有一種常用的命名空間:xmlns:app="schemas.android.com/apk/res-aut…
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.android.viewdemo.CustomView
android:id="@+id/cv_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
app:textContent="Android"
app:textSize="50sp"
app:textColor="@color/teal_200"
app:circleColor="@color/purple_500"/>
</RelativeLayout>
在XML布局中設(shè)置屬性值后,接著便是在自定義的View中獲取這些屬性值,調(diào)用context.obtainStyledAttributes()返回TypedArray數(shù)組,TypedArray調(diào)用相應(yīng)的方法獲取屬性值,如調(diào)用typedArray.getString(R.styleable.CustomView_textContent)獲得字符串,TypedArray對象在調(diào)用之后要調(diào)用typedArray.recycle()回收資源,示例如下:
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomView, 0, 0);
try {
textContent = typedArray.getString(R.styleable.CustomView_textContent);
textSize = typedArray.getDimensionPixelSize(R.styleable.CustomView_textSize, 50);
textColor = typedArray.getColor(R.styleable.CustomView_textColor, 0);
circleColor = typedArray.getColor(R.styleable.CustomView_circleColor, 0);
} finally {
typedArray.recycle();
}
2.3 提供屬性的getter和setter方法
自定義View的屬性不僅可以在XML布局中設(shè)置,還應(yīng)提供getter和setter方法,以便在代碼中更改屬性,在調(diào)用setter方法更改屬性時,View的外觀發(fā)生變化時需要調(diào)用invalidate()方法使當(dāng)前的視圖失效,進(jìn)而觸發(fā)onDraw()方法重繪視圖,如果View的大小和形狀發(fā)生了變化,則需要調(diào)用requestLayout()請求重新布局,需要注意的是invalidate()方法要在UI線程中調(diào)用,在非UI線程中調(diào)用postInvalidate(),示例如下:
public void setTextContent(String textContent) {
this.textContent = textContent;
//外觀發(fā)生變化時,在UI線程中調(diào)用
invalidate();
//大小和形狀發(fā)生了變化調(diào)用,非必要不調(diào)用,以提高性能
requestLayout();
}
2.4 重寫onMeasure()方法
此方法主要是用來控制View的大小,讓父視圖知道View希望的大小,在方法內(nèi)計算得出希望View顯示的大小后,調(diào)用setMeasuredDimension()方法將計算出的寬高傳入,示例如下;
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = 0;
int height = 0;
int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
if (modeWidth == MeasureSpec.EXACTLY) {
width = sizeWidth;
} else if (modeWidth == MeasureSpec.AT_MOST) {
width = Math.min(defaultWidth, sizeWidth);
} else {
width = defaultWidth;
}
int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
if (modeHeight == MeasureSpec.EXACTLY) {
height = sizeHeight;
} else if (modeHeight == MeasureSpec.AT_MOST) {
height = Math.min(defaultHeight, sizeHeight);
} else {
height = defaultHeight;
}
setMeasuredDimension(width, height);
}
2.5 重寫onSizeChanged()方法
當(dāng)視圖的大小發(fā)生變化時,onSizeChanged()方法會被調(diào)用,onSizeChanged()方法會攜帶4個參數(shù),分別是新的寬度、新的高度、舊的寬度、舊的高度,這對正確地繪制View至關(guān)重要,繪制需要的位置和尺寸等參數(shù)需要在此方法內(nèi)進(jìn)行計算,示例如下:
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
textY = (float) h / 2;
centerX = (float) w / 2;
centerY = (float) h / 2;
maxCircleRadius = (float) (w - 20) / 2;
}
2.6 初始化畫筆Paint
繪制View需要用到畫布Canvas和畫筆Paint,Canvas負(fù)責(zé)處理繪制什么,如點、線、圓、矩形等,Paint負(fù)責(zé)處理如何繪制,如繪制的顏色、是否填充、透明度等,畫布Canvas可以在重寫onDraw()方法后獲取,而畫筆Paint則需要在初始化階段新建一個或多個Paint對象,示例如下:
paintText = new Paint();
paintText.setAntiAlias(true);
paintText.setTextSize(textSize);
paintText.setColor(textColor);
paintText.setStyle(Paint.Style.FILL);
paintCircle = new Paint();
paintCircle.setAntiAlias(true);
paintCircle.setColor(circleColor);
paintCircle.setStyle(Paint.Style.STROKE);
paintCircle.setStrokeWidth(10);
復(fù)制代碼
2.7 重寫onDraw()方法繪制View
繪制View是重要的一環(huán),它將可見的界面呈現(xiàn)給使用者,重寫onDraw()方法后,它將提供一個畫布Canvas,它將和畫筆Paint一起執(zhí)行繪制,Canvas提供了豐富的繪制方法,如drawLine()繪制線段、drawText()繪制文本、drawPoint()繪制點、drawRect()繪制矩形等,傳入計算好的參數(shù)和畫筆,便可繪制出相應(yīng)的圖形,示例如下:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawText(textContent, 30, textY + 30, paintText);
canvas.drawCircle(centerX, centerY, circleRadius, paintCircle);
}
2.8 響應(yīng)用戶手勢操作
View還會經(jīng)常與使用者進(jìn)行交互,因此還需要響應(yīng)和處理用戶的手勢操作,一般來說,需要重寫onTouchEvent(MotionEvent event),在此方法內(nèi)處理手勢操作,常見的手勢操作有按下、滑動、抬起等,在此方法內(nèi)加上業(yè)務(wù)邏輯,示例如下:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
break;
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_UP:
break;
}
return true;
}
此外,還可以借助GestureDetector類實現(xiàn)更多的手勢檢測,如雙擊、長按、滾動等。
2.9 添加動畫效果
為了讓自定義View更有吸引力和自然,還需要添加一些動畫效果,這時候使用屬性動畫修改View的屬性,可以產(chǎn)生動畫效果,示例如下:
ObjectAnimator textAlpha = ObjectAnimator.ofInt(this, "textAlpha", 255, 50);
textAlpha.setDuration(2000);
textAlpha.setRepeatCount(ValueAnimator.INFINITE);
textAlpha.setRepeatMode(ValueAnimator.RESTART);
textAlpha.start();
textAlpha.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
int animatedValue = (int) animation.getAnimatedValue();
setTextAlpha(animatedValue);
}
});
ObjectAnimator circle = ObjectAnimator.ofFloat(this, "circleRadius", 0.0f, maxCircleRadius);
circle.setDuration(2000);
circle.setRepeatCount(ValueAnimator.INFINITE);
circle.setRepeatMode(ValueAnimator.RESTART);
circle.start();
circle.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
float animatedValue = (float) animation.getAnimatedValue();
setCircleRadius(animatedValue);
}
});
復(fù)制代碼
2.10 對外提供回調(diào)接口
自定義View還應(yīng)對外提供回調(diào)接口,以傳遞一些事件和數(shù)據(jù),方便調(diào)用方處理相應(yīng)的邏輯,常見的操作是在View內(nèi)定義一些接口,在接口內(nèi)部定義一些事件,并對外提供回調(diào)接口的方法,示例如下:
public interface OnCircleAnimationStartListener {
void onCircleAnimationStart();
}
public void setOnCircleAnimationStartListener(OnCircleAnimationStartListener onCircleAnimationStartListener) {
this.onCircleAnimationStartListener = onCircleAnimationStartListener;
}
cv_view.setOnCircleAnimationStartListener(new CustomView.OnCircleAnimationStartListener() {
@Override
public void onCircleAnimationStart() {
}
});
3.示例圓環(huán)——直接繼承自View或者ViewGroup
這種自定義View實現(xiàn)麻煩一些,但是更加靈活,也能實現(xiàn)更加復(fù)雜的UI界面,實現(xiàn)過程中需要解決以下幾個問題
如何根據(jù)相應(yīng)的屬性將UI元素繪制到界面
自定義控件的大小,也就是寬和高分別設(shè)置多少
如果是ViewGroup,如何合理安排其內(nèi)部子View的排放位置
以上3個問題可以在如下3個方法中得到解決:
onDraw()
onMeasure()
onLayout()
所以自定義View的重點工作其實就是復(fù)寫并合理實現(xiàn)這3個方法。
3.1.onDraw
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
}
onDraw方法接收一個Canvas類型的參數(shù)。Canvas可以理解為一個畫布,在這塊畫布上可以繪制各種類型的UI
系統(tǒng)提供了一系列Canvas操作方法如下:
Canvas
public class Canvas extends BaseCanvas {
void drawArc(RectF oval, startAngle, float sweepAngle, useCenter,Paint paint) 繪制弧形
void drawBitmap(@NonNull Bitmap bitmap, float left, float top, @Nullable Paint paint) 繪制圖片
void drawCircle(float cx, float cy, float radius, @NonNull Paint paint) 繪制圓形
void drawLine(float startX, float startY, float stopX, float stopY,Paint paint) 繪制直線
void drawOval(@NonNull RectF oval, @NonNull Paint paint) 繪制橢圓
void drawPath(@NonNull Path path, @NonNull Paint paint) 繪制path路徑
void drawPoint(float x, float y, @NonNull Paint paint) 繪制點
void drawRect(Rect r, @NonNull Paint paint) 繪制矩形區(qū)域
void drawRoundRect(RectF rect, float rx, float ry, Paint paint) 繪制圓角矩形
void drawText(String text, float x, float y, @NonNull Paint paint) 繪制文本
}
調(diào)用Canvas類的draw方法最終會調(diào)用BaseCanvas中的native方法
Paint
在Canvas的各種draw方法中,都需要傳入一個Paint對象。Paint相當(dāng)于一個畫筆,通過設(shè)置畫筆的各種屬性,來實現(xiàn)不同繪制效果:
public class Paint {
void setStyle(Style style) 設(shè)置繪制模式
void setColor(@ColorInt int color) 設(shè)置畫筆顏色
void setAlpha(int a) 設(shè)置畫筆透明度
void setStrokeWidth(float width) 設(shè)置線條寬度
void setStrokeCap(Cap cap) 設(shè)置畫筆繪制兩端時的樣式
void setStrokeJoin(Join join) 設(shè)置畫筆繪制時,折線的樣式
Shader setShader(Shader shader) 設(shè)置Paint的填充效果
ColorFilter setColorFilter(ColorFilter filter) 設(shè)置畫筆線的樣式
public Xfermode setXfermode(Xfermode xfermode) 設(shè)置畫筆的層疊效果
public Typeface setTypeface(Typeface typeface) 設(shè)置字體樣式
void setTextSize(float textSize) 設(shè)置文本字體大小
void setAntiAlias(boolean aa) 設(shè)置抗鋸齒開關(guān)
void setDither(boolean dither) 設(shè)置防抖動開關(guān)
}
實現(xiàn)圓環(huán)進(jìn)行條控件
自定義控件
/**
* 繪制扇形進(jìn)度控件:繪制一個圓,和其中代表進(jìn)度的扇形
* 1。接收自定義屬性- 原的顏色,扇形的顏色等
* 2。初始化Paint(2)
* 3。onDraw方法中繪制圓形和扇形
* 在onSizeChange方法中獲取到繪制的區(qū)域
*/
public class PieImageView extends View {
private Paint arcPaint;
private Paint circlePaint;
private RectF mBound;
private int radius;
public PieImageView(Context context) {
this(context, null);
}
public PieImageView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public PieImageView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initPaint(context);
}
private void initPaint(Context context) {
arcPaint = new Paint();
arcPaint.setAntiAlias(true);
arcPaint.setStyle(Paint.Style.FILL_AND_STROKE);
arcPaint.setStrokeWidth(dpTopx(0.1f, context));
arcPaint.setColor(Color.BLUE);
circlePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
circlePaint.setStyle(Paint.Style.STROKE);
circlePaint.setStrokeWidth(dpTopx(2, context));
circlePaint.setColor(Color.RED);
mBound = new RectF();
}
/**
* 拿到控件的寬高
* 設(shè)置圓形繪制的半徑
* 設(shè)置圓形繪制的區(qū)域 mBound
*/
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
Log.e("Tim", "onSizeChanged w:" + w + " ,h:" + h);
int min = Math.min(w, h);
radius = min / 3;
mBound.set(min / 2 - radius, min / 2 - radius, min / 2 + radius, min / 2 + radius);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
Log.e("Tim", "onDraw");
canvas.drawCircle(mBound.centerX(), mBound.centerY(), radius, circlePaint);
canvas.drawArc(mBound, 0, 125, true, arcPaint);
}
float density = 0;
/**
* dp轉(zhuǎn)px
*/
private int dpTopx(float dp, Context context) {
if (density == 0) {//密度
density = context.getResources().getDisplayMetrics().density;
}
Log.e("Tim", "density:" + density);
return (int) (dp * density);
}
}
在xml中使用
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.timmy.demopractice.view.PieImageView
android:layout_width="300dp"
android:layout_height="300dp"
android:background="@color/colorAccent" />
</LinearLayout>
參考文章:
http://m.itdecent.cn/p/70ea446d2b99
https://juejin.cn/post/6844903607855218702