Android中實現滑動效果

預備知識

  1. Android屏幕區域劃分
    我們先看一副圖來了解一下Android屏幕的區域劃分,如下:


    Android屏幕的區域劃分

    通過上圖我們可以很直觀的看到Android對于屏幕的劃分定義。下面我們就給出這些區域里常用區域的一些坐標或者度量方式。如下:

//獲取屏幕區域的寬高等尺寸獲取
DisplayMetrics metrics = new DisplayMetrics();
getWindowManager().getDefaultDisplay().getMetrics(metrics);
int widthPixels = metrics.widthPixels;
int heightPixels = metrics.heightPixels;
//應用程序App區域寬高等尺寸獲取
Rect rect = new Rect();
getWindow().getDecorView().getWindowVisibleDisplayFrame(rect);
//獲取狀態欄高度
Rect rect= new Rect();
getWindow().getDecorView().getWindowVisibleDisplayFrame(rect);
int statusBarHeight = rectangle.top;
//View布局區域寬高等尺寸獲取
Rect rect = new Rect();  
getWindow().findViewById(Window.ID_ANDROID_CONTENT).getDrawingRect(rect);

特別注意:上面這些方法最好在Activity的onWindowFocusChanged ()方法或者之后調運,因為只有這時候才是真正的顯示OK。

  1. Android坐標系、View坐標系、位置的獲取、距離的獲取和View寬度的獲取
    在Android中,將屏幕最左上角的頂點作為Android坐標系的原點,從這個點向右是X軸正方向,從這個點向下是Y軸正方向。
    在Android中,將View的左上角頂點作為View坐標系的原點,從這個點向右是X軸正方向,從這個點向下是Y軸正方向。
    下面我們就來看看在上面兩種坐標系下位置的獲取、距離的獲取和View寬度的獲取 的方法。
    1中我們分析了Android屏幕的劃分,可以發現我們平時開發的重點其實都在關注View布局區域,那么下面我們就來細說一下View區域常用的位置和距離。先看下面這幅圖:

    通過上圖我們可以很直觀的給出View一些坐標相關的方法解釋,不過必須要明確的是上面這些方法必須要在layout之后才有效,如下:
View的靜態坐標方法 解釋
getLeft() 返回View自身左邊到父布局左邊的距離(返回值是mLeft)
getTop() 返回View自身頂邊到父布局頂邊的距離(返回值是mTop)
getRight() 返回View自身右邊到父布局左邊的距離(返回值是mRight)
getBottom() 返回View自身底邊到父布局頂邊的距離(返回值是mBottom)
getX() 返回值為getLeft()+getTranslationX(),當setTranslationX()時getLeft()不變,getX()變。
getY() 返回值為getTop()+getTranslationY(),當setTranslationY()時getTop()不變,getY()變。

同時也可以看見上圖中給出了手指觸摸屏幕時MotionEvent提供的一些方法解釋,如下:

MotionEvent坐標方法 解釋
getX() 當前觸摸事件距離當前View左邊的距離
getY() 當前觸摸事件距離當前View頂邊的距離
getRawX() 當前觸摸事件距離整個屏幕左邊的距離
getRawY() 當前觸摸事件距離整個屏幕頂邊的距離

下面我們來看看幾個和上面方法緊密相關的獲取View寬高的View方法。如下:

View寬高方法 解釋
getWidth() layout后有效,返回值是mRight-mLeft,一般會參考measure的寬度(measure可能沒用),但不是必須的。
getHeight() layout后有效,返回值是mBottom-mTop,一般會參考measure的高度(measure可能沒用),但不是必須的。
getMeasuredWidth() 返回measure過程得到的mMeasuredWidth值,供layout參考,或許沒用。
getMeasuredHeight() 返回measure過程得到的mMeasuredHeight值,供layout參考,或許沒用。

上面解釋了自定義View時各種獲取寬高的一些方法,下面我們再來看看獲取View可見區域和頂點坐標的一些方法,不過這些方法需要在Activity的onWindowFocusChanged ()方法之后才能使用。如下圖:



下面我們就給出上面這幅圖涉及的View的一些坐標方法的結果,如下所示:

View的方法 上圖View1結果 上圖View2結果 結論描述
getLocalVisibleRect() (0, 0, 410, 100) (0, 0, 410, 470) 獲取View自身可見的坐標區域,坐標以自己的左上角為原點(0,0),另一點為可見區域右下角相對自己(0,0)點的坐標,其實View2當前height為550,可見height為470。
getGlobalVisibleRect() (30, 100, 440, 200) (30, 250, 440, 720) 獲取View在屏幕絕對坐標系中的可視區域,坐標以屏幕左上角為原點(0,0),另一個點為可見區域右下角相對屏幕原點(0,0)點的坐標。
getLocationOnScreen() (30, 100) (30, 250) 坐標是相對整個屏幕而言,Y坐標為View左上角到屏幕頂部的距離。
getLocationInWindow() (30, 100) (30, 250) 如果為普通Activity則Y坐標為View左上角到屏幕頂部(此時Window與屏幕一樣大);如果為對話框式的Activity則Y坐標為當前Dialog模式Activity的標題欄頂部到View左上角的距離。

通過layout方法實現滑動

我們知道,在View進行繪制時,會調用onLayout方法來設置顯示的位置。同樣,可以通過修改View的mLeft, mTop, mRight, mBottom四個屬性來控制View的位置。實現代碼如下所示:

@Override
public boolean onTouchEvent(MotionEvent event) {
    // TODO Auto-generated method stub
    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        // 記錄觸摸點坐標
        android.util.Log.i("chenyang", "onTouchEvent ACTION_DOWN x = " + x + ",  y = " + y);
        mLastX = x;
        mLastY = y;
        break;
    case MotionEvent.ACTION_MOVE:
        // 計算偏移量
        android.util.Log.i("chenyang", "onTouchEvent ACTION_HOVER_MOVE x = " + x + ",  y = " + y);
        int offsetX = x - mLastX;
        int offsetY = y - mLastY;
        // 在當前mLeft, mTop, mRight, mBottom的基礎上加上偏移量
        layout(getLeft() + offsetX, getTop() + offsetY, getRight()
                + offsetX, getBottom() + offsetY);
        break;

    default:
        break;
    }
    return true;
}

通過offsetLeftAndRight()與offsetTopAndBottom實現滑動

這兩個方法相當于系統提供了一個對左右、上下移動的API的封裝。與上面一樣,也是通過修改View的mLeft, mTop, mRight, mBottom四個屬性來控制View的位置,實現代碼如下:

@Override
public boolean onTouchEvent(MotionEvent event) {
    // TODO Auto-generated method stub
    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        // 記錄觸摸點坐標
        android.util.Log.i("chenyang", "onTouchEvent ACTION_DOWN x = " + x + ",  y = " + y);
        mLastX = x;
        mLastY = y;
        break;
    case MotionEvent.ACTION_MOVE:
        // 計算偏移量
        android.util.Log.i("chenyang", "onTouchEvent ACTION_HOVER_MOVE x = " + x + ",  y = " + y);
        int offsetX = x - mLastX;
        int offsetY = y - mLastY;
        offsetLeftAndRight(offsetX);
        offsetTopAndBottom(offsetY);
        break;

    default:
        break;
    }
    return true;
}

通過LayoutParams實現滑動

LayoutParams保存了一個View的布局參數,因此可以在程序中,通過改變LayoutParams來動態地修改一個View的布局參數,從而達到改變View位置的效果。我們可以很方便的在程序中使用getLayoutParams()來獲取一個View的LayoutParams(注意必須在layout之后才可以獲取到)。實現代碼如下:

@Override
public boolean onTouchEvent(MotionEvent event) {
    // TODO Auto-generated method stub
    int x = (int) event.getX();
    int y = (int) event.getY();
    switch (event.getAction()) {
    case MotionEvent.ACTION_DOWN:
        // 記錄觸摸點坐標
        android.util.Log.i("chenyang", "onTouchEvent ACTION_DOWN x = " + x + ",  y = " + y);
        mLastX = x;
        mLastY = y;
        break;
    case MotionEvent.ACTION_MOVE:
        // 計算偏移量
        android.util.Log.i("chenyang", "onTouchEvent ACTION_HOVER_MOVE x = " + x + ",  y = " + y);
        int offsetX = x - mLastX;
        int offsetY = y - mLastY;
        ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) getLayoutParams();
        layoutParams.leftMargin = getLeft() + offsetX;
        layoutParams.topMargin = getTop() + offsetY;
        setLayoutParams(layoutParams);
        break;

    default:
        break;
    }
    return true;
}

通過ViewDragHelper實現滑動

Google在其support庫中為我們提供了DrawerLayout和SlidingPaneLayout兩個布局來幫助開發者實現側邊欄滑動的效果。這兩個新的布局大大方便了我們創建自己的滑動布局界面。然而,這兩個功能強大的布局背后隱藏著一個鮮為人知卻功能強大的類---ViewDragHelper。通過ViewDragHelper基本可以實現各種不同的滑動、拖放需求,因此此方法也是各種滑動解決方案中的終極絕招。

ViewDragHelper雖然功能強大,但其使用方法也是最復雜的。下面通過一個實例,來演示一下如何使用ViewDragHelper創建一個滑動布局,在這個例子中,準備實現類似QQ滑動側邊欄的效果,初始時顯示內容界面,當用戶手指滑動超過一定距離時,內容界面側滑顯示菜單界面,整個過程下圖所示:


初始狀態

側滑展開菜單界面

實現代碼如下所示:

package com.cytmxk.test.scroll;

import android.content.Context;
import android.support.v4.view.ViewCompat;
import android.support.v4.widget.ViewDragHelper;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.widget.FrameLayout;

/**
 * Created by chenyang on 16/6/26.
 */
public class DragViewGroup extends FrameLayout {

    private static final String TAG = DragViewGroup.class.getCanonicalName();

    private ViewDragHelper mViewDragHelper = null;
    private View mMenuView = null;
    private View mMainView = null;
    private int mMenuWidth;

    public DragViewGroup(Context context) {
        super(context);
        initView();
    }

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

    public DragViewGroup(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initView();
    }

    private void initView() {
        //初始化ViewDragHelper,第一個參數是要監聽的View,通常需要是一個ViewGroup,
        //即parentView;第二個參數是一個Callback回調,后面會做解釋。
        mViewDragHelper = ViewDragHelper.create(this, callback);
    }

    //獲取菜單布局的寬度,之后可以根據菜單布局(mMenuView)的寬度處理滑動后的效果。
    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mMenuWidth = mMenuView.getMeasuredWidth();
        Log.d(TAG, "onSizeChanged mMenuWidth = " + mMenuWidth);
    }
    //初始化菜單布局(mMenuView)和主布局(mMainView)
    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();
        mMenuView = getChildAt(0);
        mMainView = getChildAt(1);
    }

    @Override
    public boolean onInterceptTouchEvent(MotionEvent ev) {
        //將觸摸事件傳遞給ViewDragHelper,此操作必不可少
        return mViewDragHelper.shouldInterceptTouchEvent(ev);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        super.onTouchEvent(event);
        //將觸摸事件傳遞給ViewDragHelper,此操作必不可少
        mViewDragHelper.processTouchEvent(event);
        return true;
    }

    private ViewDragHelper.Callback callback = new ViewDragHelper.Callback() {
        // 何時開始檢測觸摸事件,通過這個方法,我們可以指定在創建ViewDragHelper時,
       //參數parentView中的哪一個View可以被移動。
        @Override
        public boolean tryCaptureView(View child, int pointerId) {
            //如果當前觸摸的child是mMainView時開始檢測,并且只有mMainView可以被移動
            return mMainView == child;
        }

        // 觸摸到View后回調
        @Override
        public void onViewCaptured(View capturedChild,
                                   int activePointerId) {
            super.onViewCaptured(capturedChild, activePointerId);
        }

        // 當拖拽狀態改變,比如idle,dragging
        @Override
        public void onViewDragStateChanged(int state) {
            super.onViewDragStateChanged(state);
        }

        // 當位置改變的時候調用,常用與滑動時更改scale等
        @Override
        public void onViewPositionChanged(View changedView,
                                          int left, int top, int dx, int dy) {
            super.onViewPositionChanged(changedView, left, top, dx, dy);
        }

        // 處理水平滑動
        @Override
        public int clampViewPositionHorizontal(View child, int left, int dx) {
            return left;
        }

        // 處理垂直滑動
        @Override
        public int clampViewPositionVertical(View child, int top, int dy) {
            return 0;
        }

        // 拖動結束后調用
        @Override
        public void onViewReleased(View releasedChild, float xvel, float yvel) {
            super.onViewReleased(releasedChild, xvel, yvel);
            Log.d(TAG, "onViewReleased mMainView.getLeft() = " + mMainView.getLeft() + ", mMenuWidth = " + mMenuWidth);
            //手指抬起后緩慢移動到指定位置
            if (mMainView.getLeft() < mMenuWidth) {
                //關閉菜單
                //相當于Scroller的startScroll方法
                mViewDragHelper.smoothSlideViewTo(mMainView, 0, 0);
                ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
            } else {
                //打開菜單
                mViewDragHelper.smoothSlideViewTo(mMainView, mMenuWidth, 0);
                ViewCompat.postInvalidateOnAnimation(DragViewGroup.this);
            }
        }

    };

    //由于ViewDragHelper內部是利用Scroller實現滑動的,所以利用computeScroll方法實現平滑滑動
    @Override
    public void computeScroll() {
        super.computeScroll();
        if (mViewDragHelper.continueSettling(true)) {
            ViewCompat.postInvalidateOnAnimation(this);
        }
    }
}

通過scrollTo和scrollBy實現滑動

  1. 在一個View中,系統提供了scrollTo、scrollBy兩種方式來改變一個View中初始可見內容的位置。這兩個方法的區別非常好理解,與英文中To和By的區別類似,scrollTo(x, y)表示讓View中初始可見內容的在水平方向偏移到點(- x, - y)(x大于零表示向左偏移,否者向右偏移; y大于零表示向上偏移,否者向右偏移),scrollBy(dx, dy)表示讓View中初始可見內容的在水平方向偏移dx(dx大于零表示向左偏移,否者向右偏移),在垂直方向偏移dy(dy大于零表示向上偏移,否者向右偏移)如下是這兩個方法的代碼實現:
    /**
     * The offset, in pixels, by which the content of this view is scrolled
     * horizontally.
     * {@hide}
     */
    @ViewDebug.ExportedProperty(category = "scrolling")
    protected int mScrollX;
    /**
     * The offset, in pixels, by which the content of this view is scrolled
     * vertically.
     * {@hide}
     */
    @ViewDebug.ExportedProperty(category = "scrolling")
    protected int mScrollY;
    /**
     * 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);
    }

有上面的代碼可以得知mScrollX,mScrollY是用來保存View初始可見內容的偏移量。理解了mScrollX和mScrollY的用法,就不難理解getScrollX() 和getScrollY()。這兩個函數的源碼如下所示:

    /**
     * Return the scrolled left position of this view. This is the left edge of
     * the displayed part of your view. You do not need to draw any pixels
     * farther left, since those are outside of the frame of your view on
     * screen.
     *
     * @return The left edge of the displayed part of your view, in pixels.
     */
    public final int getScrollX() {
        return mScrollX;
    }

    /**
     * Return the scrolled top position of this view. This is the top edge of
     * the displayed part of your view. You do not need to draw any pixels above
     * it, since those are outside of the frame of your view on screen.
     *
     * @return The top edge of the displayed part of your view, in pixels.
     */
    public final int getScrollY() {
        return mScrollY;
    }
  1. 舉例說明,如下圖所示(注意,圖中黃色矩形區域表示的是View,綠色虛線矩形為View中初始可見的內容。一般情況下兩者的大小一致,本文為了顯示方便,將虛線框畫小了一點。圖中的黃色區域的位置始終不變,發生偏移的是初始可見的內容。):



    scrollTo(0, 100)的效果如下圖所示:



    scrollTo(100, 100)的效果圖如下:

    若函數中參數為負值,則子View的移動方向將相反:


通過Scroller實現滑動

上面舉例中通過scrollTo偏移View的初始可見內容是在瞬間完成的,這樣的效果會讓人感覺非常突兀。Google也想到了這一點,所以提供了Scroller類來模擬平滑滑動的效果。
Scroller類提供了startScroll方法來初始化一個模擬平滑滑動的過程,然后調用invalidate()方法,這個方法會導致View重繪,系統在繪制View的時候會在draw方法中調用computeScroll方法來實現模擬滑動,在computeScroll方法中通過調用Scroller的computeScrollOffset方法判斷是否完成了整個滑動,同時Scroller也提供了getCurrX、getCurrY來獲取當前滑動過程中 View初始可見內容 即將的偏移量,然后利用srcollTo方法實現偏移即可,然后執行invalidate方法實現循環調用computeScroll方法直到滑動結束。

  1. Scroller中相關API簡介如下:
mScroller.getCurrX() //獲取mScroller當前水平方向滑動過程中的位置  
mScroller.getCurrY() //獲取mScroller當前豎直方向滑動過程中的位置  
mScroller.getFinalX() //獲取mScroller最終停止滑動的水平位置  
mScroller.getFinalY() //獲取mScroller最終停止滑動的豎直位置  
mScroller.setFinalX(int newX) //設置mScroller最終停留的水平位置,沒有動畫效果,直接跳到目標位置  
mScroller.setFinalY(int newY) //設置mScroller最終停留的豎直位置,沒有動畫效果,直接跳到目標位置  
mScroller.startScroll(int startX, int startY, int dx, int dy) //使用默認完成時間250ms  
mScroller.startScroll(int startX, int startY, int dx, int dy, int duration)  
//開始滑動,startX, startY為 View初始可見內容 開始滑動的位置(即mScrollX,mScrollY的值),dx,dy分別為水平方向和垂直方向的偏移量(dx大于零表示向左偏移,否者向右偏移;dy大于零表示向上偏移,否者向下偏移), duration為完成滾動的時間 。
mScroller.computeScrollOffset() //返回值為boolean,true說明滑動尚未完成,false說明滑動已經完成。這是一個很重要的方法,通常放在View.computeScroll()中,用來判斷是否滑動是否結束。

2 舉例如下:

public void moveToDest(int index) {

    /*
     * 對 index 進行判斷 ,確保 是在合理的范圍
     * 即  index >=0  && index <=getChildCount()-1
     */
    //確保 index>=0
    index = index >= 0 ? index : 0;
    //確保 currIndex<=getChildCount()-1
    currIndex = index <= getChildCount() - 1 ? index : getChildCount() - 1;

    if (null != mOnPagerChangeListener) {
        mOnPagerChangeListener.OnPagerChange(currIndex);
    }
    myScroller.startScroll(getScrollX(), 0, currIndex * getWidth() - getScrollX(), 0, 500);
    invalidate();
}

@Override
public void computeScroll() {
    super.computeScroll();
    if (myScroller.computeScrollOffset()) {
        scrollTo(myScroller.getCurrX(), 0);
        invalidate();
    }
}

參考文檔

  1. Android應用坐標系統全面詳解
  2. Android群英傳
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,936評論 6 535
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,744評論 3 421
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,879評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,181評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,935評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,325評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,384評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,534評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,084評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,892評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,067評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,623評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,322評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,735評論 0 27
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,990評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,800評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,084評論 2 375

推薦閱讀更多精彩內容