Android自定义View

SideBar列表索引

2018-07-20  本文已影响25人  hewenyu

概述

现在很多的APP项目都会集成IM功能(IM的好处和优点我就不说了,毕竟本文的重点不是介绍IM),说到IM肯定就会有一个好友列表,说到好友列表肯定就会有一个列表索引,说到列表索引就肯定会有...(好像说过头了@A@);
当然这里的索引列表不止是用于好友列表中,一般的手机文件夹列表(参考魅族),项目文档排序(我的项目中使用到了)等涉及到列表的都可以使用索引栏来快速的定位,按照惯例接下来应该是效果图的时间了(这里给出Demo的效果图):


仿魅族文件索引
仿微信好友列表索引

实现思路

效果图显示的还可以,功能需求我就不讲解了,用过微信、手机通讯录的都知道;
这里使用的直接是英文字符,汉字的话需要将汉字转换为拼音,如果使用的是环信SDK,它自带有一个汉字转拼音的工具类,也可以使用 Pinyin.jar 等第三方jar包来转换;

代码实现

代码模块我们只讲解重要的代码,文章的最后会给出代码的地址;

 <!-- 自定义索引栏控件的属性 -->
    <declare-styleable name="SideBar">
        <!-- 索引栏的宽度 -->
        <attr name="sideBarWidth" format="dimension|reference" />
        <!-- 索引栏的内外的上下间距 -->
        <attr name="sideBarMarginTop" format="dimension|reference" />
        <attr name="sideBarMarginBottom" format="dimension|reference" />
        <attr name="sideBarPaddingTop" format="dimension|reference" />
        <attr name="sideBarPaddingBottom" format="dimension|reference" />
        <!-- 索引栏的位置 -->
        <attr name="sideBarPosition" format="enum">
            <enum name="LEFT" value="0" />
            <enum name="RIGHT" value="1" />
        </attr>
        <!-- 索引栏与左边/右边的间距 -->
        <attr name="sideBarSpacing" format="dimension|reference" />
        <!-- 索引栏默认的背景颜色 -->
        <attr name="sideBarNormalColor" format="color|reference" />
        <!-- 索引栏按压的背景颜色 -->
        <attr name="sideBarPressColor" format="color|reference" />
        <!-- 索引栏默认的背景色是否显示 -->
        <attr name="showSideBarNormalColor" format="boolean" />
        <!-- 索引栏上下两端的形状 -->
        <attr name="sideBarCap" format="enum">
            <enum name="NORMAL" value="0" />
            <enum name="ROUND" value="1" />
        </attr>

        <!-- 索引栏上字符的大小 -->
        <attr name="sideBarLetterSize" format="dimension|reference" />
        <!-- 索引栏上字符默认的颜色 -->
        <attr name="sideBarLetterNormalColor" format="color|reference" />
        <!-- 索引栏上被选中字符的颜色 -->
        <attr name="sideBarLetterSelectColor" format="color|reference" />
        <!-- 索引栏上默认字符的宽度 -->
        <attr name="sideBarLetterNormalWidth" format="dimension|reference" />
        <!-- 索引栏被按压时,字符的宽度 -->
        <attr name="sideBarLetterPressWidth" format="dimension|reference" />

        <!-- 弹窗的背景颜色 -->
        <attr name="dialogColor" format="color|reference" />
        <!-- 弹窗宽度与控件宽度的比例 -->
        <attr name="dialogSizePercent" format="float" />
        <!-- 弹窗的形状 -->
        <attr name="dialogShape" format="enum">
            <enum name="CIRCLE" value="0" />
            <enum name="SQUARE" value="1" />
        </attr>
        <!-- 弹窗为方形时的圆角 -->
        <attr name="dialogCorner" format="dimension|reference" />
        <!-- 弹窗字符文字的大小 -->
        <attr name="dialogLetterSize" format="dimension|reference" />
        <!-- 弹窗字符文字的颜色 -->
        <attr name="dialogLetterColor" format="color|reference" />
        <!-- 弹窗字符的宽度 -->
        <attr name="dialogLetterWidth" format="dimension|reference" />
        <!-- 弹窗的位置是否固定垂直居中 -->
        <attr name="dialogIsFixed" format="boolean" />
        <!-- 弹窗中心在控件的水平位置 -->
        <attr name="dialogHorizontalPercent" format="float" />
    </declare-styleable>

这里的自定义属性分别对应上述绘制分析的三点,基本上每个属性我都给出了注释,也很好理解,这里的自定义属性中包含 enum 类型,因此在接收属性的时最好也要转换成 enum 类型,例如:

/**
 * 定义索引栏两端的样式枚举
 */
public enum SideBarCap {
    NORMAL(0), ROUND(1);

    private int mIndex;

    SideBarCap(int index) {
        mIndex = index;
    }

    public int getIndex() {
        return mIndex;
    }
}

// ------------------------

// 将接收到的枚举属性的值(int)转换成枚举类型
mSideBarCap = SideBarCap
                .values()[typedArray.getInt(R.styleable.SideBar_sideBarCap, SideBarCap.ROUND.getIndex())];

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    mWidth = w;
    mHeight = h;
    // 计算每个字符的高度,mItemHeight 使用 float类型
    mItemHeight = (mHeight - mSideBarMarginTop - mSideBarMarginBottom - mSideBarPaddingTop - mSideBarPaddingBottom) / (float) mLetters.length;
}

这里的字符高度是在onSizeChange()方法中计算的,因此如果在Java代码中想要修改字符数组的数量、索引栏的高度等和 mItemHeight计算相关的参数,都需要调用 requestLayout() 方法重新计算mItemHeight,而不是调用 invalidate()

 @Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

    // 绘制索引栏背景
    drawSideBar(canvas);
    // 绘制索引栏字符
    drawSideBarLetter(canvas);
    // 绘制弹窗
    drawDialog(canvas);

}
 /**
 * 计算SideBar的位置
 *
 * @return
 */
private Rect calculateSideBarPos() {
    Rect rect = new Rect();

    rect.top = mSideBarMarginTop;
    rect.bottom = mHeight - mSideBarMarginBottom;

    switch (mSideBarPosition) {
        case LEFT:
            rect.left = mSideBarSpacing;
            rect.right = mSideBarSpacing + mSideBarWidth;
            break;
        case RIGHT:
            rect.left = mWidth - mSideBarSpacing - mSideBarWidth;
            rect.right = mWidth - mSideBarSpacing;
            break;
        default:
            rect.left = mWidth - mSideBarSpacing - mSideBarWidth;
            rect.right = mWidth - mSideBarSpacing;
            break;
    }

    return rect;
}

/**
 * 绘制索引栏的背景
 *
 * @param canvas
 */
private void drawSideBar(Canvas canvas) {
    ......(省略代码)

    // 计算索引栏的位置
    Rect rect = calculateSideBarPos();
    Path path = new Path();
    if (mSideBarCap == SideBarCap.ROUND) {
        // 圆形头
       ......(省略代码)
    } else if (mSideBarCap == SideBarCap.NORMAL) {
        // 方形头
       ......(省略代码)
    }
    ......(省略代码)

    canvas.drawPath(path, mSideBarPaint);
}

/**
 * 计算画笔的基线
 *
 * @param paint
 * @return
 */
private float calculatePaintBaseLine(Paint paint) {
    Paint.FontMetrics metrics = paint.getFontMetrics();
    return Math.abs(metrics.descent + metrics.ascent) / 2;
}

/**
 * 绘制索引栏的字符
 *
 * @param canvas
 */
private void drawSideBarLetter(Canvas canvas) {
    ......(省略代码)

    // 获取字符画笔默认的基线
    float paintBaseLine = calculatePaintBaseLine(mSideBarLetterPaint);
    // 字符的高度的起始位置
    float startY = rect.top + mSideBarPaddingTop;
    for (int i = 0; i < mLetters.length; i++) {
        String letter = mLetters[i];
        ......(省略代码)
        
        // 绘制字符
        canvas.drawText(letter, rect.left + mSideBarWidth / 2, startY + mItemHeight / 2 + paintBaseLine, mSideBarLetterPaint);
        startY += mItemHeight;
    }

}
  1. drawDialog()方法:
    此方法用于绘制弹窗,包含弹窗背景、弹窗的字符;
/**
 * 绘制字符弹窗
 *
 * @param canvas
 */
private void drawDialog(Canvas canvas) {
    ......(省略代码)

    // 计算弹窗的半径
    float dialogRadius = ((mWidth - mSideBarWidth - mSideBarSpacing) * mDialogSizePercent) / 2;

    // 默认圆心
    float cx = 0;
    float cy = mHeight / 2;

    // 计算索引栏不同位置时的弹窗水平偏移量
    if (mSideBarPosition == SideBarPosition.LEFT) {
        ......(省略代码)

    } else if (mSideBarPosition == SideBarPosition.RIGHT) {
        ......(省略代码)
    }

    if (!dialogIsFixed) {
        // 弹窗位置不固定,随着触摸点的y值移动
        ......(省略代码)
    }

    // 计算弹窗的边框
    ......(省略代码)

    if (mDialogShape == DialogShape.SQUARE) {   // 方形弹窗
        Path path = new Path();
        ......(省略代码)
        
        canvas.drawPath(path, mDialogPaint);
    } else if (mDialogShape == DialogShape.CIRCLE) {    // 圆形弹窗

        canvas.drawCircle(cx, cy, dialogRadius, mDialogPaint);
    }

    // 绘制弹窗字符
    String letter = mLetters[mCurrPosition];
    float baseLine = cy + calculatePaintBaseLine(mDialogLetterPaint);
    canvas.drawText(letter, cx, baseLine, mDialogLetterPaint);
}

弹窗可以根据参数来设置横向的位置,同时垂直方向可以设置跟随手指移动(魅族文件夹索引)也可以固定在SideBar的中间位置(微信联系人索引),同样这里的字符绘制需要将画笔的属性设置成居中对齐,然后再根据 baseLine就可以将字符绘制在弹窗的中心位置;

mDialogLetterPaint.setTextAlign(Paint.Align.CENTER);
@Override
public boolean onTouchEvent(MotionEvent event) {
    // 获取索引栏的范围
    Rect rect = calculateSideBarPos();
    float x = -1;
    float y = -1;

    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            x = event.getX();
            y = event.getY();
            if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {   
                // 不处理此次触摸事件
                return false;
            }
            break;
        case MotionEvent.ACTION_MOVE:
            x = event.getX();
            y = event.getY();
            break;
        case MotionEvent.ACTION_UP:
            x = -1;
            y = -1;
            break;
        case MotionEvent.ACTION_CANCEL:
            x = -1;
            y = -1;
            break;
    }

    if (x != -1 && y != -1) {
        isPress = true;
        int lastPos = mCurrPosition;
        calculatePosition(y);
        if (mCurrPosition != -1
                && lastPos != mCurrPosition) {  // 这里记录上次的position防止同一个字符多次传出

            if (onLetterUpdateListener != null) {
                onLetterUpdateListener.onLetterUpdate(mLetters[mCurrPosition]);
            }

        }

    } else {    // 不处理此次触摸事件
        isPress = false;
        mCurrPosition = -1;
        mCurrY = -1;
    }

    invalidate();
    // 默认消费事件,此处不需要触发点击事件等操作,因此直接返回 true 即可
    return true;
}

/**
 * 计算触摸的位置
 *
 * @param y
 */
private void calculatePosition(float y) {
    mCurrPosition = -1;
    // 字符栏绘制字符的起始/结束位置
    float startY = mSideBarMarginTop + mSideBarPaddingTop;
    float endY = mHeight - mSideBarMarginBottom - mSideBarPaddingBottom;

    // 当前的y的位置,用于弹窗位置的使用
    mCurrY = y;
    if (mCurrY <= startY) {
        mCurrY = startY;
    } else if (mCurrY >= endY) {
        mCurrY = endY;
    }

    // 计算当前 y 值对应的 字符数组的位置
    for (int i = 0; i < mLetters.length; i++) {
        if (y >= startY && y < startY + mItemHeight) {
            mCurrPosition = i;
            break;
        }
        startY += mItemHeight;
    }

    // sidebar 范围之外的y值,取sidebar两端的字符
    if (mCurrPosition == -1) {
        if (y <= startY && mLetters.length > 0) {
            mCurrPosition = 0;
        } else if (y >= endY && mLetters.length > 0) {
            mCurrPosition = mLetters.length - 1;
        }
    }

}

整个方法的逻辑其实很简单,如果down事件的位置位于 Rect 范围内,那就将接下来的时间序列交由 SideBar 消费,否则就将此次事件序列做任何处理(关于View的事件分发流程这里就不展开了,感兴趣的可以网上找资料或者查看我的文章);
然后就是将触摸事件拿到的 y 值进行计算,判断属于哪一个字符高度范围内;

/**
 * 字符切换监听
 */
public interface OnLetterUpdateListener {

    void onLetterUpdate(String letter);

}

// --------------------

// 触摸事件中将方法传递出去
if (x != -1 && y != -1) {
    isPress = true;
    int lastPos = mCurrPosition;
    calculatePosition(y);
    if (mCurrPosition != -1
            && lastPos != mCurrPosition) {  // 这里记录上次的position防止同一个字符多次传出

        if (onLetterUpdateListener != null) {
            onLetterUpdateListener.onLetterUpdate(mLetters[mCurrPosition]);
        }
    }
} 

这里有一点比较关键的是我们需要在传递字符的时候记录一下上一次的字符,如果和上一次不同才调用回调函数将数据传递出去,否则在同一个字符的 mItemHeight 高度内滑动会一直触发回调函数;

回调效果日志

总结

整个自定义控件的过程不是很难,主要是抽取出来的属性有点多,所有的属性按照三个模块来划分其实也是比较好理解的,看代码学习不如撸代码学习,写多了自然而然就会熟练,而且很多东西是共用的,比如计算Paint的baseLine,事件的分发机制等;

魅族文件索引效果图 微信好友索引效果图 左侧索引栏效果图

项目Github地址

上一篇 下一篇

猜你喜欢

热点阅读