ListView: 我偷偷给直播间公聊埋个坑
1. 前提说明
空数据占位图.jpg虽然说Google官方提倡使用RecyclerView,但不代表说ListView就完全被RecyclerView取代。在某些业务上,使用ListView可能更加容易达到某种效果,比如说当请求网络数据为空时,需要展示一张默认图。ListView本身就有方法setEmptyView(View),而RecyclerView只能通过判断获取到的数据是否为空,调用EmptyView.setVisiable()来实现。
2. 业务背景
现实.PNG平常我们使用ListView的item都需要宽度铺满整个ListView,而公聊消息不一样,每一个item的长度根据内容确定的,。我们第一想法肯定是把Item的xml根布局的layout_width改成wrap_content,于是神奇的一幕就出现了...
理想.PNG我们期望的却是酱紫!
3. 代码概览
我们先过一下编写好的Demo源码,代码量很少,和我们平时的书写习惯一致。
MainActivity
public class MainActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ListView listView = findViewById(R.id.listview);
listView.setAdapter(new ListViewAdapter(this));
listView.setDividerHeight(20);
}
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ListView
android:id="@+id/listview"
android:layout_width="match_parent"
android:layout_height="match_parent"/>
</LinearLayout>
Adapter与Item.xml
public class ListViewAdapter extends BaseAdapter {
private LayoutInflater layoutInflater;
private String[] mDatas = {"Hello", "我是一条纯洁的公聊消息", "看看这个文字内容能有多长?"};
public ListViewAdapter(Context context) {
layoutInflater = LayoutInflater.from(context);
}
@Override
public int getCount() {return Short.MAX_VALUE;}
@Override
public Object getItem(int i) {return mDatas[i % mDatas.length];}
@Override
public long getItemId(int i) {return i;}
@Override
public View getView(int position, View convertView, ViewGroup viewGroup) {
ViewHolder holder = null;
if (convertView == null) {
convertView = layoutInflater.inflate(R.layout.item_view,null); //别找了,重点在这
holder = new ViewHolder(convertView);
convertView.setTag(holder);
} else {
holder = (ViewHolder) convertView.getTag();
}
holder.contentView.setText(mDatas[position % mDatas.length]);
return convertView;
}
class ViewHolder {
public TextView contentView;
public ViewHolder(View itemView) {
contentView = itemView.findViewById(R.id.item_txt);
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content" //别找了,重点在这
android:layout_height="40dp"
android:background="#666666"
android:orientation="vertical"
android:paddingBottom="10dp"
android:paddingTop="10dp">
<TextView
android:id="@+id/item_txt"
android:layout_width="wrap_content"
android:layout_height="match_parent" />
</LinearLayout>
4. 异常点
明明item的宽度为wrap_content,但展示出来却是铺满屏幕match_parent。
5. 解决思路
既然itemView宽度展示出现问题,那itemView.getLayoutParams()就是有问题的,而itemView添加到ListView上面是通过Adapter.getView()的方式,那我们只需要查看Adapter.getView()在哪个位置调用的,于是我们在ListView及父类中搜索,定位到AbsListView的obtainView()方法
View obtainView(int position, boolean[] outMetadata) {
...
final View transientView = mRecycler.getTransientStateView(position);
if (transientView != null) {
final LayoutParams params = (LayoutParams) transientView.getLayoutParams();
if (params.viewType == mAdapter.getItemViewType(position)) {
final View updatedView = mAdapter.getView(position, transientView, this);
if (updatedView != transientView) {
setItemViewLayoutParams(updatedView, position);
mRecycler.addScrapView(updatedView, position);
}
}
....
既然我们的ItemView通过obtainView()方法获取到了View对象,我们就可以往上面跟踪,看看这个方法的调用位置:
- AbsListView.getHeightForPosition(int )
官方解释: (Returns the height of the view for the specified position)返回这个位置View的高度,排除- onMeasure()方面中被调用。我们知道view在onlayout中才排布view,排除
- measureHeightOfChildren(int, int, int, int, int)和第一个相似,排除
- addViewAbove() addViewBelow(),makeAndAddView() 看名字与addView()方法相似,基本上也就断定的这三个方法了。
接下来我们看一下这三个方法:
private View addViewAbove(View theView, int position) {
int abovePosition = position - 1;
View view = obtainView(abovePosition, mIsScrap);
int edgeOfNewChild = theView.getTop() - mDividerHeight;
setupChild(view, abovePosition, edgeOfNewChild, false, mListPadding.left,
false, mIsScrap[0]);
return view;
}
private View addViewBelow(View theView, int position) {
int belowPosition = position + 1;
View view = obtainView(belowPosition, mIsScrap);
int edgeOfNewChild = theView.getBottom() + mDividerHeight;
setupChild(view, belowPosition, edgeOfNewChild, true, mListPadding.left,
false, mIsScrap[0]);
return view;
}
private View makeAndAddView(int position, int y, boolean flow, int childrenLeft,
boolean selected) {
if (!mDataChanged) {
final View activeView = mRecycler.getActiveView(position);
if (activeView != null) {
setupChild(activeView, position, y, flow, childrenLeft, selected, true);
return activeView;
}
}
final View child = obtainView(position, mIsScrap);
setupChild(child, position, y, flow, childrenLeft, selected, mIsScrap[0]);
return child;
}
这三个方法,基本就是通过obtainView()获取到itemView,然后调用setupChild()方法去配置itemView,点进setupChild()查看一切就清楚了。
private void setupChild(View child, int position, int y, boolean flowDown, int childrenLeft,
boolean selected, boolean isAttachedToWindow) {
...
AbsListView.LayoutParams p = (AbsListView.LayoutParams) child.getLayoutParams();
if (p == null) {
p = (AbsListView.LayoutParams) generateDefaultLayoutParams();
}
...
}
protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
return new AbsListView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT, 0);
}
所以如果我们在obtainView()中通过mAdapter.getView()得到的View没有设置过LayoutParam,那么就给这个View设置了一个宽度match_parent的默认LayoutParams,那么前面的现象就说的过去了,
推理归推理,我们还是需要找到证据,回到Adapter.getView()的方法体中。
public View getView(int position, View convertView, ViewGroup viewGroup) {
ViewHolder holder = null;
if (convertView == null) {
convertView = layoutInflater.inflate(R.layout.item_view, null);
holder = new ViewHolder(convertView);
convertView.setTag(holder);
} else {
holder = (ViewHolder) convertView.getTag();
}
holder.contentView.setText(mDatas[position % mDatas.length]);
return convertView;
}
我们通过查看layoutInflater.inflate(R.layout.item_view, null)源码跟踪View的初始化过程,于是有了如下代码。
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
...
View result = root;
try {
final String name = parser.getName();
if (TAG_MERGE.equals(name)) {
...
rInflate(parser, root, inflaterContext, attrs, false);
} else {
final View temp = createViewFromTag(root, name, inflaterContext, attrs); //我才是重点行
ViewGroup.LayoutParams params = null; ...
if (root != null) { ...
params = root.generateLayoutParams(attrs); ...
if (!attachToRoot) { ...
temp.setLayoutParams(params); //我才是重点行
}
}
if (root != null && attachToRoot) {
root.addView(temp, params);
}
if (root == null || !attachToRoot) {
result = temp;
}
}
} catch (XmlPullParserException e) { }
finally {}
return result;
}
}
之前调用layoutInflater.inflate(R.layout.item_view, null)方法,将root设置为空,所以itemView并没有设置LayoutParams。
6.解决办法:
- layoutInflater.inflate(R.layout.item_view, viewGroup, false);
- getView()方法中手动为View设置LayoutParms
itemView.setLayoutParams(new AbsListView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT, 0));