【Android】使用RecyclerView优雅实现悬浮标题通讯录

ContactList是仿通讯录制作的一个app demo

主要技术点在RecyclerView,和自定义view
实现了悬浮标题头,导航侧栏

项目地址:https://github.com/hgDendi/ContactsList
欢迎star和fork

界面概览:

ContactsListDemo
ContactsListDemo
ContactsListDemo2
ContactsListDemo2

概要

contactsListStructure
contactsListStructure

如图,主要简单划分为两个部分:

​ 数据源、与界面组件。

​ 数据源主要来自手机的通讯录信息,通过ContentResolver获取。

​ 而界面组件主要有显示列表和侧边栏。而重点在于列表的分组栏的绘制与现实,这就依靠ItemDecoration来进行实现了,这也是难点。

复用方法

FloatingBarItemDecoration传入需要绘制标题栏的position和标题String的map,目前只支持竖项、单列的列表,如需要扩展,请读完此文,明白原理后很容易实现。

IndexBar传入Label的List,通过setListener加入勾子。

FloatingBarItemDecoration

An ItemDecoration allows the application to add a special drawing and layout offset to specific item views from the adapter's data set. This can be useful for drawing dividers between items, highlights, visual grouping boundaries and more.

ItemDecoration主要是用来对RecyclerView进行一些修饰,是对adapter数据集中的数据视图增加修饰或空位。经常被用来画分割线、强调效果、可见的分组边界等。

getItemOffset()

@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        int position = ((RecyclerView.LayoutParams))
          view.getLayoutParams()).getViewAdapterPosition();
        outRect.set(0, mList.containsKey(position) ? mTitleHeight : 0, 0, 0);
    }

绘制间距,为绘制标题栏空出间隙。主要逻辑是通过当前view的position判断是否需要在上方空出矩形范围。

onDraw()

主要是进行静态标题栏等绘制,即在每组view的上方,即getItemOffset()的区域进行标题栏的绘制。

@Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);
        final int left = parent.getPaddingLeft();
        final int right = parent.getWidth() - parent.getPaddingRight();
        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = parent.getChildAt(i);
            final RecyclerView.LayoutParams params = 
                (RecyclerView.LayoutParams) child.getLayoutParams();
            int position = params.getViewAdapterPosition();
            if (!mList.containsKey(position)) {
                continue;
            }
            drawTitleArea(c, left, right, child, params, position);
        }
    }

onDrawOver

实现悬浮分组栏,以及悬浮分组栏碰撞效果绘制。

对于整个列表的绘制流程,是遵循如下的顺序:

​ ItemDecoration#onDraw() -> ItemView的绘制 -> ItemDecoration#onDrawOver

故而在onDrawOver中实现可以满足“悬浮”,即在最上层的效果。

@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        final int position = ((LinearLayoutManager) parent.getLayoutManager()).findFirstVisibleItemPosition();
        if (position == RecyclerView.NO_POSITION) {
            return;
        }
        View child = parent.findViewHolderForAdapterPosition(position).itemView;
        String initial = getTag(position);
        if (initial == null) {
            return;
        }

        //flag指示当标题栏是否发生碰撞(如开头gif图中指示的)
        boolean flag = false;
        if (getTag(position + 1) != null && !initial.equals(getTag(position + 1))) {
            if (child.getHeight() + child.getTop() < mTitleHeight) {
                //与restore()对应,表示下面translate平移坐标系只对绘制当前标题栏生效
                c.save();
                flag = true;
                //translate使发生碰撞时,两个标题栏紧贴,制造出挤开的效果(dy<0,表示绘制偏下)
                c.translate(0, child.getHeight() + child.getTop() - mTitleHeight);
            }
        }

        c.drawRect(parent.getPaddingLeft(), parent.getPaddingTop(),
                parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + mTitleHeight, mBackgroundPaint);
        c.drawText(initial, child.getPaddingLeft() + mTextStartMargin,
                parent.getPaddingTop() + mTitleHeight - (mTitleHeight - mTextHeight) / 2 - mTextBaselineOffset, mTextPaint);

        if (flag) {
            c.restore();
        }
    }

IndexBar

IndexBar是侧边栏的实现,是采用的自定义View的形式。

FontMatrics

在此之前,介绍一个概念FontMatrics,是表征字体的一个矩阵。

定义BaseLine为Text的起始点(类似英文五线谱的baseline)

drawText传入的纵坐标值也为BaseLine所在的纵坐标,而非矩形区域的左下角的纵坐标(这点很重要,否则在开发者模式中开启布局边界会发现字体和边界错乱)

主要有以下几个属性:

  • Top (<0)
    • Ascent可能的最小值(绝对值最大)
  • Ascent (<0)
    • 字体最高处距BaseLine的距离
  • Descent (>0)
    • 字体最低处距BaseLine的距离
  • Bottom (>0)
    • Descent可能的最大值
  • Leading
    • 间距,用于多行文字显示时的距离
fontMatrics
fontMatrics

在此例中我们用来计算每个text的高度,以此作为测量View高度的参数。很多时候可以选择不加leanding值, 因为单行多行时候的leading值都为0.(不知道什么时候可以取到非0的值)

Paint.FontMetrics fm = mPaint.getFontMetrics();
float singleHeight = fm.bottom - fm.top + fm.leading;

onMeasure()

计算View的长宽。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    setMeasuredDimension(measureWidth(widthMeasureSpec), measureHeight(heightMeasureSpec));
}

private int measureWidth(int widthMeasureSpec) {
    int result;
    int specMode = MeasureSpec.getMode(widthMeasureSpec);
    int specSize = MeasureSpec.getSize(widthMeasureSpec);
    if (specMode == MeasureSpec.EXACTLY) {
        result = specSize;
    } else {
        result = getSuggestedMinWidth();
        if (specMode == MeasureSpec.AT_MOST) {
            result = Math.min(result, specSize);
        }
    }
    return result;
}

//获取建议的最小宽度,尽量保证不会出现显示不下的情况(极端情况下仍会显示不下)
private int getSuggestedMinWidth() {
    String maxLengthTag = "";
    for (String tag : mNavigators) {
        if (maxLengthTag.length() < tag.length()) {
            maxLengthTag = tag;
        }
    }
    return (int) (mPaint.measureText(maxLengthTag) + 0.5);
}

private int measureHeight(int heightMeasureSpec) {
    int result;
    int specMode = MeasureSpec.getMode(heightMeasureSpec);
    int specSize = MeasureSpec.getSize(heightMeasureSpec);
    if (specMode == MeasureSpec.EXACTLY) {
        result = specSize;
    } else {
        Paint.FontMetrics fm = mPaint.getFontMetrics();
        float singleHeight = fm.bottom - fm.top + fm.leading;
        //这个mLetterSpacingExtra是疏密程度,是自定义属性,默认1.4
        mBaseLineHeight = fm.bottom * mLetterSpacingExtra;
        result = (int) (mNavigators.size() * singleHeight * mLetterSpacingExtra);
        if (specMode == MeasureSpec.AT_MOST) {
            result = Math.min(result, specSize);
        }
    }
    return result;
}

onDraw()

负责绘制

protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        int height = getHeight();
        int width = getWidth();
        //高度为0,可能是因为传入参数为空,则不予显示
        if (height == 0) {
            return;
        }
        int singleHeight = height / mNavigators.size();

        //遍历绘制Text
        for (int i = 0; i < mNavigators.size(); i++) {
            float xPos = width / 2 - mPaint.measureText(mNavigators.get(i)) / 2;
            float yPos = singleHeight * (i + 1);
            if (i == mFocusIndex) {
                canvas.drawText(mNavigators.get(i), xPos, yPos - mBaseLineHeight, mFocusPaint);
            } else {
                canvas.drawText(mNavigators.get(i), xPos, yPos - mBaseLineHeight, mPaint);
            }
        }
    }

DispatchTouchEvent()

处理交互事件,主要是监听UP、CANCEL、DOWN、MOVE,其中以DOWN做为起点,CANCEL、UP做为终点,其他为中间状态。以TAG的焦点变更和事件的开始、结束做为重绘的触发点。

@Override
    public boolean dispatchTouchEvent(MotionEvent event) {
        final float y = event.getY();
        final int formerFocusIndex = mFocusIndex;
        final OnTouchingLetterChangeListener listener = mOnTouchingLetterChangeListener;
        final int c = calculateOnClickItemNum(y);

        switch (event.getAction()) {
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
                mFocusIndex = -1;
                invalidate();
                listener.onTouchingEnd(mNavigators.get(c));
                break;
            case MotionEvent.ACTION_DOWN:
                listener.onTouchingStart(mNavigators.get(c));
            default:
                if (formerFocusIndex != c) {
                    if (c >= 0 && c < mNavigators.size()) {
                        listener.onTouchingLetterChanged(mNavigators.get(c));
                        mFocusIndex = c;
                        invalidate();
                    }
                }
                break;
        }
        return true;
    }

    /**
     * @param yPos
     * @return the corresponding position in list
     */
    private int calculateOnClickItemNum(float yPos) {
        int result;
        //计算当前触摸点属于哪个TAG,超出边界按照边界值返回(尤其在MOVE的时候很容易滑出边界)
        result = (int) (yPos / getHeight() * mNavigators.size());
        if (result >= mNavigators.size()) {
            result = mNavigators.size() - 1;
        } else if (result < 0) {
            result = 0;
        }
        return result;
    }

ContactsUtils

主要是负责获得缩写,其中英文字符就直接获得英文字符,中文字符通过比对GB2312得到英文缩写

对于中文获得缩写的核心思想如下,是通过比对GB2312值得到中文中声母,继而获得缩写情况。

    //GB2312中简体中文的起止,判断范围
    private static int BEGIN = 45217;
    private static int END = 63486;

    /**
     * 各声母第一个汉字
     * {i、u、v} 不做声母
     */
    private static char[] chartable = {'啊', '芭', '擦', '搭', '蛾', '发', '噶', '哈', '击', '喀', '垃','妈', '拿', '哦', '啪', '期', '然', '撒', '塌', '挖', '昔', '压', '匝'};

    private static char[] initialtable = {'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'J', 'K','L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'W', 'X', 'Y', 'Z'};

    //此table是各声母对应的起始GB值,与initialtable对应
    private static int[] table = new int[chartable.length + 1];

    static {
        for (int i = 0; i < chartable.length; i++) {
            table[i] = gbValue(chartable[i]);
        }
        table[chartable.length] = END;
    }
    
    //计算char对应的gb值
    private static int gbValue(char ch) {
        String str = "" + ch;
        try {
            byte[] bytes = str.getBytes("GB2312");
            if (bytes.length < 2) {
                return 0;
            }
            return (bytes[0] << 8 & 0xff00) + (bytes[1] & 0xff);
        } catch (Exception e) {
            return 0;
        }
    }

ContactsManager

负责通讯录信息的获取,此处只取了电话号码和联系人名称,使用的是ContentResolver进行查询

    @NonNull
    public static ArrayList<ShareContactsBean> getPhoneContacts(Context mContext) {
        ArrayList<ShareContactsBean> result = new ArrayList<>(0);
        ContentResolver resolver = mContext.getContentResolver();
        Cursor phoneCursor = resolver.query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
                new String[]{ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME}, null, null, null);
        if (phoneCursor != null) {
            while (phoneCursor.moveToNext()) {
                String phoneNumber = phoneCursor.getString(0).replace(" ", "");
                String contactName = phoneCursor.getString(1);
                result.add(new ShareContactsBean(contactName, phoneNumber));
            }
            phoneCursor.close();
        }
        //对结果进行排序,这个排序方法写在bean中
        Collections.sort(result, new Comparator<ShareContactsBean>() {
            @Override
            public int compare(ShareContactsBean l, ShareContactsBean r) {
                return l.compareTo(r);
            }
        });
        return result;
    }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,233评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,013评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,030评论 0 241
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,827评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,221评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,542评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,814评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,513评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,225评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,497评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,998评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,342评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,986评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,812评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,560评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,461评论 2 266

推荐阅读更多精彩内容