在本章中主要介紹兩方面的內容,首先介紹View的工作原理,接着介紹自定義View的實現方式。在Android的知識體系中, View扮演着很重要的角色,簡單來理解, View是 Android在視覺上的呈現。在界面上Android提供了一套GUI庫,裏面有很多控件,但是,很多時候我們並不滿足於系統提供的控件,因爲這樣就意味這應用界面的同類化比較嚴重。那麼怎麼才能做出與衆不同的效果呢?答案是自定義View,也可以叫自定義控件,通過自定義View我們可以實現各種五花八門的效果。但是自定義View是有一定難度的,尤其是,複雜的自定義View,大部分時候我們僅僅瞭解基本控件的使用方法是無法做出複雜的自定,義控件的。爲了更好地自定義View,還需要掌握View的底層工作原理,比如View的測量,流程、佈局流程以及繪製流程,掌握這幾個基本流程後,我們就對View的底層更加了解,這樣我們就可以做出一個比較完善的自定義View。
除了View的三大流程以外, View常見的回調方法也是需要熟練掌握的,比如構造方法、onAttach,onVisibilityChanged、onDetach等。另外對於一些具有滑動效果的自定義View,我們還需要處理View的滑動,如果遇到滑動衝突就還需要解決相應的滑動衝突,關於滑動和滑動衝突這一塊內容已經在第3章中進行了全面介紹。自定義View的實現看起來很複雜,實際上說簡單也簡單。總結來說,自定義View是有幾種固定類型的,有的直接繼承自View和ViewGroup,而有的則選擇繼承現有的系統控件,這些都可以,關鍵是要選擇最適合當前需要的方式,選對自定義View的實現方式可以起到事半功倍的效果,下面就圍繞着這些,話題一一展開。
4.1初識ViewRoot和DecorView
在正式介紹View的三大流程之前,我們必須先介紹一些基本概念,這樣才能更好地理解View的measure, layout和draw過程,本節主要介紹ViewRoot和DecorView的概念。
ViewRoot對應於ViewRootlmpl類,它是連接WindowManager和DecorView的紐帶, View的三大流程均是通過ViewRoot來完成的。在ActivityThread中,當Activity對象被創建完畢後,會將DecorView添加到Window中,同時會創建ViewRootlmpl對象,並將 ViewRootlmpl對象和DecorView建立關聯,這個過程可參看如下源碼:
View的繪製流程是從ViewRoot的performTraversals方法開始的,它經過measure. layout和draw三個過程才能最終將一個View繪製出來,其中measure用來測量View的寬。和高, layout用來確定View在父容器中的放置位置,而draw則負責將View繪製在屏幕上。針對performTraversals的大致流程,可用流程圖4-1來表示。
如圖4-1所示, performTraversals會依次調用performMeasure, performLayout和 performDraw三個方法,這三個方法分別完成頂級View的measure, layout和draw這三大,流程,其中在performMeasure中會調用measure方法,在measure方法中又會調用onMeasure方法,在onMeasure方法中則會對所有的子元素進行measure過程,這個時候measure流程,就從父容器傳遞到子元素中了,這樣就完成了一次measure過程。接着子元素會重複父容器的measure過程,如此反覆就完成了整個View樹的遍歷。同理, performLayout和 performDraw的傳遞流程和performMeasure是類似的,唯一不同的是, performDraw的傳遞過程是在draw方法中通過dispatchDraw來實現的,不過這並沒有本質區別。
measure過程決定了View的寬/高, Measure完成以後,可以通過getMeasuredWidth和 getMeasuredHeight方法來獲取到View測量後的寬/高,在幾乎所有的情況下它都等同於, View最終的寬/高,但是特殊情況除外,這點在本章後面會進行說明。Layout過程決定了 View的四個頂點的座標和實際的View的寬/高,完成以後,可以通過getTop. getBottom、 getLeft和getRight來拿到View的四個頂點的位置,並可以通過getWidth和getHeight方法來拿到View的最終寬/高。Draw過程則決定了View的顯示,只有draw方法完成以後View的內容才能呈現在屏幕上。
如圖4-2所示, DecorView作爲頂級View,一般情況下它內部會包含一個豎直方向的, LinearLayout,在這個LinearLayout裏面有上下兩個部分(具體情況和Android版本及主題,有關),上面是標題欄,下面是內容欄。在Activity中我們通過setContentView所設置的佈局文件其實就是被加到內容欄之中的,而內容欄的id是content,因此可以理解爲Activity指定佈局的方法不叫setview而叫setContentView,因爲我們的佈局的確加到了id爲content的FrameLayout中。如何得到content呢?可以這樣: ViewGroup content- findViewByld (R.android.id.content)。如何得到我們設置的View呢?可以這樣: content.getChildAt(0)。同時,通過源碼我們可以知道, DecorView其實是一個FrameLayout, View層的事件都先經過DecorView,然後才傳遞給我們的View。
4.2 理解MeasureSpec
爲了更好地理解View的測量過程,我們還需要理解MeasureSpec。從名字上來看, MeasureSpec看起來像“測量規格”或者“測量說明書”,不管怎麼翻譯,它看起來都好像,是或多或少地決定了View的測量過程。通過源碼可以發現, MeasureSpec的確參與了View的measure過程。讀者可能有疑問, MeasureSpec是幹什麼的呢?確切來說, MeasureSpec在很大程度上決定了一個View的尺寸規格,之所以說是很大程度上是因爲這個過程還受父,容器的影響,因爲父容器影響View的MeasureSpec的創建過程。在測量過程中,系統會將 View的LayoutParams根據父容器所施加的規則轉換成對應的MeasureSpec,然後再根據這,一個measureSpec來測量出View的寬/高。上面提到過,這裏的寬/高是測量寬/高,不一定等,於View的最終寬/高。MeasureSpec看起來有點複雜,其實它的實現是很簡單的,下面會詳,細地分析MeasureSpec.
4.2.1 MeasureSpec MeasureSpec代表一個32位int值,高2位代表SpecMode,低30位代表SpecSize, SpecMode是指測量模式,而SpecSize是指在某種測量模式下的規格大小。下面先看一下, MeasureSpec內部的一些常量的定義,通過下面的代碼,應該不難理解MeasureSpec的工作,原理:
MeasureSpec通過將SpecMode和SpecSize打包成一個int值來避免過多的對象內存分配,爲了方便操作,其提供了打包和解包方法。SpecMode和SpecSize也是一個int值,一組SpecMode和SpecSize可以打包爲一個MeasureSpec,而一個MeasureSpec可以通過解包的形式來得出其原始的SpecMode和SpecSize,需要注意的是這裏提到的MeasureSpec是指 MeasureSpec所代表的int值,而並非MeasureSpec本身。
SpecMode有三類,每一類都表示特殊的含義,如下所示。
UNSPECIFIED
父容器不對View有任何限制,要多大給多大,這種情況一般用於系統內部,表示一種 ,測量的狀態。
EXACTLY
父容器已經檢測出View所需要的精確大小,這個時候View的最終大小就是SpecSize所指定的值。它對應於LayoutParams中的match parent和具體的數值這兩種模式。
AT MOST
父容器指定了一個可用大小即SpecSize, View的大小不能大於這個值,具體是什麼,值要看不同View的具體實現。它對應於LayoutParams中的wrap-content.
4.2.2 MeasureSpec和LayoutParams的對應關係
上面提到,系統內部是通過MeasureSpec來進行View的測量,但是正常情況下我們使用View指定MeasureSpec,儘管如此,但是我們可以給View設置LayoutParams。在View測量的時候,系統會將LayoutParams在父容器的約束下轉換成對應的MeasureSpec,然後再根據這個MeasureSpec來確定View測量後的寬/高。需要注意的是, MeasureSpec不是唯一由LavoutParams決定的, LavoutParams需要和父容器一起才能決定View的MeasureSpec,從而進一步決定View的寬/高。另外,對於頂級View (即DecorView)和普通View來說, MeasureSpec的轉換過程略有不同。對於DecorView,其MeasureSpec由窗口的尺寸和其自身的LayoutParams來共同確定;對於普通View,其MeasureSpec由父容器的MeasureSpec和自身的LayoutParams來共同決定, MeasureSpec一旦確定後, onMeasure中就可以確定View的測量寬/高。
對於DecorView來說,在ViewRootlmpl中的measureHierarchy方法中有如下一段代碼,它展示了DecorView的MeasureSpec的創建過程,其中desiredWindowWidth和desired WindowHeight是屏幕的尺寸:
childWidthMeasureSpec = getRootMeasureSpec(baseSize, lp.width); childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height); performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
接着再看一下getRootMeasureSpec方法的實現:
private static int getRootMeasureSpec(int windowSize, int rootDimension) { int measureSpec; switch (rootDimension) { case ViewGroup.LayoutParams.MATCH_PARENT: // Window can't resize. Force root view to be windowSize. measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY); break; case ViewGroup.LayoutParams.WRAP_CONTENT: // Window can resize. Set max size for root view. measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST); break; default: // Window wants to be an exact size. Force root view to be that size. measureSpec = MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY); break; } return measureSpec; }
通過上述代碼, DecorView的MeasureSpec的產生過程就很明確了,具體來說其遵守如下規則,根據它的LayoutParams中的寬/高的參數來劃分。
LayoutParams.MATCH PARENT:精確模式,大小就是窗口的大小;
LayoutParams. WRAP CONTENT:最大模式,大小不定,但是不能超過窗口的大小;
固定大小(比如100dp):精確模式,大小爲LayoutParams中指定的大小。對於普通View來說,這裏是指我們佈局中的View, View的measure過程由ViewGroup傳遞而來,先看一下ViewGroup的measureChildWithMargins方法:
protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) { final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin + widthUsed, lp.width); final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin + heightUsed, lp.height); child.measure(childWidthMeasureSpec, childHeightMeasureSpec); }
上述方法會對子元素進行measure,在調用子元素的measure方法之前會先通過getChildMeasureSpec方法來得到子元素的MeasureSpec。從代碼來看,很顯然,子元素的 MeasureSpec的創建與父容器的MeasureSpec和子元素本身的LayoutParams有關,此外還和View的margin及padding有關,具體情況可以看一下ViewGroup的getChildMeasureSpec方法,如下所示。
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) { // Parent has imposed an exact size on us case MeasureSpec.EXACTLY: if (childDimension >= 0) { resultSize = childDimension; resultMode = MeasureSpec.EXACTLY; } else if (childDimension == LayoutParams.MATCH_PARENT) { // Child wants to be our size. So be it. resultSize = size; resultMode = MeasureSpec.EXACTLY; } else if (childDimension == LayoutParams.WRAP_CONTENT) { // Child wants to determine its own size. It can't be // bigger than us. resultSize = size; resultMode = MeasureSpec.AT_MOST; } break; // Parent has imposed a maximum size on us case MeasureSpec.AT_MOST: if (childDimension >= 0) { // Child wants a specific size... so be it resultSize = childDimension; resultMode = MeasureSpec.EXACTLY; } else if (childDimension == LayoutParams.MATCH_PARENT) { // Child wants to be our size, but our size is not fixed. // Constrain child to not be bigger than us. resultSize = size; resultMode = MeasureSpec.AT_MOST; } else if (childDimension == LayoutParams.WRAP_CONTENT) { // Child wants to determine its own size. It can't be // bigger than us. resultSize = size; resultMode = MeasureSpec.AT_MOST; } break; // Parent asked to see how big we want to be case MeasureSpec.UNSPECIFIED: if (childDimension >= 0) { // Child wants a specific size... let him have it resultSize = childDimension; resultMode = MeasureSpec.EXACTLY; } else if (childDimension == LayoutParams.MATCH_PARENT) { // Child wants to be our size... find out how big it should // be resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size; resultMode = MeasureSpec.UNSPECIFIED; } else if (childDimension == LayoutParams.WRAP_CONTENT) { // Child wants to determine its own size.... find out how // big it should be resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size; resultMode = MeasureSpec.UNSPECIFIED; } break; } //noinspection ResourceType return MeasureSpec.makeMeasureSpec(resultSize, resultMode); }
上述方法不難理解,它的主要作用是根據父容器的MeasureSpec同時結合View本身的, . LayoutParams來確定子元素的MeasureSpec,參數中的padding是指父容器中已佔用的空間大小,因此子元素可用的大小爲父容器的尺寸減去padding,具體代碼如下所示。
int specSize = MeasureSpec.getSize(spec); int size = Math.max(0, specSize - padding);
getChildMeasureSpec清楚展示了普通View的MeasureSpec的創建規則,爲了更清晰地 ,理解getChildMeasureSpec的邏輯,這裏提供一個表,表中對getChildMeasureSpec的工作原理進行了梳理,請看錶4-1,注意,表中的parentSize是指父容器中目前可使用的大小。
針對表4-1,這裏再做一下說明。前面已經提到,對於普通View ,其MeasureSpec由父容器的MeasureSpec和自身的LayoutParams來共同決定,那麼針對不同的父容器和View本身不同的LayoutParams, View就可以有多種MeasureSpec。這裏簡單說一下,當View採用固定寬/高的時候,不管父容器的MeasureSpec是什麼, View的MeasureSpec都是精確模式並且其大小遵循Layoutparams中的大小。當View的寬/高是match parent時,如果父容,器的模式是精準模式,那麼View也是精準模式並且其大小是父容器的剩餘空間;如果父容,器是最大模式,那麼View也是最大模式並且其大小不會超過父容器的剩餘空間。當View的寬/高是wrap content時,不管父容器的模式是精準還是最大化, View的模式總是最大化 ,並且大小不能超過父容器的剩餘空間。可能讀者會發現,在我們的分析中漏掉了, UNSPECIFIED模式,那是因爲這個模式主要用於系統內部多次Measure的情形,一般來說,我們不需要關注此模式。
通過表4-1可以看出,只要提供父容器的MeasureSpec和子元素的LayoutParams,就,可以快速地確定出子元素的MeasureSpec了,有了MeasureSpec就可以進一步確定出子元素測量後的大小了。需要說明的是,表4-1並非是什麼經驗總結,它只是getChildMeasureSpec這個方法以表格的方式呈現出來而已。
4.3 View的工作流程
View的工作流程主要是指measure, layout, draw這三大流程,即測量、佈局和繪製,其中measure確定View的測量寬/高, layout確定View的最終寬/高和四個頂點的位置,而 draw則將View繪製到屏幕上。
4.3.1 measure過程
measure過程要分情況來看,如果只是一個原始的View,那麼通過measure方法就完,成了其測量過程,如果是一個ViewGroup,除了完成自己的測量過程外,還會遍歷去調用,所有子元素的measure方法,各個子元素再遞歸去執行這個流程,下面針對這兩種情況分別討論。
1. View的measure過程,
View的measure過程由其measure方法來完成, measure方法是一個final類型的方法,這意味着子類不能重寫此方法,在View的measure方法中會去調用View的onMeasure方法,因此只需要看onMeasure的實現即可, View的onMeasure方法如下所示。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec)); }
上述代碼很簡潔,但是簡潔並不代表簡單, setMeasuredDimension方法會設置View寬/高的測量值,因此我們只需要看getDefaultSize這個方法即可:
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; }
可以看出, getDefaultSize這個方法的邏輯很簡單,對於我們來說,我們只需要看 AT MOST和EXACTLY這兩種情況。簡單地理解,其實getDefaultSize返回的大小就是 measureSpec中的specSize,而這個specSize就是View測量後的大小,這裏多次提到測量後的大小,是因爲View最終的大小是在layout階段確定的,所以這裏必須要加以區分,但是幾乎所有情況下View的測量大小和最終大小是相等的。
至於UNSPECIFIED這種情況,一般用於系統內部的測量過程,在這種情況下, View的大小爲getDefaultSize的第一個參數size,即寬/高分別爲getSuggestedMinimumWidth和! getSuggestedMinimumHeight這兩個方法的返回值,看一下它們的源碼:
protected int getSuggestedMinimumWidth() { return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth()); }
protected int getSuggestedMinimumHeight() { return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight()); }
這裏只分析getSuggestedMinimumWidth方法的實現, getSuggestedMinimumHeight和它的實現原理是一樣的。從getSuggestedMinimumWidth的代碼可以看出,如果View沒有設置背景,那麼View的寬度爲mMinWidth,而mMinWidth對應於android:minWidth這個屬性所指定的值,因此View的寬度即爲android:minWidth屬性所指定的值。這個屬性如果不指定,那麼 mMinWidth則默認爲0;如果View指定了背景,則View的寬度爲max(mMinWidth, mBackground.getMinimumWidth()。 mMinWidth的含義我們已經知道了,那麼mBackground. getMinimumWidth()是什麼呢?我們看一下Drawable的getMinimumWidth方法,如下所示。
public int getMinimumHeight() { final int intrinsicHeight = getIntrinsicHeight(); return intrinsicHeight > 0 ? intrinsicHeight : 0; }
可以看出, getMinimumWidth返回的就是Drawable的原始寬度,前提是這個Drawable,有原始寬度,否則就返回0,那麼Drawable在什麼情況下有原始寬度呢?這裏先舉個例子,說明一下, ShapeDrawable無原始寬/高,而BitmapDrawable有原始寬/高(圖片的尺寸),詳細內容會在第6章進行介紹。
這裏再總結一下getSuggestedMinimumWidth的邏輯:如果View沒有設置背景,那麼返回android:minWidth這個屬性所指定的值,這個值可以爲0;如果View設置了背景,則返回android:minWidth和背景的最小寬度這兩者中的最大值, getSuggestedMinimumWidth和getSuggestedMinimumHeight的返回值就是View在UNSPECIFIED情況下的測量寬高。
從getDefaultSize方法的實現來看, View的寬/高由specSize決定,所以我們可以得出,如下結論:直接繼承View的自定義控件需要重寫onMeasure方法並設置wrap content時的自身大小,否則在佈局中使用wrap content就相當於使用match parent,爲什麼呢?這個原因需要結合上述代碼和表4-1才能更好地理解。從上述代碼中我們知道,如果View在佈局中使用wrap-content,那麼它的specMode是AT-MOST模式,在這種模式下,它的寬/高等於specSize;查表4-1可知,這種情況下View的specSize是parentSize,而parentSize是父 , 容器中目前可以使用的大小,也就是父容器當前剩餘的空間大小。很顯然, View的寬/高就,等於父容器當前剩餘的空間大小,這種效果和在佈局中使用match parent完全一致。如何解決這個問題呢?也很簡單,代碼如下所示。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); if (widthSpecMode==MeasureSpec.AT_MOST && heightSpecMode==MeasureSpec.AT_MOST){ setMeasuredDimension(mWidth,mHeight); }else if (widthSpecMode==MeasureSpec.AT_MOST){ setMeasuredDimension(mWidth,heightSpecSize); }else if(heightSpecMode==MeasureSpec.AT_MOST){ setMeasuredDimension(widthSpecSize,mHeight); } }
1在上面的代碼中,我們只需要給View指定一個默認的內部寬/高(mWidth和mHeight),並在wrap content時設置此寬/高即可。對於非wrap content情形,我們沿用系統的測量值,即可,至於這個默認的內部寬/高的大小如何指定,這個沒有固定的依據,根據需要靈活指定即可。如果查看TextView, ImageView等的源碼就可以知道,針對wrap content情形,它們的onMeasure方法均做了特殊處理,讀者可以自行查看它們的源碼。
2. ViewGroup的measure過程
對於ViewGroup來說,除了完成自己的measure過程以外,還會遍歷去調用所有子元素的measure方法,各個子元素再遞歸去執行這個過程。和View不同的是, ViewGroup是一個抽象類,因此它沒有重寫View的onMeasure方法,但是它提供了一個叫measureChildren的方法,如下所示。
protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) { final int size = mChildrenCount; final View[] children = mChildren; for (int i = 0; i < size; ++i) { final View child = children[i]; if ((child.mViewFlags & VISIBILITY_MASK) != GONE) { measureChild(child, widthMeasureSpec, heightMeasureSpec); } } }
從上述代碼來看,ViewGroup在measure時,會對每一個子元素進行measure,measureChild這個方法的實現也很好理解,如下所示。
protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) { final LayoutParams lp = child.getLayoutParams(); final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, mPaddingLeft + mPaddingRight, lp.width); final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, mPaddingTop + mPaddingBottom, lp.height); child.measure(childWidthMeasureSpec, childHeightMeasureSpec); }
很顯然, measureChild的思想就是取出子元素的LayoutParams ,然後再通過 getChildMeasureSpec來創建子元素的MeasureSpec,接着將MeasureSpec直接傳遞給View的measure方法來進行測量。getChildMeasureSpec的工作過程已經在上面進行了詳細分析,通過表4-1可以更清楚地瞭解它的邏輯。
我們知道, ViewGroup並沒有定義其測量的具體過程,這是因爲ViewGroup是一個抽象類,其測量過程的onMeasure方法需要各個子類去具體實現,比如LinearLayout、 RelativeLayout等,爲什麼ViewGroup不像View一樣對其onMeasure方法做統一的實現呢?那是因爲不同的ViewGroup子類有不同的佈局特性,這導致它們的測量細節各不相同,比如LinearLayout和RelativeLayout這兩者的佈局特性顯然不同,因此ViewGroup無法做統一實現。下面就通過LinearLayout的onMeasure方法來分析ViewGroup的measure過程,其他Layout類型讀者可以自行分析。
首先來看LinearLayout的onMeasure方法,如下所示。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (mOrientation == VERTICAL) { measureVertical(widthMeasureSpec, heightMeasureSpec); } else { measureHorizontal(widthMeasureSpec, heightMeasureSpec); } }
上訴代碼很簡單,我們選擇一個來看一下,比如選擇查看豎直佈局的LinearLayout的測量過程,即measureVertical方法,measureVertical的源碼比較長,下面只描述其大概邏輯,首先看一段代碼:
for (int i = 0; i < count; ++i) { final View child = getVirtualChildAt(i); if (child == null) { mTotalLength += measureNullChild(i); continue; } if (child.getVisibility() == View.GONE) { i += getChildrenSkipCount(child, i); continue; } nonSkippedChildCount++; if (hasDividerBeforeChildAt(i)) { mTotalLength += mDividerHeight; } final LayoutParams lp = (LayoutParams) child.getLayoutParams(); totalWeight += lp.weight; final boolean useExcessSpace = lp.height == 0 && lp.weight > 0; if (heightMode == MeasureSpec.EXACTLY && useExcessSpace) { // Optimization: don't bother measuring children who are only // laid out using excess space. These views will get measured // later if we have space to distribute. final int totalLength = mTotalLength; mTotalLength = Math.max(totalLength, totalLength + lp.topMargin + lp.bottomMargin); skippedMeasure = true; } else { if (useExcessSpace) { // The heightMode is either UNSPECIFIED or AT_MOST, and // this child is only laid out using excess space. Measure // using WRAP_CONTENT so that we can find out the view's // optimal height. We'll restore the original height of 0 // after measurement. lp.height = LayoutParams.WRAP_CONTENT; } // Determine how big this child would like to be. If this or // previous children have given a weight, then we allow it to // use all available space (and we will shrink things later // if needed). final int usedHeight = totalWeight == 0 ? mTotalLength : 0; measureChildBeforeLayout(child, i, widthMeasureSpec, 0, heightMeasureSpec, usedHeight); final int childHeight = child.getMeasuredHeight(); if (useExcessSpace) { // Restore the original height and record how much space // we've allocated to excess-only children so that we can // match the behavior of EXACTLY measurement. lp.height = 0; consumedExcessSpace += childHeight; } final int totalLength = mTotalLength; mTotalLength = Math.max(totalLength, totalLength + childHeight + lp.topMargin + lp.bottomMargin + getNextLocationOffset(child));
從上面這段代碼可以看出,系統會遍歷子元素並對每個子元素執行measureChild BeforeLayout方法,這個方法內部會調用子元素的measure方法,這樣各個子元素就開始依次進入measure過程,並且系統會通過mTotalLength這個變量來存儲LinearLayout在豎直方向的初步高度。每測量一個子元素, mTotalLength就會增加,增加的部分主要包括了子元素的高度以及子元素在豎直方向上的margin等。當子元素測量完畢後, LinearLayout會測量自己的大小,源碼如下所示。
mTotalLength += mPaddingTop + mPaddingBottom; int heightSize = mTotalLength; // Check against our minimum height heightSize = Math.max(heightSize, getSuggestedMinimumHeight()); // Reconcile our calculated size with the heightMeasureSpec int heightSizeAndState = resolveSizeAndState(heightSize, heightMeasureSpec, 0); heightSize = heightSizeAndState & MEASURED_SIZE_MASK; ... setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childState), heightSizeAndState);
這裏對上述代碼進行說明,當子元素測量完畢後, LinearLayout會根據子元素的情況,來測量自己的大小。針對豎直的LinearLayout而言,它在水平方向的測量過程遵循View的,測量過程,在豎直方向的測量過程則和View有所不同。具體來說是指,如果它的佈局中高, , 度採用的是match parent或者具體數值,那麼它的測量過程和View一致,即高度爲 specSize;如果它的佈局中高度採用的是wrap content,那麼它的高度是所有子元素所佔用,的高度總和,但是仍然不能超過它的父容器的剩餘空間,當然它的最終高度還需要考慮其在豎直方向的padding,這個過程可以進一步參看如下源碼:
public static int resolveSizeAndState(int size, int measureSpec, int childMeasuredState) { final int specMode = MeasureSpec.getMode(measureSpec); final int specSize = MeasureSpec.getSize(measureSpec); final int result; switch (specMode) { case MeasureSpec.AT_MOST: if (specSize < size) { result = specSize | MEASURED_STATE_TOO_SMALL; } else { result = size; } break; case MeasureSpec.EXACTLY: result = specSize; break; case MeasureSpec.UNSPECIFIED: default: result = size; } return result | (childMeasuredState & MEASURED_STATE_MASK); }
View的measure過程是三大流程中最複雜的一個, measure完成以後,通過getMeasured Width/Height方法就可以正確地獲取到View的測量寬/高。需要注意的是,在某些極端情況,下,系統可能需要多次measure才能確定最終的測量寬/高,在這種情形下,在onMeasure方法中拿到的測量寬/高很可能是不準確的。一個比較好的習慣是在onLayout方法中去獲取 View的測量寬/高或者最終寬/高。
上面已經對View的measure過程進行了詳細的分析,現在考慮一種情況,比如我們想,在Activity已啓動的時候就做一件任務,但是這一件任務需要獲取某個View的寬/高。讀!者可能會說,這很簡單啊,在onCreate或者onResume裏面去獲取這個View的寬/高不就,行了?讀者可以自行試一下,實際上在onCreate, onStart, onResume中均無法正確得到某個View的寬/高信息,這是因爲View的measure過程和Activity的生命週期方法不是同步,執行的,因此無法保證Activity執行了onCreate, onStart, onResume時某個View已經測量,完畢了,如果View還沒有測量完畢,那麼獲得的寬/高就是0,有沒有什麼方法能解決這個問題呢?答案是有的,這裏給出四種方法來解決這個問題:
(1) Activity/Viewton WindowFocusChanged. onWindowFocusChanged
這個方法的含義是: View已經初始化完畢了,寬/高已經準備好了,這個時候去獲取寬/高是沒問題的。需要注意的是, onWindowFocusChanged會被調用多次,當Activity的窗口得到焦點和失去焦點時均會被調用一次。具體來說,當Activity繼續執行和暫停執行時, onWindowFocusChanged均會被調用,如果頻繁地進行onResume和onPause,那麼onWindowFocusChanged也會被頻繁地調用。典型代碼如下:
@Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); if (hasFocus){ int width = view.getMeasuredWidth(); int height = view.getMeasuredHeight(); } }
(2)view.post(runnable).
通過post可以將一個runnable投遞到消息隊列的尾部,然後等待Looper調用此runnable的時候,View也已經初始化好了.典型代碼如下:
protected void onStart() { super.onStart(); view.post(new Runnable() { @Override public void run() { int width = view.getMeasuredWidth(); int height = view.getMeasuredHeight(); } }); }
(3)ViewTreeObserver。
使用ViewTreeObserver 的衆多回調可以完成這個功能,比如使用 OnGlobalLayoutListener這個接口,當View樹的狀態發生改變或者View樹內部的View的, ,可見性發現改變時, onGlobalLayout方法將被回調,因此這是獲取View的寬/高一個很好,的時機。需要注意的是,伴隨着View樹的狀態改變等, onGlobalLayout會被調用多次。典,型代碼如下:
protected void onStart() { super.onStart(); ViewTreeObserver observer=view.getViewTreeObserver(); observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { view.getViewTreeObserver().removeGlobalOnLayoutListener(this); int width = view.getMeasuredWidth(); int height = view.getMeasuredHeight(); } }); }
(4) view.measure(int widthMeasureSpec,int heightMeasureSpec)
通過手動對view進行measure來得到View的寬/高。這種方法比較複雜,這裏要分情況處理,根據View的LayoutParams來分:
match parent
直接放棄,無法measure出具體的寬/高。原因很簡單,根據View的measure過程,如表4-1所示,構造此種MeasureSpec需要知道parentSize,即父容器的剩餘空間,而這個時候我們無法知道parentSize的大小,所以理論上不可能測量出View的大小。
具體的數值(dp/px)
比如寬/高都是100px,如下measure:
int widthMeasureSpec = MeasureSpec.makeMeasureSpec(100, MeasureSpec.EXACTLY); int heightMeasureSpec=MeasureSpec.makeMeasureSpec(100,MeasureSpec.EXACTLY); measure(widthMeasureSpec,heightMeasureSpec);
wrap_content
如下 measure:
int widthMeasureSpec = MeasureSpec.makeMeasureSpec((1 <<30)-1, MeasureSpec.AT_MOST); int heightMeasureSpec=MeasureSpec.makeMeasureSpec((1 <<30)-1,MeasureSpec.EXACTLY); measure(widthMeasureSpec,heightMeasureSpec);
注意到(1 <<30)-1,通過分析MeasureSpec的實現可以知道, View的尺寸使用30位二進制表示,也就是說最大是30個1 (即2^30-1),也就是(1 <<30)-1,在最大化模式下, 我們用View理論上能支持的最大值去構造MeasureSpec是合理的。
關於View的measure,網絡上有兩個錯誤的用法。爲什麼說是錯誤的,首先其違背了,系統的內部實現規範(因爲無法通過錯誤的MeasureSpec去得出合法的SpecMode,從而導,致measure過程出錯),其次不能保證一定能measure出正確的結果。
第一種錯誤用法:
int widthMeasureSpec = MeasureSpec.makeMeasureSpec(-1, MeasureSpec.UNSPECIFIED); int heightMeasureSpec=MeasureSpec.makeMeasureSpec(-1,MeasureSpec.UNSPECIFIED); measure(widthMeasureSpec,heightMeasureSpec);
第二種錯誤用法:
measure(AbsListView.LayoutParams.WRAP_CONTENT, AbsListView.LayoutParams.WRAP_CONTENT);
4.3.2 layout過程
Layout的作用是ViewGroup用來確定子元素的位置,當ViewGroup的位置被確定後,它在onLayout中會遍歷所有的子元素並調用其layout方法,在layout方法中onLayout方法又會被調用。Layout過程和measure過程相比就簡單多了, layout方法確定View本身的位置,而onLayout方法則會確定所有子元素的位置,先看View的layout方法,如下所示。
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; boolean changed = isLayoutModeOptical(mParent) ? setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b); if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) { onLayout(changed, l, t, r, b); if (shouldDrawRoundScrollbar()) { if(mRoundScrollbarRenderer == null) { mRoundScrollbarRenderer = new RoundScrollbarRenderer(this); } } else { mRoundScrollbarRenderer = null; } mPrivateFlags &= ~PFLAG_LAYOUT_REQUIRED; ListenerInfo li = mListenerInfo; if (li != null && li.mOnLayoutChangeListeners != null) { ArrayList<OnLayoutChangeListener> listenersCopy = (ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone(); int numListeners = listenersCopy.size(); for (int i = 0; i < numListeners; ++i) { listenersCopy.get(i).onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB); } } } mPrivateFlags &= ~PFLAG_FORCE_LAYOUT; mPrivateFlags3 |= PFLAG3_IS_LAID_OUT; if ((mPrivateFlags3 & PFLAG3_NOTIFY_AUTOFILL_ENTER_ON_LAYOUT) != 0) { mPrivateFlags3 &= ~PFLAG3_NOTIFY_AUTOFILL_ENTER_ON_LAYOUT; notifyEnterOrExitForAutoFillIfNeeded(true); } }
layout方法的大致流程如下:首先會通過setFrame方法來設定View的四個頂點的位置,即初始化mLeft, mRight, mTop和mBottom這四個值, View的四個頂點一旦確定,那麼, View在父容器中的位置也就確定了;接着會調用onLayout方法,這個方法的用途是父容,器確定子元素的位置,和onMeasure方法類似, onLayout的具體實現同樣和具體的佈局有,關,所以View和ViewGroup均沒有真正實現onLayout方法。接下來,我們可以看一下, LinearLayout的onLayout方法,如下所示。
protected void onLayout(boolean changed, int l, int t, int r, int b) { if (mOrientation == VERTICAL) { layoutVertical(l, t, r, b); } else { layoutHorizontal(l, t, r, b); } }
LinearLayout中onLayout的實現邏輯和onMeasure的實現邏輯類似,這裏選擇, " layoutVertical繼續講解,爲了更好地理解其邏輯,這裏只給出了主要的代碼:
void layoutVertical(int left, int top, int right, int bottom) { ....... final int count = getVirtualChildCount(); ....... for (int i = 0; i < count; i++) { final View child = getVirtualChildAt(i); if (child == null) { childTop += measureNullChild(i); } else if (child.getVisibility() != GONE) { final int childWidth = child.getMeasuredWidth(); final int childHeight = child.getMeasuredHeight(); final LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) child.getLayoutParams(); ....... if (hasDividerBeforeChildAt(i)) { childTop += mDividerHeight; } childTop += lp.topMargin; setChildFrame(child, childLeft, childTop + getLocationOffset(child), childWidth, childHeight); childTop += childHeight + lp.bottomMargin + getNextLocationOffset(child); i += getChildrenSkipCount(child, i); } } }
這裏分析一下layoutVertical的代碼邏輯,可以看到,此方法會遍歷所有子元素並調用, setChildFrame方法來爲子元素指定對應的位置,其中childTop會逐漸增大,這就意味着後面的子元素會被放置在靠下的位置,這剛好符合豎直方向的LinearLayout的特性。至於 setChildFrame,它僅僅是調用子元素的layout方法而已,這樣父元素在layout方法中完成自己的定位以後,就通過onLayout方法去調用子元素的layout方法,子元素又會通過自己,的layout方法來確定自己的位置,這樣一層一層地傳遞下去就完成了整個View樹的layout過程。setChildFrame方法的實現如下所示。
private void setChildFrame(View child, int left, int top, int width, int height) { child.layout(left, top, left + width, top + height); }
我們注意到,setChildFrame中的width和height實際上就是子元素的測量寬/高,從下面的代碼可以看出:
final int childWidth = child.getMeasuredWidth(); final int childHeight = child.getMeasuredHeight();
而在layout方法中會通過setFrame去設置子元素的四個頂點的位置,在setFrame中有,如下幾句賦值語句,這樣一來子元素的位置就確定了:
mLeft = left; mTop = top; mRight = right; mBottom = bottom;下面我們來回答一個在4.3.2節中提到的問題: View的測量寬/高和最終/寬高有什麼區別?這個問題可以具體爲: View的getMeasuredWidth和getWidth這兩個方法有什麼區別,至於getMeasuredHeight和getHeight的區別和前兩者完全一樣。爲了回答這個問題,首先,我們看一下getwidth和getHeight這兩個方法的具體實現: 。 ,
public final int getHeight() { return mBottom - mTop; }
public final int getWidth() { return mRight - mLeft; }
從getWidth和getHeight的源碼再結合mLeft, mRight, mTop和mBottom這四個變量的賦值過程來看, getWidth方法的返回值剛好就是View的測量寬度,而getHeight方法的返回值也剛好就是View的測量高度。經過上述分析,現在我們可以回答這個問題了:在 View的默認實現中, View的測量寬/高和最終寬/高是相等的,只不過測量寬/高形成於View的measure過程,而最終寬/高形成於View的layout過程,即兩者的賦值時機不同,測量寬/高的賦值時機稍微早一些。因此,在日常開發中,我們可以認爲View的測量寬/高就等 196第章View的工作原理於最終寬/高,但是的確存在某些特殊情況會導致兩者不一致,下面舉例說明。
如果重寫View的layout方法,代碼如下:
上述代碼會導致在任何情況下View的最終寬/高總是比測量寬/高大100px,雖然這樣,做會導致View顯示不正常並且也沒有實際意義,但是這證明了測量寬/高的確可以不等於最終寬/高。另外一種情況是在某些情況下, View需要多次measure才能確定自己的測量寬/高,在前幾次的測量過程中,其得出的測量寬/高有可能和最終寬/高不一致,但最終來說,測量寬/高還是和最終寬/高相同。
4.3.3 draw過程
Draw過程就比較簡單了,它的作用是將View繪製到屏幕上面。View的繪製過程遵循 ,如下幾步:
(1)繪製背景background.draw(canvas)。
(2)繪製自己(onDraw)。
(3)繪製children (dispatchDraw),
(4)繪製裝飾(onDrawScrollBars),
這一點通過draw方法的源碼可以明顯看出來,如下所示。
public void draw(Canvas canvas) { final int privateFlags = mPrivateFlags; final boolean dirtyOpaque = (privateFlags & PFLAG_DIRTY_MASK) == PFLAG_DIRTY_OPAQUE && (mAttachInfo == null || !mAttachInfo.mIgnoreDirtyState); mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN; /* * Draw traversal performs several drawing steps which must be executed * in the appropriate order: * * 1. Draw the background * 2. If necessary, save the canvas' layers to prepare for fading * 3. Draw view's content * 4. Draw children * 5. If necessary, draw the fading edges and restore layers * 6. Draw decorations (scrollbars for instance) */ // Step 1, draw the background, if needed int saveCount; if (!dirtyOpaque) { drawBackground(canvas); } // skip step 2 & 5 if possible (common case) final int viewFlags = mViewFlags; boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0; boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0; if (!verticalEdges && !horizontalEdges) { // Step 3, draw the content if (!dirtyOpaque) onDraw(canvas); // Step 4, draw the children dispatchDraw(canvas); drawAutofilledHighlight(canvas); // Overlay is part of the content and draws beneath Foreground if (mOverlay != null && !mOverlay.isEmpty()) { mOverlay.getOverlayView().dispatchDraw(canvas); } // Step 6, draw decorations (foreground, scrollbars) onDrawForeground(canvas); // Step 7, draw the default focus highlight drawDefaultFocusHighlight(canvas); if (debugDraw()) { debugDrawFocus(canvas); } // we're done... return; }
View繪製過程的傳遞是通過dispatchDraw來實現的,dispatchDraw會遍歷調用所有子元素的draw方法,如此draw事件就一層層地傳遞了下去,View有一個特殊的方法setWillNotDraw,先看一下他的源碼,如下所示。
/** * If this view doesn't do any drawing on its own, set this flag to * allow further optimizations. By default, this flag is not set on * View, but could be set on some View subclasses such as ViewGroup. * * Typically, if you override {@link #onDraw(android.graphics.Canvas)} * you should clear this flag. * * @param willNotDraw whether or not this View draw on its own */ public void setWillNotDraw(boolean willNotDraw) { setFlags(willNotDraw ? WILL_NOT_DRAW : 0, DRAW_MASK); }
從setWillNotDraw這個方法的註釋中可以看出,如果一個View不需要繪製任何內容,那麼設置這個標記位爲true以後,系統會進行相應的優化。默認情況下, View沒有啓用這,個優化標記位,但是ViewGroup會默認啓用這個優化標記位。這個標記位對實際開發的意,義是:當我們的自定義控件繼承於ViewGroup並且本身不具備繪製功能時,就可以開啓這個標記位從而便於系統進行後續的優化。當然,當明確知道一個ViewGroup需要通過 onDraw來繪製內容時,我們需要顯式地關閉WILL NOT DRAW這個標記位。
4.4自定義View
本節將詳細介紹自定義View相關的知識。自定義View的作用不用多說,這個讀者都,應該清楚,如果想要做出絢麗的界面效果僅僅靠系統的控件是遠遠不夠的,這個時候就必須通過自定義View來實現這些絢麗的效果。自定義View是一個綜合的技術體系,它涉及 View的層次結構、事件分發機制和View的工作原理等技術細節,而這些技術細節每一項!又都是初學者難以掌握的,因此就不難理解爲什麼初學者都覺得自定義View很難這一現狀,了。考慮到這一點,本書在第3章和第4章的前半部分對自定義View的各種技術細節都做,了詳細的分析,目的就是爲了讓讀者更好地掌握本節的內容。儘管自定義View很難,甚至面對各種複雜的效果時往往還會覺得有點無章可循。但是,本節將從一定高度來重新審視,自定義View,並以綜述的形式介紹自定義View的分類和須知,旨在幫助初學者能夠透過,現象看本質,避免陷入只見樹木不見森林的狀態之中。同時爲了讓讀者更好地理解自定義, View,在本節最後還會針對自定義View的不同類別分別提供一個實際的例子,通過這些,例子能夠讓讀者更深入地掌握自定義View.
4.4.1 自定義View的分類
自定義View的分類標準不唯一,而筆者則把自定義View分爲4類。
1,繼承View重寫onDraw方法
這種方法主要用於實現一些不規則的效果,即這種效果不方便通過佈局的組合方式來,達到,往往需要靜態或者動態地顯示一些不規則的圖形。很顯然這需要通過繪製的方式來,實現,即重寫onDraw方法。採用這種方式需要自己支持wrap content,並且padding也需要自己處理。
2.繼承ViewGroup派生特殊的Layout
這種方法主要用於實現自定義的佈局,即除了LinearLayout, RelativeLayout、 FrameLayout這幾種系統的佈局之外,我們重新定義一種新佈局,當某種效果看起來很像幾種View組合在一起的時候,可以採用這種方法來實現。採用這種方式稍微複雜一些,需要,合適地處理ViewGroup的測量、佈局這兩個過程,並同時處理子元素的測量和佈局過程。
3,繼承特定的View (比如TextView)
這種方法比較常見,一般是用於擴展某種已有的View的功能,比如TextView,這種,方法比較容易實現。這種方法不需要自己支持wrap content和padding等。
4,繼承特定的ViewGroup (比如LinearLayout)
這種方法也比較常見,當某種效果看起來很像幾種View組合在一起的時候,可以採用!這種方法來實現。採用這種方法不需要自己處理ViewGroup的測量和佈局這兩個過程。需,要注意這種方法和方法2的區別,一般來說方法2能實現的效果方法4也都能實現,兩者,的主要差別在於方法2更接近View的底層。
上面介紹了自定義View的4種方式,讀者可以仔細體會一下,是不是的確可以這麼劃分?但是這裏要說的是,自定義View講究的是靈活性,一種效果可能多種方法都可以實現,我們需要做的就是找到一種代價最小、最高效的方法去實現,在4.4.2節會列舉一些自定義View過程中常見的注意事項。
4.4.2 自定義View須知
本節將介紹自定義View過程中的一些注意事項,這些問題如果處理不好,有些會影響! View的正常使用,而有些則會導致內存泄露等,具體的注意事項如下所示。
1.讓View支持wrap-content
這是因爲直接繼承View或者ViewGroup的控件,如果不在onMeasure中對wrap content做特殊處理,那麼當外界在佈局中使用wrap content時就無法達到預期的效果,具體情形已經在4.3.1節中進行了詳細的介紹,這裏不再重複了。
2.如果有必要,讓你的View支持padding.
這是因爲直接繼承View的控件,如果不在draw方法中處理padding,那麼padding屬性是無法起作用的。另外,直接繼承自ViewGroup的控件需要在onMeasure和onLayout中考慮padding和子元素的margin對其造成的影響,不然將導致padding和子元素的margin失效。
3,儘量不要在View中使用Handler,沒必要,
這是因爲View內部本身就提供了post系列的方法,完全可以替代Handler的作用,當,然除非你很明確地要使用Handler來發送消息。
4. View中如果有線程或者動畫,需要及時停止,參考View#onDetachedFromWindow
這一條也很好理解,如果有線程或者動畫需要停止時,那麼onDetachedFrom Window是一個很好的時機。當包含此View的Activity退出或者當前View被remove時, View的, onDetachedFromWindow方法會被調用,和此方法對應的是onAttachedToWindow,當包含此View的Activity啓動時, View的onAttachedToWindow方法會被調用。同時,當View變得不可見時我們也需要停止線程和動畫,如果不及時處理這種問題,有可能會造成內存,泄漏。
5.View帶有滑動嵌套情形時,需要處理好滑動衝突
如果有滑動衝突的話,那麼要合適的處理滑動衝突,否者將會嚴重影響View的效果,具體怎麼解決滑動衝突請參考第三章。
4.4.3 自定義View示例
4.4.1節和4.4.2節分別介紹了自定義View的類別和注意事項,本節將通過幾個實,際的例子來演示如何自定義一個規範的View,通過本節的例子再結合上面兩節的內容,可以讓讀者更好地掌握自定義View。下面仍然按照自定義View的分類來介紹具體的實,現細節。
1,繼承View重寫onDraw方法這種方法
主要用於實現一些不規則的效果,一般需要重寫onDraw方法。採用這種方式需要自己支持wrap content,並且padding也需要自己處理。下面通過一個具體的例子來演示如何實現這種自定義View.
爲了更好地展示一些平時不容易注意到的問題,這裏選擇實現一個很簡單的自定義控件,簡單到只是繪製一個圓,儘管如此,需要注意的細節還是很多的。爲了實現一個規範,的控件,在實現過程中必須考慮到wrap content模式以及padding,同時爲了提高便捷性,還要對外提供自定義屬性。我們先來看一下最簡單的實現,代碼如下所示。
public class CircleView extends View { private int mColor= Color.RED; private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG); public CircleView(Context context) { this(context,null); } public CircleView(Context context, @Nullable AttributeSet attrs) { this(context, attrs,0); } public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { paint.setColor(mColor); } @Override public void draw(Canvas canvas) { super.draw(canvas); int width = getWidth() / 2; int height=getHeight()/2; int radius=Math.min(width,height); canvas.drawCircle(width,height,radius,paint); } }
上面的代碼實現了一個具有圓形效果的自定義View,它會在自己的中心點以寬/高的最小值爲直徑繪製一個紅色的實心圓,它的實現很簡單,並且上面的代碼相信大部分初學 ,者都能寫出來,但是不得不說,上面的代碼只是一種初級的實現,並不是一個規範的自定。義View,爲什麼這麼說呢?我們通過調整佈局參數來對比一下。請看下面的佈局:
再看一下運行的效果,如圖4-3中的(1)所示,這是我們預期的效果。接着再調整, CircleView的佈局參數,爲其設置20dp的margin,調整後的佈局如下所示。
運行後看一下效果,如圖4-3中的(2)所示,這也是我們預期的效果,這說明margin,屬性是生效的。這是因爲margin屬性是由父容器控制的,因此不需要在CircleView中做特殊處理。再調整CircleView的佈局參數,爲其設置20dp的padding,如下所示。
運行後看一下效果,如圖4-3中的(3)所示。結果發現padding根本沒有生效,這就,是我們在前面提到的直接繼承自View和ViewGroup的控件, padding是默認無法生效的,需要自己處理。再調整一下CircleView的佈局參數,將其寬度設置爲wrap content,如下所示。
運行後看一下效果,如圖4-3中的(4)所示,結果發現wrap content並沒有達到預期的效果。對比下(3)和(4)的效果圖,發現寬度使用wrap content和使用match parent沒有任何區別。的確是這樣的,這一點在前面也已經提到過:對於直接繼承自View的控件,如果不對wrap content做特殊處理,那麼使用wrap content就相當於使用match parent.
爲了解決上面提到的幾種問題,我們需要做如下處理:
首先,針對wrap content的問題,其解決方法在4.3.1節中已經做了詳細的介紹,這裏只需要指定一個wrap content模式的默認寬/高即可,比如選擇200px作爲默認的寬/高。
其次,針對padding的問題,也很簡單,只要在繪製的時候考慮一下padding即可,因此我們需要對onDraw稍微做一下修改,修改後的代碼如下所示。
public void draw(Canvas canvas) { super.draw(canvas); int paddingLeft = getPaddingLeft(); int paddingRight = getPaddingRight(); int paddingTop = getPaddingTop(); int paddingBottom = getPaddingBottom(); int width = getWidth()-paddingLeft-paddingRight; int height=getHeight()-paddingTop-paddingBottom; int radius=Math.min(width,height)/2; canvas.drawCircle(paddingLeft+width/2,paddingTop+height/2,radius,paint); }
上面的代碼很簡單,中心思想就是在繪製的時候考慮到View四周的空白即可,其中圓心和半徑都會考慮到View四周的padding,從而做相應的調整。 ,
針對上面的佈局參數,我們再次運行一下,結果如圖4-4中的(1)所示,可以發現佈局參數中的wrap-content和padding均生效了。
最後,爲了讓我們的View更加容易使用,很多情況下我們還需要爲其提供自定義屬性,像android:layout width和android:padding這種以android開頭的屬性是系統自帶的屬性,那麼如何添加自定義屬性呢?這也不是什麼難事,遵循如下幾步:
第一步,在values目錄下面創建自定義屬性的XML,比如attrs.xml,也可以選擇類似,於attrs circle view.xml等這種以attrs開頭的文件名,當然這個文件名並沒有什麼限制,可以隨便取名字。針對本例來說,選擇創建attrs.xml文件,文件內容如下:
<resources> <declare-styleable name="CircleView"> <attr name="circle_color" format="color"/> </declare-styleable> </resources>
在上面的XML中聲明瞭一個自定義屬性集合"CircleView",在這個集合裏面可以不很多自定義屬性,這裏只定義了一個格式爲"color"的屬性"circle color",這裏的格式col指的是顏色。除了顏色格式,自定義屬性還有其他格式,比如reference是指資源id, dimensio 206第章View的工作原理!是指尺寸,而像string, integer和boolean這種是指基本數據類型。除了列舉的這些還有其他類型,這裏就不一一描述了,讀者查看一下文檔即可,這並沒有什麼難度。
第二步,在View的構造方法中解析自定義屬性的值並做相應處理。對於本例來說,我們需要解析circle color這個屬性的值,代碼如下所示。
public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); TypedArray a=context.obtainStyledAttributes(attrs, R.styleable.CircleView); mColor=a.getColor(R.styleable.CircleView_circle_color,Color.RED); a.recycle(); init(); }
這看起來很簡單,首先加載自定義屬性集合CircleView,接着解析CircleView屬性集,閤中的circle color屬性,它的id爲R.styleable.CircleView circle color。在這一步驟中,如, .果在使用時沒有指定circle color這個屬性,那麼就會選擇紅色作爲默認的顏色值,解析完,自定義屬性後,通過recycle方法來實現資源,這樣CircleView中所做的工作就完成了。第三步,在佈局文件中使用自定義屬性,如下所示。
上面的佈局文件中有一點需要注意,首先,爲了使用自定義屬性,必須在佈局文件中添加, schemas聲明: xmlns:app-http://schemas.android.com/apk/res-auto。在這個聲明中, app是自定義屬性的前綴,當然可以換其他名字,但是CircleView中的自定義屬性的前綴必須和這裏的一致,然後就可以在CircleView中使用自定義屬性了,比如: app:circle color-"@color/ight green".另外,也有按照如下方式聲明schemas: xmIns:app-http:// schemas.android.com/apk/res/com. ryg.chapter 4,這種方式會在apk/res/後面附加應用的包名。但是這兩種方式並沒有本質區別,筆者比較喜歡的是xmlns:app-http://schemas.android.com/apk/res-auto這種聲明方式。
到這裏自定義屬性的使用過程就完成了,運行一下程序,效果如圖4-4中的(2)所示,很顯然, CircleView的自定義屬性circle color生效了。下面給出CircleView的完整代碼,這時的CircleView已經是一個很規範的自定義View了,如下所示。
public class CircleView extends View { private int mColor= Color.RED; private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG); public CircleView(Context context) { this(context,null); } public CircleView(Context context, @Nullable AttributeSet attrs) { this(context, attrs,0); } @SuppressLint("Recycle") public CircleView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); TypedArray a=context.obtainStyledAttributes(attrs, R.styleable.CircleView); mColor=a.getColor(R.styleable.CircleView_circle_color,Color.RED); a.recycle(); init(); } private void init() { paint.setColor(mColor); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); if (widthMode==MeasureSpec.AT_MOST&&heightMode==MeasureSpec.AT_MOST){ setMeasuredDimension(200,200); }else if (widthMode==MeasureSpec.AT_MOST){ setMeasuredDimension(200,heightSize); }else if(heightMode==MeasureSpec.AT_MOST){ setMeasuredDimension(widthSize,200); } } @Override public void draw(Canvas canvas) { super.draw(canvas); int paddingLeft = getPaddingLeft(); int paddingRight = getPaddingRight(); int paddingTop = getPaddingTop(); int paddingBottom = getPaddingBottom(); int width = getWidth()-paddingLeft-paddingRight; int height=getHeight()-paddingTop-paddingBottom; int radius=Math.min(width,height)/2; canvas.drawCircle(paddingLeft+width/2,paddingTop+height/2,radius,paint); } }
2.繼承ViewGroup派生特殊的Layout
這種方法主要用於實現自定義的佈局,採用這種方式稍微複雜一些,需要合適地處理! ViewGroup的測量、佈局這兩個過程,並同時處理子元素的測量和佈局過程。在第3章的, 3.5.3節中,我們分析了滑動衝突的兩種方式並實現了兩個自定義View: HorizontalScrollViewEx和StickyLayout,其中HorizontalScrollViewEx就是通過繼承ViewGroup來實現的自定義View,這裏會再次分析它的measure和layout過程。需要說明的是,如果要採用此種方法實現一個很規範的自定義View,是有一定的代價,的,這點通過查看LinearLayout等的源碼就知道,它們的實現都很複雜。對於Horizontal ScrollViewEx來說,這裏不打算實現它的方方面面,僅僅是完成主要功能,但是需要規範,化的地方會給出說明。
這裏再回顧一下HorizontalScrollViewEx的功能,它主要是一個類似於ViewPager的控件,也可以說是一個類似於水平方向的LinearLayout的控件,它內部的子元素可以進行水平滑動並且子元素的內部還可以進行豎直滑動,這顯然是存在滑動衝突的,但是 . HorizontalScrollViewEx內部解決了水平和豎直方向的滑動衝突問題。關於' HorizontalScrollViewEx是如何解決滑動衝突的,請參看第3章的相關內容。這裏有一個假設,那就是所有子元素的寬/高都是一樣的。下面主要看一下它的onMeasure和onLayout方法的實現,先看onMeasure,如下所示。
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); int measureWidth=0; int measureHeight=0; int childCount = getChildCount(); measureChildren(widthMeasureSpec,heightMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); final View child=getChildAt(0); if (childCount==0){ setMeasuredDimension(0,0); }else if(widthMode==MeasureSpec.AT_MOST&&heightMode==MeasureSpec.AT_MOST){ measureWidth=child.getMeasuredWidth()*childCount; measureHeight=child.getMeasuredHeight()*childCount; setMeasuredDimension(measureWidth,measureHeight); }else if(widthMode==MeasureSpec.AT_MOST){ measureWidth=child.getMeasuredWidth()*childCount; setMeasuredDimension(measureWidth,heightMeasureSpec); }else if(heightMode==MeasureSpec.AT_MOST){ measureHeight=child.getMeasuredHeight()*childCount; setMeasuredDimension(widthMeasureSpec,measureHeight); } }
這裏說明一下上述代碼的邏輯,首先會判斷是否有子元素,如果沒有子元素就直接把自己的寬/高設爲0;然後就是判斷寬和高是不是採用了wrap content,如果寬採用了 wrap-content,那麼HorizontalScrollViewEx的寬度就是所有子元素的寬度之和;如果高度採用了wrap content,那麼HorizontalScrollViewEx的高度就是第一個子元素的高度。
上述代碼不太規範的地方有兩點:第一點是沒有子元素的時候不應該直接把寬/高設爲 "0,而應該根據LayoutParams中的寬/高來做相應處理;第二點是在測量 HorizontalScrollViewEx的寬/高時沒有考慮到它的padding以及子元素的margin,因爲它的 padding以及子元素的margin會影響到HorizontalScrollViewEx的寬/高。這是很好理解的,因爲不管是自己的padding還是子元素的margin,佔用的都是HorizontalScrollViewEx的空間。
接着再看一下HorizontalScrollViewEx的onLayout方法,如下所示。
protected void onLayout(boolean changed, int l, int t, int r, int b) { int childLeft=0; mChildIndex=getChildCount(); mChildWitdh=getChildAt(0).getMeasuredWidth(); for (int i=0;i<mChildIndex;i++){ View child = getChildAt(i); if (child.getVisibility()!=GONE){ layout(childLeft,0,childLeft+mChildWitdh,child.getMeasuredHeight()); childLeft+=mChildWitdh; } } }
上述代碼的邏輯並不複雜,其作用是完成子元素的定位。首先會遍歷所有的子元素,如果這個子元素不是處於GONE這個狀態,那麼就通過layout方法將其放置在合適的位置上。從代碼上來看,這個放置過程是由左向右的,這和水平方向的LinearLayout比較類似。上述代碼的不完美之處仍然在於放置子元素的過程沒有考慮到自身的padding以及子元素的margin,而從一個規範的控件的角度來看,這些都是應該考慮的。下面給出Horizontal ScrollViewEx的完整代碼,如下所示。
/** * 仿ViewPager容器 */ public class HorizontalScrollViewEx extends ViewGroup { private static final String TAG="HorizontalScrollViewEx"; private int mChildrenSize;//子控件個數 private int mChildWitdh;//子控件寬度 private int mChildIndex;//當前子控件的index //分別記錄上次滑動的座標 private float mLastX=0; private float mLastY=0; //分別記錄上次滑動的座標(onInterceptTouchEvent) private float mLastXIntercept=0; private float mLastYIntercept=0; private Scroller mScroller; private VelocityTracker mVelocityTracker; public HorizontalScrollViewEx(Context context) { this(context,null); } public HorizontalScrollViewEx(Context context, AttributeSet attrs) { this(context, attrs,0); } public HorizontalScrollViewEx(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(); } private void init() { if (mScroller==null){ mScroller=new Scroller(getContext()); mVelocityTracker=VelocityTracker.obtain(); } } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { boolean intercepted=false; float x = ev.getX(); float y = ev.getY(); switch (ev.getAction()){ case MotionEvent.ACTION_DOWN://默認返回false,不然後續事件將都由父容器處理 intercepted=false; /** * 考慮一種情況,如果此時用戶正在水平滑動,伹是在水平滑動停止之前如果用戶再迅速進行豎直滑動, * 就會導致界面在水平方向無法滑動到終點從而處於一種中間狀態。爲了避免這種不好的體驗,當水平方向正在滑動時 * ,下一個序列的點擊事件仍然交給父容器處理,這樣水平方向就不會停留在中間狀態了。 */ if (!mScroller.isFinished()){//滑動還未結束時點擊了,則停止動畫 mScroller.abortAnimation(); intercepted=true; } break; case MotionEvent.ACTION_MOVE: int deltaX= (int) (x-mLastX); int deltaY= (int) (y-mLastY); if (Math.abs(deltaX)>Math.abs(deltaY)){ intercepted=true; }else{ intercepted=true; } break; case MotionEvent.ACTION_UP://默認返回false,不然子控件的onClick無法響應 intercepted=false; break; default: break; } Log.d(TAG,"intercept="+intercepted); mLastX=x; mLastY=y; mLastXIntercept=x; mLastYIntercept=y; return intercepted; } @Override public boolean onTouchEvent(MotionEvent event) { mVelocityTracker.addMovement(event); float x = getX(); float y = getY(); switch (event.getAction()){ case MotionEvent.ACTION_DOWN: if (!mScroller.isFinished()){ mScroller.abortAnimation(); } break; case MotionEvent.ACTION_MOVE: int deltaX= (int) (x-mLastX); int deltaY= (int) (y-mLastX); scrollBy(-deltaX,0);//左右移動 break; case MotionEvent.ACTION_UP: int scrollX = getScrollX(); int scrollToChildIndex=scrollX/mChildWitdh; mVelocityTracker.computeCurrentVelocity(1000); float xVelocity = mVelocityTracker.getXVelocity(); if (Math.abs(xVelocity)>=50){//模仿ViewPager左滑右滑,當前Container的index改變 mChildIndex=xVelocity>0 ? mChildIndex-1:mChildIndex+1; }else{ mChildIndex=(scrollX+mChildWitdh/2)/mChildWitdh;//滑動到一半,根據scrollX的正負判斷index } mChildIndex=Math.max(0,Math.min(mChildIndex,mChildrenSize-1));//index最小0,最大子控件個數-1 int dx = mChildIndex * mChildWitdh - scrollX; smoothScrollBy(dx,0); mVelocityTracker.clear(); break; default: break; } mLastX=x; mLastY=y; return true; } private void smoothScrollBy(int dx, int dy) { mScroller.startScroll(getScrollX(),0,dx,0,500); invalidate(); } @Override public void computeScroll() { if (mScroller.computeScrollOffset()){ scrollTo(mScroller.getCurrX(),mScroller.getCurrY()); postInvalidate(); } } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int childLeft=0; mChildrenSize=getChildCount(); mChildWitdh=getChildAt(0).getMeasuredWidth(); for (int i=0;i<mChildrenSize;i++){ View child = getChildAt(i); if (child.getVisibility()!=GONE){ layout(childLeft,0,childLeft+mChildWitdh,child.getMeasuredHeight()); childLeft+=mChildWitdh; } } } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {//默認子元素寬高相同 super.onMeasure(widthMeasureSpec, heightMeasureSpec); int measureWidth=0; int measureHeight=0; int childCount = getChildCount(); measureChildren(widthMeasureSpec,heightMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); final View child=getChildAt(0); if (childCount==0){ setMeasuredDimension(0,0); }else if(widthMode==MeasureSpec.AT_MOST&&heightMode==MeasureSpec.AT_MOST){ measureWidth=child.getMeasuredWidth()*childCount; measureHeight=child.getMeasuredHeight()*childCount; setMeasuredDimension(measureWidth,measureHeight); }else if(widthMode==MeasureSpec.AT_MOST){ measureWidth=child.getMeasuredWidth()*childCount; setMeasuredDimension(measureWidth,heightMeasureSpec); }else if(heightMode==MeasureSpec.AT_MOST){ measureHeight=child.getMeasuredHeight()*childCount; setMeasuredDimension(widthMeasureSpec,measureHeight); } } @Override protected void onDetachedFromWindow() { mVelocityTracker.recycle(); super.onDetachedFromWindow(); } }
繼承特定的View (比如TextView)和繼承特定的ViewGroup (比如LinearLayout)這兩種方式比較簡單,這裏就不再舉例說明了,關於第3章中提到的StickyLayout的具體實現,大家可以參看筆者在Github上的開源項目: https://github.com/singwhatiwanna/Pinned HeaderExpandableListView.
4.4.4 自定義View的思想
到這裏,自定義View相關的知識都已經介紹完了,可能讀者還是覺得有點模糊。前面說過,自定義View是一個綜合的技術體系,很多情況下需要靈活地分析從而找出最高效的, ,方法,因此本章不可能去分析一個個具體的自定義View的實現,因爲自定義View五花八,門,是不可能全部分析一遍的。雖然我們不能把自定義View都分析一遍,但是我們能夠提取出一種思想,在面對陌生的自定義View時,運用這個思想去快速地解決問題。這種思想的描述如下:首先要掌握基本功,比如View的彈性滑動、滑動衝突、繪製原理等,這些東西都是自定義View所必須的,尤其是那些看起來很炫的自定義View,它們往往對這些技術點的要求更高;熟練掌握基本功以後,在面對新的自定義View時,要能夠對其分類並選擇合適的實現思路, 自定義View的實現方法的分類在4.4.1節中已經介紹過了;另外平時還需要多積累一些自定義View相關的經驗,並逐漸做到融會貫通,通過這種思想慢慢地就,可以提高自定義View的水平了。