自定义 View 实现索引控件

前言 :每个人都有属于自己的一片森林,也许我们从来不曾去过,但它一直在那里,总会在那里。迷失的人迷失了,相逢的人一定会再相逢。—— 村上春树。

好不容易到周末了,可是天公不作美,下了一整天雪,晚饭后和朋友一起去抓了娃娃,然后果断一个也没抓到,不过,乐在其中。我们还是要找到一点生活的乐趣的,不然原本简单的生活太索然无味了。言归正传,步入正题。

在手机通讯录里面,在想找到“张三”这个人的时候,不是把 ListView 一直上滑滑到对应位置,而是通过旁边的索引来找这个人名。如:张三对应的前面的是大写字母“Z”,同理李四对应“L”。本篇就是实现类似这样的功能,先看最终效果图:


image

通过本篇文章我们将涉及下面几个方面的知识:

  • 最右边索引器控件的实现(本篇内容)
  • 将索引器与 ListView 相关联,实现自动索引
    实现这样简单的控件分以下步骤进行:

1. 绘制 A-Z 索引栏

首先,把架子搭建起来:
自定义的索引栏控件 FastIndexView 派生自 View:

public class FastIndexView extends View {

    private int mHeight;//控件的高度
    private int mWidth;//控件的宽度--30dp

    public FastIndexView(Context context) {
        super(context);
    }

    public FastIndexView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        //拿到控件的宽高
        mHeight = getMeasuredHeight();
        mWidth = getMeasuredWidth();
    }
}

以及在 Activity 中:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="com.itydl.a03.MainActivity">

    <!--左侧ListView-->
    <ListView
        android:id="@+id/lv"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

    </ListView>

    <!--右侧索引栏-->
    <com.itydl.a03.view.FastIndexView
        android:background="@drawable/index_letter_bg"
        android:layout_alignParentRight="true"
        android:layout_width="25dp"
        android:layout_height="match_parent">

    </com.itydl.a03.view.FastIndexView>

</RelativeLayout>

在 Activity 中,把自定义的 FastIndexView 设置成了25dp,高度为屏幕的高度并放置在了主布局的最右侧,值得注意的是,底部放置了一个.9图片,他可以随着咱们放置内容的增加而主动增长(.9制作可以自行百度,当然也可以自己写个 shape 作为背景也可以自动拉伸)。它下面是一个 ListView,它里面就是放置联动效果的联系人的。
此时运行起来效果如下:


image

看到还是很美观的,那么接下来就把字母使用 paint 画上去。
首先咱们看看基本的绘制:

public FastIndexView(Context context, AttributeSet attrs) {
    super(context, attrs);
    //设置画笔基本属性
    mPaint = new Paint();
    mPaint.setAntiAlias(true);//抗锯齿功能
    mPaint.setColor(Color.parseColor("#fa7829"));  //设置画笔颜色
    mPaint.setStyle(Paint.Style.FILL);//设置填充样式   Style.FILL/Style.FILL_AND_STROKE/Style.STROKE
    mPaint.setTextSize(20);//设置绘制字体的大小
}

这里是创建了 paint 实例,他就是一个画笔,然后往画布 canvas 上画画:

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    canvas.drawText("A",10,20,mPaint);
    canvas.drawText("B",10,50,mPaint);
    canvas.drawText("C",10,80,mPaint);
}

画布就是 canvas。
【对于绘制相关的一些东西,后面会继续更的,就是可能更文比较慢】。
很简单,只是画了三个字母,然后运行程序:


image

已经能够把字母绘制上去了。显然,这么绘制很不靠谱,我们想办法通过数学公式计算一下怎么绘制上去:
还是看一张图理解:


image

这里把索引栏放大了几倍。在调用 canvas.drawText("A",x,y,mPaint);时候,x,y 为图中 A
红点位置(绿圈位置)。注意,每个字母的 x 看上去值一样,其实有的字母宽一些的话就不一样了,这里要清楚这一点;然后y的值只要计算出每个控件的中间位置,然后+字母的高度的一半就是字母的 y 坐标,对于控件的中间位置=index*单个控件的高度+(单个控件的高度/2)。index 表示每个字母的索引,比如这里的A索引是0,B 索引是1...这样,字母的y = 单个控件中间位置 + 字母的高度的一半。而字母的宽高可以通过如下代码获取:

        Rect bounds = new Rect();
        mPaint.getTextBounds(letter, 0, letter.length(), bounds);

这里 Rect 是一个矩形,这个矩形就是下面 A 字母外层粉红色笔勾勒的大小。


image

对于计算过程如下(以 A 为例子):


image

可见,字母的宽度也可以通过 bounds.width() 获取到的,选择其中之一即可。
那么,在 onDraw() 里面代码如下:

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

        for (int i = 0; i < LETTERS.length; i++) {
            String letter = LETTERS[i];

            //x = 控件宽度的一半 - 字母宽度的一半.mPaint.measureText(letter)可以测量当前文本的宽度
            float x = mWidth/2 - mPaint.measureText(letter)/2;
            //y = index*单个控件高度 + 控件高度的一半 + 字母高度的一半
            // 获取文本所占的矩形区域
            Rect bounds = new Rect();
            mPaint.getTextBounds(letter, 0, letter.length(), bounds);
//            float x = mWidth/2 - bounds.width()/2;//也可以通过这种方式获取字母的宽度
            float y = i * mSingleHeight + mSingleHeight/2 + bounds.height()/2f;
            canvas.drawText(letter,x,y,mPaint);
        }
    }

对于 onDraw() 里面的代码,上面已经做了非常非常详细的解释,相信没有任何问题了吧。然后再运行程序:


image

此时已经把所有的字母都绘制到控件上去了。接下来就是用户触摸反馈,点击到哪里,把这个字母给找出来。


2. 响应触摸事件

第一步绘制完毕,接下来就是用户触摸反馈,点击到哪里,把这个字母给找出来。需要重写 onTouchEvent 方法:
应该能想到,手指触摸要想找到对应的字母,显然是与 Y 的坐标是有关系的,跟 X 坐标没有任何关系。那么,具体的关系是怎么样的呢?还是通过一张图来解释:


image

还是找 A 这个字母,不管是按下还是移动,只要此时的 Y 坐标在 A 所在范围内就算是 A 字母。同理,B、C...都是如此。
那么关键点就成了如何计算手指触摸是不是在该字母的范围内了。
对于 A 的范围是0-->单个控件高度此时触摸反馈字母就是 A;对于 B 的范围是一个控件高度-->两个控件高度此时触摸反馈字母是 B;对于 C 的范围是两个空间高度-->三个控件控件高度触摸反馈字母就是 C ...那么这个规律也就找出来了:
[图片上传失败...(image-d55a72-1550319488257)]

这个 index 计算方式,不是通过 for 循环来计算,是根据此时的 Y 坐标除以单个控件的高度来计算。比如单个控件高度是 10.0f,此时触摸位置为 9.0f,此时的 index=(int)9.0f/10.0f = 0即 A;此时触摸位置为 22.3f,那么索引值为 index = (int)22.3f/10.f = 2即C。
首先看一下 DOWN 事件:

 float downY = event.getY();
 mIndex = (int) (downY/mSingleHeight);
 if(mIndex != lastIndex){
     if(mIndex *mSingleHeight < downY && downY < (mIndex +1)*mSingleHeight){
         String letter = LETTERS[mIndex];
         Log.e(TAG,"ACTION_DOWN,当前字母:"+letter);
         lastIndex = mIndex;
     }
 }

这里就是根据上面推算得出的,先拿到当前触摸到的索引位置,然后判断当前触摸是位于了索引所对应的控件范围内,则打印当前的字母。
其实对于 MOVE 事件是一致的:

case MotionEvent.ACTION_MOVE:
     mIndex = (int) (event.getY()/mSingleHeight);
     if(mIndex != lastIndex){
         if(mIndex*mSingleHeight < event.getY() && event.getY() < (mIndex+1)*mSingleHeight){
             String letter = LETTERS[mIndex];
             Log.e(TAG,"ACTION_MOVE,当前字母:"+letter);
             lastIndex = mIndex;
         }
     }
   break;

具体分析看一下DOWN事件。
这里有个值叫 lastIndex,初始化值为-1,。

private int lastIndex = -1;

是为了不让咱们 MOVE 的时候老是打印这个字母所做的标记,去掉 lastIndex 你可以试试 log,只要在这个区域内,就不停地打印该区域的字母。要记得在 UP 事件里面把 lastIndex 再置为-1,否则你当前位于 C 区域,下一次再点击 C 区域就不会打印 C 了。这个可以自己调试一下。
最后把整个代码贴出来:

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                float downY = event.getY();
                mIndex = (int) (downY/mSingleHeight);
                if(mIndex != lastIndex){
                    if(mIndex *mSingleHeight < downY && downY < (mIndex +1)*mSingleHeight){
                        String letter = LETTERS[mIndex];
                        Log.e(TAG,"ACTION_DOWN,当前字母:"+letter);
                        lastIndex = mIndex;
                    }
                }
                break;
            case MotionEvent.ACTION_MOVE:
                mIndex = (int) (event.getY()/mSingleHeight);
                if(mIndex != lastIndex){
                    if(mIndex*mSingleHeight < event.getY() && event.getY() < (mIndex+1)*mSingleHeight){
                        String letter = LETTERS[mIndex];
                        Log.e(TAG,"ACTION_MOVE,当前字母:"+letter);
                        lastIndex = mIndex;
                    }
                }
                break;
            case MotionEvent.ACTION_UP:
                lastIndex = -1;
                break;

            default:
                break;
     }
    return true;
}

然后运行程序:


image

可以清晰的看到滑动到哪个字母,就把对应的 LOG 给咱们打印出来了。
是不是已经结束了?这里其实存在一个 BUG,当我们滑动的时候,滑到 Z 再往下滑动会出现报数组角标越界异常:
[图片上传失败...(image-1e9cad-1550319488257)]

这里分析一下原因,在 MOVE 的时候有如下代码:

if(mIndex*mSingleHeight < event.getY() && event.getY() < (mIndex+1)*mSingleHeight){
             String letter = LETTERS[mIndex];
             Log.e(TAG,"ACTION_MOVE,当前字母:"+letter);
             lastIndex = mIndex;
}

假如控件高度是100,总共有10个字母,单个控件的高度是10,那么 Z 的索引显然就是9。滑动索引栏至超过 Z 之后(假设滑动到了106),106/10 = 10.此时取数组第10个位置显然取不到,因为数组最大索引就是9啊,所以要在 MOVE 的时候做一次容错处理:

if(mIndex >=0 && mIndex < LETTERS.length){
    //MOVE的逻辑
}

这样两边都做了索引判断,其实左边不判也是无所谓的主要就是右边会出现数组角标越界。当超过了数组大小,不让走 MOVE 逻辑,就解决了这个 BUG。


3. 添加监听回调支持

通过上面两步,基本完成了一半工作了,而现在仅仅是控件知道在哪个字母,客户端还不知道呢,所以设置一个回调方法告知客户端。

private OnFastIndexSelectedListener mIndexSelectedListener;

/**
 * 调用此方法,设置选择索引栏监听器
 * @param indexSelectedListener
 */
public void setIndexSelectedListener(OnFastIndexSelectedListener indexSelectedListener) {
    mIndexSelectedListener = indexSelectedListener;
}

public interface OnFastIndexSelectedListener{
    /**
     * 回调方法,同时把当前位置以及选中的字母传递出去
     * @param letter
     */
    void selected(int position,String letter);
}

这段代码没啥好讲的,注释很清楚。
然后在打印 log 的地方调用方法即可:
此时在客户端就可以使用这个控件了:


image

此时在客户端就可以使用这个控件了:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    mIndexView = (FastIndexView) findViewById(R.id.indexview);
    mIndexView.setOnIndexSelectedListener(new FastIndexView.OnFastIndexSelectedListener() {
        @Override
        public void selected(int position, String letter) {
            ToastUtils.show(getApplicationContext(),letter);
        }
    });
}

这里通过设置监听器,把得到的数字做乐土司打印,这个土司是自定义的单例土司,做一个单例土司的原因是即使下一个土司触发,也不会出现等待上一个土司完毕之后再执行当前土司。
此时运行程序:


image

效果还算可以,那么接下来就是另外一部分开发了,即对 ListView 这一端做以下处理了。


4. 把汉字转换成拼音5. 将拼音, 数字, 字母排序6. 根据拼音首字母分组

接下来把4/5/6步合并在一起讲。

  • 1、汉子转为拼音:
    这个使用国内的一个开源库,pinyin4j-2.5.0。这个工具类直接使用即可,就在这里浪费时间了。
  • 2、把数据按照拼音首字母进行排序并且在 ListVIew 上展示出来:
    首选创建一个 Person 类,用来保存 item 的数据
public class Person implements Comparable<Person>{
    private String name;
    private String pinyin;

    public Person(String name) {
        this.name = name;
        this.pinyin = PinYinUtils.getyPinyin(name);
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getPinyin() {
        return pinyin;
    }

    public void setPinyin(String pinyin) {
        this.pinyin = pinyin;
    }

    @Override
    public int compareTo(Person other) {
        return pinyin.compareTo(other.pinyin);
    }
}

他实现了 Comparable 接口,因为要使用 Comparable 对每个 bean 排序,排序的方式在 compareTo 里面做了实现:pinyin.compareTo(other.pinyin);注意,在 bean 里面的 pinyin字段直接调用 this.pinyin = PinYinUtils.getyPinyin(name); 做了汉子到拼音的转化。例如:杨童鞋--->YANGTONGXIE。这个工具类已经写好了,有兴趣可以下载代码查看我这里就不讲了。
然后在 Activity 中创建数据源并且排序:

    for (int i = 0; i < Strings.NAMES.length; i++) {
        Person person = new Person(Strings.NAMES[i]);
        mPersonList.add(person);
    }

    //对集合排序
    Collections.sort(mPersonList);

数据源有了,接下来就是设置适配器了:
适配器咱们只关心 getView 方法就好了:
首先
适配器的 item 布局:

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

    <!--ListView的item的布局-->
    <TextView
        android:visibility="gone"
        android:id="@+id/tv_title"
        android:textSize="14sp"
        android:paddingLeft="16dp"
        android:text="A"
        android:gravity="center_vertical"
        android:background="#99cccccc"
        android:layout_width="match_parent"
        android:layout_height="20dp"/>

    <TextView
        android:id="@+id/tv_name"
        android:paddingLeft="24dp"
        android:text="杨童鞋"
        android:gravity="center_vertical"
        android:background="#FFFFFF"
        android:layout_width="match_parent"
        android:layout_height="45dp"/>

</LinearLayout>

这里是使用偷懒的方式,先创建出 tittle,然后在 getView 里面根据某些条件控制它的显示和隐藏,在隐藏的时候对这块布局根部不去绘制也就不会展示出来,再布局文件里面默认让 tittle 隐藏起来。
getView() 方法:

@Override
public View getView(int position, View convertView, ViewGroup parent) {
    if(convertView == null){
        convertView = View.inflate(parent.getContext(), R.layout.list_item,null);
    }

    TextView tv_title = (TextView) convertView.findViewById(R.id.tv_title);
    TextView tv_name = (TextView) convertView.findViewById(R.id.tv_name);

    Person person = mPersonList.get(position);

    String pinyin = person.getPinyin();
    String currentLetter = pinyin.charAt(0)+"";
    int num = 0;//标记,1代表要显示tittle字母

    if(position == 0){
        num = 1;
    }else{
        //获取上一个item的首字母
        String preLetter = mPersonList.get(position - 1).getPinyin().charAt(0) + "";
        if(!TextUtils.equals(preLetter,currentLetter)){
            num = 1;
        }
    }
    tv_title.setVisibility(num == 1?View.VISIBLE:View.GONE);
    tv_title.setText(currentLetter);
    tv_name.setText(person.getName());

    return convertView;
}

getView 里面比较难理解的摘出来:

 String currentLetter = pinyin.charAt(0)+"";
    int num = 0;//标记,1代表要显示tittle字母

    if(position == 0){
        num = 1;
    }else{
        //获取上一个item的首字母
        String preLetter = mPersonList.get(position - 1).getPinyin().charAt(0) + "";
        if(!TextUtils.equals(preLetter,currentLetter)){
            num = 1;
        }
    }
    tv_title.setVisibility(num == 1?View.VISIBLE:View.GONE);
    tv_title.setText(currentLetter);

这里就是判断何时展示 tittle 与何时隐藏 tittle 的方法了:
当第0条的时候一定可以展示那么就记录标记为1,在不是第0条的时候,当前的拼音首字母如果与上一个 item 的拼音的首字母不同的时候,就让当前的 tittle 展示出来,记录 num 标志为1.
最后何时展示与隐藏就比较简单了:
tv_title.setVisibility(num == 1?View.VISIBLE:View.GONE);
因为1代表展示,0默认代表不展示的嘛。这个过程也就是根据拼音首字母分组的过程。
运行程序:


image

控件基本完成了,最后再把左侧控件点击与ListView结合起来就完了。


7. 将自定义的索引栏和 ListView 进行结合

    mIndexView.setOnIndexSelectedListener(new FastIndexView.OnFastIndexSelectedListener() {
        @Override
        public void selected(int position, String letter) {
            ToastUtils.show(getApplicationContext(),letter);
            for (int i = 0; i < mPersonList.size(); i++) {
                String currentLetter = mPersonList.get(i).getPinyin().charAt(0) + "";
                if(TextUtils.equals(currentLetter,letter)){
                    // 当前点击的字母 == ListView中的字母。强制“跳转”到指定行索引处
                    mListView.setSelection(i);
                    break;
                }
            }
        }
    });

这里就很简单了,因为我们客户端知道当前点击了哪个字母,只要拿着这个字母去集合中匹配,集合跟 ListView 数据以及索引是一一对应的,找到后拿着这个索引调用 List 的 mListView.setSelection(i);即可。

此时运行程序:

此时控件其实已经完成的差不多了,最后再对控件做一点点稍微的完善:
完善1、点击字母让点击的字母变色:
其实只需要两行代码就能搞定了:

mPaint.setColor(i == mIndex ? Color.parseColor("#fa7829") : Color.parseColor("#888888"));

因为 mIndex 这个值是在 onTouchEvent 里面记录起来的,代表着当前选中的哪个索引位置的字母,然后要想把当前位置染色绘制改变,就需要重新调用 onDraw(),那么就在 onTouchEvent 的最后调用 invalidate(); 重绘就好了,因为 mIndex 值是当前的字母,那么 for 循环所有字母好到当前字母后,把画笔颜色修改颜色值就可以了。
完善2、土司改为自己定义的布局:
在 Activity 的布局文件里面,最后覆盖一个蒙层:

<TextView
    android:visibility="gone"
    android:id="@+id/tv_showletter"
    android:text="A"
    android:gravity="center"
    android:layout_centerInParent="true"
    android:background="@drawable/shape_text"
    android:layout_width="90dp"
    android:layout_height="65dp"/>

然后背景通过 shape 画上去的,以至于背景边缘圆滑一些。且默认是隐藏起来的
然后在 Activity 中每次回调方法里面就设置它的值:

/**
 * 展示字母
 * @param letter
 */
private void showLetter(String letter) {
    mTvShow.setText(letter);
    mTvShow.setVisibility(View.VISIBLE);

    //清楚掉之前的延时操作,否则2s即使在滑动索引栏展示文本也会消失
    mHandler.removeCallbacksAndMessages(null);
    mHandler.postDelayed(new Runnable() {
        @Override
        public void run() {
            mTvShow.setVisibility(View.GONE);
        }
    },2000);
}

每次回调后调用 showLetter 方法,让自定义布局展示出来,且 使用 Handler 延迟2s后隐藏掉自定义展示的布局。这里需要注意,每次调用前,记得把之前的消息完全移除掉,否则有可能出现滑动右侧索引栏超过两秒,出现滑动中隐藏展示布局的 bug(这个可以自行调试)。

最后运行程序看看最终的效果:


image

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