那些年我們熬夜打造一可收縮流式標籤控制元件
一、前言
時間匆匆,一眨眼來廈門已經一個多月了。似乎已經適應了這邊的生活,喜歡這邊的風,溫和而舒適,還有淡淡海的味道 。。。
還在再次跟大家致個歉意,部落格的更新又延期了。新的環境,新的工作加上專案大改版,基本每天都有大量的事情,週末也不得空閒。
非常感謝大家的陪伴,一路有你們,生活會充滿美好。
標籤控制元件
本文還是繼續講解自定義控制元件,近期在專案中有這樣一個控制元件。
實現可收縮的流式標籤控制元件,具體效果圖如下:
支援多選,單選,反選
子 View 定製化
效果圖不是很清晰,文章後面會提供下載地址。
主要實現功能細分如下:
實現流式佈局(第一個子 View 始終位於首行的最右邊)
佈局可定製化(採取適配模式)
實現控制元件的收縮
主要有這三個小的功能組成。第一個流式佈局實現需要注意的是,第一個元素(子 View)需要固定在首行的最右邊,採取的解決方案是首先繪製第一個元素且繪製在最右邊;第二個佈局可定製化,怎麼來理解這句話呢?我希望實現的子 View 不單單是圓角控制元件,而是高度定製的所有控制元件,由使用者來決定,採取的解決方案是採用了適配模式;第三個控制元件的收縮,這個實現起來就比較簡單了,完成了第一步就可以獲取到控制元件的高度,採用屬性動畫來動態改變控制元件的高度。具體我們一起來往下面看看。
流式佈局
效果圖一欄:
實現效果圖的流式佈局,有兩種方案。一、直接使用 recyclerView ;二、自定義繼承 ViewGroup。本文采用第二種方案,相信大家一定非常熟悉自定義 View 三部曲 ->onMeasure() ->onLayout() ->onDraw() ,吐血推薦以下文章:
onMeasure()測量
要實現標籤流式佈局,需要涉及到以下幾個問題:
(1)、【下拉按鈕】 的測量和佈局
標籤佈局當中【下拉按鈕】始終固定在首行的最右邊,如果依次繪製子 View 可能導致【下拉按鈕】處於第二行,或未處於最右邊(與最右邊還有一定的間距)。為了滿足需求,優先測量和佈局【下拉按鈕】並把第一個 View 作為【下拉按鈕】。
(2)、何時換行
如果當前行已經放不下下一個控制元件,那麼就需要把這個控制元件移到下一行顯示。所以我們要有個變數記錄當前行已經佔據的寬度,以判斷剩下的空間是否還能容得下下一個控制元件。
(3)、如何得到佈局的寬度
為了得到佈局的寬度,我們記錄每行的高度取最大值。
(4)、如何得到佈局的高度
記錄每行的高度,佈局的高度就是所有行高度之和。
宣告的變數如下:
int lineWidth = 0; //記錄每行的寬度
int lineHeight = 0; //記錄每行的高度
int height = 0; //佈局高度
int width = 0; //佈局寬度
int count = getChildCount(); //所有子控制元件數量
boolean firstLine = true; //是否處於第一行
firstLineCount = 0; //第一行子 View 個數
然後開始測量(貼出 onMeasure 的全部程式碼,再細講):
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
//測量子View
measureChild(child, widthMeasureSpec, heightMeasureSpec);
int childWidth = 0;
int childHeight = 0;
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
if (lineWidth + childWidth > measureWidth) {
//需要換行
width = Math.max(lineWidth, width);
height += lineHeight;
//需要換行,而將此控制元件調到下一行,所以將此控制元件的高度和寬度初始化給lineHeight、lineWidth
lineHeight = childHeight;
lineWidth = childWidth;
firstLine = false;
} else {
// 否則累加值lineWidth,lineHeight取最大高度
lineHeight = Math.max(lineHeight, childHeight);
lineWidth += childWidth;
if (firstLine) {
firstLineCount++;
firstLineHeight = lineHeight;
}
}
//注意這裡是用於新增尾部收起的佈局,寬度為父控制元件寬度。所以要單獨處理
if (i == count - 1) {
height += lineHeight;
width = Math.max(width, lineWidth);
if (firstLine) {
firstLineCount = 1;
}
}
}
//如果未超過一行
if (mFirstHeight) {
measureHeight = height;
}
setMeasuredDimension((measureWidthMode == MeasureSpec.EXACTLY) ? measureWidth
: width, (measureHeightMode == MeasureSpec.EXACTLY) ? measureHeight
: height);
首先我們迴圈遍歷每個子控制元件,計算每個子控制元件的寬度和高度,程式碼如下:
View child = getChildAt(i);
//測量子View
measureChild(child, widthMeasureSpec, heightMeasureSpec);
int childWidth = 0;
int childHeight = 0;
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
需要注意 child.getMeasuredWidth() , child.getMeasuredHeight() 能夠獲取到值,必須先呼叫 measureChild() 方法;同理呼叫 onLayout() 後,getWidth() 才能獲取到值。以下以子控制元件所佔寬度來講解:
childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
子控制元件所佔寬度=子控制元件寬度+左右的 Margin 值 。還得注意一點為了獲取到子控制元件的左右 Margin 值,需要重寫以下方法:
@Override
protected LayoutParams generateLayoutParams(LayoutParams lp) {
return new MarginLayoutParams(lp);
}
@Override
protected LayoutParams generateDefaultLayoutParams() {
return new MarginLayoutParams(LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT);
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new MarginLayoutParams(getContext(), attrs);
}
下面就是計算是否需要換行,以及計算父控制元件的寬高度:
if (lineWidth + childWidth > measureWidth) {
//需要換行
width = Math.max(lineWidth, width);
height += lineHeight;
//因為由於盛不下當前控制元件,而將此控制元件調到下一行,所以將此控制元件的高度和寬度初始化給lineHeight、lineWidth
lineHeight = childHeight;
lineWidth = childWidth;
firstLine = false; //控制元件超過了一行
} else {
// 否則累加值lineWidth,lineHeight取最大高度
lineHeight = Math.max(lineHeight, childHeight);
lineWidth += childWidth;
if (firstLine) { //控制元件未超過一行
firstLineCount++; //記錄首行子控制元件個數
firstLineHeight = lineHeight;//獲取第一行控制元件的高度
}
}
由於 lineWidth 表示當前行已經佔據的寬度,所以 lineWidth + childWidth > measureWidth,加上下一個子控制元件的寬度大於了父控制元件的寬度,則說明當前行已經放不下當前子控制元件,需要放到下一行;先看 else 部分,在未換行的情況 lineHeight 為當前行子控制元件的最大值,lineWidth 為當前行所有控制元件寬度之和。
在需要換行時,首先將當前行寬 lineWidth 與目前的最大行寬 width 比較計算出最新的最大行寬 width,作為當前父控制元件所佔的寬度,還要將行高 lineHeight 累加到height 變數上,以便計算出父控制元件所佔的總高度。
width = Math.max(lineWidth, width);
height += lineHeight;
在需要換行時,需要對當前行寬,高進行賦值。
lineHeight = childHeight;
lineWidth = childWidth;
我們還需要處理一件事情,記錄首行子控制元件的個數以及首行的高度。
if (firstLine) { //控制元件未超過一行
firstLineCount++; //記錄首行子控制元件個數
firstLineHeight = lineHeight;//獲取第一行控制元件的高度
}
如果超過了一行 firstLine 賦值為 false 。
最後一個子控制元件我們需要單獨處理,獲取最終的父控制元件的寬高度。
//最後一行是不會超出width範圍的,所以要單獨處理
if (i == count - 1) {
height += lineHeight;
width = Math.max(width, lineWidth);
if (firstLine) {
firstLineCount = 1;
}
}
最後就是呼叫 setMeasuredDimension() 方法,設定到系統中。
setMeasuredDimension((measureWidthMode == MeasureSpec.EXACTLY) ? measureWidth : width, (measureHeightMode ==
MeasureSpec.EXACTLY) ? measureHeight : height);
onLayout()佈局
佈局所有的子控制元件,由於控制元件要後移和換行,所以我們要標記當前控制元件的 left 座標和 top 座標,申明的幾個變數如下:
int count = getChildCount();
int lineWidth = 0;//累加當前行的行寬
int lineHeight = 0;//當前行的行高
int top = 0, left = 0;//當前座標的top座標和left座標
int parentWidth = getMeasuredWidth(); //父控制元件的寬度
首先我們需要佈局第一個子控制元件,使它位於首行的最右邊。呼叫 child.layout
進行子控制元件的佈局。layout 的函式如下,分別計算 l , t , r , b
layout(int l, int t, int r, int b)
l = 父控制元件的寬度 - 子控制元件的右Margin - 子控制元件高度
t = 子控制元件的頂部Margin
r = l + 子控制元件寬度
b = t + 子控制元件高度
具體佈局程式碼如下:
if (i == 0) {
child.layout(parentWidth - lp.rightMargin - child.getMeasuredWidth(), lp.topMargin, parentWidth - lp
.rightMargin, lp.topMargin + child.getMeasuredHeight());
firstViewWidth = childWidth;
firstViewHeight = childHeight;
continue;
}
接著按著順序對子控制元件進行佈局,先計算出子控制元件的寬高:
View child = getChildAt(i);
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
//寬度(包含margin值和子控制元件寬度)
int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
//高度同上
int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
然後判斷當前佈局子控制元件是否為首行最後佈局的控制元件,並對 lineWidth
, lineHeight
再次計算:
if (firstLineCount == (i + 1)) {
lineWidth += firstViewWidth;
lineHeight = Math.max(lineHeight, firstViewHeight);
}
然後根據是否要換行來計算當行控制元件的 top 座標和 left 座標:
if (childWidth + lineWidth >getMeasuredWidth()){
//如果換行,當前控制元件將跑到下一行,從最左邊開始,所以left就是0,而top則需要加上上一行的行高,才是這個控制元件的top點;
top += lineHeight;
left = 0;
//同樣,重新初始化lineHeight和lineWidth
lineHeight = childHeight;
lineWidth = childWidth;
}else{
// 否則累加值lineWidth,lineHeight取最大高度
lineHeight = Math.max(lineHeight,childHeight);
lineWidth += childWidth;
}
在計算好 left,top 之後,然後分別計算出控制元件應該佈局的上、下、左、右四個點座標,需要非常注意的是 margin 不是 padding,margin 的距離是不繪製的控制元件內部的,而是控制元件間的間隔。
//計算childView的left,top,right,bottom
int lc = left + lp.leftMargin;
int tc = top + lp.topMargin;
int rc = lc + child.getMeasuredWidth();
int bc = tc + child.getMeasuredHeight();
child.layout(lc, tc, rc, bc);
//將left置為下一子控制元件的起始點
left += childWidth;
最後在 onLayout
方法當中,我們需要儲存當前父控制元件的高度來實現收縮,展開效果。
if (mFirstHeight) {
contentHeight = getHeight();
mFirstHeight = false;
if (mListener != null) {
mListener.onFirstLineHeight(firstLineHeight);
}
}
onLayout 的完整程式碼如下:
private void buildLayout() {
int count = getChildCount();
int lineWidth = 0;//累加當前行的行寬
int lineHeight = 0;//當前行的行高
int top = 0, left = 0;//當前座標的top座標和left座標
int parentWidth = getMeasuredWidth(); //父控制元件的寬度
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
if (i == 0) {
child.layout(parentWidth - lp.rightMargin - child.getMeasuredWidth(), lp.topMargin, parentWidth - lp
.rightMargin, lp.topMargin + child.getMeasuredHeight());
firstViewWidth = childWidth;
firstViewHeight = childHeight;
continue;
}
if (firstLineCount == (i + 1)) {
lineWidth += firstViewWidth;
lineHeight = Math.max(lineHeight, firstViewHeight);
}
if (childWidth + lineWidth > getMeasuredWidth()) {
//如果換行
top += lineHeight;
left = 0;
lineHeight = childHeight;
lineWidth = childWidth;
} else {
lineHeight = Math.max(lineHeight, childHeight);
lineWidth += childWidth;
}
//計算childView的left,top,right,bottom
int lc = left + lp.leftMargin;
int tc = top + lp.topMargin;
int rc = lc + child.getMeasuredWidth();
int bc = tc + child.getMeasuredHeight();
child.layout(lc, tc, rc, bc);
//將left置為下一子控制元件的起始點
left += childWidth;
}
if (mFirstHeight) {
contentHeight = getHeight();
mFirstHeight = false;
if (mListener != null) {
mListener.onFirstLineHeight(firstLineHeight);
}
}
}
佈局可定製化
為了實現佈局的可定製化,採用了適配模式,
public void setAdapter(ListAdapter adapter) {
if (adapter != null && !adapter.isEmpty()) {
buildTagItems(adapter);//構建標籤列表項
}
}
先貼出構建標籤列表項的程式碼:
private void buildTagItems(ListAdapter adapter) {
//移除所有控制元件
removeAllViews();
//新增首view
// addFirstView();
for (int i = 0; i < adapter.getCount(); i++) {
final View itemView = adapter.getView(i, null, this);
final int position = i;
if (itemView != null) {
if (i == 0) {
firstView = itemView;
itemView.setVisibility(View.INVISIBLE);
itemView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
//展開動畫
expand();
}
});
} else {
itemView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (mListener != null) {
//item 點選回撥
mListener.onClick(v, position);
}
}
});
}
itemView.setTag(TAG + i);
mChildViews.put(i, itemView);
//新增子控制元件
addView(itemView);
}
}
//新增底部收起試圖
addBottomView();
}
獲取子控制元件:
final View itemView = adapter.getView(i, null, this);
針對第一個子控制元件,點選展開試圖:
if (i == 0) {
firstView = itemView;
itemView.setVisibility(View.INVISIBLE);
itemView.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
//展開
expand();
}
});
然後新增子控制元件:
addView(itemView);
最後新增底部:
addBottomView();
原始碼在文章的末尾,文章有點長,希望各位繼續往後面看。
控制元件的展開和收縮
控制元件展開為例:
private void expand() {
//屬性動畫
ValueAnimator animator = ValueAnimator.ofInt(firstLineHeight, contentHeight);
animator.setDuration(mDuration);
animator.setInterpolator(new LinearInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//獲取到屬性動畫值,並重新整理控制元件
int value = (int) animation.getAnimatedValue();
getLayoutParams().height = value;
requestLayout();//重新佈局
}
});
animator.addListener(new Animator.AnimatorListener() {
@Override
public void onAnimationStart(Animator animation) {
if (mListener != null) { //主要對蒙層的處理
mListener.showMask();
}
firstView.setVisibility(View.INVISIBLE);//第一個View不可見
bottomCollapseLayout.setVisibility(View.VISIBLE);//底部控制元件可見
}
@Override
public void onAnimationEnd(Animator animation) {
}
@Override
public void onAnimationCancel(Animator animation) {
}
@Override
public void onAnimationRepeat(Animator animation) {
}
});
animator.start();
}
如果你對屬性動畫還有疑問的話,請參考如下文章:
文章講到這裡差不多就要結束了,提前預祝大家【五一快樂】
第二種簡單實現方式,效果圖如下:
如有什麼疑問,歡迎討論,以下是聯絡方式: