(八)flutter入门之常见滚动类组件详解

上篇博客我们学习了组件的概念以及基础的常见的组件,接下来我们开始学习包裹类容器组件,在这里我把flutter的组件分为了三种,第一种是最基础的子组件,不能包含其他子组件的组件,功能比较单一,第二种就是包裹类容器,这类组件可以包裹其他的子组件,并且可以包裹一个或者多个组件,但是这类组件不能做到整体的布局效果,只能局部控制组件的形态和位置等,但是这类组件其实很宽泛,比如可以再分为具有一定辅助功能的组件,或者可以提供滑动功能等的组件,第三类则是作为布局存在的布局类组件,这类组件和我们的安卓原生(web开发)的几种都很像。接下来我们就着重介绍常见的开发中的包裹类容器组件

可滑动组件

在安卓原生开发过程中,我们最常见的可以滑动的组件有很多,几乎每款app中都有不少这样的功能,比如listView,GridView,以及ScrollView等,同样的flutter中也提供了这几个同名组件,用来快速实现对应的业务和功能,并且在flutter中,这些组件默认都是进行了封装的,即一些常见功能的实现已经封装,只需要组合使用即可

ListView

ListView原生开发的都很熟悉,该组件提供了列表展示且可滑动的能力,老规矩,我们先从ListView的构造函数看起

ListView({
    Key key,
    Axis scrollDirection = Axis.vertical,//指定了ListView的方向为垂直方向展示
    bool reverse = false,//是否相反,默认false,即为正向加载
    ScrollController controller,//与当前ListView绑定的控制器
    bool primary,
    ScrollPhysics physics,//可以记录当前滚动组件的物理特性,比如屏幕大小和滚动位置等
    bool shrinkWrap = false,//是否进行收缩的时候包装,默认false
    EdgeInsetsGeometry padding,//当前属性可以用来控制和感知当前组件文本的方向,可以接受EdgeInsets.fromLTRB或者其类型变体
    this.itemExtent,//当前属性比较坑,如果不是null的情况下,会强制使用children的长度作为当前的值,因为当前默认滚动方向为垂直,所以当前itemExtent代表子widget的高度,这里建议给temExtent初始化设置一个值,好处在于可以提前知道列表长度,不需要进行动态改变和计算,提高性能
    bool addAutomaticKeepAlives = true,//是否把列表item包裹在AutomaticKeepAlive widget中,默认是true,好处在于如果当前item滑出屏幕外的时候,不会被gc回收,即有默认的KeepAliveNotification保存当前item存活状态,如果这时候我们需要手动去维护当前的存活状态等,建议置为false
    bool addRepaintBoundaries = true,//当前属性代表是否将列表包裹在RepaintBoundary中,默认为true,和addAutomaticKeepAlives搭配使用,好处是可以避免滑出以后再次滑入会进行重新加载和绘制操作,但是这种保存状态比较消耗内存,如果在item变动不是很频繁或者数量比较少的情况下,建议可以置为false,性能会更高
    bool addSemanticIndexes = true,//和addAutomaticKeepAlives以及addRepaintBoundaries属性作用差不多,即是否给item添加一个索引列表,好处是可以提高item查找和加载效率,ture代表默认加索引,如果需要手动控制索引,可以置为false
    double cacheExtent,//缓存的长度,默认不指定缓存长度,会按照最终item加载的长度计算
    List<Widget> children = const <Widget>[],//包裹类组件树用来挂载子组件的属性,一般都是list,可以挂载任意多个
    int semanticChildCount,//子item的个数
    DragStartBehavior dragStartBehavior = DragStartBehavior.down,
  }) : childrenDelegate = SliverChildListDelegate(
         children,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),
       super(
         key: key,
         scrollDirection: scrollDirection,
         reverse: reverse,
         controller: controller,
         primary: primary,
         physics: physics,
         shrinkWrap: shrinkWrap,
         padding: padding,
         cacheExtent: cacheExtent,
         semanticChildCount: semanticChildCount ?? children.length,
         dragStartBehavior: dragStartBehavior,
       );

从上面可以看出来,默认的构造函数中,存在一个children参数,该参数可以指定一个子组件列表,这种方式目前只适合少量子组件的方式,或者需要我们在初始化ListView之前提前将子组件维护好,但是我们在开发的过程中往往item不能一次加载出来,或者多次加载出现,这种默认构造的方式就不适合了,实际上当前这种方式其实就是使用了SingleChildScrollView+Column 的方式构建,使用案例如下:

new ListView(
  shrinkWrap: true, 
  padding: const EdgeInsets.all(20.0),
  children: <Widget>[
    //默认初始化加载四个text作为item
    const Text('第一个'),
    const Text('第二个'),
    const Text('第三个'),
    const Text('第四个'),
  ],
);

那么,我们需要动态加载该如何?这就需要使用ListView.builder了

ListView.builder

ListView.builder({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    this.itemExtent,
    @required IndexedWidgetBuilder itemBuilder,//可以看出来和上诉的构造相比,多了一个必传参数--itemBuilder,当前属性是提供加载widget的构造者,类型为IndexedWidgetBuilder
    int itemCount,//并且多了一个itemCount,用来指定动态加载的时候总共要加载多少次(数量必须和itemBuilder实现的时候数量一样,否则会报错),但是我们需要注意的是,如果当前参数不指定,即为null的时候,默认为当前item数量无限
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    double cacheExtent,
    int semanticChildCount,
    DragStartBehavior dragStartBehavior = DragStartBehavior.down,
  }) : childrenDelegate = SliverChildBuilderDelegate(
         itemBuilder,
         childCount: itemCount,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),
       super(
         key: key,
         scrollDirection: scrollDirection,
         reverse: reverse,
         controller: controller,
         primary: primary,
         physics: physics,
         shrinkWrap: shrinkWrap,
         padding: padding,
         cacheExtent: cacheExtent,
         semanticChildCount: semanticChildCount ?? itemCount,
         dragStartBehavior: dragStartBehavior,
       );

看到上面的构造,我们大概知道了区别和使用,接下来我们就通过案例了解动态加载的方案:

new ListView.builder(
        itemCount: 30,
        itemBuilder: (BuildContext context,int index){//使用itemBuilder需要两个参数,一个是构造树上下文,一个是每一个item加载的index
          //在这里我们可以实现很多功能,比如我们可以将index == 0的加载一个bannner轮播图组件,剩下的挂载指定item,即可实现轮播图和滑动列表等效果
          if(index == 0){
            return new Text("这是头部");
          }else{
            return new Text("${index}");
          }
        },
      )

在开发的过程中,我们可能还需要加一些定制化的需求,比如,给每个item下面加一个其他颜色的分割线,这个时候就需要ListView.separated 登场了,ListView.separated 比起ListVIew.builder来说多了一个separatorBuilder 参数,该参数可以实现一个分割器,接下来我们来实现一个案例,给每一个item的分割线都改为蓝色,如下:

class CustomListView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //定义一个分割线--按照安卓原生方法即一个带蓝色背景的view
    Widget divder = const Divider(color:Colors.blue);
    return ListView.separated(
      itemBuilder:(BuildContext  context,int index){
        return new Text("${index}");
      },
      separatorBuilder:(BuildContext context,int index){
        return divder;
      },
      itemCount : 10,
    );
  }
}

接下来我们看看ListView.separated的定义和其他构造有什么不同

ListView.separated({
    ...... 
    @required IndexedWidgetBuilder itemBuilder,
    @required IndexedWidgetBuilder separatorBuilder,//多出来一个构造者,当前属性用来提供分割线的构造者
    @required int itemCount,
    ......
       );

我们可以很明显看出来,与其他方法相比,区别在于,itemCount的数值必传,即必须指定数量,当前方法不适用无限加载数据(设置为null)的场景,并且还多了一个可以构建分割线的IndexedWidgetBuilder,从而实现了自定义分割线的效果

GridView

GridView在开发的时候很常用,我们经常用来实现九宫格布局或者二维网格布局的效果,首先我们先看下定义的默认构造函数:

GridView({
    Key key,
    Axis scrollDirection = Axis.vertical,
    bool reverse = false,
    ScrollController controller,
    bool primary,
    ScrollPhysics physics,
    bool shrinkWrap = false,
    EdgeInsetsGeometry padding,
    @required this.gridDelegate,//当前属性为必传属性,可以用来控制子widget如何排列,类型为SliverGridDelegate,flutter中默认实现了两个排列控制的子类,SliverGridDelegateWithFixedCrossAxisCount和SliverGridDelegateWithMaxCrossAxisExtent
    bool addAutomaticKeepAlives = true,
    bool addRepaintBoundaries = true,
    bool addSemanticIndexes = true,
    double cacheExtent,
    List<Widget> children = const <Widget>[],
    int semanticChildCount,
  }) : assert(gridDelegate != null),
       childrenDelegate = SliverChildListDelegate(
         children,
         addAutomaticKeepAlives: addAutomaticKeepAlives,
         addRepaintBoundaries: addRepaintBoundaries,
         addSemanticIndexes: addSemanticIndexes,
       ),
       super(
         key: key,
         scrollDirection: scrollDirection,
         reverse: reverse,
         controller: controller,
         primary: primary,
         physics: physics,
         shrinkWrap: shrinkWrap,
         padding: padding,
         cacheExtent: cacheExtent,
         semanticChildCount: semanticChildCount ?? children.length,
       );

从上面的默认构造函数我们可以看出来,和ListView几乎都一样的属性说明,只有一个排列的属性需要我们手动控制,所以我们基本很多用法可以按照ListView使用,接下来我们来学习gridDelegate属性中的两个实现----SliverGridDelegateWithFixedCrossAxisCountSliverGridDelegateWithMaxCrossAxisExtent使用以及区别

SliverGridDelegateWithFixedCrossAxisCount

SliverGridDelegateWithFixedCrossAxisCount实现了一个横轴为固定数量的布局方案,即我们确定一行中需要摆多少个子组件的时候,我们可以使用当前方案

const SliverGridDelegateWithFixedCrossAxisCount({
    @required this.crossAxisCount,//必传参数,指定了当前横轴子元素的数量,当前元素确定后,子元素的最大宽度就确定了,即横轴总长度/crossAxisCount
    this.mainAxisSpacing = 0.0,//主轴方向的间距
    this.crossAxisSpacing = 0.0,//横轴方向的间距
    this.childAspectRatio = 1.0,//子元素在横轴和主轴方向的长度比例,由于crossAxisCount确定后,宽度就确定了,那么当前参数就可以确定了主轴方向的长度,默认比例1:1,即等长的矩形范围
  }) : assert(crossAxisCount != null && crossAxisCount > 0),
       assert(mainAxisSpacing != null && mainAxisSpacing >= 0),
       assert(crossAxisSpacing != null && crossAxisSpacing >= 0),
       assert(childAspectRatio != null && childAspectRatio > 0);

接下来,我们来写个案例,简单看看SliverGridDelegateWithFixedCrossAxisCount的使用,案例如下:

new GridView(
  gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: 3, //横轴一行只有三个子widget
  ),
  children:<Widget>[
    Icon(Icons.ac_unit),
    Icon(Icons.airport_shuttle),
    Icon(Icons.all_inclusive),//第一行
    Icon(Icons.beach_access),
    Icon(Icons.cake),
    Icon(Icons.free_breakfast)//第二行
  ]
);

可以看到当前的代码比较简单,实现的效果大概如下:

横轴固定三个的gridView.png

同样的,我们可以使用GridView.count 快速构建一个一样的效果,代码如下:

GridView.count( 
  crossAxisCount: 3,//固定横轴三个widget,限制每一行三个
  children: <Widget>[
    Icon(Icons.ac_unit),
    Icon(Icons.airport_shuttle),
    Icon(Icons.all_inclusive),//第一行
    Icon(Icons.beach_access),
    Icon(Icons.cake),
    Icon(Icons.free_breakfast),//第二行
  ],
);

SliverGridDelegateWithMaxCrossAxisExtent

SliverGridDelegateWithMaxCrossAxisExtent 和SliverGridDelegateWithFixedCrossAxisCount不同的是,当前组件内部限制了横轴子元素的最大宽度,即每一个子元素的宽度,默认的构造如下:

const SliverGridDelegateWithMaxCrossAxisExtent({
    @required this.maxCrossAxisExtent,//必传参数,这里限制了每一个子组件在横轴上的最大宽度,注意的是当前只能限制最大的宽度,即每个子组件之间还是按照横轴数量进行比例加载的,
    this.mainAxisSpacing = 0.0,//主轴方向间距
    this.crossAxisSpacing = 0.0,//横轴间距
    this.childAspectRatio = 1.0,//宽/高比例
  }) : assert(maxCrossAxisExtent != null && maxCrossAxisExtent >= 0),
       assert(mainAxisSpacing != null && mainAxisSpacing >= 0),
       assert(crossAxisSpacing != null && crossAxisSpacing >= 0),
       assert(childAspectRatio != null && childAspectRatio > 0);

可以看出来,这里有一个maxCrossAxisExtent的概念,用来限制子组件的最大宽度,但是我们需要注意的是横轴方向每个子元素的长度仍然是等分的 ,即横轴长度是450 ,这个时候maxCrossAxisExtent 在(450/4 - 450/3)这个值之间的话,依然会按照最大的150来计算,接下来我们看一个使用的案例:

new GridView(
  padding: EdgeInsets.zero,//上下左右都没有边距
  gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
      maxCrossAxisExtent: 120.0,//限制每个子widget的宽度为120
      childAspectRatio: 2.0 //宽度:高度 = 2 : 1
  ),
  children: <Widget>[
    Icon(Icons.ac_unit),
    Icon(Icons.airport_shuttle),
    Icon(Icons.all_inclusive),//第一行
    Icon(Icons.beach_access),
    Icon(Icons.cake),
    Icon(Icons.free_breakfast),//第二行
  ],
);

可以看出来,当前的效果如下,明显每一个widget变小了不少:

限制子widget的宽度的gridview.png

同样的,SliverGridDelegateWithMaxCrossAxisExtent 快速构建的函数GridView也有提供,即GridView.extent 构造函数,可以使用当前函数快速构建一个和上述效果一样的实例:

//GridView.extent默认底层使用了SliverGridDelegateWithMaxCrossAxisExtent 进行构建
GridView.extent(
   maxCrossAxisExtent: 120.0,
   childAspectRatio: 2.0,//宽度:高度 = 2 : 1
   children: <Widget>[
     Icon(Icons.ac_unit),
     Icon(Icons.airport_shuttle),
     Icon(Icons.all_inclusive),//第一行
     Icon(Icons.beach_access),
     Icon(Icons.cake),
     Icon(Icons.free_breakfast),//第二行
   ],
 );

SingleChildScrollView

介绍完ListView和GridView以后,轮到ScrollView了,在原生开发中,我们经常会使用到ScrollView来完成滑动组件的效果,而在flutter中,当前组件叫SingleChildScrollView,并且内部只能挂载一个子widget,构造如下:

const SingleChildScrollView({
    Key key,
    this.scrollDirection = Axis.vertical,//默认的滚动的方向为垂直方向滚动
    this.reverse = false,//当前属性比较特殊,代表是否按照当前操作的方向的反方向滑动,如默认的滑动的方向是垂直方向,按照我们语言国际化的情况(阿拉伯语就是从右到左和从下到上,其他的基本都是从上到下和从左到右),默认是从上到下滚动,当前属性设置为true以后,会从下到上滑动,原理是初始化的时候初始位置在头部还是尾部,可以根据情况进行修改
    this.padding,//当前组件的内边距
    bool primary,//当前的组件是否使用默认的PrimaryScrollController,当方向默认为垂直并且没有指定控制器的时候,当前的值默认为true
    this.physics,
    this.controller,
    this.child,
    this.dragStartBehavior = DragStartBehavior.down,
  }) : assert(scrollDirection != null),
       assert(dragStartBehavior != null),
       assert(!(controller != null && primary == true),
          'Primary ScrollViews obtain their ScrollController via inheritance from a PrimaryScrollController widget. '
          'You cannot both set primary to true and pass an explicit controller.'
       ),
       primary = primary ?? controller == null && identical(scrollDirection, Axis.vertical),
       super(key: key);

接下来我们就使用滚动条展示一个A-Z的字母列表的案例,如下:

import 'package:flutter/material.dart';

class SingleLetterScrollView extends StatelessWidget {
  String _str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";//当前加载的数据集合A-Z字母集合
  @override
  Widget build(BuildContext context) {
    return Scrollbar(
      child: SingleChildScrollView(
        padding: EdgeInsets.all(16.0),//内间距为16px
        child: Center(
          child: Column( 
            //动态创建一个List<Widget>  
            children: createEle()
          ),
        ),
      ),
    );
  }

  //构建当前的每一个item的组件
  List<Widget> createEle(){
     List<Widget> widgets = [];
     for(int i = 0;i < _str.length;i ++){
        widgets.add(new Text(_str[i]));
     }
     return widgets;
  }
}

实现的效果大概如下:

SingleChildScollview实现垂直展示26字母列表.png

Sliver

Sliver组件是个很特殊的组件,用来在开发中将多个滚动组件粘连在一起进行同步操作的组件(场景为我们有一个Gridview和ListView在页面中,但是我们希望两个成为一个整体,比如我上下滑动的时候效果是一致的,这个时候传统的单独两个组件分开的方案实现不了粘连的效果,使用sliver系列的组件就可以挂载在一起),当前组件因为内部不包含Scrollable 系列的滚动模型组件,所以可以用来将多个滚动模型粘连在一起,原理是使用多个Sliver公用CustomScrollView的Scrollable ,来实现统一模型的滑动效果,Sliver系列组件比较多,常用的有SliverListSliverGridSliverAppBarSliverPaddingSliverChildBuilderDelegateSliverGridDelegateWithFixedCrossAxisCountSliverFixedExtentList等等,几乎可以说囊括了所有的滚动组件能涉及到的所有的子组件或者构造组件,由于当前组件比较复杂,下面实现了一个GridView和ListView进行粘连的效果的简单案例,复杂的操作可以自行研究,如下:

import 'package:flutter/material.dart';

class CustomScrollViewTestRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    //因为本路由没有使用Scaffold,为了让子级Widget(如Text)使用
    //Material Design 默认的样式风格,我们使用Material作为本路由的根。
    return Material(
      child: CustomScrollView(
        slivers: <Widget>[
          //AppBar,包含一个导航栏(后面会说到)
          SliverAppBar(
            pinned: true,
            expandedHeight: 250.0,
            flexibleSpace: FlexibleSpaceBar(
              title: const Text('SliverDemo'),
            ),
          ),
          new SliverPadding(
            padding: const EdgeInsets.all(8.0),//全部内边距为8px
            sliver: new SliverGrid( //具有粘连性的GridView
              gridDelegate: new SliverGridDelegateWithFixedCrossAxisCount(//具有粘连性组件的装饰器
                crossAxisCount: 2, //一行最多两个子组件
                mainAxisSpacing: 10.0,//主轴边距10px
                crossAxisSpacing: 10.0,//横轴方向边距10px
                childAspectRatio: 4.0,//宽度:高度 4:1
              ),
              delegate: new SliverChildBuilderDelegate(//子widget的构建装饰器
                  (BuildContext context, int index) {
                  //创建子widget      
                  return new Container(
                    alignment: Alignment.center,
                    color: Colors.cyan[100 * (index % 9)], //颜色加载动态加载,按照取余*100比例取
                    child: new Text('当前是GridView的第$index个item'),
                  );
                },
                childCount: 20,//构建20个widget
              ),
            ),
          ),
          //List
          new SliverFixedExtentList(//再去构建一个具有粘连性的ListView
            itemExtent: 50.0,
            delegate: new SliverChildBuilderDelegate(
                    (BuildContext context, int index) {
                  return new Container(
                    alignment: Alignment.center,
                    color: Colors.lightBlue[100 * (index % 9)],//颜色加载动态加载,按照取余*100比例取
                    child: new Text('当前是ListView的第$index个item'),
                  );
                },
                childCount: 50 //50个列表项
            ),
          ),
        ],
      ),
    );
  }
}

至此,flutter中常用到的滚动类组件基本介绍完毕,下一篇我们开始介绍功能类包裹容器

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

推荐阅读更多精彩内容