布局控件继承自ViewGroup类,它可以包含多个控件并能够按照自己的规则排列控件的位置。不规则布局控件来自笔者开发过程中遇到的业务问题,设计人员希望客户端能够根据返回的数据条数不同而展示不同的布局样式,返回的数据可能有二三四五四种情况如下图所示,如果少于或多于二三四五就视为错误返回值不展示布局。在开发时考虑到当时的界面已经非常复杂,如果采用普通的布局嵌套方式实现会增加视图树深度,导致界面渲染速度变慢,为此需要自定义布局控件来减少布局嵌套层数。
考虑到不同数量对应不同的布局样式,参考RecyclerView组件的LayoutManager思想,可以把不同的布局实现封装到多个LayoutManager对象中,在确定取得的数据大小之后再采用对应的布局对象。
public interface ILayoutManager {
int onMeasure(int width, Context context, AttributeSet attrs);
void onLayout(AbNormalLayout layout, int cellPadding);
void measureChildView(AbNormalLayout parent, View view, int cellPadding);
}
布局内部的数据会变化最好能够使用Adapter把数据封装起来,控件通过Adapter对象获取生成的视图对象,这样就能适应不同数量的数据。
public abstract class AbNormalAdapter<T> extends BaseAdapter {
private Context mContext;
private List<T> mData = new ArrayList<>();
public AbNormalAdapter(Context context, List<T> data) {
this.mContext = context;
this.mData.addAll(data);
}
@Override
public T getItem(int position) {
return mData.get(position);
}
@Override
public int getCount() {
return mData.size();
}
public void replace(List<T> data) {
mData.clear();
mData.addAll(data);
notifyDataSetChanged();
}
}
Adapter模式是Android内置的布局内容可变实现方式,它内部包含了所有需要展示的数据,父控件通过调用Adapter.getView()方法获取到每个数据对应的展示控件。Adapter还自带观察者模式,调用Adapter. registerDataSetObserver()为适配器对象内部的数据添加观察者,如果用户数据发生变化调用notifyDataSetChanged()方法就会通知所有注册的数据监听者,数据监听者对象在接收到数据更新后会通知父控件执行更新界面操作。在更新界面时如果新的数据数量和之前展示数据数量不同,就需要将之前展示的所有子视图移除AbNormalLayout,重新添加新的子控件,不过更新的数据和之前展示数据数量相同就可以复用之前的视图对象。
上图展示了自定义不规则布局内部的类相互关系,首先来看不规则布局类AbNormalLayout的实现逻辑。AbNormalLayout内部包含了不同数量值时的ILayoutManager对象和AbNormalAdapter适配器对象,适配器注册了匿名内部类的观察者对象,当AbNormalAdapter.notifyDatasetChanged()方法被调用就会回调DataObserver.onChanged()方法,从而导致AbNormalLayout重新布局并绘制。重新布局会把测量、布局和绘制操作全部都重新执行,onMeasure()方法会计算不规则布局的尺寸和并且调用它内部的所有视图对象的mesure() 方法确保自己的孩子对象都完成测量工作。
// 不规则布局代码
public class AbNormalLayout extends ViewGroup {
private AbNormalAdapter mAdapter;
private DataSetObserver mObserver;
private ILayoutManager mLayoutManager;
private AttributeSet mAttributeSet;
private int mCellPadding = UIUtils.dp2px(5);
protected List<View> mDetachedViews = new ArrayList<>();
private void init() {
mObserver = new DataSetObserver() {
@Override
public void onChanged() {
super.onChanged();
requestLayout(); // Adapter发生变化时通知不规则布局重新布局
}
@Override
public void onInvalidated() {
super.onInvalidated();
requestLayout();
}
};
}
// 设置Adapter对象
public void setAdapter(AbNormalAdapter adapter) {
if (this.mAdapter != null) {
this.mAdapter.unregisterDataSetObserver(mObserver);
this.mAdapter = null;
}
this.mAdapter = adapter;
// 注册Adapter的监听者,当调用notifyDataSetChanged()会执行onChanged()
this.mAdapter.registerDataSetObserver(mObserver);
requestLayout();
}
// 根据数据数量决定使用不同的布局管理器
private ILayoutManager getLayoutManager() {
int count = mAdapter.getCount();
switch (count) {
case 2: {
if (mLayoutManager instanceof TwoCellLayoutManager) {
return mLayoutManager;
}
return new TwoCellLayoutManager();
}
.....
case 5:
if (mLayoutManager instanceof FiveCellLayoutManager) {
return mLayoutManager;
}
return new FiveCellLayoutManager();
}
return null;
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 测量布局
this.mLayoutManager = getLayoutManager();
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int width = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int height = MeasureSpec.getSize(heightMeasureSpec);
// 如果用户设置了固定宽高,使用用户设置的宽高值
if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
mLayoutManager.onMeasure(width, getContext(), mAttributeSet)
setMeasuredDimension(width, height);
return;
}
if (widthMode != MeasureSpec.EXACTLY) {
width = UIUtils.getScreenWidth();
}
// 根据不同的数据计算布局的宽高值
setMeasuredDimension(width,
mLayoutManager.onMeasure(width, getContext(), mAttributeSet));
}
}
// 只有两个元素的布局管理类
public class TwoCellLayoutManager extends BaseLayoutManager {
@Override // 计算只有两个数据时候布局展示大小
public int onMeasure(int width, Context context, AttributeSet attrs) {
if (attrs != null) {
TypedArray array = context.obtainStyledAttributes(attrs,
R.styleable.AbNormalLayout);
mAspect = array.getFloat(R.styleable.AbNormalLayout_twoCardsAspect,
mAspect);
array.recycle();
} else {
mAspect = 0.5f;
}
return (int) (width * mAspect); // 计算两个数据时布局宽度
}
}
测量操作的规格参数是通过父控件布局大小和子控件的LayoutParams参数共同确定的,LayoutParams可以在代码中设置,也可以在XML布局文件中被设置,LayoutInflater在解析XML文件的时候会为子控件生成布局参数对象。
// LayoutParams创建源代码
// 直接添加对应布局的LayoutParams会直接被使用
TextView textView = new TextView(this);
LinearLayout linearLayout = new LinearLayout(this);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
textView.setLayoutParams(params);
linearLayout.addView(textView);
// 使用的布局参数不是对应布局的LayoutParams会被转换成
// ViewGroup.LayoutParams并生成对应布局的LayoutParams布局参数
linearLayout.addView(textView, new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,ViewGroup.LayoutParams.WRAP_CONTENT));
public void addView(View child, int index) {
LayoutParams params = child.getLayoutParams();
if (params == null) {
// 没有传递布局参数,生成默认布局参数
params = generateDefaultLayoutParams();
}
addView(child, index, params);
}
private void addViewInner(View child, int index, LayoutParams params,
boolean preventRequestLayout) {
// 添加的布局参数类型不是当前布局的LayoutParams
if (!checkLayoutParams(params)) {
// 根据传入的布局参数生成当前布局的LayoutParams
params = generateLayoutParams(params);
}
}
// LayoutInflater使用ViewGroup.generateDefaultLayoutParams(AttributeSet)
// 生成LayoutParams对象
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
viewGroup.addView(view, params);
在生成子控件的布局参数是会分成三种情况,使用XML设置的布局参数调用待用AttributeSet参数的方法生成布局参数,两外两种通过代码添加子控件时如果指定了布局参数且布局参数是正确的布局参数类型就直接使用指定的布局参数,否则将指定的布局参数作为ViewGroup.LayoutParams生成当前布局参数类型。
// View中产生LayoutParams源代码
@Override
public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(getContext(), attrs); // 对应LayoutInflater生成View
}
@Override
protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
return new LayoutParams(p); // addView(View, ViewGroup.LayoutParams params)
}
@Override
protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(0, 0); // addView(View);
}
在XML文件中指定的AbNormalLayout布局参数值需要从AttributeSet中读取,覆盖它的LayoutParams(Context, AttributeSet)构造函数,和评分控件获取属性类似读取布局参数中的高度比例、宽度比例和子控件自身宽高比。
// 不规则控件布局参数实现
public static class LayoutParams extends ViewGroup.LayoutParams {
float mWidthRatio = 1.0f;
float mHeightRatio = 1.0f;
float mAspect = 0f;
public LayoutParams(Context context, AttributeSet attrs) {
super(context, attrs);
if (attrs != null) {
TypedArray array = context.obtainStyledAttributes(attrs,
R.styleable.AbNormalLayout_Layout);
// 子视图和父控件宽度比
mWidthRatio = array.getFloat(
R.styleable.AbNormalLayout_Layout_widthRatio, mWidthRatio);
// 子视图自身的宽高比
mAspect = array.getFloat(
R.styleable.AbNormalLayout_Layout_aspect, mAspect);
// 子视图和父控件高度比
mHeightRatio = array.getFloat(
R.styleable.AbNormalLayout_Layout_heightRatio, mHeightRatio);
// 三个参数优先级 mWidthRatio > mAspect > mHeightRatio
array.recycle();
}
}
public LayoutParams(int width, int height) {
super(width, height);
}
public LayoutParams(ViewGroup.LayoutParams source) {
super(source);
}
}
通过覆盖ViewGroup的默认LayoutParams创建函数创建自定义布局的AbNormalLayout.LayoutParams参数,该参数包含了子控件占据父控件的宽高百分比和子控件的宽高百分比,其中宽度百分比优先级高于宽高比,宽高比优先级高于高度百分比,假如同时设置了宽度比(mWidthRatio)、高度比(mHeightRatio)和宽高比(mAspect),首先会根据不规则布局的宽度计算宽度值,接着利用宽高比计算高度的值,假如宽高比没有被设置,高度比就会被用来计算高度值了,否则高度比无效。那些子控件何时被加入到不规则布局里,可以参考ListView的动态添加子控件源码,ListView会在onLayout()方法中将它内部的子控件添加到自己的布局内。不规则布局首先将之前展示的子控件从布局中移除detachViews(),接着根据新数据的个数添加新的子控件setupViews(),如果之前移除的布局数和新数据个数相同就复用旧的子控件,最后再将所有的子控件按照前面定义的位置放置。
// 不规则控件布局实现
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
detachViews(); // 移除之前展示的子视图
setupViews(); // 初始化新视图
layoutChildren(); // 布局子视图
}
private void detachViews() {
int childCount = getChildCount();
// mDetachedViews被移除控件列表保存了之前展示的控件变量,
// 如果新请求到的数据个数和之前展示一样就会复用mDetachedViews中的控件对象
mDetachedViews.clear();
if (childCount >= 2 && childCount <= 5) {
if (mAdapter.getCount() == childCount) {
for (int i = 0; i < childCount; i++) {
mDetachedViews.add(getChildAt(i));
}
removeAllViewsInLayout(); // 只移除视图,不会触发重新布局requestLayout()
}
}
}
private void setupViews() {
AbNormalAdapter adapter = mAdapter;
int count = adapter.getCount();
int size = mDetachedViews.size();
for (int i = 0; i < count; i++) {
View view = null;
// 如果之前展示子视图数和新请求到数据大小相同,复用旧子视图
if (size == count) {
view = adapter.getView(i, mDetachedViews.get(i), this);
} else { // 生成新的子视图
view = adapter.getView(i, null, this);
}
mLayoutManager.measureChildView(this, view, mCellPadding);
addViewInLayout(view, i, view.getLayoutParams());
}
mDetachedViews.clear();
}
private void layoutChildren() {
if (mLayoutManager != null) {
mLayoutManager.onLayout(this, mCellPadding);
}
}
可以看到上面调用的removeAllViewsInLayout()和addViewInLayout()都有一个InLayout后缀,它们和removeAllView()与addView()有什么区别呢?查看代码会发现后者再移除或者添加子控件的时候会重新调用requestLayout()请求重新布局,而前者不会再做重新布局请求。在onLayout()内部请求重新布局就会不断的递归导致死循环布局操作根本无法结束,因此在布局过程中一定不要再调用会导致重新布局的方法。在setupViews()和layoutChildren()中调用了LayoutManager的measureChild()与onLayout()方法,现在只看有两个子控件情况下是如何测量和布局两个子控件的。
// TwoCellLayoutMananger测量和布局实现代码
@Override
public void measureChildView(AbNormalLayout parent, View view, int cellPadding) {
int width = parent.getMeasuredWidth();
int height = parent.getMeasuredHeight();
width -= cellPadding + parent.getPaddingLeft() + parent.getPaddingRight();
height -= parent.getPaddingBottom() + parent.getPaddingTop();
measure(view, width, height);
}
protected void measure(View view, int width, int height) {
ViewGroup.LayoutParams layoutParams = view.getLayoutParams();
AbNormalLayout.LayoutParams params = (AbNormalLayout.LayoutParams) layoutParams;
params.width = (int) (width * params.mWidthRatio);
if (!NumUtils.equals(params.mAspect, 0f)) { // 如果设置了aspect
params.height = (int) (params.width * params.mAspect);
} else {
params.height = (int) (height * params.mHeightRatio);
}
view.measure(View.MeasureSpec.makeMeasureSpec(params.width,
View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(params.height,
View.MeasureSpec.EXACTLY));
}
@Override
public void onLayout(AbNormalLayout layout, int cellPadding) {
int paddingTop = layout.getPaddingTop();
int paddingLeft = layout.getPaddingLeft();
View view = layout.getChildAt(0);
int x = paddingLeft, y = paddingTop;
view.layout(x, y, x + view.getMeasuredWidth(), y + view.getMeasuredHeight());
x = x + view.getMeasuredWidth() + cellPadding;
view = layout.getChildAt(1);
view.layout(x, y, x + view.getMeasuredWidth(), y + view.getMeasuredHeight());
}
上面的代码中在只有两个子控件时会根据宽度比计算它们的宽度,得到宽高度值后传入自己的测量方法中确保子控件内部的测量过程得到执行。onLayout() 会获取两个子控件调用它们的layout()布局方法把它们放到布局内左上角为(x,y)右下角为( x + view.getMeasuredWidth(), y + view.getMeasuredHeight())的矩形框内;至于三四五类型的布局实现与二的实现基本类似,就不再赘述。不规则布局的好处是把控件中不同功能的模块做了拆分,单独的对象只负责单一职责,有利于后面的扩展维护工作,比如将来还要增加六七类型的新布局样式可以只增加新的LayoutManager就可以实现,不用做过多的新改动。