Android自定义View之索引列表

601 阅读5分钟

前言

最近在学习自定义View时看到下图类似的效果,手指按下时经过字符时,旁边列表的位置变成对应字符的位置,并子项置顶,大体感受涉及的方面有自定义view,RecyclerView使用等知识,很适合练手。程序运行图如下: 索引列表.gif

功能分析

根据程序运行界面可以将涉及的view大体可以简单分为两块。

  1. 侧边字母列表
  2. 内容列表

接下来需要对上面view功能进行补充。
对于侧边字母列表,需要手指按下及划动的时候,侧边字母列表对应字母的背景颜色变换(类似于高亮显示),同时屏幕中间会显示当前选中的字母。同时会改变内容列表显示子项的位置,使得选中字母对应的子项在RecyclerView的顶部。 对于内容列表,除了显示内容外,还能根据右侧展开与关闭图片,来显示或隐藏内容。

代码实现

侧边字母列表

下面对侧边字母列表代码分析,侧边字母列表完整代码在下面。

init()方法是对view默认宽高,以及对bgPaint,textPaint,indexPaint。三个画笔进行初始化,设置画布样式和颜色。

onMeasure()方法是测量View,在这里是重写这个方法来设置视图的宽高。重写onMeasure()方法需要注意的是xml中通常会用到layout_width/layout_height,他们的取值与测量模式对于为:
wrap_content -> MeasureSpec.AT_MOST
match_parent -> MeasureSpec.EXACTLY
具体取值 -> MeasureSpec.EXACTLY
我们根据获取的模式,来确定宽高具体大小,方法末尾通过setMeasuredDimension(width,height)方法可以设置视图的宽度和高度。关于测量模式,还有一个测量模式是MeasureSpec.UNSPECIFIED,这个模式不是很了解,查看资料,MeasureSpec.UNSPECIFIED这个模式用于系统内部自身测量过程。

onLayout()方法与布局相关,这里获取每个字母项对应可以点击区域的矩形,并添加到矩形数组中。

onDraw()方法与绘制相关,在这里会绘制字母文字,与判断字母项是否选中,若选中时,字母项可点击区域高亮显示。 onTouchEvent()方法,处理触摸事件,其中手指按下与移动事件响应后处理相同,可作为同一项处理,手指抬起时,需要清除一些数据,需要单独处理。
分析功能可得知,手指在侧边字母列表内按下时,后续手指移动时,只要点的y坐标在字母项可点击区域的y坐标里面,字母项可点击区域就高亮显示。这需要保存触摸事件对应点的y值进一个矩形中,方便后续判断是否于字母项可点击区域重合。保存触摸事件对应点代码如下:

float y = event.getY();
RectF rectF = new RectF(0, y, defaultViewWidth, y);

手指按下与移动时需要判断保存触摸事件矩形与字母项可点击区域矩形是否重合,可以通过RectF.intersects()方法判断,当重合时候,保存字母项的位置position,改变选中状态selected为true,并调用invalidate()方法进行重绘,及触发onDraw方法,还会调用自己定义的接口方法,告诉view外部,处理手指按下与移动事件的代码如下:

case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_MOVE:
    for (int i = 0; i < indexRectFs.size(); i++) {
        if (RectF.intersects(indexRectFs.get(i), rectF)) {
            if (position!= i){//只有当经过的位置与上次不同的时候,才重绘,减少消耗
                position = i;
                selected = true;
                invalidate();
                if (listener!=null){
                    listener.onPosition(position);
                }
                break;
            }
        }
    }
    break;

手指抬起时,会重置字母项的位置position,选中状态selected,调用自己定义的接口方法,以及重绘方法。手指抬起代码如下:

case MotionEvent.ACTION_UP:
    position = -1;
    selected = false;
    if (listener!=null){
        listener.onPosition(-1);
    }
    invalidate();
    break;

关于自定义接口,他的功能主要是将字母项位置告诉给view外部,在手指按下,移动,抬起时让view外面知道。

侧边字母列表完整代码

public class IndexListView extends View {
    private final static String TAG = "IndexListView";
    private Paint bgPaint, textPaint, indexPaint;
    private int defaultViewHeight, defaultViewWidth;
    private boolean selected = false;
    private String[] indexS = {"A", "B", "C", "D", "E", "F",
            "G", "H", "I", "J", "K", "L",
            "M", "N", "O", "P", "Q", "R",
            "S", "T", "U", "V", "W", "X",
            "Y", "Z"};
    private int position = -1;
    private SparseArray<RectF> indexRectFs = new SparseArray<RectF>();
    //单个字母项高度
    private float indexHeight;

    public IndexListView(Context context) {
        super(context);
        init(context);
    }

    public IndexListView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }

    public IndexListView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    private void init(Context context) {
        //UIUtils.getScreenWidth()和UIUtils.getScreenHeight()获取屏幕宽度和高度,是自定义方法,也可写为具体数值
        defaultViewWidth = (int) (UIUtils.getScreenWidth(context) * 0.1);
        defaultViewHeight = (int) (UIUtils.getScreenHeight(context) * 0.6);
        bgPaint = new Paint();
        bgPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        bgPaint.setColor(Color.GREEN);

        textPaint = new Paint();
        textPaint.setTextSize(DensityUtil.sp2px(context, 12));
        textPaint.setColor(Color.parseColor("#5DA9FF"));

        indexPaint = new Paint();
        indexPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        indexPaint.setColor(Color.WHITE);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //获取视图的宽高的测量模式
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int width,height;
        if (widthMode == MeasureSpec.EXACTLY){
            width = widthSize;
        }else if (widthMode == MeasureSpec.AT_MOST){
            width = Math.min(defaultViewWidth,widthSize);
        }else {
            width = defaultViewWidth;
        }

        if (heightMode == MeasureSpec.EXACTLY){
            height = heightSize;
        }else if (heightMode == MeasureSpec.AT_MOST){
            height = Math.min(defaultViewHeight,heightSize);
        }else {
            height = defaultViewHeight;
        }
        //设置视图的宽度和高度
        setMeasuredDimension(width,height);
    }

    @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        super.onLayout(changed, left, top, right, bottom);
        float itemHeight = defaultViewHeight / indexS.length;
        for (int i = 0; i < indexS.length; i++) {
            indexRectFs.put(i, new RectF(0,itemHeight * i, defaultViewWidth, itemHeight * (i + 1)));
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        indexHeight = defaultViewHeight / indexS.length;
        if (selected) {
            canvas.drawRect(indexRectFs.get(position), bgPaint);
        }

        for (int i = 0; i < indexS.length; i++) {
            String index = indexS[i];
            canvas.drawText(index, getWidth()/3,indexHeight * (i + 1), textPaint);
        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getAction();
        float y = event.getY();
        RectF rectF = new RectF(0, y, defaultViewWidth, y);
        switch (action) {
            case MotionEvent.ACTION_DOWN:
            case MotionEvent.ACTION_MOVE:
                for (int i = 0; i < indexRectFs.size(); i++) {
                    if (RectF.intersects(indexRectFs.get(i), rectF)) {
                        if (position!= i){//只有当经过的位置与上次不同的时候,才重绘,减少消耗
                            position = i;
                            selected = true;
                            invalidate();
                            if (listener!=null){
                                listener.onPosition(position);
                            }
                            break;
                        }
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                position = -1;
                selected = false;
                if (listener!=null){
                    listener.onPosition(-1);
                }
                invalidate();
                break;
        }
        return true;
    }

    public interface OnPositionListener{
        void onPosition(int position);
    }
    private OnPositionListener listener;
    public void setOnPositionListener(OnPositionListener listener){
        this.listener = listener;
    }
}

内容列表

关于内容列表,他的逻辑相对简单些,他是一个RecyclerView,他的子项由一个TextView,一个ImageView,一个RecyclerView组成,TextView显示字母项,ImageView则是点击时会显示或隐藏RecyclerView。内容列表的子项如下图所示:

图片.png 内容列表比较重要的地方是,在接收到侧边字母列表返回的字母位置时候,将内容列表里面对应位置的子项,移动RecyclerView的顶部,并且在屏幕中间显示,当前选中的字母。相关代码如下:

binding.viewSideIndex.setOnPositionListener {position->
    //接收数据
    if (position >=0){
        val layoutManager = binding.rvIndexList.layoutManager as LinearLayoutManager
        layoutManager.scrollToPositionWithOffset(position, 0)
        binding.rlIndexMsg.visibility = View.VISIBLE
        binding.tvIndexMsg.text = dataList[position]
    }else {
        binding.rlIndexMsg.visibility = View.GONE
    }
}

binding.viewSideIndex是侧边字母列表,binding.rlIndexMsg是屏幕中间显示选中的字母的控件,binding.rvIndexList则是内容列表控件。将内容列表里面对应位置的子项移动RecyclerView的顶部是通过scrollToPositionWithOffset()方法实现的,方法第一个参数是要滚动到的位置相对于RecyclerView的左上角的偏移量。

总结

在实现上面功能时候遇到一些问题,在此总结下。
1.实现选中字母侧边列表时,内容列表内item置顶问题。
一开始考虑使用下面方法实现item置顶,但使用发现下面方法只能保证item出现在屏幕上,不能将item置顶。
scrollToPosition(position)//无动画过渡
smoothScrollToPosition(position)//有动画过渡
2.屏幕中间索引显示用TextView实现的,当TextView的textSize比较大的时候,TextView看起来不居中
这个问题是字体高度增加,导致文本的顶部与底部与TextView边界出现更多空间,可以尝试android:textSize="24sp"来看是否有该问题,目前我使用android:includeFontPadding="false",不在字体顶部与底部留出额外空白间隙。但感觉没有完美解决空白间隙问题。后续看看有没有更好办法。
项目代码已经上传github。代码有错误地方,欢迎大佬指正。