之前项目中有个需求,需要在同一行展示n个标签,标签内容有 热门、置顶、广告、促销、精致 等等,都是两个字的词语,并且标签外面需要有个圆角矩形包裹起来,我们可以直接看今日头条的主界面的样式,见下图,途中只有一个标签
我们项目中的情况与这个类似,也是一个列表,列表中的 item 的最下面的一行,显示标签,标签后面是一些时间和商店名等信息。根据条目内容,标签可能为1个、2个、3个或者一个都没有,我接手项目时,这个功能已经实现了。只是列表在上下滑动时,尤其是快速滑动,会有卡滞的感觉,我看了看适配器的代码,发现这个标签的代码写的相当个性:用一个线性布局容器,每次展示item时,先调用容器的 removeAllViews() 方法,删除所有子 view,然后根据业务逻辑,动态创建 TextView,设置文案和背景,然后添加到线性布局中。看到这,感慨怪不得会有微微卡顿的感觉,item 每次展示的时候,都会去创建 view 添加到容器,导致整个 item 都重新绘制,当然耗时耗性能。看到这,这个需要优化。
所谓控件优化,要么是复用对象,提高效率;要么就是自定义,减少层级,一个控件完成n个控件才能完成的效果。
方案一,在适配器中定义个集合,ArrayList<TextView> mScrapViews = new ArrayList<>(); 既然每次item展现的时候,都会先删除子view,然后再重新创建 view,那为什么不把 remove 的子 view 都放到 mScrapViews 中,创建子 view 的时候,优先去 mScrapViews 中取,如果 mScrapViews 集合元素为空,此时再去 new 一个 view,添加到线性布局中。 在此基础上可再进一步,当item展示时,我们先判断当前item中的标签容器中,有几个子view,标记为 a, 需要展示的标签有几个,标记为b,做个对比,如果 a > b ,说明子 view 多了,需要移除 (a-b) 个子 view,移除的 view 放入 mScrapViews 集合中;如果 a < b ,说明子
view 不够用,需要添加 (b -a ) 个 view,仍然优先去 mScrapViews 集合中取,如果集合中没有元素了,再 new 出来 view,添加到容器中;如果 a = b,则不用多余添加删除 view 的操作。这样操作下来,就相当于避免了大量重复创建 view 的过程,能提高部分效率,但不明显。
方案二,假如说标签最多有6个,那么我们直接在布局中写6个 TextView,然后根据业务逻辑来设置 VISIBLE 或 GONE,这是一种方法,标签少的话可以这么做,如果标签比较多,比如说有15个,这么做的话,findViewById() 就要写一大串代码,不适用,同时对性能有很大影响。
方案三,自定义控件登场。看看标签,我们需要自己绘制圆角方框和文案,圆角方框好说,Canvas 有现成的方法 drawRoundRect() ,可以提供一个矩形 RectF 及圆角的半径大小。绘制文案也有现成的方法 drawText() ,但这里就有一点要说明,文案的y轴的定位,和图形的不太一样,它有基准线的概念,我们看下图
canvas.drawText(String text, float x, float y, Paint paint) 中的第三个参数 y,对应的就是基准线,而不是图中最下面的黑线,那么问题来了,如果我们绘制文案,设置的 y 的值为50,此时的50是基准线 BaseLine,文案的最底部的 bottom 底线,可能是 52,这么就会有细微的偏差,如果对细节要求没那么高可以忽略,但像我们这次说的标签这种情况,就不容忽视了。上图还提供了些信息,基准线和底线的间距叫 Descent,基准线到文案的顶部叫做 Ascent, 这两个值都可以通过 Paint 来获取,descent() 和 ascent() 方法,descent 是正数,ascent 是负数,我们使用时要使用绝对值,如果想在一个 View 的中间绘制文案,计算y轴时,公式为 baseY = (int) ((canvas.getHeight() / 2) + ((Math.abs(mPaint.ascent())-Math.abs(mPaint.descent())) / 2)); 为什么是这样呢? 我们知道,如果 Baseline和屏幕中间线重合,那么文本不会居中,会偏上,因为 ascent 距离大于 descent 的距离。这两个距离,都是以基准线为准的,那么 BaseLine下移多少才能让文案居中呢? 需要 ascent - descent ,然后除以2,这就是偏移量,所以才有了上面的公式。
我们用二进制字符串 "1101" 来表示标签,1 代表有,0代表无,不同位置代表不同的标签。 这样可以随意组合,我们先写个方法来拆分字符串和返回对应的标签文字
class Util{
final static int[] FLAG_ARR = {1 << 0, 1 << 1, 1 << 2, 1 << 3 };
public static ArrayList<Integer> takeArr(String s){
return takeArr(Integer.valueOf(s, 2));
}
public static ArrayList<Integer> takeArr(long titleDisplay){
ArrayList<Integer> list = new ArrayList<>();
for (int i = 0, count = FLAG_ARR.length; i < count; i++) {
long value = FLAG_ARR[i];
value = value & titleDisplay;
if (value != 0) {
list.add(i);
}
}
return list;
}
public static String takeDis(int index){
String s;
switch (index) {
case 0:
s = "广告";
break;
case 1:
s = "置顶";
break;
case 2:
s = "热门";
break;
case 3:
s = "促销";
break;
default:
s = "广告";
break;
}
return s;
}
}
自定义控件中,我们继承 View, 在 onMeasure(int widthMeasureSpec, int heightMeasureSpec) 方法中,我们计算下标签的个数,加上间距等值,算出所需要的最小宽度和高度,如果xml布局中设置的是包裹内容,那么我们就用最小宽度和高度来作为控件的宽和高。 在 onDraw(Canvas canvas) 中,我们先画边框,根据边框间距算出每个边框的起始位置,然后可以放到一个数组里,边框画完后,根据数组的值来画出相对应的文案。 见代码
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;
import java.util.ArrayList;
public class MarkTypeView extends View {
private static final String MARK_TEXT = "广告";
private Paint mPaint;
private int baseX, baseY;
float[] mLeftArr;
private int mTextSize = 20;
int mTopSpace = 5, mLeftSpace = 20, mInnerSpace = 8, mOutInnSpace = 15; // 最后一个是两个边框的间距
int mRound = mTextSize / 4;
float mMarkLenght;
private String mMark = "1001";
public MarkTypeView(Context context) {
this(context, null);
}
public MarkTypeView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public MarkTypeView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initPaint();
}
private void initPaint() {
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setTextSize(mTextSize);
mPaint.setColor(Color.RED);
mPaint.setAntiAlias(true);
mPaint.setStrokeWidth(2);
mMarkLenght = mPaint.measureText(MARK_TEXT) + mInnerSpace * 2;
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int size = Util.takeArr(mMark).size();
int viewW = (int) (mMarkLenght * size + mLeftSpace * 2 + mOutInnSpace * (size - 1));
int viewH = (mTextSize + mTopSpace * 2 + mInnerSpace * 2) ;
setMeasuredDimension(resolveSize(viewW, widthMeasureSpec), resolveSize(viewH, heightMeasureSpec));
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawColor(Color.argb(255,204,204,204));
mPaint.setStyle(Paint.Style.STROKE);
ArrayList<Integer> list = Util.takeArr(mMark);
baseY = (int) ((canvas.getHeight() / 2) - ((mPaint.descent() + mPaint.ascent()) / 2));
int h = getMeasuredHeight();
int size = list.size();
float top = mTopSpace, bottom = h - top ;
float left,right;
RectF oval;
mLeftArr = new float[size];
for(int i = 0; i < size; i++){
left = mLeftSpace + i * (mMarkLenght + mOutInnSpace);
right = left + mMarkLenght;
oval = new RectF(left, top, right, bottom);
canvas.drawRoundRect(oval, mRound, mRound, mPaint);
mLeftArr[i] = left;
}
mPaint.setStyle(Paint.Style.FILL);
for (int i = 0; i < size; i++){
canvas.drawText(Util.takeDis(i), mLeftArr[i] + mInnerSpace, baseY, mPaint);
}
}
public void setMark(String mark) {
this.mMark = mark;
if("0000".equals(mark)){
setVisibility(View.GONE);
} else {
setVisibility(View.VISIBLE);
// invalidate();
requestLayout();
}
}
}
<com.view.MarkTypeView
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
如果不同的文案想显示不同的颜色,继续扩展即可。上面例子比较简单,像 字体大小、间距 等都可以抽取出来,做成自定义属性,可以在 xml 中配置,或者对外暴露接口,可以在代码中调用。