【Android 仿微信通讯录 导航分组列表-下】自定义View为RecyclerView打造右侧索引导航栏IndexBar

**本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布 **

转载请标明出处: http://www.jianshu.com/p/3eff217839fc
本文出自:【张旭童的博客】 (http://www.jianshu.com/users/8e91ff99b072/latest_articles)
代码传送门:喜欢的话,随手点个star。多谢
https://github.com/mcxtzhang/ItemDecorationIndexBar


一 概述

在上篇文章(http://www.jianshu.com/p/0d49d9f51d2c) 里,我们用ItemDecoration为RecyclerView打造了带悬停头部的分组列表。
其实Android版微信的通讯录界面,它的分组title也不是悬停的,我们已经领先了微信一小步(认真脸)~
再看看市面上常见的分组列表(例如饿了么点餐商品列表),不仅有悬停头部,悬停头部在切换时,还会伴有切换动画
关于ItemDecoration还有一个问题,简单布局还好,我们可以draw出来,如果是复杂的头部呢?能否写个xml,inflate进来,这样使用起来才简单,即另一种简单使用onDraw和onDrawOver的姿势。
so,本文开头我们就先用两节完善一下我们的ItemDecoration。
然后进入正题:自定义View实现右侧索引导航栏IndexBar,对数据源的排序字段按照拼音排序,最后将RecyclerView和IndexBar联动起来,触摸IndexBar上相应字母,RecyclerView滚动到相应位置。(在屏幕中间显示的其实就是一个TextView,我们set个体IndexBar即可)

由于大部分使用右侧索引导航栏的场景,都需要这几个固定步骤,对数据源排序,set给IndexBar,和RecyclerView联动等,所以最后再将其封装一把,成一个高度封装,因此扩展性不太高的控件,更方便使用,如果需要扩展的话,反正看完本文再其基础上修改应该很简单~。

最终版预览:


这里写图片描述

本文摘要:

  1. 用ItemDecoration实现悬停头部切换动画
  2. 另一种简单使用onDraw()和onDrawOver()的姿势
  3. 自定义View实现右侧索引导航栏IndexBar
  4. 使用TinyPinyin对数据源排序
  5. 联动IndexBar和RecyclerView。
  6. 封装重复步骤,方便二次使用,并可定制导航数据源

二 悬停头部的“切换动画”

实现了两种,
第一种就是仿饿了么点餐时,商品列表的悬停头部切换“动画效果”,如下:


这里写图片描述

第二种是一种头部折叠起来的视效,个人觉得也还不错~如下:(估计没人喜欢)


这里写图片描述

果然比上部残篇里的效果好看多了,那么代码多不多呢,看我的git show 记录:
这里写图片描述

就绿色部分的不到十行代码就搞定~先上这个图是为了让大家安心,代码不多,分分钟看完。

下面放上文字版代码,江湖人称 注释张 的我,已经写满了注释,
再简单说下吧,
滑动时,在判断头部即将切换(当前pos的tag和pos+1的tag不等)的时候,
1.计算出当前悬停头部应该上移的位移,
利用Canvas的画布移动方法Canvas.translate(),即可实现“饿了么”悬停头部切换效果。
2.计算出当前悬停头部应该在屏幕上还剩余的空间高度,作为头部绘制的高度
利用Canvas的Canvas.clipRect()方法,剪切画布,即可实现“折叠”的视效。

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {//最后调用 绘制在最上层
        int pos = ((LinearLayoutManager) (parent.getLayoutManager())).findFirstVisibleItemPosition();

        String tag = mDatas.get(pos).getTag();
        //View child = parent.getChildAt(pos);
        View child = parent.findViewHolderForLayoutPosition(pos).itemView;//出现一个奇怪的bug,有时候child为空,所以将 child = parent.getChildAt(i)。-》 parent.findViewHolderForLayoutPosition(pos).itemView

        boolean flag = false;//定义一个flag,Canvas是否位移过的标志
        if ((pos + 1) < mDatas.size()) {//防止数组越界(一般情况不会出现)
            if (null != tag && !tag.equals(mDatas.get(pos + 1).getTag())) {//当前第一个可见的Item的tag,不等于其后一个item的tag,说明悬浮的View要切换了
                Log.d("zxt", "onDrawOver() called with: c = [" + child.getTop());//当getTop开始变负,它的绝对值,是第一个可见的Item移出屏幕的距离,
                if (child.getHeight() + child.getTop() < mTitleHeight) {//当第一个可见的item在屏幕中还剩的高度小于title区域的高度时,我们也该开始做悬浮Title的“交换动画”
                    c.save();//每次绘制前 保存当前Canvas状态,
                    flag = true;

                    //一种头部折叠起来的视效,个人觉得也还不错~
                    //可与123行 c.drawRect 比较,只有bottom参数不一样,由于 child.getHeight() + child.getTop() < mTitleHeight,所以绘制区域是在不断的减小,有种折叠起来的感觉
                    //c.clipRect(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + child.getHeight() + child.getTop());

                    //类似饿了么点餐时,商品列表的悬停头部切换“动画效果”
                    //上滑时,将canvas上移 (y为负数) ,所以后面canvas 画出来的Rect和Text都上移了,有种切换的“动画”感觉
                    c.translate(0, child.getHeight() + child.getTop() - mTitleHeight);
                }
            }
        }
        mPaint.setColor(COLOR_TITLE_BG);
        c.drawRect(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + mTitleHeight, mPaint);
        mPaint.setColor(COLOR_TITLE_FONT);
        mPaint.getTextBounds(tag, 0, tag.length(), mBounds);
        c.drawText(tag, child.getPaddingLeft(),
                parent.getPaddingTop() + mTitleHeight - (mTitleHeight / 2 - mBounds.height() / 2),
                mPaint);
        if (flag)
            c.restore();//恢复画布到之前保存的状态


    }

这份代码核心处c.translate(0, child.getHeight() + child.getTop() - mTitleHeight);实现的是饿了么效果,被注释掉的

//c.clipRect(parent.getPaddingLeft(), parent.getPaddingTop(), parent.getRight() - parent.getPaddingRight(), parent.getPaddingTop() + child.getHeight() + child.getTop());

实现的是效果二。


三 另一种使用onDraw()和onDrawOver()的姿势

之前我们使用onDraw(),onDrawOver(),都是用canvas的方法活生生的绘制一个出View,这对于很多人(包括我)来说都不容易,xy坐标的确认,尺寸都较难把握,基本上调UI效果时间都很长。尤其是canvas.drawText()方法的y坐标,其实是baseLine的位置,不了解的童鞋肯定要踩很多坑。
当我们想要绘制的分类title、悬停头部复杂一点时,我都不敢想象要调试多久了,这个时候我们还敢用ItemDecoration吗。
有没有一种方法,就像我们平时使用的那样,在Layout布局xml里画好View,然后inflate出来就可以了呢。
这个问题开始确实也把我难住了,难道又要从入门到放弃了吗?
于是我又搜寻资料,功夫不负有心人。
解决问题的办法就是,View类的:public void draw(Canvas canvas) {方法
下面我们就看一个用法Demo吧:
布局layout:header_complex.xml(注意有个ProgressBar哦)

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/colorPrimaryDark"
    android:orientation="vertical">

    <Button
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/colorAccent"
        android:text="复杂头部" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="复杂头部"
        android:textColor="@color/colorAccent" />

    <ProgressBar
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
</LinearLayout>

onDrawOver代码如下:简单讲解下,先inflate这个复杂的Layout,然后拿到它的LayoutParams,利用这个lp拿到宽和高的MeasureSpec,然后依次调用 measure,layout,draw方法,将复杂头部显示在屏幕上。
(小安利一下,MeasureSpec不太了解的可以看看我的这篇http://blog.csdn.net/zxt0601/article/details/52331007

        View toDrawView = mInflater.inflate(R.layout.header_complex, parent, false);
        int toDrawWidthSpec;//用于测量的widthMeasureSpec
        int toDrawHeightSpec;//用于测量的heightMeasureSpec
        //拿到复杂布局的LayoutParams,如果为空,就new一个。
        // 后面需要根据这个lp 构建toDrawWidthSpec,toDrawHeightSpec
        ViewGroup.LayoutParams lp = toDrawView.getLayoutParams();
        if (lp == null) {
            lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);//这里是根据复杂布局layout的width height,new一个Lp
            toDrawView.setLayoutParams(lp);
        }
        if (lp.width == ViewGroup.LayoutParams.MATCH_PARENT) {
            //如果是MATCH_PARENT,则用父控件能分配的最大宽度和EXACTLY构建MeasureSpec。
            toDrawWidthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth() - parent.getPaddingLeft() - parent.getPaddingRight(), View.MeasureSpec.EXACTLY);
        } else if (lp.width == ViewGroup.LayoutParams.WRAP_CONTENT) {
            //如果是WRAP_CONTENT,则用父控件能分配的最大宽度和AT_MOST构建MeasureSpec。
            toDrawWidthSpec = View.MeasureSpec.makeMeasureSpec(parent.getWidth() - parent.getPaddingLeft() - parent.getPaddingRight(), View.MeasureSpec.AT_MOST);
        } else {
            //否则则是具体的宽度数值,则用这个宽度和EXACTLY构建MeasureSpec。
            toDrawWidthSpec = View.MeasureSpec.makeMeasureSpec(lp.width, View.MeasureSpec.EXACTLY);
        }
        //高度同理
        if (lp.height == ViewGroup.LayoutParams.MATCH_PARENT) {
            toDrawHeightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight() - parent.getPaddingTop() - parent.getPaddingBottom(), View.MeasureSpec.EXACTLY);
        } else if (lp.height == ViewGroup.LayoutParams.WRAP_CONTENT) {
            toDrawHeightSpec = View.MeasureSpec.makeMeasureSpec(parent.getHeight() - parent.getPaddingTop() - parent.getPaddingBottom(), View.MeasureSpec.AT_MOST);
        } else {
            toDrawHeightSpec = View.MeasureSpec.makeMeasureSpec(lp.width, View.MeasureSpec.EXACTLY);
        }
        //依次调用 measure,layout,draw方法,将复杂头部显示在屏幕上。
        toDrawView.measure(toDrawWidthSpec, toDrawHeightSpec);
        toDrawView.layout(parent.getPaddingLeft(), parent.getPaddingTop(),
                parent.getPaddingLeft() + toDrawView.getMeasuredWidth(), parent.getPaddingTop() + toDrawView.getMeasuredHeight());
        toDrawView.draw(c);

这里还有个有趣的地方,某些需要不断调用onDraw()更新绘制自己最新状态的View,例如ProgressBar,由于在屏幕上显示的并不是真正的View,只是我们手动的调用了一次draw方法,进而调用View的onDraw()显示的一次“残影”,所以ProgressBar只会显示onDraw()当时的样子,并不会主动刷新了。
看图说话,还是很容易理解的:

这里写图片描述

滑动时,由于会回调onDrawOver() 方法,所以ProgressBar又被手动调用了draw(),开始变化,滑动的快的话,progressBar会有动画效果。
停止不动时,ProgressBar也是静止的,保持draw()时绘制的状态。


四 自定义View实现右侧索引导航栏IndexBar

不管是自定义ItemDecoration还是实现右侧索引导航栏,都有大量的自定义View知识在里面 ,这里简单复习一下。
(步骤1-4是自定义View的必须套路,步骤5+是IndexBar特殊定制)
1 自定义View首先要确定这个View需要在xml里接受哪些属性?
在IndexBar里,我们先需要两个属性,每个索引的文字大小和手指按下时整个View的背景,
即在attrs.xml如下定义:

    <attr name="textSize" format="dimension" />
    <declare-styleable name="IndexBar">
        <attr name="textSize" />
        <attr name="pressBackground" format="color" />
    </declare-styleable>

2 在View的构造方法中获得我们自定义的属性
套路代码如下,都是套路,记得使用完最后要将typeArray对象 recycle()

        int textSize = (int) TypedValue.applyDimension(
                TypedValue.COMPLEX_UNIT_SP, 16, getResources().getDisplayMetrics());//默认的TextSize
        mPressedBackground = Color.BLACK;//默认按下是纯黑色
        TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.IndexBar, defStyleAttr, 0);
        int n = typedArray.getIndexCount();
        for (int i = 0; i < n; i++) {
            int attr = typedArray.getIndex(i);
            switch (attr) {
                case R.styleable.IndexBar_textSize:
                    textSize = typedArray.getDimensionPixelSize(attr, textSize);
                    break;
                case R.styleable.IndexBar_pressBackground:
                    mPressedBackground = typedArray.getColor(attr, mPressedBackground);
                default:
                    break;
            }
        }
        typedArray.recycle();

3 重写onMesure()方法(可选)
onMeasure()方法里,主要就是遍历一遍indexDatas,得到index最大宽度和高度。然后根据三种测量模式,分配不同的值给View,
EXACLTY就分配具体的测量值(match_parent,确定数值),
AT_MOST就分配父控件能给的最大值和自己需要的值之间的最小值。(保证不超过父控件限定的值)
UNSPECIFIED则分配自己需要的值。(随心所欲)

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //取出宽高的MeasureSpec  Mode 和Size
        int wMode = MeasureSpec.getMode(widthMeasureSpec);
        int wSize = MeasureSpec.getSize(widthMeasureSpec);
        int hMode = MeasureSpec.getMode(heightMeasureSpec);
        int hSize = MeasureSpec.getSize(heightMeasureSpec);
        int measureWidth = 0, measureHeight = 0;//最终测量出来的宽高

        //得到合适宽度:
        Rect indexBounds = new Rect();//存放每个绘制的index的Rect区域
        String index;//每个要绘制的index内容
        for (int i = 0; i < mIndexDatas.size(); i++) {
            index = mIndexDatas.get(i);
            mPaint.getTextBounds(index, 0, index.length(), indexBounds);//测量计算文字所在矩形,可以得到宽高
            measureWidth = Math.max(indexBounds.width(), measureWidth);//循环结束后,得到index的最大宽度
            measureHeight = Math.max(indexBounds.width(), measureHeight);//循环结束后,得到index的最大高度,然后*size
        }
        measureHeight *= mIndexDatas.size();
        switch (wMode) {
            case MeasureSpec.EXACTLY:
                measureWidth = wSize;
                break;
            case MeasureSpec.AT_MOST:
                measureWidth = Math.min(measureWidth, wSize);//wSize此时是父控件能给子View分配的最大空间
                break;
            case MeasureSpec.UNSPECIFIED:
                break;
        }

        //得到合适的高度:
        switch (hMode) {
            case MeasureSpec.EXACTLY:
                measureHeight = hSize;
                break;
            case MeasureSpec.AT_MOST:
                measureHeight = Math.min(measureHeight, hSize);//wSize此时是父控件能给子View分配的最大空间
                break;
            case MeasureSpec.UNSPECIFIED:
                break;
        }

        setMeasuredDimension(measureWidth, measureHeight);
    }

4 重写onDraw()方法
整理一下需求和思路:
利用index数据源的size,和控件可绘制的高度(高度-paddingTop-paddingBottom),求出每个index区域的高度mGapHeight。
每个index在绘制时,都是处于水平居中,竖直方向上在mGapHeight区域高度内居中。
思路整理清楚,代码很简单如下:

    public static String[] INDEX_STRING = {"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 List<String> mIndexDatas;//索引数据源
    private int mGapHeight;//每个index区域的高度
    .....
    mIndexDatas = Arrays.asList(INDEX_STRING);//数据源

在onSizeChanged方法里,获取控件的宽高,并计算出mGapHeight:

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mWidth = w;
        mHeight = h;
        mGapHeight = (mHeight - getPaddingTop() - getPaddingBottom()) / mIndexDatas.size();
    }

最后在onDraw()方法里绘制,
如果对于竖直居中baseLine的计算不太理解可以先放置,这块的确挺绕人,后面应该会写一篇 canvas.drawText()x y坐标计算的小短文.
可记住重点就是 Paint默认的TextAlign是Left,即x方向,左对齐,所以x坐标决定绘制文字的左边界。
y坐标是绘制文字的baseLine位置

    @Override
    protected void onDraw(Canvas canvas) {
        int t = getPaddingTop();//top的基准点(支持padding)
        Rect indexBounds = new Rect();//存放每个绘制的index的Rect区域
        String index;//每个要绘制的index内容
        for (int i = 0; i < mIndexDatas.size(); i++) {
            index = mIndexDatas.get(i);
            mPaint.getTextBounds(index, 0, index.length(), indexBounds);//测量计算文字所在矩形,可以得到宽高
            Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();//获得画笔的FontMetrics,用来计算baseLine。因为drawText的y坐标,代表的是绘制的文字的baseLine的位置
            int baseline = (int) ((mGapHeight - fontMetrics.bottom - fontMetrics.top) / 2);//计算出在每格index区域,竖直居中的baseLine值
            canvas.drawText(index, mWidth / 2 - indexBounds.width() / 2, t + mGapHeight * i + baseline, mPaint);//调用drawText,居中显示绘制index
        }
    }

以上四步基本完成了IndexBar的绘制工作,下面我们为它添加一些行为的响应。

5 重写onTouchEvent()方法
我们需要重写onTouchEvent()方法,
以便处理手指按下时的View背景变色,抬起时恢复原来颜色
并根据手指触摸的落点坐标,判断当前处于哪个index区域,回调给相应的监听器处理(显示当前index的值,滑动RecyclerView至相应区域等。。)
代码如下:

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                setBackgroundColor(mPressedBackground);//手指按下时背景变色
                //注意这里没有break,因为down时,也要计算落点 回调监听器
            case MotionEvent.ACTION_MOVE:
                float y = event.getY();
                //通过计算判断落点在哪个区域:
                int pressI = (int) ((y - getPaddingTop()) / mGapHeight);
                //边界处理(在手指move时,有可能已经移出边界,防止越界)
                if (pressI < 0) {
                    pressI = 0;
                } else if (pressI >= mIndexDatas.size()) {
                    pressI = mIndexDatas.size() - 1;
                }
                //回调监听器
                if (null != mOnIndexPressedListener) {
                    mOnIndexPressedListener.onIndexPressed(pressI, mIndexDatas.get(pressI));
                }
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
            default:
                setBackgroundResource(android.R.color.transparent);//手指抬起时背景恢复透明
                //回调监听器
                if (null != mOnIndexPressedListener) {
                    mOnIndexPressedListener.onMotionEventEnd();
                }
                break;
        }
        return true;
    }

6 联动IndexBar和RecyclerView
具体的操作交由监听器处理,定义和实现如下:
值得一提的就是,滑动RecyclerView到指定postion,我们使用的是LinearLayoutManager的scrollToPositionWithOffset(int position, int offset)方法,offset传入0,postion即目标postion即可。如果使用RecyclerView.scrollToPosition();等方法,滑动会很飘~定位不准。
mPressedShowTextView 就是在屏幕中间显示的当前处于哪个index的TextView。

    /**
     * 当前被按下的index的监听器
     */
    public interface onIndexPressedListener {
        void onIndexPressed(int index, String text);//当某个Index被按下

        void onMotionEventEnd();//当触摸事件结束(UP CANCEL)
    }
    
    private onIndexPressedListener mOnIndexPressedListener;
    
    public void setmOnIndexPressedListener(onIndexPressedListener mOnIndexPressedListener) {
        this.mOnIndexPressedListener = mOnIndexPressedListener;
    }
        //设置index触摸监听器
        setmOnIndexPressedListener(new onIndexPressedListener() {
            @Override
            public void onIndexPressed(int index, String text) {
                if (mPressedShowTextView != null) { //显示hintTexView
                    mPressedShowTextView.setVisibility(View.VISIBLE);
                    mPressedShowTextView.setText(text);
                }
                //滑动Rv
                if (mLayoutManager != null) {
                    int position = getPosByTag(text);
                    if (position != -1) {
                        mLayoutManager.scrollToPositionWithOffset(position, 0);
                    }
                }
            }

            @Override
            public void onMotionEventEnd() {
                //隐藏hintTextView
                if (mPressedShowTextView != null) {
                    mPressedShowTextView.setVisibility(View.GONE);
                }
            }
        });

五 封装重复步骤,方便二次使用。

在我个人的理解里,程序过多的封装是会导致扩展性的降低(也是因为我水平有限),然而我们今天要封装的这个IndexBar,由于使用场景和套路还是挺固定的(城市分组列表,商品分类列表)所以值得将相关的操作都聚合起来,二次使用更方便。毕竟,一个项目里同样的代码写第二遍的程序员都不是好的圣斗士。(其实是我的leader不想写第二遍,让我封装一下给他秒用)
梳理一下固定的操作:
1 都是先对原始数据sourceDatas源按照排序字段拼音排序。
2 然后将屏幕中hint的TextView ,以及索引数据源indexDatas(通过sourceDatas获得),通过set方法传给IndexBar。
3 联动IndexBar和RecyclerView,使得触摸IndexBar相应区域RecyclerView会滚动(借助sourceDatas获得对应postion)。
根据上述,我的设想在使用时,只需要给IndexBar设置 原始数据sourceDatas,HintTextView,和RecyclerView的LinearLayoutManager,在IndexBar内部对sourceDatas排序,并获得索引数据源indexDatas,然后设置一个默认的index触摸监听器,在手指按下滑动时,由于IndexBar持有HintTextView和LayoutManager,则HintTextView的show hide,以及LayoutManager的滚动 都在IndexBar内部完成。
最终使用预览:

        //使用indexBar
        mTvSideBarHint = (TextView) findViewById(R.id.tvSideBarHint);//HintTextView
        mIndexBar = (IndexBar) findViewById(R.id.indexBar);//IndexBar
        mIndexBar.setmPressedShowTextView(mTvSideBarHint)//设置HintTextView
                .setNeedRealIndex(true)//设置需要真实的索引
                .setmLayoutManager(mManager)//设置RecyclerView的LayoutManager
                .setmSourceDatas(mDatas);//设置数据源

布局xml:

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/rv"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    </android.support.v7.widget.RecyclerView>

    <mcxtzhang.itemdecorationdemo.IndexBar.widget.IndexBar
        android:id="@+id/indexBar"
        android:layout_width="24dp"
        android:layout_height="match_parent"
        android:layout_gravity="right"
        app:pressBackground="@color/partTranslucent"
        app:textSize="16sp" />

    <TextView
        android:id="@+id/tvSideBarHint"
        android:layout_width="80dp"
        android:layout_height="80dp"
        android:layout_gravity="center"
        android:background="@drawable/shape_side_bar_bg"
        android:gravity="center"
        android:textColor="@android:color/white"
        android:textSize="48sp"
        android:visibility="gone"
        tools:text="A"
        tools:visibility="visible" />

</FrameLayout>

其中,setNeedRealIndex(true)//设置需要真实的索引,是指索引栏的数据不是固定的A-Z,#。而是根据真实的sourceDatas生成。
因为链式调用用起来很爽,所以在这些set方法里都return 了 this。

1 抽象两个实体类和一个接口。
先把tag抽象出来,放在顶层,这里存放的就是IndexBar显示的每个index值(A-Z,#)(本例是城市的汉语拼音首字母),而且在联动滑动时,根据tag获取postion时,也需要用到tag。它是导航分组列表的基础。

public class BaseIndexTagBean {
    private String tag;//所属的分类(城市的汉语拼音首字母)

    public String getTag() {
        return tag;
    }

    public void setTag(String tag) {
        this.tag = tag;
    }
}

然后抽象一个接口和一个实体类,
接口定义一个方法getTarget(),它返回 需要被转化成拼音,并取出首字母 索引排序的 字段。(本例就是城市的名字)
实体类继承BaseIndexTagBean,并实现以上接口,且额外存放 需要排序的字段的拼音值,(本例是城市的拼音)。它根据getTarget()返回的值利用TinyPinyin库得到拼音。

public interface IIndexTargetInterface {
    String getTarget();//需要被转化成拼音,并取出首字母 索引排序的 字段
}
public abstract class BaseIndexPinyinBean extends BaseIndexTagBean implements IIndexTargetInterface {
    private String pyCity;//城市的拼音

    public String getPyCity() {
        return pyCity;
    }

    public void setPyCity(String pyCity) {
        this.pyCity = pyCity;
    }
}

有了以上两个类一个接口,我们就可以将 对原始数据源sourceDatas按照拼音排序,并取出索引数据源indexDatas的操作封装起来。

2 封装原始数据源初始化(利用TinyPinyin获取全拼音),取出索引数据源indexDatas的操作。
使用时,我们先让具体的实体bean,继承自BaseIndexPinyinBean ,在getTarget()方法返回排序目标字段。本例如下:

public class CityBean extends BaseIndexPinyinBean {

    private String city;//城市名字
    
    public CityBean() {
    }
    public CityBean(String city) {
        this.city = city;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }

    @Override
    public String getTarget() {
        return city;
    }
}

IndexBar类内代码:
使用时会调用IndexBar.setmSourceDatas()方法传入原始数据源,在方法内对数据源初始化,并取出索引数据源。

    private List<? extends BaseIndexPinyinBean> mSourceDatas;//Adapter的数据源
    public IndexBar setmSourceDatas(List<? extends BaseIndexPinyinBean> mSourceDatas) {
        this.mSourceDatas = mSourceDatas;
        initSourceDatas();//对数据源进行初始化
        return this;
    }
    /**
     * 初始化原始数据源,并取出索引数据源
     *
     * @return
     */
    private void initSourceDatas() {
        int size = mSourceDatas.size();
        for (int i = 0; i < size; i++) {
            BaseIndexPinyinBean indexPinyinBean = mSourceDatas.get(i);
            StringBuilder pySb = new StringBuilder();
            String target = indexPinyinBean.getTarget();//取出需要被拼音化的字段
            //遍历target的每个char得到它的全拼音
            for (int i1 = 0; i1 < target.length(); i1++) {
                //利用TinyPinyin将char转成拼音
                //查看源码,方法内 如果char为汉字,则返回大写拼音
                //如果c不是汉字,则返回String.valueOf(c)
                pySb.append(Pinyin.toPinyin(target.charAt(i1)));
            }
            indexPinyinBean.setPyCity(pySb.toString());//设置城市名全拼音

            //以下代码设置城市拼音首字母
            String tagString = pySb.toString().substring(0, 1);
            if (tagString.matches("[A-Z]")) {//如果是A-Z字母开头
                indexPinyinBean.setTag(tagString);
                if (isNeedRealIndex) {//如果需要真实的索引数据源
                    if (!mIndexDatas.contains(tagString)) {//则判断是否已经将这个索引添加进去,若没有则添加
                        mIndexDatas.add(tagString);
                    }
                }
            } else {//特殊字母这里统一用#处理
                indexPinyinBean.setTag("#");
                if (isNeedRealIndex) {//如果需要真实的索引数据源
                    if (!mIndexDatas.contains("#")) {
                        mIndexDatas.add("#");
                    }
                }
            }
        }
        sortData();
    }

3 封装对原始数据源sourceDatas,索引数据源indexDatas的排序操作。

    /**
     * 对数据源排序
     */
    private void sortData() {
        //对右侧栏进行排序 将 # 丢在最后
        Collections.sort(mIndexDatas, new Comparator<String>() {
            @Override
            public int compare(String lhs, String rhs) {
                if (lhs.equals("#")) {
                    return 1;
                } else if (rhs.equals("#")) {
                    return -1;
                } else {
                    return lhs.compareTo(rhs);
                }
            }
        });

        //对数据源进行排序
        Collections.sort(mSourceDatas, new Comparator<BaseIndexPinyinBean>() {
            @Override
            public int compare(BaseIndexPinyinBean lhs, BaseIndexPinyinBean rhs) {
                if (lhs.getTag().equals("#")) {
                    return 1;
                } else if (rhs.getTag().equals("#")) {
                    return -1;
                } else {
                    return lhs.getPyCity().compareTo(rhs.getPyCity());
                }
            }
        });
    }

4 是否需要真实的索引数据源。
相关变量定义:

    public static String[] INDEX_STRING = {"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 List<String> mIndexDatas;//索引数据源
    private boolean isNeedRealIndex;//是否需要根据实际的数据来生成索引数据源(例如 只有 A B C 三种tag,那么索引栏就 A B C 三项)

初始化init时,判断不需要真实的索引数据源,就用默认值(A-Z,#)

        if (!isNeedRealIndex) {//不需要真实的索引数据源
            mIndexDatas = Arrays.asList(INDEX_STRING);
        }

使用时,如果如果真实索引数据源,调用这个方法,传入true,一定要在设置数据源setmSourceDatas(List)之前调用。

    /**
     * 一定要在设置数据源{@link #setmSourceDatas(List)}之前调用
     *
     * @param needRealIndex
     * @return
     */
    public IndexBar setNeedRealIndex(boolean needRealIndex) {
        isNeedRealIndex = needRealIndex;
        if (isNeedRealIndex){
            if (mIndexDatas != null) {
                mIndexDatas = new ArrayList<>();
            }
        }
        return this;
    }

在initSourceDatas() 里,会根据这个变量往mIndexDatas里增加index。

5 IndexBar和外部联动的相关(HintTextView,和RecyclerView的LinearLayoutManager)
set方法很简单:

    public IndexBar setmPressedShowTextView(TextView mPressedShowTextView) {
        this.mPressedShowTextView = mPressedShowTextView;
        return this;
    }

    public IndexBar setmLayoutManager(LinearLayoutManager mLayoutManager) {
        this.mLayoutManager = mLayoutManager;
        return this;
    }

它们两最终都是在index触摸监听器里用到,代码上文已提及,只不过这次挪到IndexBar内部init里。
init函数如下:

    private void init(Context context, AttributeSet attrs, int defStyleAttr) {
        ...
        if (!isNeedRealIndex) {//不需要真实的索引数据源
            mIndexDatas = Arrays.asList(INDEX_STRING);
        }
        //设置index触摸监听器
        setmOnIndexPressedListener(new onIndexPressedListener() {
            @Override
            public void onIndexPressed(int index, String text) {
                if (mPressedShowTextView != null) { //显示hintTexView
                    mPressedShowTextView.setVisibility(View.VISIBLE);
                    mPressedShowTextView.setText(text);
                }
                //滑动Rv
                if (mLayoutManager != null) {
                    int position = getPosByTag(text);
                    if (position != -1) {
                        mLayoutManager.scrollToPositionWithOffset(position, 0);
                    }
                }
            }
            @Override
            public void onMotionEventEnd() {
                //隐藏hintTextView
                if (mPressedShowTextView != null) {
                    mPressedShowTextView.setVisibility(View.GONE);
                }
            }
        });
    }
    /**
     * 根据传入的pos返回tag
     *
     * @param tag
     * @return
     */
    private int getPosByTag(String tag) {
        if (TextUtils.isEmpty(tag)) {
            return -1;
        }
        for (int i = 0; i < mSourceDatas.size(); i++) {
            if (tag.equals(mSourceDatas.get(i).getTag())) {
                return i;
            }
        }
        return -1;
    }

六 完整代码

思前想后还是放出来吧,三百多行有点长

/**
 * 介绍:索引右侧边栏
 * 作者:zhangxutong
 * 邮箱:mcxtzhang@163.com
 * CSDN:http://blog.csdn.net/zxt0601
 * 时间: 16/09/04.
 */

public class IndexBar extends View {
    private static final String TAG = "zxt/IndexBar";
    public static String[] INDEX_STRING = {"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 List<String> mIndexDatas;//索引数据源
    private boolean isNeedRealIndex;//是否需要根据实际的数据来生成索引数据源(例如 只有 A B C 三种tag,那么索引栏就 A B C 三项)

    private int mWidth, mHeight;//View的宽高
    private int mGapHeight;//每个index区域的高度

    private Paint mPaint;

    private int mPressedBackground;//手指按下时的背景色

    //以下边变量是外部set进来的
    private TextView mPressedShowTextView;//用于特写显示正在被触摸的index值
    private List<? extends BaseIndexPinyinBean> mSourceDatas;//Adapter的数据源
    private LinearLayoutManager mLayoutManager;

    public IndexBar(Context context) {
        this(context, null);
    }

    public IndexBar(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public IndexBar(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context, attrs, defStyleAttr);
    }

    private void init(Context context, AttributeSet attrs, int defStyleAttr) {
        int textSize = (int) TypedValue.applyDimension(
                TypedValue.COMPLEX_UNIT_SP, 16, getResources().getDisplayMetrics());//默认的TextSize
        mPressedBackground = Color.BLACK;//默认按下是纯黑色
        TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.IndexBar, defStyleAttr, 0);
        int n = typedArray.getIndexCount();
        for (int i = 0; i < n; i++) {
            int attr = typedArray.getIndex(i);
            switch (attr) {
                case R.styleable.IndexBar_textSize:
                    textSize = typedArray.getDimensionPixelSize(attr, textSize);
                    break;
                case R.styleable.IndexBar_pressBackground:
                    mPressedBackground = typedArray.getColor(attr, mPressedBackground);
                default:
                    break;
            }
        }
        typedArray.recycle();

        if (!isNeedRealIndex) {//不需要真实的索引数据源
            mIndexDatas = Arrays.asList(INDEX_STRING);
        }
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setTextSize(textSize);
        mPaint.setColor(Color.BLACK);

        //设置index触摸监听器
        setmOnIndexPressedListener(new onIndexPressedListener() {
            @Override
            public void onIndexPressed(int index, String text) {
                if (mPressedShowTextView != null) { //显示hintTexView
                    mPressedShowTextView.setVisibility(View.VISIBLE);
                    mPressedShowTextView.setText(text);
                }
                //滑动Rv
                if (mLayoutManager != null) {
                    int position = getPosByTag(text);
                    if (position != -1) {
                        mLayoutManager.scrollToPositionWithOffset(position, 0);
                    }
                }
            }

            @Override
            public void onMotionEventEnd() {
                //隐藏hintTextView
                if (mPressedShowTextView != null) {
                    mPressedShowTextView.setVisibility(View.GONE);
                }
            }
        });
    }

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

    @Override
    protected void onDraw(Canvas canvas) {
        int t = getPaddingTop();//top的基准点(支持padding)
        Rect indexBounds = new Rect();//存放每个绘制的index的Rect区域
        String index;//每个要绘制的index内容
        for (int i = 0; i < mIndexDatas.size(); i++) {
            index = mIndexDatas.get(i);
            mPaint.getTextBounds(index, 0, index.length(), indexBounds);//测量计算文字所在矩形,可以得到宽高
            Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();//获得画笔的FontMetrics,用来计算baseLine。因为drawText的y坐标,代表的是绘制的文字的baseLine的位置
            int baseline = (int) ((mGapHeight - fontMetrics.bottom - fontMetrics.top) / 2);//计算出在每格index区域,竖直居中的baseLine值
            canvas.drawText(index, mWidth / 2 - indexBounds.width() / 2, t + mGapHeight * i + baseline, mPaint);//调用drawText,居中显示绘制index
        }
    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                setBackgroundColor(mPressedBackground);//手指按下时背景变色
                //注意这里没有break,因为down时,也要计算落点 回调监听器
            case MotionEvent.ACTION_MOVE:
                float y = event.getY();
                //通过计算判断落点在哪个区域:
                int pressI = (int) ((y - getPaddingTop()) / mGapHeight);
                //边界处理(在手指move时,有可能已经移出边界,防止越界)
                if (pressI < 0) {
                    pressI = 0;
                } else if (pressI >= mIndexDatas.size()) {
                    pressI = mIndexDatas.size() - 1;
                }
                //回调监听器
                if (null != mOnIndexPressedListener) {
                    mOnIndexPressedListener.onIndexPressed(pressI, mIndexDatas.get(pressI));
                }
                break;
            case MotionEvent.ACTION_UP:
            case MotionEvent.ACTION_CANCEL:
            default:
                setBackgroundResource(android.R.color.transparent);//手指抬起时背景恢复透明
                //回调监听器
                if (null != mOnIndexPressedListener) {
                    mOnIndexPressedListener.onMotionEventEnd();
                }
                break;
        }
        return true;
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mWidth = w;
        mHeight = h;
        mGapHeight = (mHeight - getPaddingTop() - getPaddingBottom()) / mIndexDatas.size();
    }


    /**
     * 当前被按下的index的监听器
     */
    public interface onIndexPressedListener {
        void onIndexPressed(int index, String text);//当某个Index被按下

        void onMotionEventEnd();//当触摸事件结束(UP CANCEL)
    }

    private onIndexPressedListener mOnIndexPressedListener;

    public onIndexPressedListener getmOnIndexPressedListener() {
        return mOnIndexPressedListener;
    }

    public void setmOnIndexPressedListener(onIndexPressedListener mOnIndexPressedListener) {
        this.mOnIndexPressedListener = mOnIndexPressedListener;
    }

    /**
     * 显示当前被按下的index的TextView
     *
     * @return
     */

    public IndexBar setmPressedShowTextView(TextView mPressedShowTextView) {
        this.mPressedShowTextView = mPressedShowTextView;
        return this;
    }

    public IndexBar setmLayoutManager(LinearLayoutManager mLayoutManager) {
        this.mLayoutManager = mLayoutManager;
        return this;
    }

    /**
     * 一定要在设置数据源{@link #setmSourceDatas(List)}之前调用
     *
     * @param needRealIndex
     * @return
     */
    public IndexBar setNeedRealIndex(boolean needRealIndex) {
        isNeedRealIndex = needRealIndex;
        if (mIndexDatas != null) {
            mIndexDatas = new ArrayList<>();
        }
        return this;
    }

    public IndexBar setmSourceDatas(List<? extends BaseIndexPinyinBean> mSourceDatas) {
        this.mSourceDatas = mSourceDatas;
        initSourceDatas();//对数据源进行初始化
        return this;
    }


    /**
     * 初始化原始数据源,并取出索引数据源
     *
     * @return
     */
    private void initSourceDatas() {
        int size = mSourceDatas.size();
        for (int i = 0; i < size; i++) {
            BaseIndexPinyinBean indexPinyinBean = mSourceDatas.get(i);
            StringBuilder pySb = new StringBuilder();
            String target = indexPinyinBean.getTarget();//取出需要被拼音化的字段
            //遍历target的每个char得到它的全拼音
            for (int i1 = 0; i1 < target.length(); i1++) {
                //利用TinyPinyin将char转成拼音
                //查看源码,方法内 如果char为汉字,则返回大写拼音
                //如果c不是汉字,则返回String.valueOf(c)
                pySb.append(Pinyin.toPinyin(target.charAt(i1)));
            }
            indexPinyinBean.setPyCity(pySb.toString());//设置城市名全拼音

            //以下代码设置城市拼音首字母
            String tagString = pySb.toString().substring(0, 1);
            if (tagString.matches("[A-Z]")) {//如果是A-Z字母开头
                indexPinyinBean.setTag(tagString);
                if (isNeedRealIndex) {//如果需要真实的索引数据源
                    if (!mIndexDatas.contains(tagString)) {//则判断是否已经将这个索引添加进去,若没有则添加
                        mIndexDatas.add(tagString);
                    }
                }
            } else {//特殊字母这里统一用#处理
                indexPinyinBean.setTag("#");
                if (isNeedRealIndex) {//如果需要真实的索引数据源
                    if (!mIndexDatas.contains("#")) {
                        mIndexDatas.add("#");
                    }
                }
            }
        }
        sortData();
    }

    /**
     * 对数据源排序
     */
    private void sortData() {
        //对右侧栏进行排序 将 # 丢在最后
        Collections.sort(mIndexDatas, new Comparator<String>() {
            @Override
            public int compare(String lhs, String rhs) {
                if (lhs.equals("#")) {
                    return 1;
                } else if (rhs.equals("#")) {
                    return -1;
                } else {
                    return lhs.compareTo(rhs);
                }
            }
        });

        //对数据源进行排序
        Collections.sort(mSourceDatas, new Comparator<BaseIndexPinyinBean>() {
            @Override
            public int compare(BaseIndexPinyinBean lhs, BaseIndexPinyinBean rhs) {
                if (lhs.getTag().equals("#")) {
                    return 1;
                } else if (rhs.getTag().equals("#")) {
                    return -1;
                } else {
                    return lhs.getPyCity().compareTo(rhs.getPyCity());
                }
            }
        });
    }


    /**
     * 根据传入的pos返回tag
     *
     * @param tag
     * @return
     */
    private int getPosByTag(String tag) {
        if (TextUtils.isEmpty(tag)) {
            return -1;
        }
        for (int i = 0; i < mSourceDatas.size(); i++) {
            if (tag.equals(mSourceDatas.get(i).getTag())) {
                return i;
            }
        }
        return -1;
    }
}

七 总结

不管是自定义ItemDecoration还是实现右侧索引导航栏,其实大量的自定义View知识在里面 ,
so 要想自定义ItemDecoration玩得好,自定义View少不了。

对数据源的排序字段按照拼音排序,我们使用TinyPinyin(https://github.com/promeG/TinyPinyin)帮助我们排序。
它的特性很适合Android平台。

  1. 生成的拼音不包含声调,也不处理多音字,默认一个汉字对应一个拼音;
  2. 拼音均为大写;
  3. 无需初始化,执行效率很高(Pinyin4J的4倍);
  4. 很低的内存占用(小于30KB)。
    (介绍来源于其项目github)

其实不仅仅是IndexBar以及它和RecyclerView,HintTextView的联动可以封装在一起。
悬停头部ItemDecoration也可以利用 BaseIndexTagBean 类来抽象一下,不与具体的实体类耦合,

    private List<CityBean> mDatas;

替换成

    private List<?extends BaseIndexPinyinBean> mDatas;

即可。

本文起笔于9.5晚八点,项目上线打包期间,每逢打包是非多~你们懂得,结果打包期间出现各种问题,各种bug紧急修复通宵到凌晨,9.6日,两点起床,又续写后面三节。本文篇幅也略长,写到后面自己也有点懵逼(也可能是通宵还没醒导致),总耗时6小时+,希望大家看后觉得有用可以给我刷波66666.


八 代码地址

我不想吐槽CSDN了,上传代码真心慢,
稍后补上CSDN地址。
csdn传送门:
http://download.csdn.net/detail/zxt0601/9623621

github地址:欢迎star
https://github.com/mcxtzhang/ItemDecorationIndexBar


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

推荐阅读更多精彩内容