時間過得真快,又到了寫博客的時候了(/▽╲)。這次按照計劃記錄一個簡單的自定義ViewGroup:流布局FlowLayout
的實現(xiàn)過程,將自定義控件知識儲備-View的繪制流程和自定義控件知識儲備-LayoutParams的那些事里的知識點結合起來,付諸實踐。
1. 前言
早在學習Java的Swing基礎知識的時候,就見到過里面的流布局FlowLayout,基本的效果就是讓加入此容器的控件自左往右依次排列,如果當前行的寬度不足以容納下一個控件,就會將此控件放置到下一行。其實這也跟css里向左浮動的效果很相似。
在Android的世界里,系統(tǒng)是沒有提供類似FlowLayout布局的容器的。當然了,現(xiàn)在官方給我們提供了更強大也更復雜的FlexLayout
了。不過嘛,本篇博客是總結一個自定義ViewGroup的實現(xiàn)流程,所以需要找一個難易適中的實例來進行分析,也就是FlowLayout了。(是的,我就是挑軟柿子捏︿( ̄︶ ̄)︿)。
2. 效果
閑話少說,還是先來看看蘑菇君寫的FlowLayout的功能:
- 支持最基本的從左至右的排序,空間不足則換行
- 支持設置子控件間的水平和豎直的間隔(也可以通過給每個child設置margin來實現(xiàn),不過沒有統(tǒng)一設置來的方便)
- 支持繪制行之間的分割線
- 支持FlowLayout本身的
Gravity
和child views的Gravity
- 處理好FlowLayout的padding和child views的margin
這些都是FlowLayout基本的功能,效果如下圖所示:
是不是感覺還行?至少一般的情況下是能滿足大部分人的需求滴。o( ̄▽ ̄)d
3. 分析
列舉一下自定義ViewGroup的流程:
- 自定義屬性:如果ViewGroup需要用到自定義屬性,則需要聲明、設置、解析并獲取自定義屬性值。
- 測量:在
onMeasure
方法里處理AT_MOST
和EXACTLY
兩種測量模式下ViewGroup的寬高和children的寬高。(UNSPECIFIED
模式可以暫不考慮) - 布局:在
onLayout
方法里確定children的位置。 - 繪制:如果ViewGroup里需要繪制,則重寫onDraw方法,按邏輯繪制。比如FlowLayout可以在每一行之間繪制一條分隔線。
- 處理LayoutParams:如果要為children定義布局屬性,如
layout_gravity
,則需要自定義LayoutParams,并且重寫ViewGroup相關的方法。 - 處理滑動事件:在本FlowLayout里暫時用不上...( ╯▽╰)
上面的步驟可能有所遺漏,不過也差不多啦。下面蘑菇君要根據(jù)上述的流程來一步一步的分析FlowLayout的源碼,源碼可能有點長,有些細節(jié)上的邏輯看不懂也莫方,只要了解流程對應的實現(xiàn)方式和注意事項就好,有興趣的話可以稍后自己下載源碼分析具體的邏輯實現(xiàn)。
好滴,那就讓我們來一步一步的看,這個FlowLayout是如何在我手里...被玩殘的...
3.1 自定義屬性
3.1.1 聲明屬性
首先,自定義屬性的第一步當然是聲明屬性,而最常使用的方式當然是在xml資源文件里(一般來說就是attrs.xml文件)聲明需要使用的屬性:
<declare-styleable name="FlowLayout">
<attr name="android:gravity"/>
<attr name="horizonSpacing" format="dimension|reference"/>
<attr name="verticalSpacing" format="dimension|reference"/>
<attr name="dividerColor" format="color|reference"/>
<attr name="dividerWidth" format="dimension|reference"/>
</declare-styleable>
<declare-styleable name="FlowLayout_Layout">
<attr name="android:layout_gravity"/>
</declare-styleable>
這里需要注意兩個地方:
我們聲明了兩個
declare-styleable
,一個是為FlowLayout
自身設置自定義屬性;另一個是為孩子們提供額外屬性,需要在自定義的LayoutParams
里解析獲取屬性值。大家都知道,我們在xml布局文件里使用自定義屬性時,需要引入命名空間
xmlns:app="http://schemas.android.com/apk/res-auto"
使用自定義屬性時,需要加上前綴app(或者是其它命名,只要一一對應)。但是有時候啊,我們自定義的屬性名已經(jīng)在系統(tǒng)中存在了,而且語義與我們想要的也很符合,比如如andrioid:text
、android:gravity
等等。這個時候估計誰都會有一種“拿來主義”的沖動:直接使用系統(tǒng)里已經(jīng)存在的屬性名就好了嘛,多“原生”!既然有這種“邪惡”的需求,那Google工程師自然是要滿足滴(~ ̄▽ ̄)~。
以gravity
屬性為例,我們只要在declare-styleable
里直接寫上<attr name="android:gravity"/>
即可,不過這里要注意的是不需要也不能再加上format
屬性,加上format
屬性就代表著這是在聲明一個新的屬性,不加則代表這是在使用已存在的一個屬性。
3.1.2 使用屬性
使用屬性就比較簡單了:
<wang.mogujun.widget.FlowLayout
android:id="@+id/flow2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="#6A6A6A"
android:gravity="start"
android:padding="8dp"
app:horizonSpacing="8dp"
app:verticalSpacing="12dp"
app:dividerColor="#cccccc"
app:dividerWidth="2dp"
>
3.1.3 解析并獲取屬性
在xml設置了相應的屬性后,就需要在FlowLayout里解析并獲取屬性值了:
public static final int DEFAULT_SPACING = 8;
public static final int DEFAULT_DIVIDER_COLOR = Color.parseColor("#ececec");
public static final int DEFAULT_DIVIDER_WIDTH = 3;
private int mGravity = (isIcs() ? Gravity.START : Gravity.LEFT) | Gravity.TOP;
private int mVerticalSpacing; //vertical spacing
private int mHorizontalSpacing; //horizontal spacing
private int mDividerColor;
private int mDividerWidth;
private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout, defStyleAttr, defStyleRes);
try {
mHorizontalSpacing = (int) ta.getDimension(R.styleable.FlowLayout_horizonSpacing, DEFAULT_SPACING);
mVerticalSpacing = (int) ta.getDimension(R.styleable.FlowLayout_verticalSpacing, DEFAULT_SPACING);
mDividerWidth = (int) ta.getDimension(R.styleable.FlowLayout_dividerWidth, DEFAULT_DIVIDER_WIDTH);
mDividerColor = ta.getColor(R.styleable.FlowLayout_dividerColor, DEFAULT_DIVIDER_COLOR);
int index = ta.getInt(R.styleable.FlowLayout_android_gravity, -1);
if (index > 0) {
setGravity(index);
}
initPaint();
} finally {
ta.recycle();
}
setWillNotDraw(false);
}
一般來說,我們的自定義屬性都得給個默認值,大家都這么懶,不能強人所難對不對。這默認值可以通過常量直接寫在自定義類里,如上述代碼所示。也可以寫在xml資源文件里,提供給別人統(tǒng)一修改。
其次呢,英明神武的蘑菇君自然也得提供方法讓別人方便的通過代碼去動態(tài)修改這些屬性啦(真不要臉~~( ﹁ ﹁ ) ~~~):
public void setHorizontalSpacing(int pixelSize) {
mHorizontalSpacing = pixelSize;
requestLayout();
}
public void setVerticalSpacing(int pixelSize) {
mVerticalSpacing = pixelSize;
requestLayout();
}
public void setDividerColor(@ColorInt int color) {
mDividerColor = color;
mDividerPaint.setColor(color);
invalidate();
}
...
關于自定義屬性的一些詳細知識可以參考文章: Android 深入理解Android中的自定義屬性
3.2 測量
在自定義ViewGroup時,測量流程一般是所有流程中最為復雜的一環(huán)。因為我們不僅要測量ViewGroup自身的尺寸,還得測量所有孩子的尺寸。而ViewGroup和孩子們之間的尺寸又是相互影響的。
如下圖所示,在我們的FlowLayout里,當寬的測量模式為AT_MOST
(比如FlowLayout的布局屬性android:layout_width
為wrap_content
時),F(xiàn)lowLayout的測量寬度應該是所有行里最長的那一行的寬度,在下圖中就是第二行的寬度。而當高的測量模式為AT_MOST
,F(xiàn)lowLayout的測量高度應該是所有行的高度總和。
而對于child view來說,也有個小小的限制:當FlowLayout的layout_height
為wrap_content
,而child的layout_height
為match_parent
時,我希望child的測量高為它所處那一行的高度,而不是整個FlowLayout的高度或者是wrap_content
。這也挺合情合理的吧,比如下圖中第一行的child 再見這群坑比
的layout_height
為match_parent
,所以它就和第一行的高度一樣高。
可能說得大家都有點暈了X﹏X,還是來一起看看onMeasure
方法的源碼吧:
//保存所有child view
private final List<List<View>> mLines = new ArrayList<>();
//保存所有行高
private final List<Integer> mLineHeights = new ArrayList<>();
//保存所有行寬
private final List<Integer> mLineWidths = new ArrayList<>();
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
mLines.clear();
mLineHeights.clear();
mLineWidths.clear();
int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
int widthUsed = getPaddingLeft() + getPaddingRight() + mHorizontalSpacing;
int lineWidth = widthUsed;
int lineHeight = 0;
int childCount = getChildCount();
List<View> lineViews = new ArrayList<>();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if (child.getVisibility() == View.GONE) {
continue;
}
LayoutParams lp = (LayoutParams) child.getLayoutParams();
//測量每個child的寬高,每個child可用的最大寬高為sizeWidth-spacing-padding-margin
measureChildWithMargins(child, widthMeasureSpec, mHorizontalSpacing * 2, heightMeasureSpec, mVerticalSpacing * 2);
int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
//判斷這一行是否還能容下這個child
if (lineWidth + childWidth + mHorizontalSpacing > sizeWidth) {
//需要換行,則記錄這一行的寬度,高度,下一行的初始寬度,初始高度
mLineWidths.add(lineWidth);
lineWidth = widthUsed + childWidth + mHorizontalSpacing;
mLineHeights.add(lineHeight);
lineHeight = childHeight;
mLines.add(lineViews);
lineViews = new ArrayList<>();
} else {//容得下,則累加這一行的寬度,記錄這一行的高度
lineWidth += childWidth + mHorizontalSpacing;
lineHeight = Math.max(lineHeight, childHeight);
}
lineViews.add(child);
}
//最后一行的處理
mLineHeights.add(lineHeight);
mLineWidths.add(lineWidth);
mLines.add(lineViews);
int maxWidth = Collections.max(mLineWidths);
processChildHeights();//計算所有行的累積高度
int totalHeight = getChildHeights();
//TODO 處理getMinimumWidth/height的情況
//設置自身的測量寬高
setMeasuredDimension(
(modeWidth == MeasureSpec.EXACTLY) ? sizeWidth : Math.min(maxWidth, sizeWidth),
(modeHeight == MeasureSpec.EXACTLY) ? sizeHeight : Math.min(totalHeight, sizeHeight));
//重新測量child的lp.height為MATCH_PARENT時的child的尺寸
remeasureChild(widthMeasureSpec);
}
上面的代碼邏輯都有注釋,相信大家都能理清大概的邏輯。暫時沒理解也沒關系,稍后自己去看代碼再加上自己的思考肯定能看懂滴。(蘑菇君自我感覺腦子轉的算慢的,看Github上的FlowLayout源碼花了蠻久時間才弄懂大概邏輯,自己畫圖呀,運行demo呀,弄懂了以后,才開始自己動手寫自己的FlowLayout...(??????)??)
這里要特別注意的是對children的測量過程。在上面的代碼中,我使用了ViewGroup類里提供的measureChildWithMargins
方法去測量每個child,對這個方法的具體剖析,可以去看自定義控件知識儲備-View的繪制流程,這篇文章講的很詳細。但在上文中有提到過,我們對child有個限制:
當child的
layout_height
為match_parent
時,child的測量高為它所處那一行的高度,而不是整個FlowLayout的高度或者是wrap_content
。
但是這個child所處那一行的高度是那一行所有child的高度的最大值,所以只有在完成這一行所有child的測量后,才知道這一行的高度是多少。所以上面的要求無法滿足呀!我在測量該child的高度的時候,還不知道這一行的高度是多少啊!
該怎么辦呢?其實也簡單,既然當時測量某child的時候還不知道那一行的高度,那就在第一次所有child都測量完成后,再對那些layout_height
為match_parent
的child測量一遍就好啦。所以在上面onMeasure
方法里的最后調用了remeasureChild
這個方法去重新測量一遍child:
private void remeasureChild(int parentWidthSpec) {
int numLines = mLines.size();
for (int i = 0; i < numLines; i++) {//遍歷每一行
int lineHeight = mLineHeights.get(i);
List<View> lineViews = mLines.get(i);
int children = lineViews.size();
for (int j = 0; j < children; j++) {
View child = lineViews.get(j);
LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (lp.height == LayoutParams.MATCH_PARENT) {//對高為match_parent的child進行處理
if (child.getVisibility() == View.GONE) {
continue;
}
int widthUsed = lp.leftMargin + lp.rightMargin +
getPaddingLeft() + getPaddingRight() + 2 * mHorizontalSpacing;
//再次調用child的measure方法進行測量
child.measure(
getChildMeasureSpec(parentWidthSpec, widthUsed, lp.width),
MeasureSpec.makeMeasureSpec(lineHeight - lp.topMargin - lp.bottomMargin, MeasureSpec.EXACTLY)
);
}
}
}
}
從這里我們也看得出來,一個View的onMeasure
方法是很有可能被調用多次來確定最終的測量寬高的,所以下次遇到打印日志里或者斷點調試下發(fā)現(xiàn) onMeasure
方法多次運行,莫要方呀o( ̄??)。
3.3 布局
布局過程呢,就稍微簡單一些,因為我們在onMeasure
方法里已經(jīng)將所有child的寬高和位于哪一行等信息都計算好了,只要遍歷children調用它們的layout
方法放置好它們就行。不過這里有點麻煩的就是,我們需要支持FlowLayout自身的gravity屬性和children的 gravity屬性。那就得根據(jù)具體的gravity來計算相應的偏移量了,代碼如下:
//根據(jù)gravity計算FlowLayout的垂直方向上的偏移量
private void processVerticalGravityMargin() {
int verticalGravityMargin;
int childHeights = getChildHeights();
switch ((mGravity & Gravity.VERTICAL_GRAVITY_MASK)) {
case Gravity.TOP://頂部
default:
verticalGravityMargin = 0;
break;
case Gravity.CENTER_VERTICAL://垂直居中
verticalGravityMargin = Math.max((getHeight() - childHeights) / 2, 0);
break;
case Gravity.BOTTOM://底部
verticalGravityMargin = Math.max(getHeight() - childHeights, 0);
break;
}
mVerticalGravityMargin = verticalGravityMargin;
}
//根據(jù)gravity計算FlowLayout的水平方向上的偏移量
private void processHorizontalGravityMargins() {
mLineMargins.clear();
float horizontalGravityFactor;
switch ((mGravity & Gravity.HORIZONTAL_GRAVITY_MASK)) {
case Gravity.LEFT://水平靠左
default:
horizontalGravityFactor = 0;
break;
case Gravity.CENTER_HORIZONTAL://水平居中
horizontalGravityFactor = .5f;
break;
case Gravity.RIGHT://水平靠右
horizontalGravityFactor = 1;
break;
}
int linesNum = mLineWidths.size();
for (int i = 0; i < linesNum; i++) {
int lineWidth = mLineWidths.get(i);
mLineMargins.add((int) ((getWidth() - lineWidth) * horizontalGravityFactor) + getPaddingLeft() + mHorizontalSpacing);
}
}
給FlowLayout設置gravity的效果如下:
內容居中:
內容在右下角:
計算好了每行的偏移量后,layout
方法的邏輯就很清晰了:
protected void onLayout(boolean changed, int l, int t, int r, int b) {
processHorizontalGravityMargins();
processVerticalGravityMargin();
int numLines = mLines.size();
int left;
int top = getPaddingTop() + mVerticalSpacing + mVerticalGravityMargin;
for (int i = 0; i < numLines; i++) {
int lineHeight = mLineHeights.get(i);
List<View> lineViews = mLines.get(i);
left = mLineMargins.get(i);
int children = lineViews.size();
for (int j = 0; j < children; j++) {
View child = lineViews.get(j);
if (child.getVisibility() == View.GONE) {
continue;
}
LayoutParams lp = (LayoutParams) child.getLayoutParams();
int childWidth = child.getMeasuredWidth();
int childHeight = child.getMeasuredHeight();
int gravityMargin = 0;
//根據(jù)child的gravity計算child的相應偏移量
if (Gravity.isVertical(lp.gravity)) {
switch (lp.gravity) {
case Gravity.TOP:
default:
gravityMargin = 0;
break;
case Gravity.CENTER_VERTICAL:
case Gravity.CENTER:
gravityMargin = (lineHeight - childHeight - lp.topMargin - lp.bottomMargin) / 2;
break;
case Gravity.BOTTOM:
gravityMargin = lineHeight - childHeight - lp.topMargin - lp.bottomMargin;
break;
//TODO 水平方向上可以支持gravity么?
}
}
child.layout(left + lp.leftMargin,
top + lp.topMargin + gravityMargin,
left + lp.leftMargin + childWidth,
top + lp.topMargin + gravityMargin + childHeight);
Log.i(TAG, String.format("child (%d,%d) position: (%d,%d,%d,%d)",
i, j, child.getLeft(), child.getTop(), child.getRight(), child.getBottom()));
left += childWidth + lp.leftMargin + lp.rightMargin + mHorizontalSpacing;
}
top += lineHeight + mVerticalSpacing;
}
}
3.4 繪制
本FlowLayout支持繪制分割線,這也是很容易的繪制,只要找準每條分割線的位置就行。不過萬變不離其宗嘛,我現(xiàn)在能畫一條線,下次就能畫一個圓,再下次就能畫個雞蛋,再再下次我就能飛上天,畫出太陽肩并肩...。咳咳,扯遠了,我們還是來看看onDraw
方法里的繪制邏輯:
@Override
protected void onDraw(Canvas canvas) {
int top = getPaddingTop() + mVerticalSpacing + mVerticalGravityMargin;
int numLines = mLines.size();
for (int i = 0; i < numLines; i++) {
int lineHeight = mLineHeights.get(i);
top += lineHeight + mVerticalSpacing;
canvas.drawLine(getPaddingLeft(), top - mVerticalSpacing / 2,
getWidth() - getPaddingRight(), top - mVerticalSpacing / 2, mDividerPaint);
}
}
確實很簡單,遍歷每一行,在兩行的中間根據(jù)配置的顏色和寬度畫出一條線段即可。
不過這里要注意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);
}
從這個方法的注釋中可以看出,如果一個View不需要繪制任何內容,那么設置這個標記位為true后,系統(tǒng)會進行相應的優(yōu)化。默認情況下,View沒有啟用這個優(yōu)化標記位,而ViewGroup會默認啟用這個標記位。
當我們的自定義ViewGroup需要通過重寫
onDraw
來繪制內容時,我們需要顯式地關閉WILL_NOT_DRAW這個標記位。
所以,在這個FlowLayout的構造方法里,我們可以調用setWillNotDraw(false)
來進行優(yōu)化。
3.5 處理LayoutParams
幾乎每個自定義ViewGroup都得自定義自己的LayoutParams,來給children提供更好的服務。在本FlowLayout里,能給children帶來的就是gravity屬性的支持。來看看自定義的LayoutParams:
public static class LayoutParams extends MarginLayoutParams {
public int gravity = -1;
public LayoutParams(Context c, AttributeSet attrs) {
super(c, attrs);
TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout);
try {
gravity = a.getInt(R.styleable.FlowLayout_Layout_android_layout_gravity, -1);
} finally {
a.recycle();
}
}
public LayoutParams(int width, int height) {
super(width, height);
gravity = Gravity.TOP;
}
public LayoutParams(int width, int height, int gravity) {
super(width, height);
this.gravity = gravity;
}
public LayoutParams(ViewGroup.LayoutParams source) {
super(source);
}
}
同時,F(xiàn)lowLayout還需要對以下幾個方法進行重寫:
@Override
protected LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
}
@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
return super.checkLayoutParams(p) && p instanceof LayoutParams;
}
@Override
protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
return new LayoutParams(p);
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(getContext(), attrs);
}
啥?不知道為啥要按上述代碼那樣做?那是時候去看看自定義控件知識儲備-LayoutParams的那些事了。看完了你就大徹大悟,遁入......咳咳。
3. 展示
哎呀呀,這篇文章已經(jīng)夠長了,我就不貼資源文件,截圖等東西啦,大家有需要的話,可以去Github上下載源碼進行學習。
Github地址: https://github.com/yisizhu520/FlowLayout
PS:蘑菇君寫的這個FlowLayout肯定還存在bug,而且我自己也知道幾個不影響使用的小bug,但是我沒有去改,等待有緣人去發(fā)現(xiàn)哈(≧?≦)?。
也歡迎大家去提交issue和pull request,一起交流,一起進步。
4. 總結
終于寫完這篇博客了,真是寫死我了?(T?T)。希望這篇文章除了能加深自己對自定義ViewGroup的理解外,還能幫助到大家。以前一直以為自己了解了自定義ViewGroup的一些知識,想要寫一個容器控件出來應該不難的。然而,紙上得來終覺淺,當自己真的開始寫的時候,發(fā)現(xiàn)滿滿的都是細節(jié),滿滿的都是套路。比如在FlowLayout里的測量、布局、繪制都得考慮到間距的問題,什么margin啊,padding啊,spacing啊,都需要小心對待。不過,最終還是在不斷的調試和修改中寫出來了這個FlowLayout,想想還有點小激動呢!以后要做的應該就是不斷的練習和總結,畢竟編程這件事,沒啥好說的,just code it!
我是蘑菇君,我為自己帶鹽