SideBar列表索引

概述

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


仿魅族文件索引

仿微信好友列表索引

实现思路

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

  • 自定义控件的选择
    索引栏主要模块为字符索引栏和字符弹窗,可以自定义一个ViewGroup来包含两个模块,也可以在一个View里面直接绘制两个模块,当然我肯定会选择后者(别问我为什么就是不想回答);

  • 绘制绘制分析
    我们可以把索引栏分成三个部分来绘制:
    1.字符栏的背景效果绘制;
    2.字符栏的字符绘制;
    3.字符弹窗的绘制;
    我们可以定义三个方法来绘制三个不同的模块,只需要在 onDraw() 中调用即可;

  • 触摸事件分析
    根据效果图分析,索引栏控件是覆盖在ListView上方,只有down事件在字符索引上才会处理此次事件,因此这里需要对 onTouchEvent() 方法进行处理;

代码实现

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

  • 步骤一:自定义相关属性
    我们首先需要创建一个类 SideBar 继承自View,然后定义自定义属性,这里我们把背景、文字等属性都设定为自定义属性,因此自定义属性的字段会比较多:
 <!-- 自定义索引栏控件的属性 -->
    <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())];

  • 步骤二:初始化相关属性
    这个步骤最关键的地方在于索引栏每个字符的mItemHeight的计算,由于一开始给定的类型是 int 而这里的字符数量有 20+,每个字符如果都相差0.5,造成的误差就会比较大,最终导致字符在索引栏上下不对称,因此这里的 mItemHeight 推荐使用 float 类型;
@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()

  • 步骤三:重写onDraw()方法
    根据上面的绘制分析,这里把三个部分的绘制抽取成三个方法;
 @Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);

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

}
  • drawSideBar() 方法:
    此方法用于绘制索引栏的背景;
    我们给索引栏的位置设置了一个枚举类型有左侧、右侧,因此这里最好的处理方式是将索引栏的上下左右位置保存到一个Rect对象中;
    索引栏上下两端的形状也有一个枚举类型分别为Normal、Round,因此在确定了索引栏的位置后,最好将索引栏的形状封装成一个Path对象,然后交由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);
}

  • drawSideBarLetter()方法:
    此方法用于绘制索引栏上的字符,mItemHeight已经计算出来了,也就意味着每个字符的中心位置已经确定了,因此我们只要计算出画笔的 baseLine 即可,关于如何计算 Paint 的 baseLine 值,可以参考我的文章Android 为控件增加数字提示,DrawText 方法解析
/**
 * 计算画笔的基线
 *
 * @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);
  • 步骤四:重写 onTouchEvent()
    根据前面的分析,SideBar 只消费 x 位于前面计算出来的 字符栏的 Rect 对象范围内的 down 事件,否则不处理;
@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 值进行计算,判断属于哪一个字符高度范围内;

  • 步骤五:回调函数,将字符更新的事件传递出去
    在步骤四中计算好了字符位置后我们需要将我们的更新字符事件传递出去,通常我们会采用回调函数的方式将我们需要的数据传递出去,这里我们只需要将我们的当前位置的 letter 传递出去即可;
/**
 * 字符切换监听
 */
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地址

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,884评论 6 513
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,212评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 167,351评论 0 360
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,412评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,438评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,127评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,714评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,636评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,173评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,264评论 3 339
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,402评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,073评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,763评论 3 332
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,253评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,382评论 1 271
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,749评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,403评论 2 358

推荐阅读更多精彩内容