目錄

1. 自定義View基礎
1.1 分類
自定義View的實現方式有以下幾種
類型 | 定義 |
---|---|
自定義組合控件 | 多個控件組合成為一個新的控件,方便多處復用 |
繼承系統View控件 | 繼承自TextView等系統控件,在系統控件的基礎功能上進行擴展 |
繼承View | 不復用系統控件邏輯,繼承View進行功能定義 |
繼承系統ViewGroup | 繼承自LinearLayout等系統控件,在系統控件的基礎功能上進行擴展 |
繼承ViewViewGroup | 不復用系統控件邏輯,繼承ViewGroup進行功能定義 |
1.2 View繪制流程
View的繪制基本由measure()、layout()、draw()這個三個函數完成
函數 | 作用 | 相關方法 |
---|---|---|
measure() | 測量View的寬高 | measure(),setMeasuredDimension(),onMeasure() |
layout() | 計算當前View以及子View的位置 | layout(),onLayout(),setFrame() |
draw() | 視圖的繪制工作 | draw(),onDraw() |
1.3 坐標系
在Android坐標系中,以屏幕左上角作為原點,這個原點向右是X軸的正軸,向下是Y軸正軸。如下所示:

除了Android坐標系,還存在View坐標系,View坐標系內部關系如圖所示。

View獲取自身高度
由上圖可算出View的高度:
- width = getRight() - getLeft();
- height = getBottom() - getTop();
View的源碼當中提供了getWidth()和getHeight()方法用來獲取View的寬度和高度,其內部方法和上文所示是相同的,我們可以直接調用來獲取View得寬高。
View自身的坐標
通過如下方法可以獲取View到其父控件的距離。
- getTop();獲取View到其父布局頂邊的距離。
- getLeft();獲取View到其父布局左邊的距離。
- getBottom();獲取View到其父布局底邊的距離。
- getRight();獲取View到其父布局右邊的距離。
1.4 構造函數
無論是我們繼承系統View還是直接繼承View,都需要對構造函數進行重寫,構造函數有多個,至少要重寫其中一個才行。如我們新建TestView
,
public class TestView extends View { /** * 在java代碼里new的時候會用到 * @param context */ public TestView(Context context) { super(context); } /** * 在xml布局文件中使用時自動調用 * @param context */ public TestView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); } /** * 不會自動調用,如果有默認style時,在第二個構造函數中調用 * @param context * @param attrs * @param defStyleAttr */ public TestView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } /** * 只有在API版本>21時才會用到 * 不會自動調用,如果有默認style時,在第二個構造函數中調用 * @param context * @param attrs * @param defStyleAttr * @param defStyleRes */ @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) public TestView(Context context, @Nullable AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); } }
1.5 自定義屬性
Android系統的控件以android開頭的都是系統自帶的屬性。為了方便配置自定義View的屬性,我們也可以自定義屬性值。
Android自定義屬性可分為以下幾步:
- 自定義一個View
- 編寫values/attrs.xml,在其中編寫styleable和item等標簽元素
- 在布局文件中View使用自定義的屬性(注意namespace)
- 在View的構造方法中通過TypedArray獲取
實例說明
- 自定義屬性的聲明文件
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="test"> <attr name="text" format="string" /> <attr name="testAttr" format="integer" /> </declare-styleable> </resources>
- 自定義View類
public class MyTextView extends View { private static final String TAG = MyTextView.class.getSimpleName(); //在View的構造方法中通過TypedArray獲取 public MyTextView(Context context, AttributeSet attrs) { super(context, attrs); TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.test); String text = ta.getString(R.styleable.test_testAttr); int textAttr = ta.getInteger(R.styleable.test_text, -1); Log.e(TAG, "text = " + text + " , textAttr = " + textAttr); ta.recycle(); } }
- 布局文件中使用
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" xmlns:app="http://schemas.android.com/apk/res/com.example.test" android:layout_width="match_parent" android:layout_height="match_parent" > <com.example.test.MyTextView android:layout_width="100dp" android:layout_height="200dp" app:testAttr="520" app:text="helloworld" /> </RelativeLayout>
屬性值的類型format
(1). reference:參考某一資源ID
- 屬性定義:
<declare-styleable name = "名稱"> <attr name = "background" format = "reference" /> </declare-styleable>
- 屬性使用:
<ImageView android:background = "@drawable/圖片ID"/>
(2). color:顏色值
- 屬性定義:
<attr name = "textColor" format = "color" />
- 屬性使用:
<TextView android:textColor = "#00FF00" />
(3). boolean:布爾值
- 屬性定義:
<attr name = "focusable" format = "boolean" />
- 屬性使用:
<Button android:focusable = "true"/>
(4). dimension:尺寸值
- 屬性定義:
<attr name = "layout_width" format = "dimension" />
- 屬性使用:
<Button android:layout_width = "42dip"/>
(5). float:浮點值
- 屬性定義:
<attr name = "fromAlpha" format = "float" />
- 屬性使用:
<alpha android:fromAlpha = "1.0"/>
(6). integer:整型值
- 屬性定義:
<attr name = "framesCount" format="integer" />
- 屬性使用:
<animated-rotate android:framesCount = "12"/>
(7). string:字符串
- 屬性定義:
<attr name = "text" format = "string" />
- 屬性使用:
<TextView android:text = "我是文本"/>
(8). fraction:百分數
- 屬性定義:
<attr name = "pivotX" format = "fraction" />
- 屬性使用:
<rotate android:pivotX = "200%"/>
(9). enum:枚舉值
- 屬性定義:
<declare-styleable name="名稱"> <attr name="orientation"> <enum name="horizontal" value="0" /> <enum name="vertical" value="1" /> </attr> </declare-styleable>
- 屬性使用:
<LinearLayout
android:orientation = "vertical"> </LinearLayout>
注意:枚舉類型的屬性在使用的過程中只能同時使用其中一個,不能 android:orientation = “horizontal|vertical"
(10). flag:位或運算
- 屬性定義:
<declare-styleable name="名稱"> <attr name="gravity"> <flag name="top" value="0x01" /> <flag name="bottom" value="0x02" /> <flag name="left" value="0x04" /> <flag name="right" value="0x08" /> <flag name="center_vertical" value="0x16" /> ... </attr> </declare-styleable>
- 屬性使用:
<TextView android:gravity="bottom|left"/>
注意:位運算類型的屬性在使用的過程中可以使用多個值
(11). 混合類型:屬性定義時可以指定多種類型值
- 屬性定義:
<declare-styleable name = "名稱"> <attr name = "background" format = "reference|color" /> </declare-styleable>
- 屬性使用:
<ImageView android:background = "@drawable/圖片ID" /> 或者: <ImageView android:background = "#00FF00" />
2. View繪制流程
這一章節偏向於解釋View繪制的源碼實現,可以更好地幫助我們掌握整個繪制過程。
View的繪制基本由measure()、layout()、draw()這個三個函數完成
函數 | 作用 | 相關方法 |
---|---|---|
measure() | 測量View的寬高 | measure(),setMeasuredDimension(),onMeasure() |
layout() | 計算當前View以及子View的位置 | layout(),onLayout(),setFrame() |
draw() | 視圖的繪制工作 | draw(),onDraw() |
2.1 Measure()
MeasureSpec
MeasureSpec
是View的內部類,它封裝了一個View的尺寸,在onMeasure()
當中會根據這個MeasureSpec
的值來確定View的寬高。
MeasureSpec
的值保存在一個int值當中。一個int值有32位,前兩位表示模式mode
后30位表示大小size
。即MeasureSpec
= mode
+ size
。
在MeasureSpec
當中一共存在三種mode
:UNSPECIFIED
、EXACTLY
和
AT_MOST
。
對於View來說,MeasureSpec
的mode和Size有如下意義
模式 | 意義 | 對應 |
---|---|---|
EXACTLY | 精准模式,View需要一個精確值,這個值即為MeasureSpec當中的Size | match_parent |
AT_MOST | 最大模式,View的尺寸有一個最大值,View不可以超過MeasureSpec當中的Size值 | wrap_content |
UNSPECIFIED | 無限制,View對尺寸沒有任何限制,View設置為多大就應當為多大 | 一般系統內部使用 |
使用方式
// 獲取測量模式(Mode) int specMode = MeasureSpec.getMode(measureSpec) // 獲取測量大小(Size) int specSize = MeasureSpec.getSize(measureSpec) // 通過Mode 和 Size 生成新的SpecMode int measureSpec=MeasureSpec.makeMeasureSpec(size, mode);
在View當中,MeasureSpace
的測量代碼如下:
public static int getChildMeasureSpec(int spec, int padding, int childDimension) { int specMode = MeasureSpec.getMode(spec); int specSize = MeasureSpec.getSize(spec); int size = Math.max(0, specSize - padding); int resultSize = 0; int resultMode = 0; switch (specMode) { //當父View要求一個精確值時,為子View賦值 case MeasureSpec.EXACTLY: //如果子view有自己的尺寸,則使用自己的尺寸 if (childDimension >= 0) { resultSize = childDimension; resultMode = MeasureSpec.EXACTLY; //當子View是match_parent,將父View的大小賦值給子View } else if (childDimension == LayoutParams.MATCH_PARENT) { resultSize = size; resultMode = MeasureSpec.EXACTLY; //如果子View是wrap_content,設置子View的最大尺寸為父View } else if (childDimension == LayoutParams.WRAP_CONTENT) { resultSize = size; resultMode = MeasureSpec.AT_MOST; } break; // 父布局給子View了一個最大界限 case MeasureSpec.AT_MOST: if (childDimension >= 0) { //如果子view有自己的尺寸,則使用自己的尺寸 resultSize = childDimension; resultMode = MeasureSpec.EXACTLY; } else if (childDimension == LayoutParams.MATCH_PARENT) { // 父View的尺寸為子View的最大尺寸 resultSize = size; resultMode = MeasureSpec.AT_MOST; } else if (childDimension == LayoutParams.WRAP_CONTENT) { //父View的尺寸為子View的最大尺寸 resultSize = size; resultMode = MeasureSpec.AT_MOST; } break; // 父布局對子View沒有做任何限制 case MeasureSpec.UNSPECIFIED: if (childDimension >= 0) { //如果子view有自己的尺寸,則使用自己的尺寸 resultSize = childDimension; resultMode = MeasureSpec.EXACTLY; } else if (childDimension == LayoutParams.MATCH_PARENT) { //因父布局沒有對子View做出限制,當子View為MATCH_PARENT時則大小為0 resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size; resultMode = MeasureSpec.UNSPECIFIED; } else if (childDimension == LayoutParams.WRAP_CONTENT) { //因父布局沒有對子View做出限制,當子View為WRAP_CONTENT時則大小為0 resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size; resultMode = MeasureSpec.UNSPECIFIED; } break; } return MeasureSpec.makeMeasureSpec(resultSize, resultMode); }
這里需要注意,這段代碼只是在為子View設置
MeasureSpec
參數而不是實際的設置子View的大小。子View的最終大小需要在View中具體設置。
從源碼可以看出來,子View的測量模式是由自身LayoutParam和父View的MeasureSpec來決定的。
父View mode | 子View |
---|---|
UNSPECIFIED | 父布局沒有做出限制,子View有自己的尺寸,則使用,如果沒有則為0 |
EXACTLY | 父布局采用精准模式,有確切的大小,如果有大小則直接使用,如果子View沒有大小,子View不得超出父view的大小范圍 |
AT_MOST | 父布局采用最大模式,存在確切的大小,如果有大小則直接使用,如果子View沒有大小,子View不得超出父view的大小范圍 |
在測量子View大小時:
父View mode | 子View |
---|---|
UNSPECIFIED | 父布局沒有做出限制,子View有自己的尺寸,則使用,如果沒有則為0 |
EXACTLY | 父布局采用精准模式,有確切的大小,如果有大小則直接使用,如果子View沒有大小,子View不得超出父view的大小范圍 |
AT_MOST | 父布局采用最大模式,存在確切的大小,如果有大小則直接使用,如果子View沒有大小,子View不得超出父view的大小范圍 |
onMeasure()
整個測量過程的入口位於View
的measure
方法當中,該方法做了一些參數的初始化之后調用了onMeasure
方法,這里我們主要分析onMeasure
。
onMeasure
方法的源碼如下:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec)); }
很簡單這里只有一行代碼,涉及到了三個方法我們挨個分析。
- setMeasuredDimension(int measuredWidth, int measuredHeight) :該方法用來設置View的寬高,在我們自定義View時也會經常用到。
- getDefaultSize(int size, int measureSpec):該方法用來獲取View默認的寬高,結合源碼來看。
/** * 有兩個參數size和measureSpec * 1、size表示View的默認大小,它的值是通過`getSuggestedMinimumWidth()方法來獲取的,之后我們再分析。 * 2、measureSpec則是我們之前分析的MeasureSpec,里面存儲了View的測量值以及測量模式 */ public static int getDefaultSize(int size, int measureSpec) { int result = size; int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); //從這里我們看出,對於AT_MOST和EXACTLY在View當中的處理是完全相同的。所以在我們自定義View時要對這兩種模式做出處理。 switch (specMode) { case MeasureSpec.UNSPECIFIED: result = size; break; case MeasureSpec.AT_MOST: case MeasureSpec.EXACTLY: result = specSize; break; } return result; }
- getSuggestedMinimumWidth():getHeight和該方法原理是一樣的,這里只分析這一個。
//當View沒有設置背景時,默認大小就是mMinWidth,這個值對應Android:minWidth屬性,如果沒有設置時默認為0. //如果有設置背景,則默認大小為mMinWidth和mBackground.getMinimumWidth()當中的較大值。 protected int getSuggestedMinimumWidth() { return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth()); }
ViewGroup
的測量過程與View有一點點區別,其本身是繼承自View
,它沒有對View
的measure
方法以及onMeasure
方法進行重寫。
為什么沒有重寫onMeasure
呢?ViewGroup除了要測量自身寬高外還需要測量各個子View
的大小,而不同的布局測量方式也都不同(可參考LinearLayout
以及FrameLayout
),所以沒有辦法統一設置。因此它提供了測量子View
的方法measureChildren()
以及measureChild()
幫助我們對子View進行測量。
measureChildren()
以及measureChild()
的源碼這里不再分析,大致流程就是遍歷所有的子View,然后調用View
的measure()
方法,讓子View
測量自身大小。具體測量流程上面也以及介紹過了
measure
過程會因為布局的不同或者需求的不同而呈現不同的形式,使用時還是要根據業務場景來具體分析,如果想再深入研究可以看一下LinearLayout
的onMeasure
方法。
2.2 Layout()
要計算位置首先要對Android坐標系有所了解,前面的內容我們也有介紹過。
layout()
過程,對於View
來說用來計算View
的位置參數,對於ViewGroup
來說,除了要測量自身位置,還需要測量子View
的位置。
layout()
方法是整個Layout()流程的入口,看一下這部分源碼
/** * 這里的四個參數l、t、r、b分別代表View的左、上、右、下四個邊界相對於其父View的距離。 * */ public void layout(int l, int t, int r, int b) { if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) { onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec); mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT; } int oldL = mLeft; int oldT = mTop; int oldB = mBottom; int oldR = mRight; //這里通過setFrame或setOpticalFrame方法確定View在父容器當中的位置。 boolean changed = isLayoutModeOptical(mParent) ? setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b); //調用onLayout方法。onLayout方法是一個空實現,不同的布局會有不同的實現。 if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) { onLayout(changed, l, t, r, b); } }
從源碼我們知道,在layout()
方法中已經通過setOpticalFrame(l, t, r, b)
或 setFrame(l, t, r, b)
方法對View自身的位置進行了設置,所以onLayout(changed, l, t, r, b)
方法主要是ViewGroup
對子View的位置進行計算。
有興趣的可以看一下
LinearLayout
的onLayout
源碼,可以幫助加深理解。
2.3 Draw()
draw流程也就是的View繪制到屏幕上的過程,整個流程的入口在View
的draw()
方法之中,而源碼注釋也寫的很明白,整個過程可以分為6個步驟。
- 如果需要,繪制背景。
- 有過有必要,保存當前canvas。
- 繪制View的內容。
- 繪制子View。
- 如果有必要,繪制邊緣、陰影等效果。
- 繪制裝飾,如滾動條等等。
通過各個步驟的源碼再做分析:
public void draw(Canvas canvas) { int saveCount; // 1. 如果需要,繪制背景 if (!dirtyOpaque) { drawBackground(canvas); } // 2. 有過有必要,保存當前canvas。 final int viewFlags = mViewFlags; if (!verticalEdges && !horizontalEdges) { // 3. 繪制View的內容。 if (!dirtyOpaque) onDraw(canvas); // 4. 繪制子View。 dispatchDraw(canvas); drawAutofilledHighlight(canvas); // Overlay is part of the content and draws beneath Foreground if (mOverlay != null && !mOverlay.isEmpty()) { mOverlay.getOverlayView().dispatchDraw(canvas); } // 6. 繪制裝飾,如滾動條等等。 onDrawForeground(canvas); // we're done... return; } } /** * 1.繪制View背景 */ private void drawBackground(Canvas canvas) { //獲取背景 final Drawable background = mBackground; if (background == null) { return; } setBackgroundBounds(); //獲取便宜值scrollX和scrollY,如果scrollX和scrollY都不等於0,則會在平移后的canvas上面繪制背景。 final int scrollX = mScrollX; final int scrollY = mScrollY; if ((scrollX | scrollY) == 0) { background.draw(canvas); } else { canvas.translate(scrollX, scrollY); background.draw(canvas); canvas.translate(-scrollX, -scrollY); } } /** * 3.繪制View的內容,該方法是一個空的實現,在各個業務當中自行處理。 */ protected void onDraw(Canvas canvas) { } /** * 4. 繪制子View。該方法在View當中是一個空的實現,在各個業務當中自行處理。 * 在ViewGroup當中對dispatchDraw方法做了實現,主要是遍歷子View,並調用子類的draw方法,一般我們不需要自己重寫該方法。 */ protected void dispatchDraw(Canvas canvas) { }
3. 自定義組合控件
自定義組合控件就是將多個控件組合成為一個新的控件,主要解決多次重復使用同一類型的布局。如我們頂部的HeaderView以及dailog等,我們都可以把他們組合成一個新的控件。
我們通過一個自定義HeaderView實例來了解自定義組合控件的用法。
1. 編寫布局文件
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:id="@+id/header_root_layout" android:layout_height="45dp" android:background="#827192"> <ImageView android:id="@+id/header_left_img" android:layout_width="45dp" android:layout_height="45dp" android:layout_alignParentLeft="true" android:paddingLeft="12dp" android:paddingRight="12dp" android:src="@drawable/back" android:scaleType="fitCenter"/> <TextView android:id="@+id/header_center_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:lines="1" android:maxLines="11" android:ellipsize="end" android:text="title" android:textStyle="bold" android:textColor="#ffffff"/> <ImageView android:id="@+id/header_right_img" android:layout_width="45dp" android:layout_height="45dp" android:layout_alignParentRight="true" android:src="@drawable/add" android:scaleType="fitCenter" android:paddingRight="12dp" android:paddingLeft="12dp"/> </RelativeLayout>
布局很簡單,中間是title的文字,左邊是返回按鈕,右邊是一個添加按鈕。
2. 實現構造方法
//因為我們的布局采用RelativeLayout,所以這里繼承RelativeLayout。 //關於各個構造方法的介紹可以參考前面的內容 public class YFHeaderView extends RelativeLayout { public YFHeaderView(Context context) { super(context); } public YFHeaderView(Context context, AttributeSet attrs) { super(context, attrs); } public YFHeaderView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } }
3. 初始化UI
//初始化UI,可根據業務需求設置默認值。 private void initView(Context context) { LayoutInflater.from(context).inflate(R.layout.view_header, this, true); img_left = (ImageView) findViewById(R.id.header_left_img); img_right = (ImageView) findViewById(R.id.header_right_img); text_center = (TextView) findViewById(R.id.header_center_text); layout_root = (RelativeLayout) findViewById(R.id.header_root_layout); layout_root.setBackgroundColor(Color.BLACK); text_center.setTextColor(Color.WHITE); }
4. 提供對外的方法
可以根據業務需求對外暴露一些方法。
//設置標題文字的方法 private void setTitle(String title) { if (!TextUtils.isEmpty(title)) { text_center.setText(title); } } //對左邊按鈕設置事件的方法 private void setLeftListener(OnClickListener onClickListener) { img_left.setOnClickListener(onClickListener); } //對右邊按鈕設置事件的方法 private void setRightListener(OnClickListener onClickListener) { img_right.setOnClickListener(onClickListener); }
5. 在布局當中引用該控件
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <com.example.yf.view.YFHeaderView android:layout_width="match_parent" android:layout_height="45dp"> </com.example.yf.view.YFHeaderView> </LinearLayout>
到這里基本的功能已經有了。除了這些基礎功能外,我們還可以做一些功能擴展,比如可以在布局時設置我的View顯示的元素,因為可能有些需求並不需要右邊的按鈕。這時候就需要用到自定義屬性來解決了。
前面已經簡單介紹過自定義屬性的相關知識,我們之間看代碼
1.首先在values目錄下創建attrs.xml
內容如下:
<resources> <declare-styleable name="HeaderBar"> <attr name="title_text_clolor" format="color"></attr> <attr name="title_text" format="string"></attr> <attr name="show_views"> <flag name="left_text" value="0x01" /> <flag name="left_img" value="0x02" /> <flag name="right_text" value="0x04" /> <flag name="right_img" value="0x08" /> <flag name="center_text" value="0x10" /> <flag name="center_img" value="0x20" /> </attr> </declare-styleable> </resources>
這里我們定義了三個屬性,文字內容、顏色以及要顯示的元素。
2.在java代碼中進行設置
private void initAttrs(Context context, AttributeSet attrs) { TypedArray mTypedArray = context.obtainStyledAttributes(attrs, R.styleable.HeaderBar); //獲取title_text屬性 String title = mTypedArray.getString(R.styleable.HeaderBar_title_text); if (!TextUtils.isEmpty(title)) { text_center.setText(title); } //獲取show_views屬性,如果沒有設置時默認為0x26 showView = mTypedArray.getInt(R.styleable.HeaderBar_show_views, 0x26); text_center.setTextColor(mTypedArray.getColor(R.styleable.HeaderBar_title_text_clolor, Color.WHITE)); mTypedArray.recycle(); showView(showView); } private void showView(int showView) { //將showView轉換為二進制數,根據不同位置上的值設置對應View的顯示或者隱藏。 Long data = Long.valueOf(Integer.toBinaryString(showView)); element = String.format("%06d", data); for (int i = 0; i < element.length(); i++) { if(i == 0) ; if(i == 1) text_center.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE); if(i == 2) img_right.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE); if(i == 3) ; if(i == 4) img_left.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE); if(i == 5) ; } }
3.在布局文件中進行設置
<com.example.yf.view.YFHeaderView android:layout_width="match_parent" android:layout_height="45dp" app:title_text="標題" app:show_views="center_text|left_img|right_img"> </com.example.yf.view.YFHeaderView>
OK,到這里整個View基本定義完成。整個YFHeaderView的代碼如下
public class YFHeaderView extends RelativeLayout { private ImageView img_left; private TextView text_center; private ImageView img_right; private RelativeLayout layout_root; private Context context; String element; private int showView; public YFHeaderView(Context context) { super(context); this.context = context; initView(context); } public YFHeaderView(Context context, AttributeSet attrs) { super(context, attrs); this.context = context; initView(context); initAttrs(context, attrs); } public YFHeaderView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); this.context = context; initView(context); initAttrs(context, attrs); } private void initAttrs(Context context, AttributeSet attrs) { TypedArray mTypedArray = context.obtainStyledAttributes(attrs, R.styleable.HeaderBar); String title = mTypedArray.getString(R.styleable.HeaderBar_title_text); if (!TextUtils.isEmpty(title)) { text_center.setText(title); } showView = mTypedArray.getInt(R.styleable.HeaderBar_show_views, 0x26); text_center.setTextColor(mTypedArray.getColor(R.styleable.HeaderBar_title_text_clolor, Color.WHITE)); mTypedArray.recycle(); showView(showView); } private void showView(int showView) { Long data = Long.valueOf(Integer.toBinaryString(showView)); element = String.format("%06d", data); for (int i = 0; i < element.length(); i++) { if(i == 0) ; if(i == 1) text_center.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE); if(i == 2) img_right.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE); if(i == 3) ; if(i == 4) img_left.setVisibility(element.substring(i,i+1).equals("1")? View.VISIBLE:View.GONE); if(i == 5) ; } } private void initView(final Context context) { LayoutInflater.from(context).inflate(R.layout.view_header, this, true); img_left = (ImageView) findViewById(R.id.header_left_img); img_right = (ImageView) findViewById(R.id.header_right_img); text_center = (TextView) findViewById(R.id.header_center_text); layout_root = (RelativeLayout) findViewById(R.id.header_root_layout); layout_root.setBackgroundColor(Color.BLACK); text_center.setTextColor(Color.WHITE); img_left.setOnClickListener(new OnClickListener() { @Override public void onClick(View view) { Toast.makeText(context, element + "", Toast.LENGTH_SHORT).show(); } }); } private void setTitle(String title) { if (!TextUtils.isEmpty(title)) { text_center.setText(title); } } private void setLeftListener(OnClickListener onClickListener) { img_left.setOnClickListener(onClickListener); } private void setRightListener(OnClickListener onClickListener) { img_right.setOnClickListener(onClickListener); } }
4. 繼承系統控件
繼承系統的控件可以分為繼承View子類(如TextVIew等)和繼承ViewGroup子類(如LinearLayout等),根據業務需求的不同,實現的方式也會有比較大的差異。這里介紹一個比較簡單的,繼承自View的實現方式。
業務需求:為文字設置背景,並在布局中間添加一條橫線。
因為這種實現方式會復用系統的邏輯,大多數情況下我們希望復用系統的onMeaseur
和onLayout
流程,所以我們只需要重寫onDraw
方法 。實現非常簡單,話不多說,直接上代碼。
public class LineTextView extends TextView { //定義畫筆,用來繪制中心曲線 private Paint mPaint; /** * 創建構造方法 * @param context */ public LineTextView(Context context) { super(context); init(); } public LineTextView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); init(); } public LineTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { mPaint = new Paint(); mPaint.setColor(Color.BLACK); } //重寫draw方法,繪制我們需要的中間線以及背景 @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int width = getWidth(); int height = getHeight(); mPaint.setColor(Color.BLUE); //繪制方形背景 RectF rectF = new RectF(0,0,width,height); canvas.drawRect(rectF,mPaint); mPaint.setColor(Color.BLACK); //繪制中心曲線,起點坐標(0,height/2),終點坐標(width,height/2) canvas.drawLine(0,height/2,width,height/2,mPaint); } }
對於View的繪制還需要對
Paint()
、canvas
以及Path
的使用有所了解,不清楚的可以稍微了解一下。
這里的實現比較簡單,因為具體實現會與業務環境密切相關,這里只是做一個參考。
5. 直接繼承View
直接繼承View會比上一種實現方復雜一些,這種方法的使用情景下,完全不需要復用系統控件的邏輯,除了要重寫onDraw
外還需要對onMeasure
方法進行重寫。
我們用自定義View來繪制一個正方形。
- 首先定義構造方法,以及做一些初始化操作
ublic class RectView extends View{ //定義畫筆 private Paint mPaint = new Paint(); /** * 實現構造方法 * @param context */ public RectView(Context context) { super(context); init(); } public RectView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); init(); } public RectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { mPaint.setColor(Color.BLUE); } }
- 重寫draw方法,繪制正方形,注意對padding屬性進行設置
/** * 重寫draw方法 * @param canvas */ @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); //獲取各個編劇的padding值 int paddingLeft = getPaddingLeft(); int paddingRight = getPaddingRight(); int paddingTop = getPaddingTop(); int paddingBottom = getPaddingBottom(); //獲取繪制的View的寬度 int width = getWidth()-paddingLeft-paddingRight; //獲取繪制的View的高度 int height = getHeight()-paddingTop-paddingBottom; //繪制View,左上角坐標(0+paddingLeft,0+paddingTop),右下角坐標(width+paddingLeft,height+paddingTop) canvas.drawRect(0+paddingLeft,0+paddingTop,width+paddingLeft,height+paddingTop,mPaint); }
之前我們講到過View的measure
過程,再看一下源碼對這一步的處理
public static int getDefaultSize(int size, int measureSpec) { int result = size; int specMode = MeasureSpec.getMode(measureSpec); int specSize = MeasureSpec.getSize(measureSpec); switch (specMode) { case MeasureSpec.UNSPECIFIED: result = size; break; case MeasureSpec.AT_MOST: case MeasureSpec.EXACTLY: result = specSize; break; } return result; }
在View的源碼當中並沒有對AT_MOST
和EXACTLY
兩個模式做出區分,也就是說View在wrap_content
和match_parent
兩個模式下是完全相同的,都會是match_parent
,顯然這與我們平時用的View不同,所以我們要重寫onMeasure
方法。
- 重寫
onMeasure
方法
/** * 重寫onMeasure方法 * * @param widthMeasureSpec * @param heightMeasureSpec */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); //處理wrap_contentde情況 if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) { setMeasuredDimension(300, 300); } else if (widthMode == MeasureSpec.AT_MOST) { setMeasuredDimension(300, heightSize); } else if (heightMode == MeasureSpec.AT_MOST) { setMeasuredDimension(widthSize, 300); } }
整個自定義View的代碼如下:
public class RectView extends View { //定義畫筆 private Paint mPaint = new Paint(); /** * 實現構造方法 * * @param context */ public RectView(Context context) { super(context); init(); } public RectView(Context context, @Nullable AttributeSet attrs) { super(context, attrs); init(); } public RectView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { mPaint.setColor(Color.BLUE); } /** * 重寫onMeasure方法 * * @param widthMeasureSpec * @param heightMeasureSpec */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) { setMeasuredDimension(300, 300); } else if (widthMode == MeasureSpec.AT_MOST) { setMeasuredDimension(300, heightSize); } else if (heightMode == MeasureSpec.AT_MOST) { setMeasuredDimension(widthSize, 300); } } /** * 重寫draw方法 * * @param canvas */ @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); //獲取各個編劇的padding值 int paddingLeft = getPaddingLeft(); int paddingRight = getPaddingRight(); int paddingTop = getPaddingTop(); int paddingBottom = getPaddingBottom(); //獲取繪制的View的寬度 int width = getWidth() - paddingLeft - paddingRight; //獲取繪制的View的高度 int height = getHeight() - paddingTop - paddingBottom; //繪制View,左上角坐標(0+paddingLeft,0+paddingTop),右下角坐標(width+paddingLeft,height+paddingTop) canvas.drawRect(0 + paddingLeft, 0 + paddingTop, width + paddingLeft, height + paddingTop, mPaint); } }
整個過程大致如下,直接繼承View時需要有幾點注意:
1、在onDraw當中對padding屬性進行處理。
2、在onMeasure過程中對wrap_content屬性進行處理。
3、至少要有一個構造方法。
6. 繼承ViewGroup
自定義ViewGroup的過程相對復雜一些,因為除了要對自身的大小和位置進行測量之外,還需要對子View的測量參數負責。
需求實例
實現一個類似於Viewpager的可左右滑動的布局。
代碼比較多,我們結合注釋分析。
public class HorizontaiView extends ViewGroup { private int lastX; private int lastY; private int currentIndex = 0; private int childWidth = 0; private Scroller scroller; private VelocityTracker tracker; /** * 1.創建View類,實現構造函數 * 實現構造方法 * @param context */ public HorizontaiView(Context context) { super(context); init(context); } public HorizontaiView(Context context, AttributeSet attrs) { super(context, attrs); init(context); } public HorizontaiView(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context); } private void init(Context context) { scroller = new Scroller(context); tracker = VelocityTracker.obtain(); } /** * 2、根據自定義View的繪制流程,重寫`onMeasure`方法,注意對wrap_content的處理 * 重寫onMeasure方法 * @param widthMeasureSpec * @param heightMeasureSpec */ @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); //獲取寬高的測量模式以及測量值 int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); //測量所有子View measureChildren(widthMeasureSpec, heightMeasureSpec); //如果沒有子View,則View大小為0,0 if (getChildCount() == 0) { setMeasuredDimension(0, 0); } else if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) { View childOne = getChildAt(0); int childWidth = childOne.getMeasuredWidth(); int childHeight = childOne.getMeasuredHeight(); //View的寬度=單個子View寬度*子View個數,View的高度=子View高度 setMeasuredDimension(getChildCount() * childWidth, childHeight); } else if (widthMode == MeasureSpec.AT_MOST) { View childOne = getChildAt(0); int childWidth = childOne.getMeasuredWidth(); //View的寬度=單個子View寬度*子View個數,View的高度=xml當中設置的高度 setMeasuredDimension(getChildCount() * childWidth, heightSize); } else if (heightMode == MeasureSpec.AT_MOST) { View childOne = getChildAt(0); int childHeight = childOne.getMeasuredHeight(); //View的寬度=xml當中設置的寬度,View的高度=子View高度 setMeasuredDimension(widthSize, childHeight); } } /** * 3、接下來重寫`onLayout`方法,對各個子View設置位置。 * 設置子View位置 * @param changed * @param l * @param t * @param r * @param b */ @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int childCount = getChildCount(); int left = 0; View child; for (int i = 0; i < childCount; i++) { child = getChildAt(i); if (child.getVisibility() != View.GONE) { childWidth = child.getMeasuredWidth(); child.layout(left, 0, left + childWidth, child.getMeasuredHeight()); left += childWidth; } } } }
到這里我們的View布局就已經基本結束了。但是要實現Viewpager
的效果,還需要添加對事件的處理。事件的處理流程之前我們有分析過,在制作自定義View的時候也是會經常用到的,不了解的可以參考之前的文章Android Touch事件分發超詳細解析。
/** * 4、因為我們定義的是ViewGroup,從onInterceptTouchEvent開始。 * 重寫onInterceptTouchEvent,對橫向滑動事件進行攔截 * @param event * @return */ @Override public boolean onInterceptTouchEvent(MotionEvent event) { boolean intercrpt = false; //記錄當前點擊的坐標 int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_MOVE: int deltaX = x - lastX; int delatY = y - lastY; //當X軸移動的絕對值大於Y軸移動的絕對值時,表示用戶進行了橫向滑動,對事件進行攔截 if (Math.abs(deltaX) > Math.abs(delatY)) { intercrpt = true; } break; } lastX = x; lastY = y; //intercrpt = true表示對事件進行攔截 return intercrpt; } /** * 5、當ViewGroup攔截下用戶的橫向滑動事件以后,后續的Touch事件將交付給`onTouchEvent`進行處理。 * 重寫onTouchEvent方法 * @param event * @return */ @Override public boolean onTouchEvent(MotionEvent event) { tracker.addMovement(event); //獲取事件坐標(x,y) int x = (int) event.getX(); int y = (int) event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_MOVE: int deltaX = x - lastX; int delatY = y - lastY; //scrollBy方法將對我們當前View的位置進行偏移 scrollBy(-deltaX, 0); break; //當產生ACTION_UP事件時,也就是我們抬起手指 case MotionEvent.ACTION_UP: //getScrollX()為在X軸方向發生的便宜,childWidth * currentIndex表示當前View在滑動開始之前的X坐標 //distance存儲的就是此次滑動的距離 int distance = getScrollX() - childWidth * currentIndex; //當本次滑動距離>View寬度的1/2時,切換View if (Math.abs(distance) > childWidth / 2) { if (distance > 0) { currentIndex++; } else { currentIndex--; } } else { //獲取X軸加速度,units為單位,默認為像素,這里為每秒1000個像素點 tracker.computeCurrentVelocity(1000); float xV = tracker.getXVelocity(); //當X軸加速度>50時,也就是產生了快速滑動,也會切換View if (Math.abs(xV) > 50) { if (xV < 0) { currentIndex++; } else { currentIndex--; } } } //對currentIndex做出限制其范圍為【0,getChildCount() - 1】 currentIndex = currentIndex < 0 ? 0 : currentIndex > getChildCount() - 1 ? getChildCount() - 1 : currentIndex; //滑動到下一個View smoothScrollTo(currentIndex * childWidth, 0); tracker.clear(); break; } lastX = x; lastY = y; return true; } private void smoothScrollTo(int destX, int destY) { //startScroll方法將產生一系列偏移量,從(getScrollX(), getScrollY()),destX - getScrollX()和destY - getScrollY()為移動的距離 scroller.startScroll(getScrollX(), getScrollY(), destX - getScrollX(), destY - getScrollY(), 1000); //invalidate方法會重繪View,也就是調用View的onDraw方法,而onDraw又會調用computeScroll()方法 invalidate(); } //重寫computeScroll方法 @Override public void computeScroll() { super.computeScroll(); //當scroller.computeScrollOffset()=true時表示滑動沒有結束 if (scroller.computeScrollOffset()) { //調用scrollTo方法進行滑動,滑動到scroller當中計算到的滑動位置 scrollTo(scroller.getCurrX(), scroller.getCurrY()); //沒有滑動結束,繼續刷新View postInvalidate(); } }
這部分代碼比較多,為了方便閱讀,在代碼當中進行了注釋。
之后就是在XML代碼當中引入自定義View
<com.example.yf.view.HorizontaiView android:id="@+id/test_layout" android:layout_width="match_parent" android:layout_height="400dp"> <ListView android:id="@+id/list1" android:layout_width="match_parent" android:layout_height="match_parent"> </ListView> <ListView android:id="@+id/list2" android:layout_width="match_parent" android:layout_height="match_parent"> </ListView> <ListView android:id="@+id/list3" android:layout_width="match_parent" android:layout_height="match_parent"> </ListView> </com.example.yf.view.HorizontaiView>
好了,可以運行看一下效果了。
總結
本篇文章對常用的自定義View的方式進行了總結,並簡單分析了View的繪制流程。對各種實現方式寫了簡單的實現。
作者:銀灬楓
鏈接:https://www.jianshu.com/p/705a6cb6bfee
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯系作者獲得授權並注明出處。