Android 今日头条下拉刷新实现

前言

前两天在玩今日头条,觉得今日头条的下拉刷新蛮有意思的,就自己实现了一下,整体上实现了同样的效果。无图无真相,效果图如下:
今日头条效果:


image

实现效果:


image

项目地址 TodayNewsHeader

实现过程分为两部分:

  • 图形绘制
  • 结合下拉刷新动起来

图形绘制

测量,坐标计算

实现过程中图形的绘制全部是通过Path 完成,需要精确计算 path 各个部分的坐标值

对Path不熟悉的请看Path使用详解

这里需要注意的是:在绘制时坐标不能从 0 开始,绘制线条是通过Paint.setStyle(Paint.Style.STROKE)方法,如果从0开始绘制 会出现左侧,顶部线条只能绘制一半的情况
主要参数:

  private int strokeWidth;  //线宽
    //绘制不能从  坐标0 开始 会有 stroke*1 的偏移量
    private int contentWidth, contentHeight;  //内容宽度 内容高度
    private float roundCorner;  //外层 圆角矩形 圆角半径
    private float lineWidth;  // 线条宽度
    private float rectWidth;  //小矩形宽度
    private float shortLineWidth; //短线宽度
    private float spaceRectLine;  //小矩形距 断线距离

坐标说明图:


image

特地说明一下 roundCorner: 为 圆角矩形的圆角
在这里将 contentHeight 分为 7等份,roundCorner 为 1/7的contentHeight
之后每个线条之间间距 一个 roundCorner

测量计算关键变量代码:


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);

        int width = MeasureSpec.getSize(widthMeasureSpec);


        int heightMode = MeasureSpec.getMode(heightMeasureSpec);


        int height = MeasureSpec.getSize(heightMeasureSpec);

        
        //设定最小值时,增加 stoke 的偏移保证 边界绘制完整
        int minWidth = dip2px(30) + strokeWidth * 2;

        int minHeight = dip2px(35) + strokeWidth * 2;

        //判断 测量模式  如果是  wrap_content 需要对 宽高进行限定
        //同时确定 高度 也对 最小值进行限定

        if (widthMode == MeasureSpec.AT_MOST) {
            width = minWidth;
        } else if (widthMode == MeasureSpec.EXACTLY && width < minWidth) {
            width = minWidth;
        }

        if (heightMode == MeasureSpec.AT_MOST) {
            height = minHeight;
        } else if (heightMode == MeasureSpec.EXACTLY && height < minHeight) {
            height = minHeight;
        }

        // 在确定宽高之后 对内容 宽高再次进行计算,留出 stroke 的偏移
        contentWidth = width - strokeWidth * 2;

        contentHeight = height - strokeWidth * 2;

        setMeasuredDimension(width, height);

        initNeedParamn();
        //初始化最外层 圆角矩形 path
        initPath();
    }

    /**
     * 初始化绘制所需要的参数
     */
    private void initNeedParamn() {
        //圆角半径
        roundCorner = contentHeight / 7f;
        //线条宽度
        lineWidth = contentWidth - roundCorner * 2;
        //小矩形宽度
        rectWidth = lineWidth / 2f;
        //短线宽度
        shortLineWidth = (lineWidth / 8f) * 3f;  //短线条宽度
        //矩形与 断线之间的间距
        spaceRectLine = (lineWidth / 8f) * 1f; //矩形与线条之间间距
    }
绘制

通过观察进入头条gif 效果,图形的绘制分为两部分

  • 拖拽过程绘制
  • 刷新过程绘制

在两个过程中,最外层圆角矩形是不变的,先来绘制这个圆角矩形。最外层圆角矩形Path初始化:

private Path roundPath; //最外层 圆形Path
/**
     * 初始化 path
     */
    private void initPath() {


        roundPath = new Path();
        //从右侧第一个圆角作为起点
        roundPath.moveTo(contentWidth, roundCorner);
        roundPath.arcTo(contentWidth - roundCorner * 2, 0, contentWidth, roundCorner * 2, 0, -90, false);
        roundPath.lineTo(roundCorner, 0);
        roundPath.arcTo(0, 0, roundCorner * 2, roundCorner * 2, -90, -90, false);
        roundPath.lineTo(0, contentHeight - roundCorner);
        roundPath.arcTo(0, contentHeight - roundCorner * 2, roundCorner * 2, contentHeight, -180, -90, false);
        roundPath.lineTo(contentWidth - roundCorner, contentHeight);
        roundPath.arcTo(contentWidth - roundCorner * 2, contentHeight - roundCorner * 2, contentWidth, contentHeight, -270, -90, false);
  //path闭合 自动 lineTo(contentWidth, roundCorner)
        roundPath.close();
    }

小矩形与线条Path创建

测量完成后,需要的参数已经计算完成,我们可以根据指定坐标提供小矩形和线条的Path

 /**
     * 根据 左上 坐标 创建 矩形 Path
     *
     * @param left 左坐标
     * @param top  上坐标
     * @return
     */
    public Path provideRectPath(float left, float top) {
        Path path = new Path();
        path.moveTo(left + rectWidth, top);
        path.lineTo(left, top);
        path.lineTo(left, top + roundCorner * 2f);
        path.lineTo(left + rectWidth, top + roundCorner * 2f);
        path.close();
        return path;
    }


    /**
     * 根据线条 左上 坐标和线宽创建线条 Path
     *
     * @param left 左坐标
     * @param top  上坐标
     * @param lineWidth  线宽
     * @return
     */
    public Path provideLinePath(float left, float top, float lineWidth) {
        Path path = new Path();
        path.moveTo(left, top);
        path.lineTo(left + lineWidth, top);
        return path;
    }

每个图形都是通过Path绘制,对每个绘制的状态进行封装

 /**
     * 绘制的状态
     */
    public abstract class State {

        protected List<PathWrapper> mPathList;

        public State() {
            mPathList = new ArrayList<>();
            initStatePath();
        }
         //初始化 PathWrapper集合
        protected abstract void initStatePath();
        
        //将绘制分配给 PathWrapper执行
        void onDraw(Canvas canvas, Paint paint) {
            for (PathWrapper path : mPathList) {
                path.onDraw(canvas, paint);
            }
        }
    }

这里的 PathWrapper 会在下面的拖拽过程进行解释

拖拽过程

下拉拖拽过程:

image

头部刷新View 跟随手指下拉显示,当下拉高度超过了一定距离,Path图形开始绘制,手指继续下拉 ,图形绘制完全,并且可以看到会有一个渐进绘制的效果。这里需要根据下拉率 fraction来计算绘制比例

渐进绘制分析
每个图形根据 fraction 的绘制比例是不同的,我在这里设计的映射关系如下表:

图形 fraction 绘制比例
外层圆角矩形 0~1 0~1
矩形 0~0.25 0~1
短线条1 0.25~0.33 0~1
短线条2 0.33~0.41 0~1
短线条3 0.41~0.5 0~1
长线条1 0.5~0.66 0~1
长线条2 0.66~0.82 0~1
长线条3 0.82~1 0~1

这里需要公式去计算每个图形的绘制比例,并且需要一个容器去保存每个图形的path 和绘制比例,PathWrapper 就应运而生。

public class PathWrapper {
    protected Path mPath; //图形 Path
    protected float fraction;  //绘制的比例


    public PathWrapper(Path path, float fraction) {
        mPath = path;
        this.fraction = fraction;
    }


    public void onDraw(Canvas canvas, Paint paint) {
        if(fraction<=0) {
            return;
        }

        Path dst = new Path();
        PathMeasure measure = new PathMeasure(mPath, false);         // 将 Path 与 PathMeasure 关联

        float length = measure.getLength();

        // 截取一部分 并使用 moveTo 保持截取得到的 Path 第一个点的位置不变
        measure.getSegment(0, length*fraction, dst, true);

        canvas.drawPath(dst, paint);
    }
}

PathWrapper 保存了path 和 绘制比例。
这里有一个巧妙的设计是将 图形的绘制 封装到了 PathWrapper中,这么早的好处在哪里呢?不要急,接下来会分析到。而关于绘制代码有问题的可以参考 Path使用详解

image

这个图形可以看到 小矩形有一个灰色的填充效果,与其他图形的绘制有所分别,就不能使用通用的绘制方法进行绘制,需要特殊对待。
这时候PathWrapper 封装 绘制代码的作用就提现了出来,对于线条图形使用通用的方法,对于矩形图形,创建单独的 RectPathWrapper 继承自PathWrapper对 public void onDraw(Canvas canvas, Paint paint)方法进行重写,自定义绘制规则。

public class RectPathWrapper extends PathWrapper {

    Paint mPaint;

    public RectPathWrapper(Path path, float fraction) {
        super(path, fraction);
        //创建新的画笔  设置填充样式    颜色
         mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setColor(0x32000000);
    }


    public void onDraw(Canvas canvas, Paint paint) {
        if (fraction <= 0) {
            return;
        }

        Path dst = new Path();
        PathMeasure measure = new PathMeasure(mPath, false);         // 将 Path 与 PathMeasure 关联

        float length = measure.getLength();

        measure.getSegment(0, length * fraction, dst, true);                   // 截取一部分 并使用 moveTo 保持截取得到的 Path 第一个点的位置不变
        //绘制线条
        canvas.drawPath(dst, paint);
         //绘制填充
        canvas.drawPath(dst, mPaint);
    }
}

关于绘制比例计算
再看一次映射关系

图形 fraction 绘制比例
外层圆角矩形 0~1 0~1
矩形 0~0.25 0~1
短线条1 0.25~0.33 0~1
短线条2 0.33~0.41 0~1
短线条3 0.41~0.5 0~1
长线条1 0.5~0.66 0~1
长线条2 0.66~0.82 0~1
长线条3 0.82~1 0~1

直接贴出 DragState的代码

 class DragState extends State {


        private float fraction = 0f;


        public void setFraction(float fraction) {
            this.fraction = fraction;
            mPathList.clear();
            initStatePath();
        }
        
        @Override
        protected void initStatePath() {
            //圆角 矩形 
            PathWrapper pathWrapper = new PathWrapper(roundPath, fraction);
            mPathList.add(pathWrapper);

            //小矩形
            Path rectPath = provideRectPath(roundCorner, roundCorner);
            pathWrapper = new RectPathWrapper(rectPath, Math.min(1, 4 * fraction));
            mPathList.add(pathWrapper);

            //短线条1
            float shortLeft = roundCorner + rectWidth + spaceRectLine;
            Path shortLine1 = provideLinePath(shortLeft, roundCorner, shortLineWidth);
            pathWrapper = new PathWrapper(shortLine1, Math.min(1, 12.5f * (fraction - 0.25f)));
            mPathList.add(pathWrapper);

//

             //短线条2
            Path shortLine2 = provideLinePath(shortLeft, roundCorner * 2f, shortLineWidth);
            pathWrapper = new PathWrapper(shortLine2, Math.min(1, 12.5f * (fraction - 0.33f)));
            mPathList.add(pathWrapper);
//
             //短线条3
            Path shortLine3 = provideLinePath(shortLeft, roundCorner * 3f, shortLineWidth);
            pathWrapper = new PathWrapper(shortLine3, Math.min(1, 12.5f * (fraction - 0.41f)));
            mPathList.add(pathWrapper);
//
            //长线条1
            Path longLine1 = provideLinePath(roundCorner, roundCorner * 4f, lineWidth);
            pathWrapper = new PathWrapper(longLine1, Math.min(1, 6.25f * (fraction - 0.5f)));
            mPathList.add(pathWrapper);
            //长线条2
            Path longLine2 = provideLinePath(roundCorner, roundCorner * 5f, lineWidth);
            pathWrapper = new PathWrapper(longLine2, Math.min(1, 6.25f * (fraction - 0.66f)));
            mPathList.add(pathWrapper);
          //长线条3
            Path longLine3 = provideLinePath(roundCorner, roundCorner * 6f, lineWidth);
            pathWrapper = new PathWrapper(longLine3, Math.min(1, 6.25f * (fraction - 0.82f)));
            mPathList.add(pathWrapper);
        }
    }

接下里就可以写个按钮不断改变 fraction 来观察绘制效果了

刷新过程

刷新的过程可以分为四中状态:


image
image
image

image

刷新过程显示就是四中状态图形在一定时间间隔内循环切换显示
这部分就比较简单了,确定好图形直接绘制即可,这里贴出 第二个状态的代码

 class RefreshState2 extends State {


        @Override
        protected void initStatePath() {

            PathWrapper pathWrapper = new PathWrapper(roundPath, 1);
            mPathList.add(pathWrapper);


            Path rectPath = provideRectPath(contentWidth-roundCorner-rectWidth, roundCorner);
            pathWrapper = new RectPathWrapper(rectPath, 1);
            mPathList.add(pathWrapper);


            float shortLeft = roundCorner;
            Path shortLine1 = provideLinePath(shortLeft, roundCorner, shortLineWidth);
            pathWrapper = new RectPathWrapper(shortLine1, 1);
            mPathList.add(pathWrapper);


            Path shortLine2 = provideLinePath(shortLeft, roundCorner * 2f, shortLineWidth);
            pathWrapper = new PathWrapper(shortLine2, 1);
            mPathList.add(pathWrapper);
//

            Path shortLine3 = provideLinePath(shortLeft, roundCorner * 3f, shortLineWidth);
            pathWrapper = new PathWrapper(shortLine3, 1);
            mPathList.add(pathWrapper);
//
//
            Path longLine1 = provideLinePath(roundCorner, roundCorner * 4f, lineWidth);
            pathWrapper = new PathWrapper(longLine1, 1);
            mPathList.add(pathWrapper);
//
            Path longLine2 = provideLinePath(roundCorner, roundCorner * 5f, lineWidth);
            pathWrapper = new PathWrapper(longLine2, 1);
            mPathList.add(pathWrapper);
//
            Path longLine3 = provideLinePath(roundCorner, roundCorner * 6f, lineWidth);
            pathWrapper = new PathWrapper(longLine3, 1);
            mPathList.add(pathWrapper);
        }

    }

代码比较简单就是 计算坐标,创建Path 然后绘制交由公共的PathWrapper 完成
状态的切换

public void setDragState() {
        if (mDragState instanceof DragState) {
            mDragState = new RefreshState1();
        } else if (mDragState instanceof RefreshState1) {
            mDragState = new RefreshState2();
        } else if (mDragState instanceof RefreshState2) {
            mDragState = new RefreshState3();
        } else if (mDragState instanceof RefreshState3) {
            mDragState = new RefreshState4();
        } else if (mDragState instanceof RefreshState4) {
            mDragState = new RefreshState1();
        }
        postInvalidate();
    }

结合下拉刷新动起来

下拉刷新使用 SmartRefreshLayout,正如它的介绍所说 SmartRefreshLayout是一个“聪明”或者“智能”的下拉刷新布局,并且支持自定义多种Header,Footer。自定义Header文档说明

代码直接贴出来

public class TodayNewsHeader extends LinearLayout implements RefreshHeader {

    public static String REFRESH_HEADER_PULLDOWN = "下拉推荐";
    public static String REFRESH_HEADER_REFRESHING = "推荐中...";
    public static String REFRESH_HEADER_RELEASE = "松开推荐";
    private NewRefreshView mNewRefreshView;
    private TextView releaseText;


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

    public TodayNewsHeader(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public TodayNewsHeader(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initView(context);
    }

    private void initView(Context context) {
        this.setGravity(Gravity.CENTER_HORIZONTAL);
        this.setOrientation(LinearLayout.VERTICAL);

        mNewRefreshView = new NewRefreshView(context);

        LinearLayout.LayoutParams lpNewRefresh = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        lpNewRefresh.setMargins(30, dip2px(context,30), 30, 0);

        this.addView(mNewRefreshView, lpNewRefresh);


        LinearLayout.LayoutParams lpReleaseText = new LinearLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT);
        lpReleaseText.setMargins(0, 30, 0, 30);

        releaseText = new TextView(context);
        releaseText.setText(REFRESH_HEADER_PULLDOWN);
        releaseText.setTextColor(0xff666666);
        addView(releaseText, lpReleaseText);
        
    }

    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            mNewRefreshView.setDragState();
            mHandler.sendEmptyMessageDelayed(0, 250);
        }
    };

    @NonNull
    @Override
    public View getView() {
        return this;
    }

    @NonNull
    @Override
    public SpinnerStyle getSpinnerStyle() {
        return SpinnerStyle.Translate;
    }

    @Override
    public void setPrimaryColors(int... colors) {

    }

    @Override
    public void onInitialized(@NonNull RefreshKernel kernel, int height, int extendHeight) {

    }

    @Override
    public void onPulling(float percent, int offset, int height, int extendHeight) {
        Log.e("TAG", "fraction:" + percent);
        mNewRefreshView.setFraction((percent - 0.8f) * 6f);
    }

    @Override
    public void onReleasing(float percent, int offset, int height, int extendHeight) {
        onPulling(percent, offset, height, extendHeight);
    }

    @Override
    public void onReleased(RefreshLayout refreshLayout, int height, int extendHeight) {
        mHandler.removeCallbacksAndMessages(null);
        mHandler.sendEmptyMessage(0);
    }

    @Override
    public void onStartAnimator(@NonNull RefreshLayout refreshLayout, int height, int extendHeight) {

    }

    @Override
    public int onFinish(@NonNull RefreshLayout refreshLayout, boolean success) {
        mHandler.removeCallbacksAndMessages(null);
        mNewRefreshView.setDrag();
        return 0;
    }

    @Override
    public void onHorizontalDrag(float percentX, int offsetX, int offsetMax) {

    }

    @Override
    public boolean isSupportHorizontalDrag() {
        return false;
    }

    @Override
    public void onStateChanged(RefreshLayout refreshLayout, RefreshState oldState, RefreshState newState) {
        switch (newState) {
            case None:
                break;
            case PullDownToRefresh:
                releaseText.setText(REFRESH_HEADER_PULLDOWN);
                break;
            case PullUpToLoad:
                break;
            case ReleaseToRefresh:
                releaseText.setText(REFRESH_HEADER_RELEASE);
                break;
            case Refreshing:
                releaseText.setText(REFRESH_HEADER_REFRESHING);
                break;
            case Loading:
                break;
        }
    }

    /**
     * 根据手机的分辨率从 dip 的单位 转成为 px(像素)
     */
    public static int dip2px(Context context,float dpValue) {
        final float scale = context.getResources().getDisplayMetrics().density;
        return (int) (dpValue * scale + 0.5f);
    }

    @Override
    protected void onDetachedFromWindow() {
        super.onDetachedFromWindow();
        mHandler.removeCallbacksAndMessages(null);
        mHandler = null;
    }
}
  • TodayNewsHeader继承自 LinearLayout 在 initView() 方法中 创建 NewRefreshView 和下方显示文字,并添加到自身中。
  • 关于getSpinnerStyle() 方法说明,参考官方说明

变换方式
Translate 平行移动 特点: 最常见,HeaderView高度不会改变,
Scale 拉伸形变 特点:在下拉和上弹(HeaderView高度改变)时候,会自动触发OnDraw事件
FixedFront 固定在前面 特点:不会上下移动,HeaderView高度不会改变
FixedBehind 固定在后面 特点:不会上下移动,HeaderView高度不会改变(类似微信浏览器效果)
Screen 全屏幕 特点:固定在前面,尺寸充满整个布局

  • onPulling 与 onReleasoing 拖拽过程与下拉放回过程,执行 mNewRefreshView.setFraction();操作,修改 绘制比例
  • onReleased 出发下拉刷新,开启刷新动画,我们在上面分析刷新过程是 四中状态图形在一定时间间隔内循环切换显示,这里我采用可Handler 的形式
 private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            mNewRefreshView.setDragState();
            mHandler.sendEmptyMessageDelayed(0, 250);
        }
    };
...
//使用handler 的好习惯,先清除消息再发送
  mHandler.removeCallbacksAndMessages(null);
        mHandler.sendEmptyMessage(0);

  • onFinish刷新完成会调用,返回值为 头部延迟收回的时间 在这个方法里 需要清除 handler 并且重置 NewRefreshView 的状态为拖拽状态
  • onStateChanged方法 刷新状态变化时回调,在这里完成下方文本的切换显示

项目地址 TodayNewsHeader

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

推荐阅读更多精彩内容