记一次RecyclerView
嵌套FlowLayout
滑动后FlowLayout
子View
内容丢失问题的排查解决过程
文章目录
布局基础可参考: Android 手把手教您自定义ViewGroup(一)
一、需求及问题描述
1.1 业务需求
因业务需求需要实现一个纵向列表,列表中有一系列类似标签的相似内容,数量不定,需要换行,第一印象考虑用嵌套RecyclerView
的方式实现,但是子RecyclerView
需要指定每行元素的个数,考虑到更好的兼容性,采用嵌套RecyclerView
嵌套FlowLayout
的方式实现。
1.2 问题描述
在列表滑动过程中,当初始的一屏滑出屏幕外时,继续滑动,后面出现的item
中FlowLayut
的内容为空白,如果上滑,原来有内容的item
中FlowLayout
中内容也变为空白了。
二、问题排查
1.1 初步分析
根据问题描述初步分析,可能是RecyclerView
的item
复用导致出现的问题,经过各种尝试,包括给ViewHolder
设置tag
等方式,都未能解决,最后索性禁用RecyclerView
的复用机制,问题得以解决:
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
holder.setIsRecyclable(false)
super.onBindViewHolder(holder, position)
}
1.2 深入分析
禁用RecyclerView
的复用机制虽然能解决问题,但是却极大牺牲了列表的性能,有些得不偿失,所以考虑深入分析问题,力求找到更加优雅的解决方式。
后仔细分析,RecyclerView
的item View
,中除了FlowLayout
外还有其它的View
,而这些View
的内容不会随着列表的滚动而消失,所以考虑可能是FlowLayout
自身的问题。
将FlowLayout
单独拿出来分析,第一步先在初始化的时候添加一些View
,一切正常;第二步调用removeAllViews()
方法,然后继续添加一些子View
,此时复现问题。作为对比,对LinearLayout
做相同操作,此时每一步都是正常的,所以断定是FlowLayout
自身的问题。
问题范围缩小后,利用 开发者工具 的 边界布局 功能,并且对于FlowLayout
做断点和日志一步一步分析,先是确定第二步添加完子View
之后,获取这些子View
的数量是正确的,然后排查View
的大小,测试可知添加的子View
的view.getMeasuredWidth()
大小是正确的,但是view.getWidth()
大小为0,说明FlowLayout
的onMeasure()
方法是没有问题的,而子View
并未得到正确绘制,问题可能出在onLayout()
方法上,因为FlowLayout
是自定义ViewGroup
,并且未涉及到绘制,所以不考虑onDraw()
方法。在onLayout(boolean changed, int l, int t, int r, int b)
方法中添加日志,发现其代码逻辑只在changed
参数为true
时才进行重新摆放的操作逻辑,而在FlowLayout
首次添加子View
时,其changed
参数才为true
,在removeAllViews()
后再重新添加子View
时,changed
参数为false
,此时便没有执行摆放逻辑,导致子View
的大小便为0,即出现最开始的问题。
解决问题也很简单,即不考虑changed
参数,在任何时候都进行摆放操作逻辑就可以了。经过验证,问题的确可以解决。
三、源码
3.1 FlowLayout
package com.joywifi.vlottery.widget;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.util.AttributeSet;
import android.util.SparseArray;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.IntDef;
import com.blankj.utilcode.util.LogUtils;
import java.util.ArrayList;
import java.util.List;
public class FlowLayout extends ViewGroup {
private Line mLine = null;
public static final int DEFAULT_SPACING = 20;
//所有的子控件
private SparseArray<View> mViews;
/**
* 横向间隔
*/
private int mHorizontalSpacing = DEFAULT_SPACING;
/**
* 纵向间隔
*/
private int mVerticalSpacing = DEFAULT_SPACING;
/**
* 当前行已用的宽度,由子View宽度加上横向间隔
*/
private int mUsedWidth = 0;
/**
* 代表每一行的集合
*/
private final List<Line> mLines = new ArrayList<Line>();
//子View的对齐方式
private int isAlignByCenter = 1;
/**
* 最大的行数
*/
private int mMaxLinesCount = Integer.MAX_VALUE;
/**
* 是否需要布局,只用于第一次
*/
boolean mNeedLayout = true;
public FlowLayout(Context context) {
this(context, null);
}
public FlowLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public FlowLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public interface AlienState {
int RIGHT = 0;
int LEFT = 1;
int CENTER = 2;
@IntDef(value = {RIGHT, LEFT, CENTER})
@interface Val {
}
}
//设置第二行的位置
public void setAlignByCenter(@AlienState.Val int isAlignByCenter) {
this.isAlignByCenter = isAlignByCenter;
requestLayoutInner();
}
protected void requestLayoutInner() {
new Handler(Looper.getMainLooper()).post(new Runnable() {
@Override
public void run() {
requestLayout();
}
});
}
//设置 要添加的数据 子布局样式
public void setAdapter(List<?> list, int res, FlowSetData mItemView) {
if (list == null) {
return;
}
removeAllViews();
int layoutPadding = dipToPx(getContext(), 8);
setHorizontalSpacing(layoutPadding);
setVerticalSpacing(layoutPadding);
int size = list.size();
for (int i = 0; i < size; i++) {
Object item = list.get(i);
View inflate = LayoutInflater.from(getContext()).inflate(res, null);
if (inflate != null) {
if (item != null) {
mItemView.getCover(item, new FlowViewHolder(inflate), inflate, i);
addView(inflate);
}
}
}
}
public class FlowViewHolder {
View mConvertView;
public FlowViewHolder(View mConvertView) {
this.mConvertView = mConvertView;
mViews = new SparseArray<>();
}
public <T extends View> T getView(int viewId) {
View view = mViews.get(viewId);
if (view == null) {
view = mConvertView.findViewById(viewId);
mViews.put(viewId, view);
}
try {
return (T) view;
} catch (ClassCastException e) {
e.printStackTrace();
}
return null;
}
public void setText(int viewId, String text) {
TextView view = getView(viewId);
view.setText(text);
}
}
public static int dipToPx(Context ctx, float dip) {
return (int) TypedValue.applyDimension(1, dip, ctx.getResources().getDisplayMetrics());
}
public void setHorizontalSpacing(int spacing) {
if (mHorizontalSpacing != spacing) {
mHorizontalSpacing = spacing;
requestLayoutInner();
}
}
public void setVerticalSpacing(int spacing) {
if (mVerticalSpacing != spacing) {
mVerticalSpacing = spacing;
requestLayoutInner();
}
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int sizeWidth = MeasureSpec.getSize(widthMeasureSpec) - getPaddingRight() - getPaddingLeft();
int sizeHeight = MeasureSpec.getSize(heightMeasureSpec) - getPaddingTop() - getPaddingBottom();
int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
restoreLine();// 还原数据,以便重新记录
final int count = getChildCount();
for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
if (child.getVisibility() == GONE) {
continue;
}
int childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(sizeWidth, modeWidth == MeasureSpec.EXACTLY ? MeasureSpec.AT_MOST : modeWidth);
int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(sizeHeight, modeHeight == MeasureSpec.EXACTLY ? MeasureSpec.AT_MOST : modeHeight);
// 测量child
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
if (mLine == null) {
mLine = new Line();
}
int childWidth = child.getMeasuredWidth();
mUsedWidth += childWidth;// 增加使用的宽度
if (mUsedWidth <= sizeWidth) {// 使用宽度小于总宽度,该child属于这一行。
mLine.addView(child);// 添加child
mUsedWidth += mHorizontalSpacing;// 加上间隔
if (mUsedWidth >= sizeWidth) {// 加上间隔后如果大于等于总宽度,需要换行
if (!newLine()) {
break;
}
}
} else {// 使用宽度大于总宽度。需要换行
if (mLine.getViewCount() == 0) {// 如果这行一个child都没有,即使占用长度超过了总长度,也要加上去,保证每行都有至少有一个child
mLine.addView(child);// 添加child
if (!newLine()) {// 换行
break;
}
} else {// 如果该行有数据了,就直接换行
if (!newLine()) {// 换行
break;
}
// 在新的一行,不管是否超过长度,先加上去,因为这一行一个child都没有,所以必须满足每行至少有一个child
mLine.addView(child);
mUsedWidth += childWidth + mHorizontalSpacing;
}
}
}
if (mLine != null && mLine.getViewCount() > 0 && !mLines.contains(mLine)) {
// 由于前面采用判断长度是否超过最大宽度来决定是否换行,则最后一行可能因为还没达到最大宽度,所以需要验证后加入集合中
mLines.add(mLine);
}
int totalWidth = MeasureSpec.getSize(widthMeasureSpec);
int totalHeight = 0;
final int linesCount = mLines.size();
for (int i = 0; i < linesCount; i++) {// 加上所有行的高度
totalHeight += mLines.get(i).mHeight;
}
totalHeight += mVerticalSpacing * (linesCount - 1);// 加上所有间隔的高度
totalHeight += getPaddingTop() + getPaddingBottom();// 加上padding
// 设置布局的宽高,宽度直接采用父view传递过来的最大宽度,而不用考虑子view是否填满宽度,因为该布局的特性就是填满一行后,再换行
// 高度根据设置的模式来决定采用所有子View的高度之和还是采用父view传递过来的高度
setMeasuredDimension(totalWidth, resolveSize(totalHeight, heightMeasureSpec));
}
private void restoreLine() {
mLines.clear();
mLine = new Line();
mUsedWidth = 0;
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
//if (changed) { //处理removeAllViews然后再addView()不成功的问题
int left = getPaddingLeft();//获取最初的左上点
int top = getPaddingTop();
int count = mLines.size();
for (int i = 0; i < count; i++) {
Line line = mLines.get(i);
line.LayoutView(left, top);//摆放每一行中子View的位置
top += line.mHeight + mVerticalSpacing;//为下一行的top赋值
}
//}
}
/**
* 新增加一行
*/
private boolean newLine() {
mLines.add(mLine);
if (mLines.size() < mMaxLinesCount) {
mLine = new Line();
mUsedWidth = 0;
return true;
}
return false;
}
public class Line {
int mWidth = 0;// 该行中所有的子View累加的宽度
int mHeight = 0;// 该行中所有的子View中高度的那个子View的高度
List<View> views = new ArrayList<View>();
public void addView(View view) {// 往该行中添加一个
views.add(view);
mWidth += view.getMeasuredWidth();
int childHeight = view.getMeasuredHeight();
mHeight = mHeight < childHeight ? childHeight : mHeight;//高度等于一行中最高的View
}
public int getViewCount() {
return views.size();
}
//摆放行中子View的位置
public void LayoutView(int l, int t) {
int left = l;
int top = t;
int count = getViewCount();
int layoutWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();//行的总宽度
//剩余的宽度,是除了View和间隙的剩余空间
int surplusWidth = layoutWidth - mWidth - mHorizontalSpacing * (count - 1);
if (surplusWidth >= 0) {
for (int i = 0; i < count; i++) {
final View view = views.get(i);
int childWidth = view.getMeasuredWidth();
int childHeight = view.getMeasuredHeight();
//计算出每个View的顶点,是由最高的View和该View高度的差值除以2
int topOffset = (int) ((mHeight - childHeight) / 2.0 + 0.5);
if (topOffset < 0) {
topOffset = 0;
}
//布局View
if (i == 0) {
switch (isAlignByCenter) {
case AlienState.CENTER:
left += surplusWidth / 2;
break;
case AlienState.RIGHT:
left += surplusWidth;
break;
default:
left = 0;
break;
}
}
view.layout(left, top + topOffset, left + childWidth, top + topOffset + childHeight);
left += childWidth + mVerticalSpacing;//为下一个View的left赋值
}
}
}
}
public interface FlowSetData<T> {
void getCover(T item, FlowViewHolder holder, View inflate, int position);
}
}
3.2 使用
fun setDrawNumber(number: String?, red: Int, blue: Int) {
if (number.isNullOrEmpty()) {
return
}
val numbers = number.split(";")
setAdapter(
numbers, R.layout.item_ball
) { item, holder, inflate, position ->
if(position >= red) {
inflate.setBackgroundResource(R.drawable.shape_ball_blue)
}else {
inflate.setBackgroundResource(R.drawable.shape_ball_red)
}
holder.getView<TextView>(R.id.tv_ball).text = item.toString()
}
}
//或者
fun setDrawNumber2(number: String?, red: Int, blue: Int) {
if (number.isNullOrEmpty()) {
return
}
if (childCount > 0) {
removeAllViews()
}
number.split(";").forEachIndexed { index, s ->
if (index in 0 until red) {
addNumber(s)
} else if (index in red until (red + blue)) {
addNumber(s, isRed = false)
}
}
}
private fun addNumber(number: String, isRed: Boolean = true) {
val textView = TextView(context)
val params = RelativeLayout.LayoutParams(DensityUtils.dp2px(35f), DensityUtils.dp2px(35f))
params.marginEnd = DensityUtils.dp2px(5f)
textView.apply {
layoutParams = params
text = number
textSize = 20f
gravity = Gravity.CENTER
setTextColor(resources.getColor(R.color.color_white))
typeface = Typeface.defaultFromStyle(Typeface.BOLD)
background =
context.getDrawable(if (isRed) R.drawable.shape_ball_red else R.drawable.shape_ball_blue)
minWidth = DensityUtils.dp2px(35f)
minHeight = DensityUtils.dp2px(35f)
visibility = View.VISIBLE
}
addView(textView)
}