打造一款 ZoomLayout

1. ZoomLayout 需要實現(xiàn)的功能

1.1 需求列表

  1. 觸摸滑動及慣性滑動
  2. 多指縮放
  3. 雙擊縮放

除了實現(xiàn)這些主要功能外噪服,還需要處理一下的細(xì)節(jié)

  1. ZoomLayout的寬高大于子 View 時巷波,子 View 居中顯示
  2. ZoomLayout 需要響應(yīng)事件浑玛,但是不能把事件攔截掉
  3. 處理滑動沖突蟆盐,比如將 ZoomLayout 放在ViewPager

1.2 使用舉例

<?xml version="1.0" encoding="utf-8"?>
<com.xuliwen.zoom.ZoomLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:max_zoom="3.0"
    app:min_zoom="1.0"
    app:double_click_zoom="2.0">
    
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">
        
        <ImageView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:scaleType="fitXY"
            android:adjustViewBounds="true"
            android:src="@mipmap/image1"/>

        <ImageView
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:scaleType="fitXY"
            android:adjustViewBounds="true"
            android:src="@mipmap/image2"/>
        
    </LinearLayout>

1.3 效果和源碼

效果如下:

zoomlayout.gif

ZoomLayout 源碼 里面有完整的代碼、Demo厦瓢、使用說明

2. 實現(xiàn)

2.1 基礎(chǔ)知識

在講具體實現(xiàn)之前揍障,先提一下會用到的一些基礎(chǔ)的知識,不了解的同學(xué)可以先去了解一下

  1. GestureDetector 用于獲取單擊救军、雙擊财异、滾動、拋擲 等動作
  2. ScaleGestureDetector 用于獲取縮放的動作
  3. OverScroller 滾動的輔助類

2.2 重寫 measureChildWithMargins

@Override
    protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,
                                           int parentHeightMeasureSpec, int heightUsed) {
        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin
                        + widthUsed, lp.width);
        final int usedTotal = getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin +
                heightUsed;
        final int childHeightMeasureSpec;
        if (lp.height == WRAP_CONTENT) {
            childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
                    Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal),
                    MeasureSpec.UNSPECIFIED);
        } else {
            childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
                    getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin
                            + heightUsed, lp.height);
        }
        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }

ZoomLayout 繼承了 LinearLayout 后唱遭,發(fā)現(xiàn)屏幕外的 View 是沒有繪制出來的戳寸,但是我們平時使用 ScrollView 的時候,屏幕外的 View 也能繪制出來拷泽,查看 ScrollView 的源碼疫鹊,發(fā)現(xiàn)它重寫了 measureChildWithMargins 方法。

2.3 實現(xiàn)滾動

private GestureDetector.SimpleOnGestureListener mSimpleOnGestureListener = new GestureDetector.SimpleOnGestureListener() {
        @Override
        public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
            if (!isEnabled()) {
                return false;
            }
            processScroll((int) distanceX, (int) distanceY, getScrollRangeX(), getScrollRangeY());
            return true;
        }
    };
    
private void processScroll(int deltaX, int deltaY,
                               int scrollRangeX, int scrollRangeY) {
        int oldScrollX = getScrollX();
        int oldScrollY = getScrollY();
        int newScrollX = oldScrollX + deltaX;
        int newScrollY = oldScrollY + deltaY;
        final int left = 0;
        final int right = scrollRangeX;
        final int top = 0;
        final int bottom = scrollRangeY;

        if (newScrollX > right) {
            newScrollX = right;
        } else if (newScrollX < left) {
            newScrollX = left;
        }

        if (newScrollY > bottom) {
            newScrollY = bottom;
        } else if (newScrollY < top) {
            newScrollY = top;
        }
        if (newScrollX < 0) {
            newScrollX = 0;
        }
        if (newScrollY < 0) {
            newScrollY = 0;
        }
        scrollTo(newScrollX, newScrollY);
    }
    

滾動可以在 onScroll 回調(diào)拿到司致,distanceX拆吆、distanceY 分別是 X 軸和 Y 軸上拿到的
滾動距離,getScrollX()脂矫、getScrollY() 則拿到了當(dāng)前的滾動距離枣耀,這樣就可以算出
newScrollXnewScrollY 了,最后調(diào)用 scrollTo 進(jìn)行滾動 羹唠。還有一點要注意的是奕枢,scrollXscrollY都有滾動范圍娄昆,實現(xiàn)如下:

// mCurrentZoom 是當(dāng)前的縮放值;ScrollRange 大于 0 的時候說明可以滾動

private int getScrollRangeX() {
        final int contentWidth = getWidth() - getPaddingRight() - getPaddingLeft();
        return (getContentWidth() - contentWidth);
    }

    private int getContentWidth() {
        return (int) (child().getWidth() * mCurrentZoom);
    }

    private int getScrollRangeY() {
        final int contentHeight = getHeight() - getPaddingBottom() - getPaddingTop();
        return getContentHeight() - contentHeight;
    }

    private int getContentHeight() {
        return (int) (child().getHeight() * mCurrentZoom);
    }

    private View child() {
        return getChildAt(0);
    }

2.4 實現(xiàn) Fling(拋擲)滾動

Fling 滾動就是我們往某個方向快速滑動缝彬,當(dāng)我們手指抬起后萌焰,View 還會沿著某個方向繼續(xù)滾動。

private GestureDetector.SimpleOnGestureListener mSimpleOnGestureListener = new GestureDetector.SimpleOnGestureListener() {
        @Override
        public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
            if (!isEnabled()) {
                return false;
            }
            fling((int) -velocityX, (int) -velocityY);
            return true;
        }
    };
    
private boolean fling(int velocityX, int velocityY) {
        if (Math.abs(velocityX) < mMinimumVelocity) {
            velocityX = 0;
        }
        if (Math.abs(velocityY) < mMinimumVelocity) {
            velocityY = 0;
        }
        final int scrollY = getScrollY();
        final int scrollX = getScrollX();
        final boolean canFlingX = (scrollX > 0 || velocityX > 0) &&
                (scrollX < getScrollRangeX() || velocityX < 0);
        final boolean canFlingY = (scrollY > 0 || velocityY > 0) &&
                (scrollY < getScrollRangeY() || velocityY < 0);
        boolean canFling = canFlingY || canFlingX;
        if (canFling) {
            velocityX = Math.max(-mMaximumVelocity, Math.min(velocityX, mMaximumVelocity));
            velocityY = Math.max(-mMaximumVelocity, Math.min(velocityY, mMaximumVelocity));
            int height = getHeight() - getPaddingBottom() - getPaddingTop();
            int width = getWidth() - getPaddingRight() - getPaddingLeft();
            int bottom = getContentHeight();
            int right = getContentWidth();
            mOverScroller.fling(getScrollX(), getScrollY(), velocityX, velocityY, 0, Math.max(0, right - width), 0,
                    Math.max(0, bottom - height), 0, 0);
            notifyInvalidate();
            return true;
        }
        return false;
    }
    
 private void notifyInvalidate() {
        // 效果和 invalidate 一樣谷浅,但是會使得動畫更平滑
        ViewCompat.postInvalidateOnAnimation(this);
    }
    
@Override
    public void computeScroll() {
        super.computeScroll();
        if (mOverScroller.computeScrollOffset()) { // 判斷是否可以滾動
            int oldX = getScrollX();
            int oldY = getScrollY();
            int x = mOverScroller.getCurrX();
            int y = mOverScroller.getCurrY();
            if (oldX != x || oldY != y) {
                final int rangeY = getScrollRangeY();
                final int rangeX = getScrollRangeX();
                processScroll(x - oldX, y - oldY, rangeX, rangeY);
            }
            if (!mOverScroller.isFinished()) { // 如果滾動沒有停止扒俯,那就再調(diào)用一次 notifyInvalidate(),會觸發(fā)下一次的 computeScroll()
                notifyInvalidate();
            }
        }
    }    

大體的思路是通過 onFling() 拿到手勢一疯,然后計算是否能夠滑動撼玄,可以的話就調(diào)用
mOverScroller.fling() 開始滾動,最后不要忘了調(diào)用 notifyInvalidate()墩邀。調(diào)用
notifyInvalidate() 后我們就可以在 computeScroll() 回調(diào)中使用 processScroll()進(jìn)行滾動

2.5 手勢縮放

 private ScaleGestureDetector.SimpleOnScaleGestureListener mSimpleOnScaleGestureListener = new ScaleGestureDetector.SimpleOnScaleGestureListener() {
        @Override
        public boolean onScale(ScaleGestureDetector detector) {
            if (!isEnabled()) {
                return false;
            }
            float newScale;
            newScale = mCurrentZoom * detector.getScaleFactor();
            if (newScale > mMaxZoom) {
                newScale = mMaxZoom;
            } else if (newScale < mMinZoom) {
                newScale = mMinZoom;
            }
            setScale(newScale, (int) detector.getFocusX(), (int) detector.getFocusY());
            return true;
        }

        @Override
        public boolean onScaleBegin(ScaleGestureDetector detector) {
            return true;
        }

        @Override
        public void onScaleEnd(ScaleGestureDetector detector) {
        }
    };
    
public void setScale(float scale, int centerX, int centerY) {
        float preScale = mCurrentZoom;
        mCurrentZoom = scale;
        int sX = getScrollX();
        int sY = getScrollY();
        int dx = (int) ((sX + centerX) * (scale / preScale - 1));
        int dy = (int) ((sY + centerY) * (scale / preScale - 1));
        child().setPivotX(0);
        child().setPivotY(0);
        child().setScaleX(mCurrentZoom);
        child().setScaleY(mCurrentZoom);
        processScroll(dx, dy, getScrollRangeX(), getScrollRangeY());
        notifyInvalidate();
    }    

實現(xiàn)思路是 onScale() 中拿到縮放手勢掌猛,縮放不僅會影響到 scale,其實還會影響到
scrollX眉睹、scrollY荔茬,所以縮放的時候,也要調(diào)用 processScroll() 竹海。由于我們的 scrollX慕蔚、scrollY 是基于 ZoomLayout 的左上角計算的(這里先默認(rèn)子 View 左上角和 ZoomLayout 左上角已知,后面還需要適配這一點)斋配,所以我們這里的縮放也要基于左上角計算孔飒,通過 setPivotX(0)setPivotY(0) 設(shè)置縮放中心點為左上角

2.6 雙擊縮放

private GestureDetector.SimpleOnGestureListener mSimpleOnGestureListener = new GestureDetector.SimpleOnGestureListener() {
        @Override
        public boolean onDoubleTap(MotionEvent e) {
            float newScale;
            if (mCurrentZoom < 1) {
                newScale = 1;
            } else if (mCurrentZoom < mDoubleClickZoom) {
                newScale = mDoubleClickZoom;
            } else {
                newScale = 1;
            }
            smoothScale(newScale, (int) e.getX(), (int) e.getY());
            return true;
        }
    };

public void smoothScale(float newScale, int centerX, int centerY) {
        if (mCurrentZoom > newScale) {
            if (mAccelerateInterpolator == null) {
                mAccelerateInterpolator = new AccelerateInterpolator();
            }
            mScaleHelper.startScale(mCurrentZoom, newScale, centerX, centerY, mAccelerateInterpolator);
        } else {
            if (mDecelerateInterpolator == null) {
                mDecelerateInterpolator = new DecelerateInterpolator();
            }
            mScaleHelper.startScale(mCurrentZoom, newScale, centerX, centerY, mDecelerateInterpolator);
        }
        notifyInvalidate();
    }

@Override
    public void computeScroll() {
        super.computeScroll();
        if (mScaleHelper.computeScrollOffset()) {
            setScale(mScaleHelper.getCurScale(), mScaleHelper.getStartX(), mScaleHelper.getStartY());
        }
    }    
    

雙擊縮放和手勢縮放都是縮放艰争,不同點在于雙擊縮放我們需要自己去計算每個時間點的
scale坏瞄,比如說雙擊后,View 會在 200 ms 內(nèi)從 1倍 scale 變成 2倍 scale甩卓,那么我們就要自己去計算 200ms惦积,scale 的變化∶推担看到這里大家都應(yīng)該想到了其實就是對 scale 這個值做一個屬性動畫嘛。這里將其封裝在了 ScaleHelper 中蛛勉。跟 Fling 的思路一樣鹿寻,通過 notifyInvalidate() 和 computeScroll() 實現(xiàn)循環(huán)。

3. 其他功能

3.1 適配 ViewPager

很簡單诽凌,ViewPager 會通過子 ViewcanScrollHorizontallycanScrollVertically 判斷是否可以橫向毡熏、豎向滾動,ZoomLayout 重寫他們就是了

    @Override
    public boolean canScrollHorizontally(int direction) {
        if (direction > 0) {
            return getScrollX() < getScrollRangeX();
        } else {
            return getScrollX() > 0 && getScrollRangeX() > 0;
        }
    }

    @Override
    public boolean canScrollVertically(int direction) {
        if (direction > 0) {
            return getScrollY() < getScrollRangeY();
        } else {
            return getScrollY() > 0 && getScrollRangeY() > 0;
        }
    }

3.2 事件傳遞

我們不希望 ZoomLayout 或者子 View 把事件消耗掉侣诵,而是兩者都能收到事件痢法。
下面是我的實現(xiàn):
ZoomLayoutdispatchTouchEvent 去接收事件狱窘,因為這樣即使子 View 消耗了事件,事件依然會經(jīng)過這里财搁。
然后在 onDraw 設(shè)置 child().setClickable(true)蘸炸,這里是為了讓事件能被
子 View 消耗掉,因為只有子 View 消耗了事件尖奔,事件才能一直傳遞到 ZoomLayout 中去搭儒。

@Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        mGestureDetector.onTouchEvent(ev);
        mScaleDetector.onTouchEvent(ev);
        return super.dispatchTouchEvent(ev);
    }
    
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        child().setClickable(true);
    }

3.3 布局的控制

我們希望 ZoomLayout 的寬高比子 View 的寬高大的時候,居中顯示提茁,否則就顯示為
left|top淹禾,我的思路是我們可以在 onDraw 中拿到準(zhǔn)確的寬、高茴扁,通過寬高的對比铃岔,決定使用什么布局

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        child().setClickable(true);
        if (child().getHeight() < getHeight() || child().getWidth() < getWidth()) {
            setGravity(Gravity.CENTER);
        } else {
            setGravity(Gravity.TOP);
        }
    }

當(dāng)適配到這里的時候,我們會發(fā)現(xiàn)一個問題峭火,比如子 View 的高度小于 ZoomLayout 的時候毁习,我們是安裝子 View 的中心放大,如果這是我們放大子 View躲胳,放大到子 View 高度大于 ZoomLayout蜓洪,我們這時候需要將子 View translate 到 ZoomLayout 的頂部,原因是我們第 2.5 步說到的 scroolX坯苹、scrollY 是基于左上角計算的隆檀。所以適配后的代碼是這樣的

    public void setScale(float scale, int centerX, int centerY) {
        float preScale = mCurrentZoom;
        mCurrentZoom = scale;
        int sX = getScrollX();
        int sY = getScrollY();
        int dx = (int) ((sX + centerX) * (scale / preScale - 1));
        int dy = (int) ((sY + centerY) * (scale / preScale - 1));
        if (getScrollRangeX() < 0) {
            child().setPivotX(child().getWidth() / 2);
            child().setTranslationX(0);
        } else {
            child().setPivotX(0);
            int willTranslateX = -(child().getLeft());
            child().setTranslationX(willTranslateX);
        }
        if (getScrollRangeY() < 0) {
            child().setPivotY(child().getHeight() / 2);
            child().setTranslationY(0);
        } else {
            int willTranslateY = -(child().getTop());
            child().setTranslationY(willTranslateY);
            child().setPivotY(0);
        }
        child().setScaleX(mCurrentZoom);
        child().setScaleY(mCurrentZoom);
        processScroll(dx, dy, getScrollRangeX(), getScrollRangeY());
        notifyInvalidate();
    }

在適配業(yè)務(wù)的過程,遇到了另外一個問題粹湃,就是 ZoomLayout 的高度有可能是會發(fā)生變化的恐仑,比如鍵盤彈出來的時候,ZoomLayout 可能會被壓小为鳄,
我的思路是在 onDraw 中監(jiān)聽寬高的變化裳仆,有變化的時候,調(diào)用 setScale 去設(shè)置為正確的狀態(tài)孤钦。

public void setScale(float scale, int centerX, int centerY) {
        // 記下最近一次的狀態(tài)
        mLastCenterX = centerX;
        mLastCenterY = centerY;
        mCurrentZoom = scale;
    }


    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        super.onLayout(changed, l, t, r, b);
        if (mNeedReScale) {
            // 需要重新刷新歧斟,因為寬高已經(jīng)發(fā)生變化
            setScale(mCurrentZoom, mLastCenterX, mLastCenterY);
            mNeedReScale = false;
        }
    }    
    
@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        if (mLastChildWidth != child().getWidth() || mLastChildHeight != child().getHeight() || mLastWidth != getWidth()
            || mLastHeight != getHeight()) {
            // 寬高變化后,記錄需要重新刷新偏形,放在下次 onLayout 處理静袖,避免 View 的一些配置:比如 getTop() 沒有初始化好
            // 下次放在 onLayout 處理的原因是 setGravity 會在 onLayout 確定完位置,這時候去 setScale 導(dǎo)致位置的變化就不會導(dǎo)致用戶看到
            // 閃一下的問題
            mNeedReScale = true;
        }
        mLastChildWidth = child().getWidth();
        mLastChildHeight = child().getHeight();
        mLastWidth = child().getWidth();
        mLastHeight = getHeight();
        if (mNeedReScale) {
            notifyInvalidate();
        }
    }    

上面有個小細(xì)節(jié)是發(fā)現(xiàn) mNeedReScale 為 true 時沒有立即調(diào)用 setScale俊扭,因為這時候 setGravity 還沒有生效队橙,我把它放在了下一次
onLayout

總結(jié)

實現(xiàn)一個 ZoomLayout 主要是需要熟悉手勢的使用,然后實現(xiàn)過程中比較難也比較麻煩的是各種坐標(biāo)相關(guān)的計算,以及各種細(xì)節(jié)的適配捐康。實現(xiàn)過程中很多代碼都參考了 LargeImageView 仇矾、ScrollView
ZoomLayout 的實現(xiàn)還有很多改進(jìn)的地方解总,比如事件的處理等贮匕,歡迎交流~

ZoomLayout 源碼 里面有完整的代碼、Demo倾鲫、使用說明

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末粗合,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子乌昔,更是在濱河造成了極大的恐慌隙疚,老刑警劉巖,帶你破解...
    沈念sama閱讀 222,729評論 6 517
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件磕道,死亡現(xiàn)場離奇詭異供屉,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)溺蕉,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,226評論 3 399
  • 文/潘曉璐 我一進(jìn)店門伶丐,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人疯特,你說我怎么就攤上這事哗魂。” “怎么了漓雅?”我有些...
    開封第一講書人閱讀 169,461評論 0 362
  • 文/不壞的土叔 我叫張陵录别,是天一觀的道長。 經(jīng)常有香客問我邻吞,道長组题,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 60,135評論 1 300
  • 正文 為了忘掉前任抱冷,我火速辦了婚禮崔列,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘旺遮。我一直安慰自己赵讯,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 69,130評論 6 398
  • 文/花漫 我一把揭開白布耿眉。 她就那樣靜靜地躺著瘦癌,像睡著了一般。 火紅的嫁衣襯著肌膚如雪跷敬。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,736評論 1 312
  • 那天,我揣著相機(jī)與錄音西傀,去河邊找鬼斤寇。 笑死,一個胖子當(dāng)著我的面吹牛拥褂,可吹牛的內(nèi)容都是我干的娘锁。 我是一名探鬼主播,決...
    沈念sama閱讀 41,179評論 3 422
  • 文/蒼蘭香墨 我猛地睜開眼饺鹃,長吁一口氣:“原來是場噩夢啊……” “哼莫秆!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起悔详,我...
    開封第一講書人閱讀 40,124評論 0 277
  • 序言:老撾萬榮一對情侶失蹤镊屎,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后茄螃,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體缝驳,經(jīng)...
    沈念sama閱讀 46,657評論 1 320
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,723評論 3 342
  • 正文 我和宋清朗相戀三年归苍,在試婚紗的時候發(fā)現(xiàn)自己被綠了用狱。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,872評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡拼弃,死狀恐怖夏伊,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情吻氧,我是刑警寧澤溺忧,帶...
    沈念sama閱讀 36,533評論 5 351
  • 正文 年R本政府宣布,位于F島的核電站医男,受9級特大地震影響砸狞,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜镀梭,卻給世界環(huán)境...
    茶點故事閱讀 42,213評論 3 336
  • 文/蒙蒙 一刀森、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧报账,春花似錦研底、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,700評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至羽圃,卻和暖如春乾胶,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,819評論 1 274
  • 我被黑心中介騙來泰國打工识窿, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留斩郎,地道東北人。 一個月前我還...
    沈念sama閱讀 49,304評論 3 379
  • 正文 我出身青樓喻频,卻偏偏與公主長得像缩宜,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子甥温,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,876評論 2 361

推薦閱讀更多精彩內(nèi)容