教你步步為營掌握Android中的自定義ViewGroup
本篇是《教你步步為營掌握自定義View》一文的姊妹篇。自定義ViewGroup的文章很多,但都有一個缺點,沒有回應用戶關切,比如我在讀那些文章時,就很想知道,自定義的ViewGroup如何使用layout_gravity?在onMeasure中,自定義的ViewGroup會將所有子View的尺寸加起來設置成自己的尺寸,如果超過了自定義ViewGroup的parent限定的尺寸怎么辦?而且onMeasure中,ViewGroup給 每一個 子View設置的MeasureSpec中的寬高都是它的parent給它的寬高,為什么不是每measure一個子View,就把它的尺寸減去后再去measure下一個子View呢?如果一個ViewGroup把一個子View layout到了自己掌控的屏幕區域之外,這個View還怎么發揮作用呢?如果你也有類似的疑問,這篇文章也許能讓你有茅塞頓開之感。
一、自定義ViewGroup必須清楚的基本原理
在學習一個技術點的時候,我會先搞清楚它的基本原理,然后再動手編碼,因為我希望對自己寫的每一行代碼最終將會怎樣執行有精準的把握。否則,我寫代碼時就沒有底氣,就像在棉花堆上走路,每一步都會心里發虛。學習自定義ViewGroup當然也不例外。下面,我們就一起看看自定義ViewGroup的原理吧。
1、自定義ViewGroup本質上就干一件事-layout
通過上篇文章,我們知道ViewGroup是一個組合View,它與普通的基本View(只要不是ViewGroup,都是基本View)最大的區別在于,它可以容納其他View,這些View既可以是基本View,也可以ViewGroup,但是在我們的ViewGroup眼中,不管是View還是ViewGroup,它們都抽象成了一個普通的View,ViewGroup的 最最根本的職責 就是,在自己內部,給它們每一個人找一個合適的位置,也就是調用它們的如下方法:
public void layout(int left, int top, int right, int bottom)
如下圖所示:
ViewGroup-demo.png
這個方法,可謂是一箭雙雕,既確定了子View的位置,也確定了子View的大小,請注意,這個大小是由我們的ViewGroup最后決定的分給該子View的屏幕區域大小,一般情況下,作為老大哥,我們的ViewGroup在設定這個大小時,會考慮子View的自身要求的,也就是它們measured的大小(getMeasuredWidth , getMeasuredHeight),通常最后給每個子View設定的大小就是它們所要求的大小,但這不是絕對的。假如有一個二愣子性格的ViewGroup,它宣稱:“我所有的子View的大小都必須是30*30的尺寸!”,這種SB的ViewGroup在調用每個子View的layout方法時,通過讓bottom-top=right-left=30,就把所有的子View最后占據的屏幕區域設定為30*30了,不管各個子View所要求的大小是多少,此時都沒有任何用處了。當然,除了有特殊需求,我相信沒人愿意用這種ViewGroup的,這里我們可以知道,我們自定義ViewGroup,大體上有兩條路可選,一條就是讓這個ViewGroup滿足我們開發中的特定需求,這個時候,你可以隨心所欲地去定義ViewGroup,反正我也只是自己用,不打算給別人用的。另一條就是自定義一個ViewGroup,提供給更多的人使用,這個時候,你就要遵守一些基本的規矩,讓你的ViewGroup符合使用者的使用習慣和期望,這樣大家才能愿意用你的ViewGroup。那么使用者使用一個ViewGroup最基本的期望是什么?我想,應該是使用者放入這個ViewGroup中的子View layout出來的尺寸和每個子View measured的尺寸相符。只有這樣,才能確保使用者的每個子View順利完成自己的交互任務。
對于上面的圖,有兩點非常容易讓人產生誤解,需要解釋一下:
- 關于left、right、top、bottom。它們都是坐標值,既然是坐標值,就要明確坐標系,這個坐標系是什么?我們知道,這些值都是ViewGroup設定的,那么,這個坐標系自然也是由ViewGroup決定的了。沒錯,這個坐標系就是以ViewGroup左上角為原點,向右x,向下y構建起來的。進一步我們又想問,ViewGroup的左上角又在哪里呢?我們知道,在ViewGroup的parent(也是ViewGroup)眼中,我們的ViewGroup就是一個普通的View,parent也會調用我們的ViewGroup的如下方法:
//注意,這個layout方法是ViewGroup的parent在layout我們的ViewGroup, //不要和我們的ViewGroup layout自己的子View搞混了。 public void layout(int left, int top, int right, int bottom)
由此我們看到,Google創建的這一套坐標系統非常的高效,只要確定DecorView左上角在屏幕上的位置,那么,所有的View在屏幕上的相對位置都可以精準地確定。 - 第二點就是上圖中代表ViewGroup的那個方框。這個方框是什么意思,是代表ViewGroup的大小嗎?如果是的話,這個大小是不是ViewGroup在onMeasure方法中設定的各個子View大小的和?正確的答案是,這個方框是ViewGroup的parent在layout我們的ViewGroup時,給ViewGroup設定的大小,parent調用我們的ViewGroup的如下layout方法:
//注意,這個layout方法是ViewGroup的parent在layout我們的ViewGroup, //不要和我們的ViewGroup layout自己的子View搞混了。 public void layout(int left, int top, int right, int bottom)
答案是:我們的ViewGroup在layout自己的子View時,想怎么layout就怎么layout,可以diao,也可以不diao parent給自己設定的尺寸。
為什么是這樣呢?既然可以不diao這個尺寸,為什么我們的ViewGroup還要辛苦地在onMeasure方法中計算每一個子View的寬高,還二乎乎地將它們的尺寸加起來,告訴它的parent呢?我頭有點暈,讓我歇一會兒。好吧,看張美圖提提神!
圖片來源網絡-如侵刪
2、為了優雅地layout,必須先把尺寸的問題搞明白
上文中,ViewGroup在自己的layout方法中,獲得了parent給自己設定的尺寸大小,即 availableWidth和availableHeight ,這個值相當于parent告訴ViewGroup:“請以你的左上角為圓點,向右為x,向下為y的坐標系,給你的每一個子View確定位置和大小。我可以向你保證,這個坐標系中的點P1(0,0)、點P2(availableWidth,0)、點P3(0,availableHeight)、點P4(availableWidth,availableHeight)組成的方框區域內的子View都可以獲得在手機屏幕(這里指硬件意義上的屏幕)上展示自己的機會。這個方框之外的子View,能不能在手機屏幕上展示自己,我就管不了了。”從這里我們看到,parent給我們的ViewGroup設定的尺寸,并不一定就完全對應著手機屏幕上的一塊相同大小的區域,在有些情況下,parent給我們的ViewGroup設定的這個尺寸可能比整個手機屏幕還大。但是,parent仍然向我們保證,在該區域內layout的子View,都能獲得在手機屏幕上展示自己的機會,parent是如何做到這一點的呢?答案是:通過parent的scroll功能。這里我們不詳細敘述scroll,如果你不是很理解,請查看相關資料。
好奇的我們可能要問:“假如我是一個ViewGroup,我把一個子View的一部分layout在了parent給定的區域內,另一部分超出了該區域,這個子View是不是最多只能獲得部分展示自己的機會?”不用懷疑,答案是:Yes!
你可能還要問:“那些在完全被layout在parent限定的區域之外的子View應該怎么辦呢?它們難道就該在無邊黑暗中永不見天日嗎?”這確實有點殘酷,所以,作為一個ViewGroup,你可以有三個選擇:
選擇一:很簡單,不要將子View 放到這個區域之外,萬事大吉!
如果這個ViewGroup的子View數量太多,parent給限定的區域實在放不下它們怎么辦?此時ViewGroup可以讓子View重疊,以便所有的子View能夠在parent限定的區域內layout出來。像下面這樣:
dieluohan.jpg
選擇二:讓你的ViewGroup實現scroll功能,從而確保parent限定區域外的子View也能夠有機會展示自己。
選擇三:將你的ViewGroup的parent換成ScrollView。這樣你的ViewGroup就不用自己實現scroll功能了。但是ScrollView只能允許子View的高度超過自己,不允許子View的寬度超過自己。所以,作為ViewGroup,可以在不超過availableWidth的情況下,將子View layout 到任意的高度上。如下圖所示:
ViewGroup-demo1.png
看到沒?作為一個優秀的ViewGroup,當你layout自己的子View時,只要保證子View在availableWidth之內,即使超過了parent要求的高度也沒有關系,開發者還是愿意使用你的,因為他們可以為你指定ScrollView作為parent。這就是我們看到許多的ViewGroup在layout 子View時, 寧超高度,不超寬度 的原因。
關于ScrollView怎樣實現的scroll功能,講起來比較復雜,我們暫時放下不表。
至此,你應該明白,上文中我們提出的,對于parent指定的availableWidth和availableHeight,作為ViewGroup還是要盡量不超過parent限定的區域,如果一定要超過的話,那就超availableHeight,而不要超availableWidth。
3、讓我們了解一下layout_gravity
我們看到,Android系統提供的FrameLayout、LinearLayout等都支持子View設定layout_gravity,它到底是干什么用的?我們自己自定義ViewGroup時能不能也用上它?
關于它的作用,一句話就能說明白,當ViewGroup給子View分配的空間超過子View要求的大小時,就需要gravity幫助ViewGroup為子View精確定位。可見,layout_gravity就是ViewGroup在layout階段,協助ViewGroup給它的子View確定位置的,沒錯,就是協助確定子View的 left,top,bottom,right四個值。
下面,我們以FrameLayout為例來進行說明。假設FrameLayout中有一個子View,這個子View的所要求的展示尺寸(measuredWidth,measuredHeight)小于FrameLayout的尺寸,但是FrameLayout是個實心眼,它不管子View要求多大,都會把它所有的屏幕區域給子View,這樣就可以保證,用戶在這個區域中的交互動作,都是與子View的交互。那么問題來了,FrameLayout在layout子View時,總不能讓它的left和top為0,right和bottom等于自己的寬和高吧。如果這么干,子View就要在這個尺寸下,繪制自己,就不可避免地要對它包含的drawables進行拉伸,展示效果必然受到影響,那怎么辦?
FrameLayout會提取子View的 LayoutParams中的gravity,看看子View想在哪個位置,假設子View的layout_gravity的值是"top|left",那么FrameLayout就會把子View layout到自己的左上角,大小嘛就是子View所要求的大小。但是請注意,雖然此時子View繪制時是按照自己要求的大小繪制的,但是,能與它發生交互的區域卻是整個FrameLayout所占的屏幕區域。
所以,要不要使用layout_gravity,就看你自定義的ViewGroup是不是給子View分配大于它們要求的空間。
二、實戰自定義ViewGroup
好了,下面我們就通過實戰來檢驗下我們剛學到的自定義ViewGroup的知識。我們將定義的ViewGroup,名為StaggerLayout。它展示的效果是這樣的:
StaggerLayout-demo.png
代碼如下:
package com.milter.www.customviewgroupforblog;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
/**
* Created by Administrator on 2016/8/14.
*/
public class StaggerLayout extends ViewGroup {
public static final String TAG = "StaggerLayout" ;
/*
首先,定義好我們的四個構造方法,注意,ViewGroup的構造方法與上篇中的自定義View AnalogClock遵循相同的最佳實踐。
*/
//第一個構造方法
public StaggerLayout(Context context) {
this(context, null);
}
//第二個構造方法
public StaggerLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
//第三個構造方法
public StaggerLayout(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
//第四個構造方法
public StaggerLayout(Context context, AttributeSet attrs,
int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
/*
maxHeight和maxWidth就是我們最后計算匯總后的ViewGroup需要的寬和高。用來報告給ViewGroup的parent。
在計算maxWidth時,我們首先簡單地把所有子View的寬度加起來,
如果該ViewGroup所有的子View的寬度加起來都沒有
超過parent的寬度限制,那么我們把該ViewGroup的measured寬度設為maxWidth,
如果最后的結果超過了parent的寬度限制,我們就設置measured寬度為parent的限制寬度,
這是通過對maxWidth進行resolveSizeAndState處理得到的。
對于maxHeight,在每一行中找出最高的一個子View,然后把所有行中最高的子View加起來。
這里我們在報告maxHeight時,也進行一次resolveSizeAndState處理。
*/
int maxHeight = 0;
int maxWidth = 0;
/*
mLeftHeight表示當前行已有子View中最高的那個的高度。當需要換行時,把它的值加到maxHeight上,
然后將新行中第一個子View的高度設置給它。
mLeftWidth表示當前行中所有子View已經占有的寬度,
當新加入一個子View導致該寬度超過parent的寬度限制時,
增加maxHeight的值,同時將新行中第一個子View的寬度設置給它。
*/
int mLeftHeight = 0;
int mLeftWidth = 0;
final int count = getChildCount();
Log.d(TAG,"Child count is " + count);
final int widthSize = MeasureSpec.getSize(widthMeasureSpec);
Log.d(TAG,"widthSize in Measure is :"+ widthSize);
// 遍歷我們的子View,并測量它們,根據它們要求的尺寸進而計算我們的StaggerLayout需要的尺寸。
for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
//可見性為gone的子View,我們就當它不存在。
if (child.getVisibility() == GONE)
continue;
// 測量該子View
measureChild(child, widthMeasureSpec, heightMeasureSpec);
//簡單地把所有子View的測量寬度相加。
maxWidth += child.getMeasuredWidth();
mLeftWidth += child.getMeasuredWidth();
//這里判斷是否需將index 為i的子View放入下一行,如果需要,就要更新我們的maxHeight,mLeftHeight和mLeftWidth。
if (mLeftWidth > widthSize) {
maxHeight += mLeftHeight;
mLeftWidth = child.getMeasuredWidth();
mLeftHeight = child.getMeasuredHeight();
} else {
mLeftHeight = Math.max(mLeftHeight, child.getMeasuredHeight());
}
}
//這里把最后一行的高度加上,注意不要遺漏。
maxHeight += mLeftHeight;
//這里將寬度和高度與Google為我們設定的建議最低寬高對比,確保我們要求的尺寸不低于建議的最低寬高。
maxHeight = Math.max(maxHeight, getSuggestedMinimumHeight());
maxWidth = Math.max(maxWidth, getSuggestedMinimumWidth());
//報告我們最終計算出的寬高。
setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, 0),
resolveSizeAndState(maxHeight, heightMeasureSpec, 0));
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int count = getChildCount();
//childLeft和childTop代表在StaggerLayout的坐標系中,能夠用來layout子View的區域的
//左上角的頂點的坐標。
final int childLeft = getPaddingLeft();
final int childTop = getPaddingTop();
//childRight代表在StaggerLayout的坐標系中,能夠用來layout子View的區域的
//右邊那條邊的坐標。
final int childRight = r - l - getPaddingRight();
/*
curLeft和curTop代表StaggerLayout準備用來layout子View的起點坐標,這個點的坐標隨著
子View一個一個地被layout,在不斷變化,有點像數據庫中的Cursor,指向下一個可用區域。
maxHeight代表當前行中最高的子View的高度,當需要換行時,curTop要加上該值,以確保新行中
的子View不會與上一行中的子View發生重疊。
*/
int curLeft, curTop, maxHeight;
maxHeight = 0;
curLeft = childLeft;
curTop = childTop;
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
if (child.getVisibility() == GONE)
return;
int curWidth, curHeight;
curWidth = child.getMeasuredWidth();
curHeight = child.getMeasuredHeight();
//用來判斷是否應當將該子View放到下一行
if (curLeft + curWidth >= childRight) {
/*
需要移到下一行時,更新curLeft和curTop的值,使它們指向下一行的起點
同時將maxHeight清零。
*/
curLeft = childLeft;
curTop += maxHeight;
maxHeight = 0;
}
//所有的努力只為了這一次layout
child.layout(curLeft, curTop, curLeft + curWidth, curTop + curHeight);
//更新maxHeight和curLeft
if (maxHeight < curHeight)
maxHeight = curHeight;
curLeft += curWidth;
}
}
}
好了,這樣我們就基本掌握了自定義ViewGroup了。實際上,自定義ViewGroup是一個可難可簡的事,關鍵是要滿足自己的需求。如果要定義出一個能夠滿足大多數開發者使用需求的自定義ViewGroup,就像LinearLayout和RelativeLayout那樣的,還是很有難度的,如果你不信,你可以看看它們的源碼。
來自:http://www.jianshu.com/p/5e61b6af4e4c