flutter第三篇:布局

05、Container、Text

Container在没有显式设置宽高的情况下,

①如果没有child,则其宽高和其父容器一样。

②如果有child,则其宽高等于其child的宽高。

③如果既有child,又设置了alignment,则其宽高和其父容器一样。

利用Container的alignment属性,可以设置其子组件在Container中的位置,如居中。

设置Container的decoration属性为一个BoxDecoration实例:①利用BoxDecoration实例的border属性,可以给Container设置边框。如赋值为Border.all(),此时边框宽度是1,颜色是黑色。如果想自定义,则可以在这个all()方法中用width指定宽度、用color指定颜色。②利用BoxDecoration实例的color属性,可以给Container设置背景色。③利用BoxDecoration实例的borderRadius属性,可以给Container设置圆角,如赋值为BorderRadius.circular(10)。

Container嵌套的情况下,如果两个Cotainer都显式设置了宽高,如果没有设置外部Container的alignment属性,那么内部的Container设置的宽高是不生效的,其会自动扩展成和外部Container一样大。给外部Container设置alignment属性后,内部Container大小才正常。

Container的padding是内边距,是其child组件与container边框的距离,margin是外边距,是container边框与其父组件边框的距离。Container的margin、padding不能是负数。

Text

Text默认可以换行,如果字数很多,则当文字到Text外部容器(如果没有容器,则是屏幕)的右边界时,就会往下换一行,直到Text外部容器(如果没有容器,则是屏幕)的底边界。如果想限制成仅一行,则需要设置maxLines为1。如果设置了overflow为TextOverflow.ellipsis,则Text恒定为一行,当文字达到外部容器的右边界时,最后三个字符会变成三个点,即省略号。

字体加粗要利用TextStyle的fontWeight属性。fontSize默认是14.

06、Image

加载远程图片:Image.network(),ios和android都可以加载http图片,无需额外配置。

常用参数有:

alignment:用于设置其在外部容器的显示位置,默认居中。比如被Container包裹时控制其在Container中的位置,当然利用Container的alignment属性也能实现同样的效果。

fit:用来控制图片的拉伸和挤压,在外部Container没有设置alignment时生效:

①BoxFit.fill,全图显示,图片会被缩放,充满父容器。图片的宽等于父容器的宽,图片的高等于父容器的高。可能会变形。

②BoxFit.contain,等比例缩放,直到宽或高有一个方向充满父容器,可能有空隙。不会变形。

③BoxFit.scaleDown,和BoxFit.contain差不多,但只能缩小图片,不能放大图片。

④BoxFit.cover,对图片进行裁剪,然后充满父容器。不会变形。

⑤BoxFit.fitWidth、BoxFit.fitHeight,在一个方向上充满父容器,另一个方向上等比例放大后,如果超出尺寸,会裁剪。不会变形。

repeat:平铺。在图片尺寸比父容器小时有效,平铺就好像地板砖一样,一个一个铺。默认不平铺。

有一张图片,用Container包裹以限制其宽高。如果想把图片设为圆角,则除了设置Container的borderRadius外,还要设置Container的clipBehavior属性,设为Clip.hardEdge或Clip.antiAlias,否则只有container变圆角,图片不变圆角。

创建圆形图片的三种方式:

①、使用Container:

Container默认是矩形,可以设置其decoration属性,设置BoxDecoration的shape属性值为BoxShape.circle,使其变为圆形。

复制代码
  @override
  Widget build(BuildContext context) {
    return Container(
      width: 40,
      height: 40,
      decoration: const BoxDecoration(
          shape: BoxShape.circle,
          image: DecorationImage(
              fit: BoxFit.cover,
              image: NetworkImage(
                "http://oss.echowa.xndm.tech/test/op/98ir9yjgJc8krUcyp9Ztd4.png",
              ))),
    );
  }
复制代码

②、使用ClipOval

Oval是椭圆的意思

复制代码
  @override
  Widget build(BuildContext context) {
    return ClipOval(
      child: Image.network(
          "http://oss.echowa.xndm.tech/test/op/98ir9yjgJc8krUcyp9Ztd4.png",
          width: 40,
          height: 40,
          fit: BoxFit.cover),
    );
  }
复制代码

③、使用CircleAvatar:

这种方式最简单,CircleAvatar其实是对AnimatedContainer的封装。

  @override
  Widget build(BuildContext context) {
    return const CircleAvatar(
      radius: 20,
      backgroundImage: NetworkImage(
          "http://oss.echowa.xndm.tech/test/op/98ir9yjgJc8krUcyp9Ztd4.png"),
    );
  }

CircleAvatar默认的半径是20dp,如果觉得太小或太多,可以用radius属性自定义。

Image.network()每次加载都要重新从服务器获取图片,没有缓存到本地。在实际开发中,cached_network_image插件使用的更多,https://pub.dev/packages/cached_network_image,这个插件会把图片缓存到本地。

07、Icon

如果内置的图标不能满足我们的需求,那么我们可以去阿里巴巴矢量图标库寻找合适的图标。

登录后,根据关键字搜索想要的图标,找到后,点击加入购物车,然后去购物车,点击下载代码,下载到本地。解压后,把文件夹中的iconfont.json和iconfont.ttf文件复制到项目中的fonts目录(如果没有就新建),也可以是assets/fonts目录。然后修改项目的pubspec.yaml文件,找到fonts块,解除注释,修改其family和asset的值,family可以理解为是一个唯一名称,asset是上述ttf文件的路径。然后新建一个类,在其中定义一个常量,示例如下:

import 'package:flutter/material.dart';

class Fonts {
  static const IconData xiaomi =
      IconData(0xe66e, fontFamily: "Schyler", matchTextDirection: true);
}

IconData构造方法中,第一个参数是码点,固定以0x开头,0x后面是iconfont.json文件中unicode的值,第二个参数fontFamily的值就是pubspec.yaml文件中指定的family的值。

此时,就可以通过Icon(Fonts.xiaomi)来使用这个图标了。

08+09、ListView

在ListView中,如果ListView是上下滚动的,那么如果其中有Container,则Container的width属性会失效,不管设成多少,在水平方向上都会铺满。为了自定义宽度,我们可以把ListView用指定宽度的SizedBox包裹(每个元素都一样宽,且边框从左侧开始),或者使用ListView的padding属性,给左右两边留点空隙。是的,ListView也有padding属性,如果想让滚动区域和屏幕两边有点间距的话,可以使用padding属性。

如果想让ListView中某元素在水平方向上居中,则可以用Center包裹该元素。

当ListView的元素超过一屏时,ListView不会越界,而是会可以滚动。如果要禁止滚动,则需要把其physics属性设为一个NeverScrollableScrollPhysics实例。有些场景下,默认不能滚动,设置了physics反而能滚动。总之,涉及到滚动,设置并调整physics就好了。

shrinkWrap,是否根据子组件的总长度来设置ListView的长度。当ListView在一个无边界(滚动方向上)的容器中时,shrinkWrap必须为true,否则会报Vertical viewport was given unbounded height。例如,有一个column,在此column中有一个container,此container的child是一个listView,那么要么设置此container的高度,要么设置listView的shrinkWrap值为true。

ListTile是一个固定高度的行,在这个行中可以放标题和子标题,在标题、子标题之前(利用leading属性)之后(利用trailing属性)还可以放一个Icon或图片。ListTile经常用在ListView中,也可以单独使用。

如果要获取滚动条的位置,则需要利用ListView的controller属性,赋值为一个ScrollController实例。通过ScrollController实例的addListener()方法,可以监听滚动条的位置变化。scrollController.position.pixels可以获得滚动条顶端与appBar底部(或者说body顶部)的距离。注意不是与屏幕顶部的距离,滚动条顶端与屏幕顶部中间还隔着状态栏、appBar。

要创建ListView实例,不仅可以通过其无参构造方法,还可以使用其builder()命名构造器,itemBuilder属性所对应的函数的第二个入参是索引,利用这个参数,我们可以根据索引的不同返回不同的组件。

ListView是显示不了滚动条的,如果要显示,则需要用Scrollbar包裹。

如果要实现像淘宝的商品详情页那样,页面在往上滑的过程中,展示的依次是宝贝部分、评价部分、详情部分、推荐部分,且点击顶部的宝贝、评价、详情、推荐时,页面可以直接跳到相应部分,那么需要用SingleChildScrollView包裹Column,在Column的children放各部分,各部分都要用Container包裹,且Container要配置key,假如key分别是k0、k1、k2、k3。点击评价时,直接调用Scrollable的ensureVisible()静态方法,入参是k1.currentContext as BuildContext,如Scrollable.ensureVisible(controller.k1.currentContext as BuildContext)。

如果想在ListView某一行中居中放一个小图片,如128×128,则可以:

第一种方法:用一个宽128的Container包裹此ListView,但是此ListView的其他行的宽度也最多只有128了,且如果还得把Container用Center包裹,否则是图片是居左显示。

第二种方法:在ListView中放一个alignment属性为Alignment.center的Container,在此Container中再放一个包裹了图片的Container。外部Container的宽度会自动扩展成和ListView的宽度一样,不管设不设其alignment属性。如果不设外部Container的alignment属性,那么内部的Container的宽度也会自动扩展成和外部Container的宽度一样。

10、GridView

GridView是网格,好像方格本一样,一行有几个格子,一行一行的,格子里面有内容。我们把格子叫做单元格。

GridView的count()命名构造方法用于构建一个可以指定一行有多少个单元格的网格,利用crossAxisCount属性指定,如指定为2,那么在任意设备上,一行都只有2个单元格。

GridView的extent()命名构造方法用于构建一个可以指定单元格在水平方向上最大宽度的网格,利用maxCrossAxisExtent属性指定,如指定为120,这种网格,一行最终能展示多少个单元格是不确定的,在不同设备上,可能不一样。

GridView的children属性用于指定网格的内容。GridView的crossAxisSpacing属性用于指定在同一行中相邻单元格的间距,默认没有任何间距。GridView的mainAxisSpacing属性用于指定行间距,默认没有任何间距。GridView的childAspectRatio属性用于指定宽高比,默认是1,即单元格是正方形。

GridView的builder()命名构造器、custom()命名构造器、GridView(),这三种方式,都既可以构造一个count类型的GridView,又可以构造一个extent类型的Gridview实例,取决于给gridDelegate属性赋值为一个SliverGridDelegateWithFixedCrossAxisCount实例,还是一个SliverGridDelegateWithMaxCrossAxisExtent实例。在builder()中,用itemCount属性指定单元格的总量,用itemBuilder属性指定各单元格的内容,itemBuilder属性所对应的函数的第二个入参是索引,利用这个参数,我们可以根据索引的不同返回不同的组件。在custom()中,利用childrenDelegate指定各单元格的内容。

GridView的physics属性、shrinkWrap属性同ListView。如果ListView中有GridView,那么要么用Container包裹GridView,并设置高度,要么设置GridView的shrinkWrap属性为true。

11、Padding、Row、Column、Flex、Expanded

布局:

Padding组件,当我们想让一个组件离它上下左右有点间距时,就可以把这个组件用Padding包裹。比如我们想实现文本框离左边10dp,就可以用Padding包裹文本框,然后设置Padding组件的padding属性。padding属性值为一个EdgeInsets实例,可以通过调用EdgeInsets的only()方法、all()方法、fromLTRB()方法来得到。

Padding(
        padding: EdgeInsets.only(left: 10),
        child: Text("离左侧10dp"),
      )

有一些容器组件有padding属性,如Container,如果想设置其child与容器壁的距离,就可以给padding赋值。

线性布局:Row、Column

mainAxisAlignment是在主轴方向上子元素的对齐方式,Row的主轴方向是水平方向,Column的主轴方向是竖直方向,默认是MainAxisAlignment.start,即左对齐。MainAxisAlignment.center表示居中、MainAxisAlignment.end表示右对齐。特殊的,

①MainAxisAlignment.spaceBetween表示分散对齐,最左边元素贴着左边框,最右边元素贴着右边框。如果Row的宽度是x,元素个数是n,那么元素间距是x/(n-1)。如果children中只有两个子元素,那么将会形成一左一右的效果。

②MainAxisAlignment.spaceAround表示最左边元素与左边框间距是元素间距1/2、最右边元素与右边框间距是元素间距1/2的分散对齐,如果Row的宽度是x,元素个数是n,那么元素间距是x/n。

③MainAxisAlignment.spaceEvenly表示最左边元素与左边框间距、最右边元素与右边框间距都等于元素间距的分散对齐,如果Row的宽度是x,元素个数是n,那么元素间距是x/(n+1)。evenly是平均的意思。

mainAxisSize是行的宽度,默认是MainAxisSize.max,即行宽默认等于外层容器的宽度,哪怕只有少量的元素。设为MainAxisSize.min,则等于元素的宽度。

crossAxisAlignment是在交叉轴方向上子元素的对齐方式,Row的交叉轴方向是竖直方向,Column的主轴方向是水平方向,默认是CrossAxisAlignment.center,即居中对齐,即同一行的元素在竖直方向上是居中的,同一列的元素在水平方向上是居中的。

Row和Column都是Flex的子类。

弹性布局:Flex/Column/Row + Expanded

如果我们要实现的布局是在一行或一列中,某些元素的宽度是固定的,某些元素的宽度是自适应的,或是每个元素的宽度都是固定比例,那么就要用到Flex。具体来说,把要自适应的元素用Expanded包裹,并放到Flex的children中。而由于Row、Column都是特定的Flex,所以也可以放到Row或Column的children中,看具体需求。以把Expanded放到Row的children中为例,Expanded有一个flex属性,值是个整数,表示占据宽度的份数,多个Expanded指定flex后,每一个Expanded的实际宽度就可以计算出来。如在Row的children中只有两个Expanded,第一个Expanded的flex为1,第二个Expanded的flex为2,那么第一个Expanded的实际宽度则为Row宽度的1/3,第二个Expanded的实际宽度为Row宽度的2/3。如果除了这两个Expanded外,还有一个固定宽度的Container,假设为100,那么第一个Expanded的实际宽度则为(Row宽度-100)/3,第二个Expanded的实际宽度为2*(Row宽度-100)/3,即在给Expanded分配宽度时,要先把固定宽度的组件的宽度减出来。

在Row的children中,用Expanded包裹的元素要指定高度,如果此组件没有高度属性,则需要用Container或是SizedBox包裹起来。同理,在Column的children中,用Expanded包裹的元素要指定宽度,如果此组件没有宽度属性,也需要用Container或是SizedBox包裹起来。

12、Stack Positioned Align

Stack用于实现层叠布局。Stack组件的children中各元素会堆叠,一层叠一层,后面的叠前面的。如果我们想自定义元素的位置,那么可以把元素用Positioned包裹,通过指定Positioned组件的left、right、top、bottom属性即可调整元素的位置(设置left值为0、right值为0就会占满整行),通过指定Positioned的width、height即可调整元素的宽高。在水平方向上,left、right、width这三个属性不能同时都设置,都不设置、设置任意一个、任意两个都可以。在竖直方向上同理。left、right、top、bottom都可以是负数。Positioned必须用在Stack中。

Stack的fit属性表示没有定位或者部分定位的子组件如何适应,默认值为StackFit.loose,表示使用子组件大小,StackFit.expand表示扩展到Stack的大小。clipBehavior属性表示超出Stack的子组件是否被剪掉,默认值Clip.hardEdge表示被剪掉,Clip.hardEdge表示不被剪掉。

有一些组件有alignment属性,可以通过此属性调整其child在组件中的位置,比如Container组件,其alignment值如果设为Alignment.center的话,其child就会位于Container的正中间,即横向和纵向都居中。如果组件没有alignment属性,但又想设置其child的位置,则可以把该组件的child设置为一个Align组件,Align组件的child属性设置为原来的child,通过设置Align的alignment的属性调整原child的位置。Center是Align的子类,表示横向和纵向都居中。

13、AspectRatio、Card、CircleAvatar、Chip

如果想调整一个组件的宽高比,那么可以用AspectRatio包裹它,用其aspectRatio属性指定宽高比。

Chip是标签,看起来跟TextButton差不多,但是自带边框、边框自带圆角,边框内有文字,文字前后可放图标,点击后置图标可以触发事件。

label指定边框内的文字,值是任意Widget,一般是个Text。想修改文字的样式,需要用labelStyle属性,值是个TextStyle。要想在文字上下左右加点padding,需要用labelPadding属性。

想要在文字前面放一个图标,需要用avatar属性,值也是任意Widget。想要在文字后面放一个图标,需要用deleteIcon属性,值也是任意Widget。想自定义后置按钮颜色,需要用deleteIconColor属性。点击后置按钮时,触发onDeleted事件。长按后置按钮时,会弹出deleteButtonTooltipMessage。想要在后置按钮周围加padding,需要用padding属性。想自定义边框内背景颜色,需要用backgroundColor属性。利用visualDensity属性可以微调Chip的宽高。想自定义边框,需要用shape属性,值是个OutlinedBorder,如修改边框颜色为红色,边框粗细为2,边框圆角半径为20,则赋值为RoundedRectangleBorder(side: const BorderSide(color: Colors.red, width: 2), borderRadius: BorderRadius.circular(20))。注意,label、avatar、deleteIcon、labelPadding、padding都在边框内部,Chip的宽高是由内部撑起的。

14、ElevatedButton、TextButton、OutlinedButton、IconButton

ElevatedButton默认有灰色背景,child是任意Widget,可以是图标Icon,也可以是文本Text。ElevatedButton的默认形状与胶囊的俯视图类似。如果ElevatedButton没有被容器包裹,那么其宽高是根据子元素的宽高自适应的。如果被指定了宽高的Container包裹,那么如果container的宽高一样,那么ElevatedButton将变成圆形,除非利用其style属性,赋值为一个ButtonStyle,然后通过ButtonStyle的shape属性显式指定为其他形状,如指定shape值为WidgetStateProperty.all(const RoundedRectangleBorder()),那么ElevatedButton将会是正方形。如果container的宽高不一样,那么ElevatedButton依旧类似胶囊的俯视图,除非利用ButtonStyle的shape属性显式指定ElevatedButton为其他形状,如指定shape值为WidgetStateProperty.all(const RoundedRectangleBorder()),那么ElevatedButton将会是长方形。

TextButton默认背景透明,只能看见child,看不见背景、边框等,按下去,才能看到背景,才知道原来是个按钮。child是任意Widget。

OutlineButton默认背景透明且有边框,child是任意Widget。

IconButton和TextButton一样,默认背景透明,只能看见icon,看不见背景、边框等,按下去,才能看到背景,才知道原来是个按钮。icon是任意Widget。IconButton和TextButton的区别是,IconButton的icon一般是个Icon,而TextButton的child一般是个Text。

其实要实现一个效果,用哪个Button都行。各Button只是默认样式不一样,但通过配置,能达到一样的效果。

ElevatedButton、TextButton、OutlinedButton都有一个icon()构造方法,可用于实现在一个按钮中,左侧是图标,右侧是文本的效果。用icon属性指定图标,用label属性指定文本。

使用xxxButton的style属性值ButtonStyle组件的shape属性,使其值为WidgetStateProperty.all(CircleBorder())。还可以通过CircleBorder的side属性设置边框的颜色与宽度。如果想设置圆形按钮的大小,则需要把按钮放到Container中。xxxButton的child或icon默认是不居中的,因为ButtonStyle有一个默认的padding,如果不居中很明显的话,需要设置padding为0。若要设置xxxButton的背景色、前景色,则需要设置ButtonStyle的backgroundColor、foregroundColor,如backgroundColor值为WidgetStateProperty.all(Colors.red),foregroundColor值为WidgetStateProperty.all(Colors.white)。xxxButton左右边框默认是带弧度的,如果要自定义弧度,则需要设置ButtonStyle的shape属性,如值为WidgetStateProperty.all(RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)))。看效果默认是WidgetStateProperty.all(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20))),即不配置shape和配置shape为WidgetStateProperty.all(RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)))是一样的。BorderRadius.circular的值超过20后不生效。

默认大小的圆形按钮和自定义大小的圆形按钮示例:

复制代码
  @override
  Widget build(BuildContext context) {
    return ListView(
      children: [
        Row(
          children: [
            ElevatedButton(
              onPressed: () {},
              style: ButtonStyle(
                  shape: WidgetStateProperty.all(const CircleBorder(
                      side: BorderSide(
                color: Colors.yellow,
              )))),
              child: const Text("圆形按钮"),
            ),
            Container(
              width: 120,
              height: 120,
              child: ElevatedButton(
                onPressed: () {},
                style: ButtonStyle(
                    shape: WidgetStateProperty.all(const CircleBorder(
                        side: BorderSide(
                  color: Colors.yellow,
                )))),
                child: const Text("自定义大小圆形按钮"),
              ),
            ),
          ],
        ),
      ],
    );
  }
复制代码

15、Wrap

Wrap是一行或一列,默认是一行。这个行和Row的区别是在Row中放很多元素时,如果超过屏幕宽度,会溢出,而Wrap不会,Wrap会自动换行。Wrap和横向的ListView也不一样,横向的ListView在超出屏幕宽度时,会有滚动条,而Wrap不会有滚动条,会换行。

Wrap是不会溢出的,也是不会有滚动条的。即使换了很多行,超过了屏幕的高度,也不会溢出,也不会有滚动条,超出屏幕的就看不到了。

利用Wrap的spacing属性可以调整在水平方向上元素的间距,默认没有间距。利用Wrap的runSpacing属性可以调整行间距,默认没有间距。

16、StatefulWidget

17、Scaffold之bottomNavigationBar

先讲下Scaffold。在build()方法中,如果不返回Scaffold,那么整个屏幕的背景色都会是黑色的。Scaffold有很多属性,如appBar用于状态栏、顶部导航栏的布局,body用于屏幕主体部分的布局,floatingActionButton用于展示一个浮动按钮,drawer、endDrawer用于左、右侧边栏的布局,bottomNavigationBar用于底部导航栏的布局,backgroundColor用于设置body的背景色,虽然注解说是整个Scaffold的背景色,但实测只能控制body的背景色。默认情况下,body是从appBar下面开始布局的,如果想让body从屏幕顶部开始布局,则可以利用extendBodyBehindAppBar属性,设为true即可。此外,为了实现appBar不遮挡顶部的body,还需要让appBar有透明度,如把背景色设为Colors.xxx.withOpacity(xxx),0是透明,1是不透明。特殊地,如果body是一个ListView的话,body是不会从屏幕顶部开始的,哪怕ListView用Container包裹。如果想实现ListView的效果,可以用SingleChildScrollView替代,让其child是一个Column即可。

想要添加底部导航栏,就要用到Scaffold的bottomNavigationBar属性,值是任意Widget,可以赋值为一个BottomNavigationBar实例。BottomNavigationBar实例有几个属性要配置:

items,值是BottomNavigationBarItem集合。BottomNavigationBarItem的icon属性是底部导航栏选项展示的图标,label属性值是底部导航栏选项展示的文案。

currentIndex,是选中的导航栏选项的索引,默认是0,表示默认选中第一个选项。

selectedItemColor,导航栏的选项被选中后,图标和文案变成的颜色。

type,当导航栏有3个以上选项时,必须设置为BottomNavigationBarType.fixed,否则只会展示第一个选项,后面的展示不出来。

onTap,导航栏选中回调函数,入参是选中的选项的索引,通过这个函数,应用可以知道是哪个选项被用户选中了,进而改变该选项的颜色,切换页面等。

Scaffold有了底部导航栏后,其body属性值应是根据选中的导航栏选项变化的,可以赋值为pages[i],pages的值是底部导航栏对应的页面集合,i是onTap对应的回调函数的入参。底部导航栏不支持页面的左右滑动。

18、Scaffold之floatingActionButton属性。FloatingActionButton组件。

要想在底部添加一个浮动按钮,就要用到Scaffold的floatingActionButton属性。值是任意Widget,可以赋值为一个FloatingActionButton实例,也可以用其他组件。可以通过Scaffold的floatingActionButtonLocation属性调整floatingActionButton的位置,默认是FloatingActionButtonLocation.endFloat,在右下角,在底部导航栏的上面。如果值为FloatingActionButtonLocation.centerDocked,则在底部正中间位置,在底部导航栏的上面。如果其位置需要微调,则我们可以把floatingActionButton指定为一个Container,把FloatingActionButton放到Container里面,通过调整Container的padding和margin来微调FloatingActionButton的位置。同时,Container和FloatingActionButton都可以指定背景色,可以一样,也可以不一样,具体看需求。

下例是参考闲鱼App的底部导航栏的布局:底部导航栏一共有5个选项,正中间选项的图标被圆形的浮动按钮盖住,浮动按钮外部环绕一圈白色区域。浮动按钮在未被选中时是黄色,被选中后变为红色,且页面也要切换到对应页面。

复制代码
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: pages[_currentIndex],
      bottomNavigationBar: BottomNavigationBar(
          type: BottomNavigationBarType.fixed,
          selectedItemColor: Colors.red,
          onTap: (i) {
            _currentIndex = i;
            setState(() {});
          },
          currentIndex: _currentIndex,
          items: const [
            BottomNavigationBarItem(icon: Icon(Icons.home), label: "首页"),
            BottomNavigationBarItem(icon: Icon(Icons.category), label: "分类"),
            BottomNavigationBarItem(icon: Icon(Icons.message), label: "消息"),
            BottomNavigationBarItem(icon: Icon(Icons.settings), label: "设置"),
            BottomNavigationBarItem(icon: Icon(Icons.people), label: "用户")
          ]),
      floatingActionButton: Container(
        width: 60,
        height: 60,
        decoration: const BoxDecoration(
          shape: BoxShape.circle,
          color: Colors.white,
        ),
        padding: const EdgeInsets.all(4),
        margin: const EdgeInsets.only(top: 9),
        child: FloatingActionButton(
          backgroundColor: _currentIndex == 2 ? Colors.red : Colors.yellow,
          // 浮动按钮在未点击时,背景色是黄色,点击时变红
          shape: const CircleBorder(),
          onPressed: () {
            _currentIndex = 2;
            setState(() {});
          },
          child: const Icon(
            Icons.add,
            color: Colors.white,
          ),
        ),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
    );
  }
复制代码

上例中,就用Container包裹了FloatingActionButton,然后通过Container的padding、margin属性对FloatingActionButton的位置进行了微调。Container的padding属性是调整FloatingActionButton与Container四个边框的距离,如果padding大,那么FloatingActionButton所占区域就小。Container的margin属性是调整Container的位置,进而调整FloatingActionButton的位置。

19、Scaffold之drawer属性。Drawer、DrawerHeader、UserAccountsDrawerHeader组件。

要想在顶部添加侧边栏,需要用Scaffold的drawer、endDrawer属性,分别用于添加左侧边栏、右侧边栏。所谓侧边栏,即看起来是三条横线,点击后会滑出一个页面,用手从屏幕左边框往右滑,或者从屏幕右边框往左滑,也会出现这个页面。以左侧边栏为例,点击那三条横线,会从屏幕左侧滑出一个页面,用手从屏幕左边框往右滑,也可滑出此页面。如果没有设置appBar,那么侧边栏的三条横线会看不见,只能用手滑出来。drawer属性值是任意Widget,可以赋值为一个Drawer实例,也可以用其他组件。Drawer实例的child也支持任意组件。我们可以在侧边栏实现这样的效果http://oss.echowa.xndm.tech/test/op/fQ6NpA9Wetf5LcV3tnv3y8.png,即侧边栏中,顶部有个背景图,背景图上面放圆形头像、昵称、邮箱,背景图下面有两行,每一行都是左边是圆形图标,右边是一段文字。顶部其实是个DrawerHeader,我们可以给Drawer的child属性赋值为一个Column,Column第一个元素是一个DrawerHeader,第二、三个元素均是一个ListTile。见如下示例:

复制代码
      drawer: const Drawer(
        child: Column(
          children: [
            UserAccountsDrawerHeader(
              accountName: Text("张三"),
              accountEmail: Text("xxx@qq.com"),
              currentAccountPicture: CircleAvatar(
                backgroundImage:
                    NetworkImage("https://www.itying.com/images/flutter/1.png"),
              ),
              decoration: BoxDecoration(
                image: DecorationImage(
                  image: NetworkImage(
                      "https://www.itying.com/images/flutter/2.png"),
                  fit: BoxFit.cover,
                ),
              ),
            ),
            ListTile(
              leading: CircleAvatar(
                child: Icon(
                  Icons.people,
                ),
              ),
              title: Text("个人中心"),
            ),
            Divider(),
            ListTile(
              leading: CircleAvatar(
                child: Icon(
                  Icons.settings,
                ),
              ),
              title: Text("系统设置"),
            )
          ],
        ),
      ),
复制代码

上例中,DrawerHeader用的是UserAccountsDrawerHeader,这是个预定义的、布局好的DrawerHeader。如果UserAccountsDrawerHeader的布局不满足我们的需求,那么我们就得用DrawerHeader,然后自定义布局,如下示例:

复制代码
      drawer: Drawer(
        // 宽度默认304dp
        child: Column(
          children: [
            Row(
              children: [
                Expanded(
                  child: DrawerHeader(
                    decoration: const BoxDecoration(
                      image: DecorationImage(
                        image: NetworkImage(
                            "https://www.itying.com/images/flutter/2.png"),
                        fit: BoxFit.cover,
                      ),
                    ),
                    padding: EdgeInsets.zero,
                    child: Container(
                        padding: const EdgeInsets.only(left: 20),
                        child: const Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            CircleAvatar(
                              radius: 40,
                              backgroundImage: NetworkImage(
                                  "https://www.itying.com/images/flutter/1.png"),
                            ),
                            SizedBox(
                              height: 20,
                            ),
                            Text(
                              "张三",
                              style: TextStyle(color: Colors.white),
                            ),
                            Text(
                              "xxx@qq.com",
                              style: TextStyle(color: Colors.white),
                            ),
                          ],
                        )),
                  ),
                )
              ],
            ),
            const ListTile(
              leading: CircleAvatar(
                child: Icon(
                  Icons.people,
                ),
              ),
              title: Text("个人中心"),
            ),
            const Divider(),
            const ListTile(
              leading: CircleAvatar(
                child: Icon(
                  Icons.settings,
                ),
              ),
              title: Text("系统设置"),
            )
          ],
        ),
      ),
复制代码

逻辑仍是给Drawer的child属性赋值为一个Column,里面有3个元素,第一个元素是DrawerHeader,第二、三个元素是ListTile。而之所以把DrawerHeader用Expanded包裹并放到Row中,是为了把DrawerHeader在水平方向上铺满(304dp),否则DrawerHeader的宽度是由其child的宽度和padding指定的left+right决定的,如果不人为指定等于Drawer的宽度的话,会出现Drawer内部右侧有一部分区域没有被背景图片或背景色覆盖的情况。

20、Scaffold的appBar属性。AppBar、TabBar、TabBarView组件

如果想设置顶部导览栏,就需要用到Scaffold的appBar属性,值是个PreferredSizeWidget,一般赋值为一个AppBar实例。AppBar的默认高度是56dp,由于其没有height属性,所以如果想要调整高度,则需要用一个PreferredSize来包裹这个AppBar,通过设置PreferredSize的preferredSize属性进而设置AppBar的高度。AppBar有很多属性可以使用,利用其backgroundColor属性可以为其设置背景颜色,这样我们可以看出AppBar所占的区域。利用其title属性可以给页面加标题,值可以是任意Widget。利用其titleSpacing属性可以修改title的位置,默认前后空16px,使得title不能对齐左边屏幕。利用其centerTitle属性可以配置title是否居中显示。利用其leading属性可以在title左边添加一个组件,leading组件宽、高默认都是56dp,如果想调整,需要用container包裹,然后给container设置宽高。利用其actions属性可以在title右边添加多个组件,这两种组件通常为IconButton,可以配置点击事件。利用其elevation属性可以设置阴影度。利用其bottom属性可以在title下一行添加一些tab,实现顶部导航栏的效果。如今日头条首页,从上往下看,最开始是一片红色背景的区域,正中间有一个搜索框,搜索框右边有个发布按钮,红色区域下面有一排tab,在tab下面的页面上用手左右滑动,页面的内容会变化,tab也会自动切换,选中的tab的颜色为红色,未选中的tab的颜色为黑色,选中的tab下面有一个红色的小横线。直接用手点击某一个tab,该tab会被选中,tab和下面的小横线的颜色都会变红,页面内容也变成这个tab相关的内容。

给AppBar实例的bottom属性赋值为一个TabBar实例,给TabBar实例的tabs属性赋值为一个列表,里面元素是包裹了Text的Tab实例。TabBar实例必须指定controller属性,否则run时会报错。给TabBar的controller属性赋值为一个TabController实例。创建TabController实例时必须指定其length属性和vsync属性,前者就是tab的个数,后者是一个TickerProvider实例,我们让当前xxxState混入SingleTickerProviderStateMixin,给vsync赋值为this即可。如此,在页面顶部就会有一排tab,用手点击某个tab时,该tab和下面的小横线会变蓝,但是tab列表不可滑动。我们可以用TabBar的labelColor属性调整选中的tab的颜色、用TabBar的unselectedLabelColor属性调整未选中的tab的颜色、用TabBar的tabAlignment属性调整tab的对齐方式,值为TabAlignment.start表示左对齐。用TabBar的indicatorColor属性调整小横线的颜色、用TabBar的indicatorWeight属性调整小横线的粗细、用TabBar的indicatorSize属性调整小横线的长度,用TabBar的indicatorPadding属性调整小横线与tab的间距。其实,小横线在flutter中用indicator表示,翻译为指示器。默认情况下,tab会全部展示在屏幕中,如果tab很多,那么tab的文本就会被挤成多行,比较难看,此时,我们可以设置TabBar的isScrollable属性值为true,这样,在屏幕中只会展示一部分tab,其他的tab我们可以左右滑动tab列表查看。我们可以在initState()方法中调用TabController实例的addListener()方法,添加监听器,监听点击选中或者滑到的tab

    tabController.addListener(() {
      if (tabController.animation?.value == tabController.index) {
        print("controller index:${tabController.index}");
      }
    });

要想实现【点击某tab,页面跟着变化】,需要指定Scaffold的body为一个TabBarView实例,在TabBarView的children中放页面集,有多少个tab,就放多少个页面。还必须指定TabBarView的controller属性,否则run时会报错,赋值为上面TabBar用到的TabController实例,即和TabBar的controller共用一个TabController实例,如此,tab和下面的页面才会协调。所以,TabController实例需要是个全局变量,在xxxState中定义,在initState()方法中实例化,在dispose()方法中销毁。

代码示例如下:

复制代码
    return Scaffold(
        appBar: AppBar(
          backgroundColor: Colors.red,
          leading: IconButton(
            icon: const Icon(Icons.menu),
            onPressed: () {
              print("左侧按钮图标");
              Scaffold.of(context).openDrawer();
            },
            tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
          ),
          actions: [
            IconButton(
              icon: const Icon(Icons.more_horiz),
              onPressed: () {
                print("更多");
                Scaffold.of(context).openDrawer();
              },
              tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
            )
          ],
          bottom: TabBar(
            onTap: (i) {
              print("i:$i");
            },
            tabs: const [
              Tab(child: Text("关注")),
              Tab(
                child: Text("推荐"),
              ),
              Tab(child: Text("热榜")),
              Tab(child: Text("发现")),
              Tab(
                child: Text("深圳"),
              ),
              Tab(child: Text("岛屿读书")),
            ],
            tabAlignment: TabAlignment.start,
            isScrollable: true,
            indicatorColor: Colors.yellow,
            indicatorPadding: const EdgeInsets.all(5),
            indicatorSize: TabBarIndicatorSize.tab,
            controller: tabController,
            labelColor: Colors.yellow,
            unselectedLabelColor: Colors.black,
          ),
        ),
        body: TabBarView(
          controller: tabController,
          children: const [
            Text("我是关注"),
            Text("我是推荐"),
            Text("我是热榜"),
            Text("我是发现"),
            Text("我是深圳"),
            Text("我是岛屿读书"),
          ],
        ));
复制代码

上例的效果和今日头条的效果有一点不同,那就是在上例中各tab在红色区域里面,而今日头条的各tab在红色区域下面。

为此,我们把TabBar放到title中。

我们最终实现的效果是:应用一打开,就去tab页,在tab页有底部导航栏,第一个底部导航栏是首页,首页和今日头条首页一样,有顶部导航栏。

tab页:

复制代码
import 'package:flutter/material.dart';

import './category.dart';
import './home.dart';
import './message.dart';
import './setting.dart';
import './user.dart';

class TabPage extends StatefulWidget {
  const TabPage({super.key});

  @override
  State<TabPage> createState() => _TabPageState();
}

class _TabPageState extends State<TabPage> {
  int _currentIndex = 0;

  final List<Widget> pages = const [
    HomePage(),
    CategoryPage(),
    MessagePage(),
    SettingPage(),
    UserPage(),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Tab页"),
        backgroundColor: Colors.red,
      ),
      body: pages[_currentIndex],
      bottomNavigationBar: BottomNavigationBar(
          type: BottomNavigationBarType.fixed,
          selectedItemColor: Colors.red,
          onTap: (i) {
            _currentIndex = i;
            setState(() {});
          },
          currentIndex: _currentIndex,
          items: const [
            BottomNavigationBarItem(icon: Icon(Icons.home), label: "首页"),
            BottomNavigationBarItem(icon: Icon(Icons.category), label: "分类"),
            BottomNavigationBarItem(icon: Icon(Icons.message), label: "消息"),
            BottomNavigationBarItem(icon: Icon(Icons.settings), label: "设置"),
            BottomNavigationBarItem(icon: Icon(Icons.people), label: "用户")
          ]),
    );
  }
}
复制代码

home页:

复制代码
import 'package:flutter/material.dart';

import '../tool/keep_alive_wrapper.dart';

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage>
    with SingleTickerProviderStateMixin {
  late TabController tabController;

  @override
  void initState() {
    super.initState();
    tabController = TabController(length: 6, vsync: this);
    tabController.addListener(() {
      if (tabController.animation?.value == tabController.index) {
        print("controller index:${tabController.index}");
      }
    });
  }

  @override
  void dispose() {
    super.dispose();
    tabController.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: PreferredSize(
            // 自定义AppBar的高度
            preferredSize: const Size.fromHeight(56),
            child: AppBar(
              elevation: 1,
              backgroundColor: Colors.white,
              titleSpacing: 0,
              // 自定义title的高度
              title: SizedBox(
                height: 30,
                child: TabBar(
                  tabs: const [
                    Tab(child: Text("关注")),
                    Tab(
                      child: Text("推荐"),
                    ),
                    Tab(child: Text("热榜")),
                    Tab(child: Text("发现")),
                    Tab(
                      child: Text("深圳"),
                    ),
                    Tab(child: Text("岛屿读书")),
                  ],
                  isScrollable: true,
                  tabAlignment: TabAlignment.start,
                  indicatorColor: Colors.red,
                  indicatorSize: TabBarIndicatorSize.label,
                  controller: tabController,
                  labelColor: Colors.red,
                  unselectedLabelColor: Colors.black,
                ),
              ),
            )),
        body: TabBarView(
          controller: tabController,
          children: [
            KeepAliveWrapper(
              child: ListView(children: const [
                ListTile(title: Text("我关注1")),
                ListTile(title: Text("我关注2")),
                ListTile(title: Text("我关注3")),
                ListTile(title: Text("我关注4")),
                ListTile(title: Text("我关注5")),
                ListTile(title: Text("我关注6")),
                ListTile(title: Text("我关注7")),
                ListTile(title: Text("我关注8")),
                ListTile(title: Text("我关注9")),
                ListTile(title: Text("我关注10")),
                ListTile(title: Text("我关注11")),
                ListTile(title: Text("我关注12")),
                ListTile(title: Text("我关注13")),
                ListTile(title: Text("我关注14")),
                ListTile(title: Text("我关注15")),
                ListTile(title: Text("我关注16")),
                ListTile(title: Text("我关注17")),
              ]),
            ),
            const Text("我是推荐"),
            const Text("我是热榜"),
            const Text("我是发现"),
            const Text("我是深圳"),
            const Text("我是岛屿读书"),
          ],
        ));
  }
}
复制代码

如上,首页和今日头条的首页不一样的一点是,我们的小横条比今日头条的粗。自己调低TabBar的indicatorWeight属性值也不管用。不知道怎么搞。页面拆解:顶部appBar、底部导航栏是tab页面的,中间的tab和内容,是home页。

21、自定义KeepAliveWrapper

上例中用到了KeepAliveWrapper,这是个自定义的类,用于缓存页面。当在多个tab间切换时,假如在tab1的页面浏览到了一定位置,切到其他tab,一顿乱切,最终又切回tab1,默认情况下,tab1的页面浏览的位置会重置,即tab1的页面会从头开始展示。而我们用KeepAliveWrapper包裹ListView后,在切回tab1的时候,就可以看到之前在tab1的页面浏览的位置。KeepAliveWrapper只有搭配TabBarView或者PageView时才生效,其他情况下不生效。

keep_alive_wrapper.dart代码如下:

复制代码
import 'package:flutter/material.dart';

class KeepAliveWrapper extends StatefulWidget {
  const KeepAliveWrapper(
      {super.key, @required this.child, this.keepAlive = true});

  final Widget? child;
  final bool keepAlive;

  @override
  State<KeepAliveWrapper> createState() => _KeepAliveWrapperState();
}

class _KeepAliveWrapperState extends State<KeepAliveWrapper>
    with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return widget.child!;
  }

  @override
  bool get wantKeepAlive => widget.keepAlive;

  @override
  void didUpdateWidget(covariant KeepAliveWrapper oldWidget) {
    if (oldWidget.keepAlive != widget.keepAlive) {
      updateKeepAlive();
    }
    super.didUpdateWidget(oldWidget);
  }
}
复制代码

22、普通路由

23、命名路由

24、路由跳转、路由替换

25、Dialog、AlertDialog、SimpleDialog、showModalBottomSheet、showToast、showMenu

AlertDialog可以用来实现提示框(样式如删除确认对话框),SimpleDialog可以用来实现select选择框,showModalBottomSheet函数可以用来实现底部弹框。有一个开源的第三方包fluttertoast,也可以用来实现底部弹框。

showMenu函数可以实现弹出下拉菜单的效果。items属性是菜单集合,集合元素类型是PopupMenuItem。position属性用于设置菜单出现的位置,值可以是RelativeRect.fromLTRB()。

26、自定义Dialog

27+28、PageView、AutomaticKeepAliveClientMixin

PageView可以用来实现滚屏和轮播图。所谓滚屏,指的是像抖音推荐页那种可以一直往下滑。

PageView的children里面有多少元素,就代表有多少屏,可以用手左右滑或上下滑来切屏。scrollDirection属性决定了滚屏的方向,默认是左右滚屏,即默认展示第一屏,用手往左滑,会切到第二屏、第三屏,直到最后一屏。scrollDirection设置成Axis.vertical,则会上下滚屏,用手往上滑,会切到第二屏、第三屏,直到最后一屏。利用onPageChanged属性可配一个回调函数,入参是切到的屏的索引,以对切屏进行监听。如果想让pageView切到特定一屏,则需要利用其controller属性,赋值为一个PageController实例,调用pageController的jumpToPage()方法或者animateToPage()即可,传入要切的屏的索引,前者直接切过去,后者带动画。curve可以赋值为Curves.ease,直接在Curves后面打点可以看到很多动画效果。

利用构造函数生成的PageView实例不支持从最后一屏直接滑到第一屏,想要实现此功能,必须得用PageView的builder()命名构造器生成PageView实例,此时需要在itemBuilder函数中指定组件。itemBuilder函数第二个参数是左滑的步数,我们可以通过步数对屏数取余来拿到屏的索引,进而返回对应这个索引的屏,如返回某一张图片,返回某个page。用itemCount指定步数的最大值,如果itemCount等于屏数,那么滑到最后一屏时,再往左滑就滑不动了。如果itemCount比屏数大1,那么从最后一屏可以滑到第一屏,但是再往左滑,就滑不动了。不指定itemCount,则可以无限滑。在往左滑时,onPageChanged函数的入参index会从1开始一直递增,即使从最后一屏滑到第一屏,index也不会变成0,而是继续加1。假如我们有3屏,或者说要轮播的图片有3个,那么从第一屏滑到第二屏、从第二屏滑到第三屏时,onPageChanged函数中入参index的值依次为1、2,而从第三屏滑到第一屏时,onPageChanged函数中入参index的值为3,而不是0。也就是说,这个入参index,不是屏的索引,而是往左滑的步数,即往左滑就加1,往右滑就减1,和屏的索引是两码事。假如我们的轮播图底部有指示器,那么指示器的个数得和屏数一样。如果要让当前屏对应的指示器的颜色为红色,其他指示器的颜色为白色,那么在配置指示器的颜色属性时可以使用三目表达式,如果指示器的索引等于当前屏的索引,则为红色,否则为白色。

要想让轮播图底部有指示器,如小圆形,需要用Stack,其children中第一个元素是用Container包裹的PageView(用Container包裹PageView,主要是为了限定其高度,否则其高度就等于屏幕的高度,这明显不合适),第二个元素是一个Positioned,里面包裹一个Row,Row里面放一定数量的小圆形。为了让小圆形在水平方向上居中,需要设置Row的主轴对齐方式为居中,且Positioned的left、right属性均需要设置为0(Row在被Positioned包裹时,当设置了Positioned的top、bottom、left、right这四个属性中的任何属性时,Row的mainAxisSize都会失效,此时Row的宽度取决于Positioned的left、right、width)。为了让小圆形在竖直方向上靠近Container的底部,需要适当调整Positioned的bottom属性值。

如果想让轮播图自动滚,那么需要创建一个定时器,当到了时间后,根据当前屏的索引判断当前屏是否是最后一屏,如果是,就跳到第一屏,否则跳到下一屏。

pageView各屏的数据也可以缓存,用KeepAliveWrapper包裹各屏的组件就好了。

其实,我们可以用现成的组件来实现轮播图,flutter_swiper_view。参考文中示例即可快速实现一个轮播图。轮播图默认的指示器是圆点,如果想要变成横线,则需要给SwiperPagination的builder属性赋值为一个RectSwiperPaginationBuilder实例,通过这个实例的activeColor属性和color属性设置选中和未选中的指示器的颜色。

29、key

local key有ValueKey、UniqueKey。ValueKey的值不能相同,否则在运行时会报Duplicate keys found。UniqueKey会随机生成一个唯一值。

global key有GlobalKey。GlobalKey自动全局唯一。globalKey有currentWidget、currentState、currentContext属性。

调用globalKey的currentWidget属性可以获取对应组件,需要把返回值强转为组件类型,也就是那个我们自定义的StatelessWidget或StatefulWidget的子类类型,如var box = gk1.currentWidget as Box,这个用处比较小。

调用globalKey的currentState属性可以获取对应组件的状态,需要把返回值强转为组件状态类型,也就是那个我们自定义的StatefulWidget的子类衍生的_xxxState,如var boxState = gk1.currentState as _BoxState,拿到状态的实例后,就可以访问_xxxState的属性和方法了。

调用globalKey的currentContext属性,可以有很多用途:

①用于Scrollable的区域跳转。如上述讲解。

②获取对应组件的宽高、位置。获取宽高时,既可以用gk1.currentContext?.size拿到Size实例,进而获取其宽高,又可以通过gk1.currentContext?.findRenderObject()拿到RenderObject实例,然后强转为RenderBox类型,然后调用其localToGlobal(Offset.zero)方法获取Offset实例,进而获取其宽高。通过Offset实例还可以获取其位置。注意,获取宽高、位置时,需要在组件加载好之后才行,否则gk1.currentContext?.size、gk1.currentContext?.findRenderObject()均会返回null。比如,在initState()方法或onInit()方法或onReady()方法中调用,就会返回null。可以在监听中调用,如对滚动条的监听。

          var renderBox = gk1.currentContext?.findRenderObject() as RenderBox;
          print(renderBox.size.width);
          print(renderBox.size.height);
          print(renderBox.localToGlobal(Offset.zero).dx);
          print(renderBox.localToGlobal(Offset.zero).dy);

renderBox.size.width获取组件的宽,renderBox.size.height获取组件的高,renderBox.localToGlobal(Offset.zero).dx获取组件与屏幕左侧的间距,renderBox.localToGlobal(Offset.zero).dy获取组件与屏幕顶部的间距。

30、AnimatedList

MediaQuery.of(context).size,可以获得设备屏幕的宽高。

MediaQuery.of(context).orientation,可以看设备是横屏还是竖屏。比如红米手机,正常情况下返回竖屏,旋转屏幕后,返回横屏。

 

posted on   koushr  阅读(73)  评论(0编辑  收藏  举报

相关博文:
阅读排行:
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效
点击右上角即可分享
微信分享提示