Android 如何自定義View?
在看這篇博客之前可以先看View的工作原理
1. 自定義View
1. 自定義View的分類
1. 繼承View重寫onDraw方法
這種方式需要通過繪製的方式來實現,即重寫onDraw方法。採用這種方式需要自己支持wrap_content,並且padding也需自己處理。
2. 繼承ViewGroup派生特殊的Layout
這種方法主要用於實現自定義佈局,採用這種方式稍微複雜一些,需要合適的處理ViweGroup的測量,佈局兩個過程,並且同時處理子元素的測量和佈局過程。
3. 繼承特定的View
這種方式比較常見,一般是用於擴展某種已有的View的功能,這種方式不需要自己支持wrap_content和padding。
4. 繼承特定的ViewGroup
這種方式不需要自己處理測量和佈局這兩個過程。
2. 自定義View須知
1. 讓View支持wrap_content
這是因爲直接繼承View或者ViewGroup的控件,如果不在onMeasure中對wrap_content做特殊處理時,那麼當外界在佈局中使用wrap_content時就無法達到預期的效果。具體原因在View的工作原理
2. 如果有必要,讓View支持padding
這是因爲直接繼承View的控件,如果不在draw方法中處理padding,那麼padding屬性是無法起作用的。另外,直接繼承自ViewGroup的控件需要在onMeasure和onLayout中考慮padding和子元素的margin對其造成的影響。
3. 儘量不要在View中使用Handler,沒必要
這是因爲View的內部本身就提供了post系列的方法,完全可以替代Handler的作用。
4. View中如果有線程或者動畫,需要及時停止
如果有線程或者動畫需要停止時,那麼onDetachedFromWindow是一個很好的時機。當包含此view的Activity退出或者當前View被remove時,View的onDetachedFromWindow方法會被調用,和此方法對應的是onAttachedToWindow,當包含此View的Activity啓動時,View的onAttachedToWindow會被調用。
5. View帶有滑動嵌套情形時,需要處理好滑動衝突
如果有滑動衝突的話,那麼要合適的處理滑動衝突。
2. 示例
1. 繼承現有控件
相對而言,這是一種較簡單的方式。因爲大部分核心工作,比如關於控件大小的測量,控件位置的擺放等相關的計算,在系統內部都已經實現並封裝好,我們只需要在此基礎上做一些擴展,並按照自己的意圖顯示相應的元素。
如:
public class CustomToolBar extends RelativeLayout {
private ImageView leftImage,rightImage;
private TextView titleTextView;
public CustomToolBar(Context context) {
this(context,null);
}
public CustomToolBar(Context context, AttributeSet attrs) {
this(context, attrs,0);
}
public CustomToolBar(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init(context);
}
private void init(Context context) {
leftImage = new ImageView(context);
leftImage.setPadding(12,12,12,12);
rightImage = new ImageView(context);
rightImage.setPadding(12,12,12,12);
leftImage.setImageResource(R.mipmap.ic_launcher);
rightImage.setImageResource(R.mipmap.ic_launcher);
LayoutParams leftParams = new LayoutParams((int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,48,getResources().getDisplayMetrics()),(int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,48,getResources().getDisplayMetrics()));
leftParams.addRule(ALIGN_PARENT_LEFT,TRUE);
this.addView(leftImage,leftParams);
titleTextView = new TextView(context);
titleTextView.setText("CustomToolBar");
titleTextView.setTextSize(20);
titleTextView.setTextColor(Color.WHITE);
LayoutParams titleParams = new LayoutParams(LayoutParams.WRAP_CONTENT,LayoutParams.WRAP_CONTENT);
titleParams.addRule(CENTER_IN_PARENT,TRUE);
this.addView(titleTextView,titleParams);
LayoutParams rightParams = new LayoutParams((int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,48,getResources().getDisplayMetrics()),(int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP,48,getResources().getDisplayMetrics()));
rightParams.addRule(ALIGN_PARENT_RIGHT,TRUE);
addView(rightImage,rightParams);
}
}
2. 自定義屬性
有時候我們想在XML中使用CustomToolBar時,希望能在XML中直接指定Title的顯示內容,字體顏色,leftImage和rightImage的顯示圖片等。這就需要自定義屬性。
-
attrs.xml中聲明自定義屬性
在res目錄下的attrs.xml文件中(沒有就自己創建一個),使用標籤自定義屬性,如下所示:
<declare-styleable name="CustomToolBar"> <attr name = "titleText" format="string|reference"/> <attr name="myTitleTextColor" format="color|reference"/> <attr name="titleTextSize" format="dimension|reference"/> <attr name="leftImageSrc" format="reference"/> <attr name="rightImageSrc" format="reference"/> </declare-styleable>
解釋:
- declare-styleable標籤代表一個自定義屬性集合,一般會與自定義控件結合使用
- attr標籤則代表一條具體的屬性,name是屬性名稱,format代表屬性的格式。
在XML佈局中使用自定義屬性
首先添加命名空間xmnls:app ,然後通過命名空間app引用自定義屬性,並傳入相應的圖片資源和字符串內容。
在CustomToolBar中,獲取自定義屬性的引用值
private void initAttrs(Context context) {
TypedArray ta = context.obtainStyledAttributes(R.styleable.CustomToolBar);
titleText = ta.getString(R.styleable.CustomToolBar_titleText);
textColor = ta.getColor(R.styleable.CustomToolBar_myTitleTextColor,Color.WHITE);
titleTextSize = ta.getDimension(R.styleable.CustomToolBar_titleTextSize,12);
leftImageId = ta.getResourceId(R.styleable.CustomToolBar_leftImageSrc,R.mipmap.ic_launcher);
rightImageId = ta.getResourceId(R.styleable.CustomToolBar_rightImageSrc,R.mipmap.ic_launcher);
}
3. 直接繼承View或ViewGroup
這種方式相比第一種麻煩一些,但是更加靈活,也能實現更加複雜的UI界面。一般需要解決以下問題:
- 如何根據相應的屬性將UI元素繪製到界面;
- 自定義控件的大小,也就是寬和高分別設置多少;
- 如果是ViewGroup,如何合理安排子元素的擺放位置。
以上三個問題依次在如下三個方法中解決:
- onDraw
- onMeasure
- onLayout
因此自定義View的工作重點其實就是複寫並且合理的實現這三個方法。注意:並不是每個自定義View都需要實現這三個方法,大多數情況下只需要實現其中2個甚至1個方法也能滿足需求。
onDraw
onDraw方法接收一個Canvas參數。Canvas可以理解爲一個畫布,在這塊畫布上可以繪製各種類型的UI元素。
系統提供了一系列Cnavas操作方法,如下:
void drawRect(RectF rect,Paint paint);//繪製矩形區域
void drawOval(RectF oval,Paint paint);//繪製橢圓
void drawCircle(float cx,float cy,float radius,Paint paint);//繪製圓形
void drawArc(RectF oval,float startAngle,float sweepAngle,boolean useCenter,Paint paint);//繪製弧形
void drawPath(Path path,Paint paint);//繪製path路徑
void drawLine(float startX,float startY,float stopX,float stopY,Paint paint);//繪製連線
void drawOval(float x,float y,Paint paint);//繪製點
從上圖中可以看出,Canvas中每一個繪製操作都需要傳入一個Paint對象。Paint就相當於一個畫筆,我們可以設置畫筆的各種屬性,實現不同的繪製效果。
setStyle(Style style);//設置繪製模式
setColor(int color);//設置顏色
setAlpha(int a);//設置透明度
setShader(Shader sahder);//設置Paint的填充效果
setStroke(float width);//設置線條寬度
setTextSize(float textSize);//設置文字大小
setAntiAlias(boolean aa);//設置抗鋸齒開關
setDither(boolean dither);//設置防抖動開關
如下代碼,定義PieImageView繼承自View,然後在onDraw方法中,分別使用canvas的drawArc,和drawCircle來繪製弧度和圓形。這兩個形狀結合在一起就能表示一個簡易的圓形進度條控件。
public class PieImageView extends View {
private static final int MAX_PROGRESS = 100;
private Paint mArcPaint;
private RectF mBound;
private Paint mCirclePaint;
private int mProgress = 0;
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);
init();
}
public void setProgress(@IntRange(from = 0,to = MAX_PROGRESS) int mProgress){
this.mProgress = mProgress;
ViewCompat.postInvalidateOnAnimation(this);
}
private void init() {
mArcPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mArcPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mArcPaint.setStrokeWidth(dpToPixel(0.1f,getContext()));
mArcPaint.setColor(Color.RED);
mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mCirclePaint.setStyle(Paint.Style.STROKE);
mCirclePaint.setStrokeWidth(dpToPixel(2,getContext()));
mCirclePaint.setColor(Color.argb(120,0xff,0xff,0xff));
mBound = new RectF();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int measureWidth = MeasureSpec.getSize(widthMeasureSpec);
int measureHeight = MeasureSpec.getSize(heightMeasureSpec);
//判斷是wrap_content模式
if (MeasureSpec.AT_MOST == widthMode||MeasureSpec.AT_MOST == heightMode){
//將寬高設置爲傳入寬高的最小值
int size = Math.min(measureWidth,measureHeight);
setMeasuredDimension(size,size);
}else{
setMeasuredDimension(measureWidth,measureHeight);
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
int min = Math.min(w,h);
int max = w + h - min;
int r = Math.min(w,h)/3;
mBound.set((max >> 1) - r,(min >> 1) -r,(max>>1)+r,(min>>1)+r);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mProgress!=MAX_PROGRESS&&mProgress!=0){
float mAngle = mProgress*360f/MAX_PROGRESS;
canvas.drawArc(mBound,270,mAngle,true,mArcPaint);
canvas.drawCircle(mBound.centerX(),mBound.centerY(),mBound.height()/2,mCirclePaint);
}
}
private float scale = 0;
private int dpToPixel(float dp, Context context) {
if (scale == 0){
scale = context.getResources().getDisplayMetrics().density;
}
return (int)(dp*scale);
}
}
在Activity中設置進度爲45
如果在上面代碼中的佈局文件中,將PieImageView的寬高設置爲wrap_content(也就是自適應),顯示效果如下:
寬是父容器的寬,高等於寬,這是因爲我們在onMeasure中處理了wrap_content的情況。
4. ViewGroup的onMeausre
如果我們自定義的控件是一個容器,onMeasure方法會更加複雜一些。因爲ViewGroup在測量自己的寬高之前,需要先確定其內部子View的所佔大小,然後才能確定自己的大小。
當我們要自己定義一個ViewGroup時,也需要在onMeaure方法中綜合考慮子View的寬度。比如如果要實現一個流式佈局FlowLayout,效果如下:
在大多數App的搜索界面經常會使用FlowLayout來展示歷史搜索或者熱門搜索項。FlowLayout的每一行中的item的個數不一定,當每行item累計寬度超過可用總寬度,則需要重啓一行進行擺放。如下:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//獲得寬高的測量模式和測量值
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec)-getPaddingLeft()-getPaddingRight();
int heightSize = MeasureSpec.getSize(heightMeasureSpec)-getPaddingBottom()-getPaddingTop();
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
//獲得容器中子View的個數
int childCount = getChildCount();
//記錄每一行View的總寬度
int totalLineWidth = 0;
//記錄每一行最高view的高度
int perLineMaxHeight = 0;
//記錄當前ViewGroup的總高度
int totalHeight = 0;
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
//對子view進行測量
measureChild(childView,widthMeasureSpec,heightMeasureSpec);
MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams();
//獲得子view的測量寬度
int childWidth = childView.getMeasuredWidth()+lp.leftMargin+lp.rightMargin;
//獲得子view的測量高度
int childHeight = childView.getMeasuredHeight()+lp.topMargin+lp.bottomMargin;
if (totalLineWidth+childWidth>widthSize){
//統計總高度
totalHeight+=perLineMaxHeight;
//開啓新一行
totalLineWidth=childWidth;
perLineMaxHeight=childHeight;
}else{
//記錄每一行的總寬度
totalLineWidth+=childWidth;
//比較每一行最高的view
perLineMaxHeight = Math.max(perLineMaxHeight,childHeight);
}
//當前view已是最後一個view時,將改行最大高度添加到totalHeight中
if (i == childCount-1){
totalHeight+=perLineMaxHeight;
}
}
//如果高度的測量模式是EXACTLY,則高度用測量值,否則用計算出來的總高度(這時的測量模式是AT_MOST)
heightSize = heightMode == MeasureSpec.EXACTLY?heightSize:totalHeight;
setMeasuredDimension(widthSize,heightSize);
}
上述onMeasure方法的主要目的有2個:
- 通過measureChild方法遞歸測量子View
- 通過疊加每一行的高度,計算出最終FlowLayout的最終高度totalHeight
ViewGroup中的onLayout方法聲明如下:
protected abstract void onLayout(boolean changed,int l,int t,int r,int b);
它是一個抽象方法 ,也就是每一個自定義ViewGroup都必須實現如何排布子View,具體就是遍歷每一個子View,調用child.layout(l,t,r,b);爲每個子View設置具體的佈局位置。如下:
protected void onLayout(boolean changed, int l, int t, int r, int b) {
Log.d("PADDING", "onLayout: viewgroup width--->"+getWidth());
Log.d("PADDING", "onLayout: paddingLeft --->"+getPaddingLeft());
mAllViews.clear();
mPerLineMaxHeight.clear();
//存放每一行的子view
List<View> lineViews = new ArrayList<>();
//記錄每一行已存放view的總寬度
int totalLineWidth = 0;
//記錄每一行最高View的高度
int lineMaxHeight = 0;
/**************************遍歷所有View,將View添加到List<List<View>>集合中*************************/
//獲得View的總個數
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams();
int childWidth = childView.getMeasuredWidth()+lp.leftMargin+lp.rightMargin;
int childHeight = childView.getMeasuredHeight()+lp.topMargin+lp.bottomMargin;
if (totalLineWidth+childWidth>getWidth()){
mAllViews.add(lineViews);
mPerLineMaxHeight.add(lineMaxHeight);
//開啓新一行
totalLineWidth = 0;
lineMaxHeight = 0;
lineViews = new ArrayList<>();
}
totalLineWidth+=childWidth;
lineViews.add(childView);
lineMaxHeight = Math.max(lineMaxHeight,childHeight);
}
//單獨處理最後一行
mAllViews.add(lineViews);
mPerLineMaxHeight.add(lineMaxHeight);
/***********************遍歷集合中的所有view並顯示出來***********************/
//表示一個view和父容器左邊的距離
int mLeft = getPaddingLeft();
//表示view和父容器頂部的距離
int mTop = getPaddingTop();
for (int i = 0; i < mAllViews.size(); i++) {
//獲得每一行的所有view
lineViews = mAllViews.get(i);
lineMaxHeight = mPerLineMaxHeight.get(i);
for (int j = 0; j < lineViews.size(); j++) {
View childView = lineViews.get(j);
MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams();
int leftChild = mLeft + lp.leftMargin;
int topChild = mTop + lp.topMargin;
int rightChild = leftChild + childView.getMeasuredWidth();
int bottomChild = topChild+childView.getMeasuredHeight();
//四個參數分別表示view的左上角和右下角
childView.layout(leftChild,topChild,rightChild,bottomChild);
mLeft+=lp.leftMargin+childView.getMeasuredWidth()+lp.rightMargin;
}
mLeft=getPaddingLeft();
mTop+=lineMaxHeight;
}
}
最終使用效果:
<com.song.lagoucustomizedview.FlowLayout 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"
android:padding="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="2dp"
android:background="#aa0000"
android:text="Hello World!"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:background="#aa0000"
android:text="Android"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="1dp"
android:background="#aa0000"
android:text="Java"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:background="#aa0000"
android:text="Android Studio"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="2dp"
android:background="#aa0000"
android:text="ViewGroup"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="2dp"
android:background="#aa0000"
android:text="GoodBye"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:background="#aa0000"
android:text="Layout"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="3dp"
android:background="#aa0000"
android:text="Variable"
android:textSize="20sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:background="#aa0000"
android:text="Hello World!"
android:textSize="20sp" />
</com.song.lagoucustomizedview.FlowLayout>