我們在APP中經常看到這樣的效果:
這是美團的熱門搜索界面,裏面羅列出了長度不等的標籤,應用會根據標籤的長度自動換行,比如第一行有3個標籤,而第二行只有2個標籤,這篇文章就來講下如何實現這種效果,首先來看效果圖,爲了更好的展示不同長度和寬度時的顯示效果,這裏加了一個紅色背景,實際使用時自己去掉即可
1. 當寬度爲MATCH_PARENT,高度爲WRAP_CONTENT時
2. 當寬度爲WRAP_CONTENT,高度爲WRAP_CONTENT時
3. 當寬度爲指定值時,這裏爲400dp,高度爲WRAP_CONTENT時
4. 當寬度和高度都爲指定值時,這裏寬度爲500dp,高度爲200dp
其它情況不一一列出,不管寬高度如何設置,MATCH_PARENT,WRAP_CONTENT或者指定值(當然指定值不能小於實際需要的長度),均可正常工作,下面來說下原理:
這裏毫無疑問,用到了自定義控件,這裏每個標籤都是一個View,所以這個自定義控件是一個ViewGroup,用來管理這裏所有的子View。自定義ViewGroup最重要的就是onMeasure和onLayout方法,前者用來測量自定義控件本身的大小,後者用來確定自定義控件中的每個子控件的位置,來看自定義ViewGroup的實現:
1. 繼承ViewGroup,聲明構造函數
public class TagLayout extends ViewGroup {
private List<int[]> children;
public TagLayout(Context context, AttributeSet attrs) {
super(context, attrs);
children = new ArrayList<int[]>();
}
這裏聲明瞭一個變量children,這個變量用來存儲每個child的位置,int數組中int[0]爲child的left座標,int[1]爲child的top座標,int[2]爲child的right座標,int[3]爲child的bottom座標,之所以設置這樣一個變量存儲每個child的位置,是因爲在onMeasure中計算自定義控件的大小時,就需要根據所有子控件佔據的空間來確定,這時已經算出了子控件的位置,而在onLayout中,再次計算子控件的大小就重複了,所以設置這樣一個變量,在onMeasure中賦值,而在onLayout中使用
2. 實現onMeasure方法
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
measureChildren(widthMeasureSpec, heightMeasureSpec);
final int count = getChildCount(); // tag的數量
int left = 0; // 當前的左邊距離
int top = 0; // 當前的上邊距離
int totalHeight = 0; // WRAP_CONTENT時控件總高度
int totalWidth = 0; // WRAP_CONTENT時控件總寬度
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) child.getLayoutParams();
if (i == 0) { // 第一行的高度
totalHeight = params.topMargin + child.getMeasuredHeight() + params.bottomMargin;
}
if (left + params.leftMargin + child.getMeasuredWidth() + params.rightMargin > getMeasuredWidth()) { // 換行
left = 0;
top += params.topMargin + child.getMeasuredHeight() + params.bottomMargin; // 每個TextView的高度都一樣,隨便取一個都行
totalHeight += params.topMargin + child.getMeasuredHeight() + params.bottomMargin;
}
children.add(new int[]{left + params.leftMargin, top + params.topMargin, left + params.leftMargin + child.getMeasuredWidth(), top + params.topMargin + child.getMeasuredHeight()});
left += params.leftMargin + child.getMeasuredWidth() + params.rightMargin;
if (left > totalWidth) { // 當寬度爲WRAP_CONTENT時,取寬度最大的一行
totalWidth = left;
}
}
int height = 0;
if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) {
height = MeasureSpec.getSize(heightMeasureSpec);
} else {
height = totalHeight;
}
int width = 0;
if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) {
width = MeasureSpec.getSize(widthMeasureSpec);
} else {
width = totalWidth;
}
setMeasuredDimension(width, height);
}
這個方法有點長,但原理比較簡單,首先調用measureChildren(widthMeasureSpec, heightMeasureSpec);方法,這個方法的作用在於測量每個子控件的大小和模式,因爲下面我們需要獲取每個子控件的寬高和margin等參數的值,所以必須首先調用該方法。
接下來聲明瞭一些用到的變量left, top, totalHeight, totalWidth等,變量的作用註釋已經說明了。
下面進入核心部分,遍歷自定義控件的所有子控件,在這裏,大家首先需要明白,對自定義控件的長和寬的值,有兩種大的情況,一種是MATCH_PARENT或指定值,一種是WRAP_CONTENT,其中前者的值是確定的,我們可以直接通過MeasureSpec來獲取,而後者的值是不定的,我們需要自己計算,上面的totalHeight和totalWidth變量,都是針對的後一種情況。
當i == 0,也就是第一個子控件時,我們首先計算totalHeight的值,因爲每個子控件的高度都一樣,所以就取第一個即可,它的值包括3個部分,自身的高度和上下的邊距。
接下來,我們計算當前的left加上我們現在準備添加的子控件的寬度後,是否大於自定義控件的寬度,如果大於,那說明要換行了,這個時候要將left重新賦值爲0,因爲換行後它的左邊距離爲0了,並且將top和totalHeight都加上一個子控件的高度,也就是一行的高度。而如果當前的left加上準備添加的子控件的寬度小於自定義控件的寬度,則說明在這行新加一個子控件是沒有問題的。
接着,將子控件的位置存儲在children中,這裏就是每個子控件在自定義控件中的left,top,right,bottom,根據當前已有的left,top以及子控件本身的寬高和margin,很容易計算出來,存儲在children中後,就可以在onLayout的時候直接用了。
完後,將left的值加上當前子控件佔據的控件,也就是放上新子控件後,left的新位置,同時還要記得比較left和totalWidth的值,當自定義控件的寬度爲WRAP_CONTENT時,totalWidth的值爲最寬一行的寬度。
下面,當前自定義控件的模式,也就是最開始說的兩種大的情況,當模式爲EXACTLY時,說明寬高是精確的,直接通過MeasureSpec.getSize取出即可,而否則,就是寬高不固定的情況,這時就要用我們上面定義的totalWidth和totalHeight的值了。
最後,調用setMeasuredDimension方法來確定自定義控件的寬高。
3. 實現onLayout方法
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int count = getChildCount();
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
int[] position = children.get(i);
child.layout(position[0], position[1], position[2], position[3]);
}
}
在onMeasure方法中,我們已經得到了每個子控件的left, top, right,bottom,所以這裏就很簡單了,直接調用layout方法確定每個子控件的位置即可。
完整的TagLayout.java方法如下:
public class TagLayout extends ViewGroup {
private List<int[]> children;
public TagLayout(Context context, AttributeSet attrs) {
super(context, attrs);
children = new ArrayList<int[]>();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
measureChildren(widthMeasureSpec, heightMeasureSpec);
final int count = getChildCount(); // tag的數量
int left = 0; // 當前的左邊距離
int top = 0; // 當前的上邊距離
int totalHeight = 0; // WRAP_CONTENT時控件總高度
int totalWidth = 0; // WRAP_CONTENT時控件總寬度
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) child.getLayoutParams();
if (i == 0) { // 第一行的高度
totalHeight = params.topMargin + child.getMeasuredHeight() + params.bottomMargin;
}
if (left + params.leftMargin + child.getMeasuredWidth() + params.rightMargin > getMeasuredWidth()) { // 換行
left = 0;
top += params.topMargin + child.getMeasuredHeight() + params.bottomMargin; // 每個TextView的高度都一樣,隨便取一個都行
totalHeight += params.topMargin + child.getMeasuredHeight() + params.bottomMargin;
}
children.add(new int[]{left + params.leftMargin, top + params.topMargin, left + params.leftMargin + child.getMeasuredWidth(), top + params.topMargin + child.getMeasuredHeight()});
left += params.leftMargin + child.getMeasuredWidth() + params.rightMargin;
if (left > totalWidth) { // 當寬度爲WRAP_CONTENT時,取寬度最大的一行
totalWidth = left;
}
}
int height = 0;
if (MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY) {
height = MeasureSpec.getSize(heightMeasureSpec);
} else {
height = totalHeight;
}
int width = 0;
if (MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) {
width = MeasureSpec.getSize(widthMeasureSpec);
} else {
width = totalWidth;
}
setMeasuredDimension(width, height);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int count = getChildCount();
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
int[] position = children.get(i);
child.layout(position[0], position[1], position[2], position[3]);
}
}
}
其它的工作就比較簡單了,界面佈局activity_main.xml如下:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent"
>
<com.my.flowlayout.TagLayout
android:id="@+id/tags"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#F00">
</com.my.flowlayout.TagLayout>
</LinearLayout>
注意這裏com.my.flowlayout要改成你自己的包名,android:background="#F00"是我爲了清楚看到不同寬高的背景,實際使用中請去掉
主類MainActivity.java如下:
public class MainActivity extends Activity {
TagLayout mFlowLayout;
String[] tags = new String[] {"別人家孩子作業做到轉鍾", "別人家孩子週末都在家學習", "成天就知道玩遊戲", "別人上清華了", "比你優秀的人還比你勤奮", "我怎麼教出你這麼個不爭氣的敗家子", "因爲你是小明?"};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mFlowLayout = (TagLayout) findViewById(R.id.tags);
for (int i = 0; i < tags.length; i++) {
TextView tv = new TextView(this);
tv.setText(tags[i]);
tv.setTextColor(Color.BLACK);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
params.setMargins(10, 10, 10, 10);
tv.setLayoutParams(params);
tv.setBackgroundResource(R.drawable.text_background);
mFlowLayout.addView(tv);
}
}
}
這裏使用了我們自定義的ViewGroup類TagLayout,完後動態添加TextView,也就是標籤,params.setMargins(10, 10, 10, 10);是每個標籤的margin的值,可以自行根據實際需要修改,這裏還用到了一個背景text_background.xml,如下:
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#CCC"/>
<corners android:radius="5dp"/>
<padding
android:bottom="5dp"
android:left="10dp"
android:right="10dp"
android:top="5dp"/>
</shape>
這就是每個標籤的背景,沒什麼好說的,至此就完成了所有的工作。
如果不想複製粘貼,也可以直接下載項目源碼下載