那些年我们熬夜打造一可收缩流式标签控件
一、前言
时间匆匆,一眨眼来厦门已经一个多月了。似乎已经适应了这边的生活,喜欢这边的风,温和而舒适,还有淡淡海的味道 。。。
还在再次跟大家致个歉意,博客的更新又延期了。新的环境,新的工作加上项目大改版,基本每天都有大量的事情,周末也不得空闲。
非常感谢大家的陪伴,一路有你们,生活会充满美好。
标签控件
本文还是继续讲解自定义控件,近期在项目中有这样一个控件。
实现可收缩的流式标签控件,具体效果图如下:
flow-
支持多选,单选,反选
-
子 View 定制化
效果图不是很清晰,文章后面会提供下载地址。
主要实现功能细分如下:
-
实现流式布局(第一个子 View 始终位于首行的最右边)
-
布局可定制化(采取适配模式)
-
实现控件的收缩
主要有这三个小的功能组成。第一个流式布局实现需要注意的是,第一个元素(子 View)需要固定在首行的最右边,采取的解决方案是首先绘制第一个元素且绘制在最右边;第二个布局可定制化,怎么来理解这句话呢?我希望实现的子 View 不单单是圆角控件,而是高度定制的所有控件,由用户来决定,采取的解决方案是采用了适配模式;第三个控件的收缩,这个实现起来就比较简单了,完成了第一步就可以获取到控件的高度,采用属性动画来动态改变控件的高度。具体我们一起来往下面看看。
流式布局
效果图一栏:
flow实现效果图的流式布局,有两种方案。一、直接使用 recyclerView ;二、自定义继承 ViewGroup。本文采用第二种方案,相信大家一定非常熟悉自定义 View 三部曲 ->onMeasure() ->onLayout() ->onDraw() ,吐血推荐以下文章:
自定义View系列教程02--onMeasure源码详尽分析
onMeasure()测量
要实现标签流式布局,需要涉及到以下几个问题:
(1)、【下拉按钮】 的测量和布局
flow标签布局当中【下拉按钮】始终固定在首行的最右边,如果依次绘制子 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();
}
如果你对属性动画还有疑问的话,请参考如下文章:
自定义控件三部曲之动画篇(四)——ValueAnimator基本使用
自定义控件三部曲之动画篇(七)——ObjectAnimator基本使用
文章讲到这里差不多就要结束了,提前预祝大家【五一快乐】
第二种简单实现方式,效果图如下:
GIF.gif如有什么疑问,欢迎讨论,以下是联系方式:
qq