3.2 View的事件体系

View的事件体系(2)

掌握滑动的方法是实现绚丽的自定义控件的基础。通过三种方式可以实现View的滑动:

1 是通过View本身提供的scrollerTo/scrollerBy方法来实现滑动;
2 是通过动画给View施加平移效果来实现滑动;
3 是通过改变ViewLayoutParams使得View重新布局从而实现滑动。

3.2.1 scrollTo/scrollBy

为了实现View的滑动,View提供了专门的方法来实现这个功能,那就是scrollToscrollBy,我们先来看看这两个方法的实现。

/**
     * Set the scrolled position of your view. This will cause a call to
     * {@link #onScrollChanged(int, int, int, int)} and the view will be
     * invalidated.
     * @param x the x position to scroll to
     * @param y the y position to scroll to
     */
    public void scrollTo(int x, int y) {
        if (mScrollX != x || mScrollY != y) {
            int oldX = mScrollX;
            int oldY = mScrollY;
            mScrollX = x;
            mScrollY = y;
            invalidateParentCaches();
            onScrollChanged(mScrollX, mScrollY, oldX, oldY);
            if (!awakenScrollBars()) {
                postInvalidateOnAnimation();
            }
        }
    }
/**
     * Move the scrolled position of your view. This will cause a call to
     * {@link #onScrollChanged(int, int, int, int)} and the view will be
     * invalidated.
     * @param x the amount of pixels to scroll by horizontally
     * @param y the amount of pixels to scroll by vertically
     */
    public void scrollBy(int x, int y) {
        scrollTo(mScrollX + x, mScrollY + y);
    }

从上面的源码可以看出,scrollBy实际上也是调用了scrollTo方法,它实现了基于当前位置的相对滑动,而scrollTo则实现了基于所传递参数的绝对滑动,这个不难理解。利用scrollToscrollBy来实现View的滑动,这不是一件困难的事,但是我们要明白滑动过程中View内部的两个属性mScrollXmScrollY的改变规则,这两个属性可以通过getScrollXgetScrollY方法分别得到。这里先简要概况一下:在滑动过程中,mScrollX的值总是等于View左边缘和View内容左边缘在水平方向的距离,而mScrollY的值总是等于View上边缘和View内容上边缘在竖直方向的距离。View边缘是指View的位置,由4个顶点组成,而View内容边缘是指View中内容的边缘,scrollToscrollBy只能改变View内容的位置而不能改变View在布局中的位置mScrollXmScrollY的单位为像素,并且当View左边缘在View内容左边缘的右边时,mScrollX为正值,反之为负值;当View上边缘在View内容上边缘的下边时,mScrollY为正值,反之为负值。换句话说,如果从左向右滑动,mScrollX为负值,反之为正值;如果从上往下滑动,那么mScrollY为负值,反之为正值。

为了更好地理解这个问题,下面举个例子,在图中假设水平和竖直方向的滑动距离都为100像素,针对图中各种滑动情况,都给出了对应的mScrollXmScrollY的值。根据上面的分析可以知道,使用scrollToscrollBy来实现View的滑动,只能将View的内容进行移动,并不能将View本身进行移动,也就是说,不管怎么滑动,也不可能将当前View滑动到附近View所在的区域,这个需要仔细体会一下。

image

我写了一个简单的对scrollToscrollBy的应用,地址

3.2.2 使用动画

本节介绍另外一种滑动方式,即使用动画,通过动画我们能够让一个View进行平移,而平移就是一种滑动,使用动画来移动View,主要是操作ViewtranslationXtranslationY属性,既可以采用传统的View动画,也可以采用属性动画,如果采用属性动画的话,为了能够兼容3.0以下的版本,需要采用开源动画库nineoldandroidshttp://nineoldandroids.com/)。

采用View动画的代码,如下所示。此动画可以在100ms内将一个View从原始位置向右下角移动100个像素。

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
     android:fillAfter="true"
     android:zAdjustment="normal">

    <translate
        android:duration="100"
        android:fromXDelta="0"
        android:fromYDelta="0"
        android:interpolator="@android:anim/linear_interpolator"
        android:toXDelta="100"
        android:toYDelta="100"
        />

</set>

如果采用属性动画的话,就更简单了,以下代码可以将一个View100ms内从原始位置向右平移100像素。

ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();

上面简单介绍了通过动画来移动View的方法,使用动画来做View的滑动需要注意一点,View动画是对View的影像做操作,它并不能真正改变View的位置参数,包括宽/高,并且如果希望动画后的状态得以保留还必须将fillAfter属性设置为true,否则 动画完成后其动画结果会消失。比如我们要把View向右移动100像素,如果fillAfterfalse,那么在动画完成的一刹那,View会瞬间恢复到动画前的状态;如果fillAftertrue,在动画完成后,View会停留在距原始位置100像素的右边。使用属性动画并不会存在上述的问题,但是在Android3.0以下,无法使用属性动画,这个时候我们可以使用动画兼容库nineoldandroids来实现属性动画,尽管如此,在Android3.0以下的手机上通过nineoldandroids来实现的属性动画本质上仍然是View动画。

上面提到View动画并不能真正改变View的位置,这会带来一个很严重的问题。试想一下,比如我们通过View动画将一个Button向右移动100px,并且这个View设置的有单击事件,然后你会惊奇地发现,单击新位置无法触发onClick事件,而单击原始位置仍然可以触发onClick事件,尽管Button已经不再原始位置了。这个问题带来的影响是致命的,但是它却又是可以理解的,因为不管Button怎么做变换,但是它的位置信息(四个顶点和宽/高)并不会随着动画而改变,因此在系统眼里,这个Button并没有发生任何改变,它的真身仍然在原始位置。在这种情况下,单击新位置当然不会触发onClick事件了,因为Button的真身并没有发生改变,在新位置上只是View的影像而已。基于这一点,我们不能简单地给一个View做平移动画并且还希望它在新位置继续触发一些单击事件。

Android3.0开始,使用属性动画可以解决上面的问题,但是大多数应用都需要兼容到Android2.2,在Android2.2上无法使用属性动画,因此这里还是会有问题。那么这种问题难道就无法解决了吗?也不是的,虽然不能直接解决这个问题,但是还可以间接解决这个问题,这里给出一个简单的解决方法。针对上面View动画的问题,我们可以在新位置预先创建一个和目标Button一模一样的Button,它们不但外观一样连onClick事件也一样。当目标Button完成平移动画后,就把目标Button隐藏,同时把预先创建的Button显示出来,通过这种间接地方式我们解决了上面的问题。这仅仅是参考,面对这种问题时读者可以灵活应对。

3.23 改变布局参数

本节将介绍第三种实现View滑动的方法,那就是改变布局参数,即改变LayoutParams。这个比较好理解了,比如我们想把一个Button向右平移100px ,我们只需要将这个ButtonLayoutParams里的marginLeft参数的值增加100px即可,是不是很简单呢?还有一种情形,为了达到移动Button的目的,我们可以在Button的左边放置一个空的View,这个空View的默认宽度是0,当我们需要向右移动Button时,只需要重新设置空View的宽度即可,当空View的宽度增大时(假设Button的父容器是水平方向的LinearLayout),Button就自动被挤向右边,即实现了向右平移的效果。如何重新设置一个ViewLayoutParams呢?很简单,如下所示。

MarginLayoutParams params = (MarginLayoutParams) mButton1.getLayoutParams();
params.width += 100;
params.leftMargin += 100;
mButton1.requestLayout();
//或者mButton1.setLayoutParams(params);

通过改变LayoutParams的方式去实现View的滑动同样是一种很灵活的方法,需要根据不同情况去做不同的处理。

3.2.4 各种滑动方式的对比

上面分别介绍了三种不同的滑动方式,它们都能实现View的滑动,那么它们之间的差别是什么呢?

先看scrollToscrollBy这种方式,它是View提供的原生方法,其作用是专门用于View的滑动,它可以比较方便地实现滑动效果并且不影响内部元素的单击事件。但是它的缺点也是很显然的:它只能滑动View的内容,并不能滑动View本身。

再看动画,通过动画来实现View的滑动,这要分情况。如果是Android3.0以上并采用属性动画,那么采用这种方式没有明显的缺点;如果是使用View动画或者在Android3.0以下使用属性动画,均不能改变View本身的属性。在实际使用中,如果动画元素不需要响应用户的交互,那么使用动画来做滑动是比较合适的,否则就不太适合。但是动画有一个很明显的优点,那就是一些复杂的效果必须要通过动画才能实现。

最后再看一下改变布局这种方式,它除了使用起来麻烦点以外,也没有明显的缺点,它的主要适用对象是一些具有交互性的View,因为这些View需要和用户交互,直接通过动画去实现会有问题,所以这个时候我们可以使用直接改变布局参数的方式来实现。

针对上面的分析做一下总结,如下所示。

1.scrollTo/scrollBy:操作简单,适合对View内容的滑动
2.动画:操作简单,主要适用于没有交互的View和实现复杂的动画效果
3.改变布局参数:操作稍微复杂,适用于有交互的View

下面我们实现一个跟手滑动的效果,这是一个自定义View,拖动它可以让它在整个屏幕上随意滑动。这个View实现起来很简单,我们只要重写它的onTouchEvent方法并处理ACTION_MOVE事件,根据两次滑动之间的距离就可以实现它的滑动了。为了实现全屏滑动,我们采用动画的方式来实现。原因很简单,这个效果无法使用scrollTo来实现。另外,它还可以采用改变布局的方式来实现。
核心代码如下:

@Override
    public boolean onTouchEvent(MotionEvent event) {

        // 得到相对于屏幕左上角的距离
        int x = (int) event.getRawX();
        int y = (int) event.getRawY();

        switch (event.getAction()) {

            case MotionEvent.ACTION_DOWN:
                break;

            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                Log.d("TAG","deltaX:" + deltaX + "deltaY:" + deltaY);
                int translationX = (int) (ViewHelper.getTranslationX(this) + deltaX);
                int translationY = (int) (ViewHelper.getTranslationY(this) + deltaY);
                ViewHelper.setTranslationX(this,translationX);
                ViewHelper.setTranslationY(this,translationY);
                break;

            case MotionEvent.ACTION_UP:
                break;

            default:
                break;
        }

        mLastX = x;
        mLastY = y;

        return true;
    }

通过上述代码可以看出,首先我们通过getRawXgetRawY方法来获取手指当前的坐标,注意不能使用getXgetY方法,因为这个是要全屏滑动的,所以需要获取当前点击事件在屏幕中的坐标而不是相对于View自身的坐标。其次,我们要得到两次滑动之间的位移,有了这个位移就可以移动当前的View,移动方法采用的是动画兼容库nineoldandroids中的ViewHelper类所提供的setTranslationXsetTranslationY方法。实际上,ViewHelper类提供了一系列get/set方法,因为ViewsetTranslationXsetTranslationY只能在Android3.0及其以上版本才能使用,但是ViewHelper所提供的方法是没有版本要求的,与此类似的还有setXsetScaleXsetAlpha等方法,这一系列方法实际上是为属性动画服务的,这个自定义View可以在2.x及其以上版本工作,但是由于动画的性质,如果给它加上onClick事件,那么在3.0以下版本它将无法在新位置响应用户的点击。

3.3 弹性滑动

知道了View的滑动,我们还要知道如何实现View的弹性滑动,比较生硬地滑动过去,这种方式的用户体验实在太差了,因此我们要实现渐进式滑动。那么如何实现弹性滑动呢?其实实现方法有很多,但是它们都有一个共同思想:将一次大的滑动分成若干次小的滑动并在一个时间段内完成。弹性滑动的具体实现方式有很多,比如通过ScrollerHandler#postDelayed以及Thread#sleep等,下面一一进行介绍。

3.3.1 使用Scroller

Scroller的使用方法在前面已经介绍过了,我们来分析一下它的源码,从而探究为什么它能实现View的弹性滑动。

Scroller scroller = new Scroller(getContext());

    private void smoothScrollTo(int destX,int destY){
        int scrollX = getScrollX();
        int deltaX = destX - scrollX;
        //1000ms内滑向destX,效果是慢慢滑动
        scroller.startScroll(scrollX,0,deltaX,0,1000);
        invalidate();
      }

    @Override
    public void computeScroll() {
        if(scroller.computeScrollOffset()){
            scrollTo(scroller.getCurrX(),scroller.getCurrY());
            postInvalidate();
        }
    }

上面是Scroller的典型的使用方法,这里先描述它的工作原理:当我们构造一个Scroller对象并且调用它的startScroller方法时,Scroller内部其实什么也没有做,它只是保存了我们传递的几个参数,这几个参数从startScroll的原型上就可以看出来。

public void startScroll(int startX, int startY, int dx, int dy, int duration) {
        mMode = SCROLL_MODE;
        mFinished = false;
        mDuration = duration;
        mStartTime = AnimationUtils.currentAnimationTimeMillis();
        mStartX = startX;
        mStartY = startY;
        mFinalX = startX + dx;
        mFinalY = startY + dy;
        mDeltaX = dx;
        mDeltaY = dy;
        mDurationReciprocal = 1.0f / (float) mDuration;
    }

这个方法的参数含义很清楚,startXstartY表示的是滑动的起点dxdy表示的是要滑动的距离,而duration表示的是滑动时间,即整个滑动过程完成所需要的时间,注意这里的滑动是指View内容的滑动而非View本身位置的改变。可以看到,仅仅调用startScroll()方法是无法让View滑动的,因为它内部并没有做滑动相关的事,那么Scroller到底是如何让View弹性滑动的呢?答案就是startScroll方法下面的invalidate方法。虽然有点不可思议,但是的确是这样的。invalidate()方法会导致View的重绘,在Viewdraw()方法中又会去调用computeScroll()方法,computeScroll()方法在View中是一个空实现,因此需要我们自己去实现,上面的代码已经实现了computeScroll()方法。正是因为这个computeScroll()方法,View才能实现弹性滑动;这看起来还是很抽象,其实是这样的:当View重绘后,会在draw()方法中调用computeScroll(),而computeScroll()又会去向Scroller获取当前的scrollXscrollY;然后通过scrollTo()方法实现滑动;接着又调用postInvalidate()方法来进行第二次重绘,这一次重绘的过程和第一次重绘一样,还是会导致computeScroll()方法被调用;然后继续向Scroller获取当前的scrollXscrollY,并通过scrollTo方法滑动到新的位置,如此反复,直到整个滑动过程结束。

我们再看一下Scroller的computeScrollOffset()方法的实现。

    /**
     * Call this when you want to know the new location.  If it returns true,
     * the animation is not yet finished.
     */ 
    public boolean computeScrollOffset() {
        if (mFinished) {
            return false;
        }

        int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);

        if (timePassed < mDuration) {
            switch (mMode) {
            case SCROLL_MODE:
                final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
                mCurrX = mStartX + Math.round(x * mDeltaX);
                mCurrY = mStartY + Math.round(x * mDeltaY);
                break;
            case FLING_MODE:
                final float t = (float) timePassed / mDuration;
                final int index = (int) (NB_SAMPLES * t);
                float distanceCoef = 1.f;
                float velocityCoef = 0.f;
                if (index < NB_SAMPLES) {
                    final float t_inf = (float) index / NB_SAMPLES;
                    final float t_sup = (float) (index + 1) / NB_SAMPLES;
                    final float d_inf = SPLINE_POSITION[index];
                    final float d_sup = SPLINE_POSITION[index + 1];
                    velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);
                    distanceCoef = d_inf + (t - t_inf) * velocityCoef;
                }

                mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;

                mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
                // Pin to mMinX <= mCurrX <= mMaxX
                mCurrX = Math.min(mCurrX, mMaxX);
                mCurrX = Math.max(mCurrX, mMinX);

                mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));
                // Pin to mMinY <= mCurrY <= mMaxY
                mCurrY = Math.min(mCurrY, mMaxY);
                mCurrY = Math.max(mCurrY, mMinY);

                if (mCurrX == mFinalX && mCurrY == mFinalY) {
                    mFinished = true;
                }

                break;
            }
        }
        else {
            mCurrX = mFinalX;
            mCurrY = mFinalY;
            mFinished = true;
        }
        return true;
    }

这个方法会根据时间的流逝来计算出当前的scrollX和scrollY的值。计算方法也很简单,大意就是根据时间流逝的百分比来算出scrollX和scrollY改变的百分比并计算出当前的值,这个过程类似于动画中的插值器的概念,这里我们先不去深究这个具体过程。这个方法的返回值也很重要,它返回true表示滑动还未结束,false表示滑动已经结束,因此当这个方法返回true时,我们要继续进行View的滑动。

通过上面的分析,我们应该明白Scroller的工作原理了,这里做一下概括:Scroller本身并不能实现View的滑动,它需要配合View的computeScroll方法才能完成弹性滑动的效果,它不断地让View重绘,而每一次重绘距滑动起始时间会有一个时间间隔,通过这个时间间隔Scroller就可以得出View当前的滑动位置,知道了滑动位置就可以通过scrollTo方法来完成View的滑动。就这样,VIew的每一次重绘都会导致VIew进行小幅度的滑动,而多次的小幅度滑动就组成了弹性滑动,这就是Scroller的工作机制。

3.3.2 通过动画

动画本身就是一种渐进的过程,因此通过它来实现的滑动天然就具有弹性效果,比如以下代码可以让一个View在100ms内向右移动100像素。

ObjectAnimator.ofFloat(targetView,"translationX",0,100).setDuration(100).start();

不过这里想说的并不是这个问题,我们可以利用动画的特性来实现一些动画不能实现的效果。还拿scrollTo来说,我们也想模仿Scroller来实现View的弹性滑动,那么利用动画的特性,我们可以采用如下方式来实现:

final int startX = 0;
final int deltaX = 100;

final ValueAnimator animator = ValueAnimator.ofInt(0,1).setDuration(1000);
        
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
     @Override
     public void onAnimationUpdate(ValueAnimator valueAnimator) {
           float fraction = animator.getAnimatedFraction();
           mButton1.scrollTo(startX + (int) (deltaX * fraction),0);
        }
   });
        
   animator.start();

在上述代码中,我们的动画本质上没有作用于任何对象上,它只是在1000ms内完成了整个动画过程。利用这个特性,我们就可以在动画的每一帧到来时获取动画完成的比例,然后在根据这个比例计算出当前View所要滑动的距离。注意,这里的滑动针对的是View的内容而非View的本身。可以发现,这个方法的思想其实和Scroller比较类似,都是通过改变一个百分比配合scrollTo方法来完成View的滑动。需要说明一点,采用这种方法除了能够完成弹性滑动以外,还可以实现其他动画效果,我们完全可以在onAnimationUpdate()方法中加上我们想要的其他操作。

3.3.3 使用延时策略

本节介绍另外一种实现弹性滑动的方法,那就是延时策略。它的核心思想是通过发送一系列延时消息从而达到一种渐进式的效果,具体来说可以使用Handler或View的postDelayed()方法,也可以使用线程的sleep()方法。对于postDelayed()方法来说,我们可以它来延时发送一个消息,然后在消息中来进行View的滑动,如果接连不断地这种延时消息,那么就可以实现弹性滑动的效果。对于sleep()方法来说,通过在View循环中不断地滑动View和sleep,就可以实现弹性滑动的效果。

下面来使用Handler来做个示例,其他方法请自行去尝试,思想都是类似的。下面的代码在大约1000ms内将View的内容向左移动了100像素,代码比较简单。之所以说大约1000ms,是因为采用这种方式无法精确地定时,原因是系统的消息调度也是需要时间的,并且所需时间不定。

    private static final int MESSAGE_SCROLL_TO = 1;
    
    private static final int FRAME_COUNT = 30;
    
    private static final int DELAYED_TIME = 33;
    
    private int mCount = 0;
    
    @SuppressLint("HandlerLeak")
    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MESSAGE_SCROLL_TO:
                    mCount++;
                    if (mCount <= FRAME_COUNT) {
                        float fraction = mCount / (float) FRAME_COUNT;
                        int scrollX = (int) (fraction * 100);
                        mButton1.scrollTo(scrollX,0);
                        mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO,DELAYED_TIME);
                    }
                    break;
                default:
                    break;
            }
        }
    };

上面几种弹性滑动的实现方法,在介绍中侧重更多的是实现思想,在实际使用中可以对其灵活地进行扩展从而实现更多复杂的效果。

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

推荐阅读更多精彩内容