Flutter 笔记 | Flutter 布局组件

布局类组件都会包含一个或多个子组件,布局类组件都是直接或间接继承SingleChildRenderObjectWidgetMultiChildRenderObjectWidget的Widget,它们一般都会有一个childchildren属性用于接收子 Widget。

不同的布局类组件对子组件排列(layout)方式不同,如下表所示:

Widget 说明 用途
LeafRenderObjectWidget 非容器类组件基类 Widget树的叶子节点,用于没有子节点的widget,通常基础组件都属于这一类,如Image
SingleChildRenderObjectWidget 单子组件基类 包含一个子Widget,如:ConstrainedBoxDecoratedBox
MultiChildRenderObjectWidget 多子组件基类 包含多个子Widget,一般都有一个children参数,接受一个Widget数组。如Row、Column、Stack

我们看一下继承关系 Widget > RenderObjectWidget > (Leaf/SingleChild/MultiChild) RenderObjectWidget

RenderObjectWidget类中定义了创建、更新RenderObject的方法,子类必须实现他们,关于RenderObject我们现在只需要知道它是最终布局、渲染UI界面的对象即可,也就是说,对于布局类组件来说,其布局算法都是通过对应的RenderObject对象来实现的,所以如果对某个布局类组件的原理感兴趣,可以查看其对应的RenderObject的实现,比如Stack(层叠布局)对应的RenderObject对象就是RenderStack,而层叠布局的实现就在RenderStack中。

尺寸约束类布局

尺寸限制类容器用于限制容器大小,Flutter中提供了多种这样的容器,如ConstrainedBoxSizedBoxUnconstrainedBoxAspectRatio 等。

Box布局模型

Flutter 中有两种布局模型:

  1. 基于 RenderBox 的盒模型布局。
  2. 基于 Sliver ( RenderSliver ) 按需加载列表布局。

两种布局方式在细节上略有差异,但大体流程相同,布局流程如下:

  1. 上层组件向下层组件传递约束(constraints)条件。
  2. 下层组件确定自己的大小,然后告诉上层组件。注意下层组件的大小必须符合父组件的约束。
  3. 上层组件确定下层组件相对于自身的偏移和确定自身的大小(大多数情况下会根据子组件的大小来确定自身的大小)。

比如,父组件传递给子组件的约束是“最大宽高不能超过100,最小宽高为0”,如果我们给子组件设置宽高都为200,则子组件最终的大小是100*100,因为任何时候子组件都必须先遵守父组件的约束,在此基础上再应用子组件约束(相当于父组件的约束和自身的大小求一个交集)。

盒模型布局组件有两个特点:

  1. 组件对应的渲染对象都继承自 RenderBox 类。
  2. 在布局过程中父级传递给子级的约束信息由 BoxConstraints 描述。

BoxConstraints

BoxConstraints 是盒模型布局过程中父级组件传递给子组件的约束信息,用来描述子组件可用的空间范围,它包含最小和最大宽高信息,子组件大小需要在约束的范围内,BoxConstraints 默认的构造函数如下:

const BoxConstraints({
    
    
  this.minWidth = 0.0, //最小宽度
  this.maxWidth = double.infinity, //最大宽度
  this.minHeight = 0.0, //最小高度
  this.maxHeight = double.infinity //最大高度
})

它包含 4 个属性,BoxConstraints还定义了一些便捷的构造函数,用于快速生成特定限制规则的BoxConstraints,如BoxConstraints.tight(Size size),它可以生成固定宽高的限制;BoxConstraints.expand()可以生成一个尽可能大的用以填充另一个容器的BoxConstraints

约定:为了描述方便,如果我们说一个组件不约束其子组件或者取消对子组件约束时是指对子组件约束的最大宽高为无限大,而最小宽高为0,相当于子组件完全可以自己根据需要的空间来确定自己的大小。

ConstrainedBox

ConstrainedBox用于对子组件添加额外的约束。例如,如果你想让子组件的最小高度是80像素,你可以使用const BoxConstraints(minHeight: 80.0)作为子组件的约束。

示例

我们先定义一个redBox,它是一个背景颜色为红色的盒子,不指定它的宽度和高度:

Widget redBox = DecoratedBox(
  decoration: BoxDecoration(color: Colors.red),
);

我们实现一个最小高度为50,宽度尽可能大的红色容器。

ConstrainedBox(
  constraints: BoxConstraints(
    minWidth: double.infinity, //宽度尽可能大
    minHeight: 50.0 //最小高度为50像素
  ),
  child: Container(
    height: 5.0, 
    child: redBox ,
  ),
)

效果:

在这里插入图片描述
可以看到,我们虽然将Container的高度设置为5像素,但是最终却是50像素,这正是ConstrainedBox最小高度限制生效了。如果将Container的高度设置为80像素,那么最终红色区域的高度也会是80像素,因为在此示例中,ConstrainedBox只限制了最小高度,并未限制最大高度

SizedBox

SizedBox用于给子元素指定固定的宽高,如:

SizedBox(
  width: 80.0,
  height: 80.0,
  child: redBox
)

效果:
在这里插入图片描述

实际上SizedBox只是ConstrainedBox的一个定制,上面代码等价于:

ConstrainedBox(
  constraints: BoxConstraints.tightFor(width: 80.0,height: 80.0),
  child: redBox, 
)

BoxConstraints.tightFor(width: 80.0,height: 80.0)等价于:

BoxConstraints(minHeight: 80.0,maxHeight: 80.0,minWidth: 80.0,maxWidth: 80.0)

而实际上ConstrainedBoxSizedBox都是通过RenderConstrainedBox来渲染的,我们可以看到ConstrainedBoxSizedBoxcreateRenderObject()方法都返回的是一个RenderConstrainedBox对象:


RenderConstrainedBox createRenderObject(BuildContext context) {
    
    
  return RenderConstrainedBox(
    additionalConstraints: ...,
  );
}

多重限制

如果某一个组件有多个父级ConstrainedBox限制,那么最终会是哪个生效?我们看一个例子:

ConstrainedBox(
  constraints: BoxConstraints(minWidth: 60.0, minHeight: 60.0), //父
  child: ConstrainedBox(
    constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0),//子
    child: redBox,
  ),
)

上面我们有父子两个ConstrainedBox,他们的约束条件不同,运行效果:
在这里插入图片描述
最终显示效果是宽90,高60,也就是说是子ConstrainedBoxminWidth生效,而minHeight是父ConstrainedBox生效。单凭这个例子,我们还总结不出什么规律,我们将上例中父子约束条件换一下:

ConstrainedBox(
  constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0),
  child: ConstrainedBox(
    constraints: BoxConstraints(minWidth: 60.0, minHeight: 60.0),
    child: redBox,
  )
)

运行效果:

在这里插入图片描述

最终的显示效果仍然是90,高60,效果相同,但意义不同,因为此时minWidth生效的是父ConstrainedBox,而minHeight是子ConstrainedBox生效。

通过上面示例,我们发现有多重限制时,对于minWidthminHeight来说,是取父子中相应数值较大的。实际上,只有这样才能保证父限制与子限制不冲突。

UnconstrainedBox

虽然任何时候子组件都必须遵守其父组件的约束,但前提条件是它们必须是父子关系,假如有一个组件 A,它的子组件是BB 的子组件是 C,则 C 必须遵守 B 的约束,同时 B 必须遵守 A 的约束,但是 A 的约束不会直接约束到 C,除非BA对它自己的约束透传给了C。 利用这个原理,就可以实现一个这样的 B 组件:

  1. B 组件中在布局 C 时不约束C(可以为无限大)。
  2. C 根据自身真实的空间占用来确定自身的大小。
  3. B 在遵守 A 的约束前提下结合子组件的大小确定自身大小。

而这个 B 组件就是 UnconstrainedBox 组件,也就是说UnconstrainedBox 的子组件将不再受到约束,大小完全取决于自己。一般情况下,我们会很少直接使用此组件,但在 "去除"多重限制 的时候也许会有帮助,我们看下下面的代码:

ConstrainedBox(
  constraints: BoxConstraints(minWidth: 60.0, minHeight: 100.0),  //父
  child: UnconstrainedBox( //“去除”父级限制
    child: ConstrainedBox(
      constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0),//子
      child: redBox,
    ),
  )
)

上面代码中,如果没有中间的UnconstrainedBox,那么根据上面所述的多重限制规则,那么最终将显示一个90×100的红色框。但是由于UnconstrainedBox去除”了父ConstrainedBox的限制,则最终会按照子ConstrainedBox的限制来绘制redBox,即90×20,如图所示:

在这里插入图片描述

但是,请注意,UnconstrainedBox对父组件限制的“去除”并非是真正的去除:上面例子中虽然红色区域大小是90×20,但上方仍然有80的空白空间。也就是说父限制的minHeight(100.0)仍然是生效的,只不过它不影响最终子元素redBox的大小,但仍然还是占有相应的空间,可以认为此时的父ConstrainedBox是作用于子UnconstrainedBox上,而redBox只受子ConstrainedBox限制,这一点请务必注意。

那么有什么方法可以彻底去除父ConstrainedBox的限制吗?答案是否定的!请牢记,任何时候子组件都必须遵守其父组件的约束,所以在定义一个通用的组件时,如果要对子组件指定约束,那么一定要注意,因为一旦指定约束条件,子组件自身就不能违反约束。

在实际开发中,当我们发现已经使用 SizedBoxConstrainedBox给子元素指定了固定宽高,但是仍然没有效果时,几乎可以断定:已经有父组件指定了约束

举个例子,如 Material 组件库中的AppBar(导航栏)的右侧菜单中,我们使用SizedBox指定了 loading 按钮的大小,代码如下:

 AppBar(
   title: Text(title),
   actions: <Widget>[
     SizedBox(
       width: 20, 
       height: 20,
       child: CircularProgressIndicator(
         strokeWidth: 3,
         valueColor: AlwaysStoppedAnimation(Colors.white70),
       ),
     )
   ],
)

运行效果:

在这里插入图片描述

我们会发现右侧loading按钮大小并没有发生变化!这正是因为AppBar中已经指定了actions按钮的约束条件,所以我们要自定义loading按钮大小,就必须通过UnconstrainedBox来 “去除” 父元素的限制,代码如下:

AppBar(
  title: Text(title),
  actions: <Widget>[
    UnconstrainedBox(
      child: SizedBox(
        width: 20,
        height: 20,
        child: CircularProgressIndicator(
          strokeWidth: 3,
          valueColor: AlwaysStoppedAnimation(Colors.white70),
        ),
      ),
    )
  ],
)

运行效果:

在这里插入图片描述

可以看到生效了!实际上将 UnconstrainedBox 换成 Center 或者 Align 也是可以的。

另外,需要注意,UnconstrainedBox 虽然在其子组件布局时可以取消约束(子组件可以为无限大),但是 UnconstrainedBox 自身是受其父组件约束的,所以当 UnconstrainedBox 随着其子组件变大后,如果UnconstrainedBox 的大小超过它父组件约束时,也会导致溢出报错,比如:

Column(
  children: <Widget>[
    UnconstrainedBox(
      alignment: Alignment.topLeft,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(children: [Text('xx' * 30)]),
      ),
    ),
 ]

运行效果:

在这里插入图片描述

文本已经超过屏幕宽度,溢出了。

AspectRatio

AspectRatio的作用是可以调整子元素child的宽高比

AspectRatio首先会在布局限制条件允许的范围内尽可能的扩展,widget的高度是由宽度和比率决定的,类似于BoxFit中的contain,按照固定比率去尽量占满区域。

如果在满足所有限制条件过后无法找到一个可行的尺寸,AspectRatio最终将会去优先适应布局限制条件,而忽略所设置的比率。

属性 说明
aspectRatio 宽高比,最终可能不会根据这个值去布局,具体则要看综合因素,外层是否允许按照这种比率进行布局,这只是一个参考值
child 子组件

示例:

import 'package:flutter/material.dart';

void main() {
    
    
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
    
    
  const MyApp({
    
    Key? key}) : super(key: key);

  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
    
    
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        appBar: AppBar(title: const Text("Flutter App")),
        body: const LayoutDemo(),
      ),
    );
  }
}

// 需求:页面上显示一个容器,宽度是屏幕的宽度,高度是容器宽度的一半
class LayoutDemo extends StatelessWidget {
    
    
  const LayoutDemo({
    
    Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    
        
    return AspectRatio(
      aspectRatio: 2/1,
      child: Container(
        color: Colors.red,
      ),
    );
  }
}

在这里插入图片描述

LimitedBox

LimitedBox用于指定child的最大宽高,它可以将child限制在其设定的最大宽高中的,但是这个限定是有条件的。当LimitedBox不受父组件约束时,才会限制它的尺寸,什么叫不受父组件约束?大多数组件都会对子组件约束,没有约束的父组件有ListViewRowColumn等。如果LimitedBox的父组件受到约束,此时LimitedBox将会不做任何操作,我们可以认为没有这个组件。

例如如下代码:

  
  Widget build(BuildContext context) {
    
    
      return Center(
              child: LimitedBox(
                        maxWidth: 100,
                        maxHeight: 50,
                        child: Container(color: Colors.red, child: Text("ss"*50)),
              ),
      );
  }

在这里插入图片描述

Text组件并没有像我们想的那样被限制在100x50的区域内,这是因为Center组件对LimitedBox施加了约束,因此我们可以使用UnconstrainedBox去掉LimitedBox的父约束:

  
  Widget build(BuildContext context) {
    
    
      return Center(
              child: UnconstrainedBox(
                 child: LimitedBox(
                        maxWidth: 100,
                        maxHeight: 50,
                        child: Container(color: Colors.red, child: Text("ss"*50)),
                 )
              ),
      );
  }

此时效果如下:

在这里插入图片描述

如果我们在 ListView 中直接添加 Container 组件,如下:

ListView(
  children: <Widget>[
    Container(color: Colors.green, ),
    Container(color: Colors.red, ), 
  ], 
)

这时你会发现什么也没有,因为在容器不受约束时,大小将会设置 0,只需将 Container 包裹在 LimitedBox 中即可:

ListView(
  children: <Widget>[
    LimitedBox(
      maxHeight: 100, 
      child: Container(color: Colors.green),
    ),
    LimitedBox(
      maxHeight: 100, 
      child: Container(color: Colors.red), 
    ), 
  ], 
)

效果:

在这里插入图片描述

FractionallySizedBox

FractionallySizedBox 的作用是使子组件宽高可以根据父容器宽高的百分比来设置

示例:

class FractionallySizedBoxWidget extends StatelessWidget {
    
    
  const FractionallySizedBoxWidget({
    
    Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    
    
     return Scaffold(
       appBar: AppBar(title: const Text("FractionallySizedBox"),),
       body: Center(
         //child宽高分别是父组件宽高的80%
         child: FractionallySizedBox(
           widthFactor: 0.8,
           heightFactor: 0.8,
           child: Container(
             color: Colors.red,
           ),
         ),
       ),
     );
  }
}

效果:

在这里插入图片描述

线性布局(Row和Column)

所谓线性布局,即指沿水平或垂直方向排列子组件。Flutter 中通过RowColumn来实现线性布局,类似于Android 中的LinearLayout控件。RowColumn都继承自Flex

主轴和纵轴

对于线性布局,有主轴和纵轴之分,如果布局是沿水平方向,那么主轴就是指水平方向,而纵轴即垂直方向;如果布局沿垂直方向,那么主轴就是指垂直方向,而纵轴就是水平方向。其实纵轴就是相对于主轴方向的交叉轴。

在线性布局中,有两个定义对齐方式的枚举类MainAxisAlignmentCrossAxisAlignment,分别代表主轴对齐和交叉轴对齐。

Row

Row可以沿水平方向排列其子widget。常用属性:

属性 说明
textDirection 表示水平方向子组件的布局顺序(是从左往右还是从右往左),默认为系统当前Locale环境的文本方向(如中文、英语都是从左往右,而阿拉伯语是从右往左)
mainAxisSize 表示在主轴(水平)方向占用的空间,默认是MainAxisSize.max,表示尽可能多的占用水平方向的空间,此时无论子 widgets 实际占用多少水平空间,Row的宽度始终等于水平方向的最大宽度;而MainAxisSize.min表示尽可能少的占用水平空间,当子组件没有占满水平剩余空间,则Row的实际宽度等于所有子组件占用的的水平空间
mainAxisAlignment 表示子组件在Row的水平方向的对齐方式,如果mainAxisSize值为MainAxisSize.min,则此属性无意义,因为子组件的宽度等于Row的宽度。只有当mainAxisSize的值为MainAxisSize.max时,此属性才有意义
verticalDirection 表示Row交叉轴(垂直)的对齐方向,默认是VerticalDirection.down,表示从上到下
crossAxisAlignment 表示子组件在交叉轴方向的对齐方式,Row的高度等于子组件中最高的子元素高度,它的取值和MainAxisAlignment一样(包含start、end、 center三个值),不同的是crossAxisAlignment的参考系是verticalDirection,即verticalDirection值为VerticalDirection.downcrossAxisAlignment.start指顶部对齐,verticalDirection值为VerticalDirection.up时,crossAxisAlignment.start指底部对齐
children 子组件数组

其中 mainAxisAlignment 的取值如下:

MainAxisAlignment 说明
MainAxisAlignment.start 表示沿textDirection的初始方向对齐,如textDirection取值为TextDirection.ltr时,则MainAxisAlignment.start表示左对齐,textDirection取值为TextDirection.rtl时表示从右对齐
MainAxisAlignment.end MainAxisAlignment.start正好相反
MainAxisAlignment.center 表示居中对齐
MainAxisAlignment.spaceBetween 将空闲空间均匀地分布在子组件之间
MainAxisAlignment.spaceAround 将空闲空间均匀地分布在子组件之间,并且在第一个child之前和最后一个child之后显示均分空间的一半
MainAxisAlignment.spaceEvenly 将空闲空间均匀地分布在子组件之间,并且在第一个child之前和最后一个child之后也显示均分的空间

可以这么理解:textDirectionmainAxisAlignment的参考系。

示例:

Column(
  //测试Row对齐方式,排除Column默认居中对齐的干扰
  crossAxisAlignment: CrossAxisAlignment.start,
  children: <Widget>[
    Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Text(" hello world "),
        Text(" I am Jack "),
      ],
    ),
    Row(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        Text(" hello world "),
        Text(" I am Jack "),
      ],
    ),
    Row(
      mainAxisAlignment: MainAxisAlignment.end,
      textDirection: TextDirection.rtl,
      children: <Widget>[
        Text(" hello world "),
        Text(" I am Jack "),
      ],
    ),
    Row(
      crossAxisAlignment: CrossAxisAlignment.start,  
      verticalDirection: VerticalDirection.up,
      children: <Widget>[
        Text(" hello world ", style: TextStyle(fontSize: 30.0),),
        Text(" I am Jack "),
      ],
    ),
  ],
);

效果:

在这里插入图片描述
解释:

  • 第一个 Row 很简单,默认为居中对齐;
  • 第二个 Row ,由于mainAxisSize值为MainAxisSize.minRow 的宽度等于两个Text的宽度和,所以对齐是无意义的,所以会从左往右显示;
  • 第三个 Row 设置textDirection值为TextDirection.rtl,所以子组件会从右向左的顺序排列,而此时MainAxisAlignment.end表示左对齐,所以最终显示结果就是图中第三行的样子;
  • 第四个 Row 测试的是纵轴的对齐方式,由于两个子 Text 字体不一样,所以其高度也不同,我们指定了verticalDirection值为VerticalDirection.up,即从低向顶排列,而此时crossAxisAlignment值为CrossAxisAlignment.start表示底对齐。

Column

Column可以在垂直方向排列其子组件。参数和Row一样,不同的是布局方向为垂直。

示例:

import 'package:flutter/material.dart';

class CenterColumnRoute extends StatelessWidget {
    
    
  
  Widget build(BuildContext context) {
    
    
    return Column(
      crossAxisAlignment: CrossAxisAlignment.center,
      children: <Widget>[
        Text("hi"),
        Text("world"),
      ],
    );
  }
}

效果:

在这里插入图片描述

解释:

  • 由于我们没有指定ColumnmainAxisSize,所以使用默认值MainAxisSize.max,则Column会在垂直方向占用尽可能多的空间,此例中会占满整个屏幕高度。
  • 由于我们指定了 crossAxisAlignment 属性为CrossAxisAlignment.center,那么子项在Column交叉轴方向(水平方向)会居中对齐。注意,在水平方向对齐是有边界的,总宽度为Column占用空间的实际宽度,而实际的宽度取决于子项中宽度最大Widget。在本例中,Column有两个子Widget,而显示“world”的Text宽度最大,所以Column的实际宽度则为Text("world") 的宽度,所以居中对齐后Text("hi")会显示在Text("world")的中间部分。

实际上,RowColumn都只会在主轴方向占用尽可能大的空间,而在交叉轴的长度则取决于他们最大子元素的长度

如果我们想让本例中的两个文本控件在整个手机屏幕中间对齐,我们有两种方法:

  1. Column的宽度指定为屏幕宽度;这很简单,我们可以通过ConstrainedBoxSizedBox来强制更改宽度限制。

    例如:将ConstrainedBoxminWidth设为double.infinity,可以使宽度占用尽可能多的空间。

ConstrainedBox(	
  constraints: BoxConstraints(minWidth: double.infinity), 
  child: Column(
    crossAxisAlignment: CrossAxisAlignment.center,
    children: <Widget>[
      Text("hi"),
      Text("world"),
    ],
  ),
);

在这里插入图片描述

  1. 可以使用Center 组件

嵌套情况

如果Row里面嵌套Row,或者Column里面再嵌套Column,那么只有最外面的RowColumn会占用尽可能大的空间,里面RowColumn所占用的空间为实际大小,下面以Column为例说明:

Container(
  color: Colors.green,
  child: Padding(
    padding: const EdgeInsets.all(16.0),
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      mainAxisSize: MainAxisSize.max, //有效,外层Colum高度为整个屏幕
      children: <Widget>[
        Container(
          color: Colors.red,
          child: Column(
            mainAxisSize: MainAxisSize.max,//无效,内层Colum高度为实际高度  
            children: <Widget>[
              Text("hello world "),
              Text("I am Jack "),
            ],
          ),
        )
      ],
    ),
  ),
);

运行效果:

在这里插入图片描述

如果要让里面的Column占满外部Column,可以使用Expanded 组件:

Expanded( 
  child: Container(
    color: Colors.red,
    child: Column(
      mainAxisAlignment: MainAxisAlignment.center, //垂直方向居中对齐
      children: <Widget>[
        Text("hello world "),
        Text("I am Jack "),
      ],
    ),
  ),
)

运行效果:

在这里插入图片描述

弹性布局(Flex、Expanded)

弹性布局允许子组件按照一定比例来分配父容器空间。弹性布局的概念在其他UI系统中也都存在,如 H5 中的弹性盒子布局,Android 中的FlexboxLayout等。Flutter 中的弹性布局主要通过FlexExpanded来配合实现。

Flex

Flex组件可以沿着水平或垂直方向排列子组件,如果你知道主轴方向,可以直接使用RowColumn会方便一些,因为它们都继承自Flex,参数基本相同,所以能使用Flex的地方基本上都可以使用RowColumn

Flex本身功能是很强大的,它也可以和Expanded组件配合实现弹性布局。接下来我们只讨论Flex和弹性布局相关的属性(其他属性已经在介绍RowColumn时介绍过了)。

Flex({
    
    
  ...
  required this.direction, //弹性布局的方向, Row默认为水平方向,Column默认为垂直方向
  List<Widget> children = const <Widget>[],
})

Flex继承自MultiChildRenderObjectWidget,对应的RenderObjectRenderFlexRenderFlex中实现了其布局算法。

Flexible

Flexible 只能作为 Row、Column、Flex 的child(否则会报错,因为 RowColumn 都继承自 Flex,所以 Flexible 也可以作为它们的孩子),Flexible 组件可以控制 Row、Column、Flex 的子控件占满父控件,可利用剩余空间对占位空间进行延展。

比如,Row 中有 3 个子控件,两边的固定宽,中间的占满剩余的空间,代码如下:

Row(
 children: <Widget>[
    Container(color: Colors.blue, height: 50, width: 100, ),
    Flexible(child: Container(color: Colors.red, height: 50) ),
    Container(color: Colors.blue, height: 50, width: 100, ), 
  ], 
)

效果如图:

在这里插入图片描述

还是有 3 个子控件,希望第一个占 1/6,第二个占 2/6,第三个占 3/6,代码如下:

Column(
  children: <Widget>[
    Flexible(
      flex: 1, 
      child: Container(
        color: Colors.blue, 
        alignment: Alignment.center, 
        child: const Text('1 Flex/ 6 Total',style: TextStyle(color: Colors.white),), 
      ), 
    ),
    Flexible(
      flex: 2,
      child: Container(
        color: Colors.red, 
        alignment: Alignment.center,
        child: const Text('2 Flex/ 6 Total',style: TextStyle(color: Colors.white),),
      ),
    ),
    Flexible(
      flex: 3, 
      child: Container(
        color: Colors.green, 
        alignment: Alignment.center, 
        child: const Text('3 Flex/ 6 Total',style: TextStyle(color: Colors.white),), 
      ), 
    ), 
  ],
)

效果如图:

在这里插入图片描述

子控件占比 = 当前子控件 flex / 所有子控件 flex 之和。

Flexiblefit 参数表示填满剩余空间的方式:

  • tight:必须(强制)填满剩余空间。
  • loose:尽可能大的填满剩余空间,但是可以不填满。

这两个看上去不是很好理解,什么叫尽可能大的填满剩余空间?什么时候填满?看下面的例子:

Row(
 children: <Widget>[
   Container(color: Colors.blue, height: 50, width: 100,),
   Flexible(
       child: Container(
         color: Colors.red, 
         height: 50, 
         child: const Text('Container',style: TextStyle(color: Colors.white),),
       )
   ),
   Container(color: Colors.blue, height: 50, width: 100, ), 
 ],
)

效果:

在这里插入图片描述

这段代码是在最上面代码的基础上给中间的红色 Container 添加了 Text 子控件,
此时红色 Container 就不在充满空间,再给 Container 添加对齐方式,代码如下:

Row(
 children: <Widget>[
   Container(color: Colors.blue, height: 50, width: 100,),
   Flexible(
       child: Container(
         color: Colors.red, 
         height: 50, 
         alignment: Alignment.center,
         child: const Text('Container',style: TextStyle(color: Colors.white),),
       )
   ),
   Container(color: Colors.blue, height: 50, width: 100, ), 
 ],
)

效果:

在这里插入图片描述
此又填满剩余空间。Container 默认是适配子控件大小的,但当设置对齐方式时 Container 将会填满父控件(详细可参考这里),因此是否填满剩余空间取决于子控件是否需要填满父控件。

如果把 Flexible 中子控件由 Container 改为 OutlineButton,代码如下:

Row(
 children: <Widget>[
   Container(color: Colors.blue, height: 50, width: 100,),
   Flexible(child: OutlineButton(child: Text('OutlineButton'), ),),
   Container(color: Colors.blue, height: 50, width: 100, ), 
 ],
)

OutlineButton 正常情况下是不充满父控件的,因此最终的效果应该是不填满剩余空间,效果如图:

在这里插入图片描述

Expanded

ExpandedFlexible的一种特例,它继承自 Flexible

class Expanded extends Flexible {
    
    
  /// Creates a widget that expands a child of a [Row], [Column], or [Flex]
  /// so that the child fills the available space along the flex widget's
  /// main axis.
  const Expanded({
    
    
    super.key,
    super.flex,
    required super.child,
  }) : super(fit: FlexFit.tight);
}

所以Expanded 也只能作为 Row、Column、Flex 的孩子,它可以按比例“扩伸”Flex子组件所占用的空间。注意到,Expandedfit 参数固定为 FlexFit.tight,也就是说 Expanded 必须(强制)填满剩余空间。

  • flex:弹性系数,如果为 0null,则child是没有弹性的,即不会被扩伸占用的空间。如果大于0,所有的Expanded按照其 flex 的比例来分割主轴的全部空闲空间。

上面的 OutlineButton 想要充满剩余空间可以使用 Expanded

Row(
 children: <Widget>[
   Container(color: Colors.blue, height: 50, width: 100,),
   Expanded(child: OutlineButton(child: Text('OutlineButton'), ),),
   Container(color: Colors.blue, height: 50, width: 100, ), 
 ],
)

效果:

在这里插入图片描述

下面我们再看一个例子:

class FlexLayoutTestRoute extends StatelessWidget {
    
    
  
  Widget build(BuildContext context) {
    
    
    return Column(
      children: <Widget>[
        //Flex的两个子widget按1:2来占据水平空间  
        Flex(
          direction: Axis.horizontal,
          children: <Widget>[
            Expanded(
              flex: 1,
              child: Container(
                height: 30.0,
                color: Colors.red,
              ),
            ),
            Expanded(
              flex: 2,
              child: Container(
                height: 30.0,
                color: Colors.green,
              ),
            ),
          ],
        ),
        Padding(
          padding: const EdgeInsets.only(top: 20.0),
          child: SizedBox(
            height: 100.0,
            //Flex的三个子widget,在垂直方向按2:1:1来占用100像素的空间  
            child: Flex(
              direction: Axis.vertical,
              children: <Widget>[
                Expanded(
                  flex: 2,
                  child: Container(
                    height: 30.0,
                    color: Colors.red,
                  ),
                ),
                Spacer(
                  flex: 1,
                ),
                Expanded(
                  flex: 1,
                  child: Container(
                    height: 30.0,
                    color: Colors.green,
                  ),
                ),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

运行效果:

在这里插入图片描述

示例中的Spacer的功能是占用指定比例的空间,实际上它只是Expanded的一个包装类,Spacer的源码如下:

class Spacer extends StatelessWidget {
    
    
  const Spacer({
    
    Key? key, this.flex = 1})
    : assert(flex != null),
      assert(flex > 0),
      super(key: key);
  
  final int flex;

  
  Widget build(BuildContext context) {
    
    
    return Expanded(
      flex: flex,
      child: const SizedBox.shrink(),
    );
  }
}

Flex布局实战

在日常使用中,常常会遇到这样的UI:前面是一个标题,后面是若干标签,无论标题多长,标签都应该完整展示,标题根据剩余空间截断。具体来说,应该满足下图所展示的几种典型情况。

图11-1 目标UI样式的几种典型布局

上图展示了目标UI样式的几种布局:当标题和标签都可以完全显示时,两者依次显示,如图中第1行所示;当标题过长时,标签完整显示,标题自行截断,如图中第2行和第3行所示;标签最大长度不能超过屏幕一半,如图中第4行所示。

需要注意的是,以上UI是无法通过多个Flexible组件来实现的, 从以上描述中可以发现,标签部分必须先布局,并限制最大宽度为屏幕的一半;而标题部分必须后布局,并且最大宽度是标签布局结束后留下的空间。结合第6章的分析,应该将标签设置成一个非Flex节点,而将标题设置成Flex节点,这样就可以实现上述的预期布局顺序,具体实现代码如下:

class Tile extends StatelessWidget {
    
    
  final String title;
  final String tag;
  const Tile({
    
    Key key, this.title, this.tag}) : super(key: key);
  
  Widget build(BuildContext context) {
    
    
    return Row(
      children: [
        Flexible( // 填充剩余宽度
          child: Text(title,
            maxLines: 1, overflow: TextOverflow.ellipsis,
            style: TextStyle(fontSize: 20, color: Colors.black87),
          ),
        ), // Flexible
        ConstrainedBox( // 约束最大宽度
          constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width/2),
          child: Text(tag, maxLines: 1,
            style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
          ),
        ), // ConstrainedBox
      ],
    ); // Row
  }
} // StatelessWidget 

以上实现中,首先通过ConstrainedBox约束了标签部分的最大宽度,然后借助Flexible组件让标题能够充分填充剩余空间。

流式布局(Wrap、Flow)

在介绍 RowColum 时,如果子 widget 超出屏幕范围,则会报溢出错误,如:

Row(
  children: <Widget>[
    Text("xxx"*100)
  ],
);

在这里插入图片描述

可以看到,右边溢出部分报错。这是因为Row默认只有一行,超出屏幕后不会折行。我们把超出屏幕显示范围会自动折行的布局称为流式布局。Flutter中通过WrapFlow来支持流式布局,将上例中的 Row 换成Wrap后溢出部分则会自动折行,下面我们分别介绍WrapFlow

Wrap

WrapRow\Column 的大部分属性类似,RowColumn都是单行单列的,Wrap则突破了这个限制,当mainAxis上空间不足时,则向crossAxis上去扩展显示。

下面是Wrap常用的几个属性:

属性 说明
direction 主轴的方向,默认水平Axis.horizontal
alignment 主轴的对其方式,默认WrapAlignment.start
textDirection 文本方向
verticalDirection 定义了children摆放顺序,默认是VerticalDirection.down,同Flex相关属性
spacing 主轴方向子widget的间距
runSpacing 交叉轴方向的间距
runAlignment 交叉轴方向的对齐方式

示例:

Wrap(
   spacing: 8.0, // 主轴(水平)方向间距
   runSpacing: 4.0, // 交叉轴(垂直)方向间距
   alignment: WrapAlignment.center, //沿主轴方向居中
   children: <Widget>[
     Chip(
       avatar: CircleAvatar(backgroundColor: Colors.blue, child: Text('A')),
       label: Text('Hamilton'),
     ),
     Chip(
       avatar: CircleAvatar(backgroundColor: Colors.blue, child: Text('M')),
       label: Text('Lafayette'),
     ),
     Chip(
       avatar: CircleAvatar(backgroundColor: Colors.blue, child: Text('H')),
       label: Text('Mulligan'),
     ),
     Chip(
       avatar: CircleAvatar(backgroundColor: Colors.blue, child: Text('J')),
       label: Text('Laurens'),
     ),
   ],
)

效果:
在这里插入图片描述

Flow

我们一般很少会使用Flow,因为其过于复杂,需要自己实现子 widget 的位置转换,在很多场景下首先要考虑的是Wrap是否满足需求。Flow主要用于一些需要自定义布局策略或性能要求较高(如动画中)的场景。Flow有如下优点:

  • 性能好;Flow是一个对子组件尺寸以及位置调整非常高效的控件,Flow用转换矩阵在对子组件进行位置调整的时候进行了优化:在Flow定位过后,如果子组件的尺寸或者位置发生了变化,在FlowDelegate中的paintChildren()方法中调用context.paintChild 进行重绘,而context.paintChild在重绘时使用了转换矩阵,并没有实际调整组件位置。
  • 灵活;由于我们需要自己实现FlowDelegatepaintChildren()方法,所以我们需要自己计算每一个组件的位置,因此,可以自定义布局策略。

缺点:

  • 使用复杂。
  • Flow 不能自适应子组件大小,必须通过指定父容器大小或实现TestFlowDelegategetSize返回固定大小。

示例:我们对六个色块进行自定义流式布局:

Flow(
  delegate: TestFlowDelegate(margin: EdgeInsets.all(10.0)),
  children: <Widget>[
    Container(width: 80.0, height:80.0, color: Colors.red,),
    Container(width: 80.0, height:80.0, color: Colors.green,),
    Container(width: 80.0, height:80.0, color: Colors.blue,),
    Container(width: 80.0, height:80.0,  color: Colors.yellow,),
    Container(width: 80.0, height:80.0, color: Colors.brown,),
    Container(width: 80.0, height:80.0,  color: Colors.purple,),
  ],
)

实现TestFlowDelegate:

class TestFlowDelegate extends FlowDelegate {
    
    
  EdgeInsets margin;

  TestFlowDelegate({
    
    this.margin = EdgeInsets.zero});

  double width = 0;
  double height = 0;

  
  void paintChildren(FlowPaintingContext context) {
    
    
    var x = margin.left;
    var y = margin.top;
    //计算每一个子widget的位置
    for (int i = 0; i < context.childCount; i++) {
    
    
      var w = context.getChildSize(i)!.width + x + margin.right;
      if (w < context.size.width) {
    
    
        context.paintChild(i, transform: Matrix4.translationValues(x, y, 0.0));
        x = w + margin.left;
      } else {
    
    
        x = margin.left;
        y += context.getChildSize(i)!.height + margin.top + margin.bottom;
        //绘制子widget(有优化)
        context.paintChild(i, transform: Matrix4.translationValues(x, y, 0.0));
        x += context.getChildSize(i)!.width + margin.left + margin.right;
      }
    }
  }

  
  Size getSize(BoxConstraints constraints) {
    
    
    // 指定Flow的大小,简单起见我们让宽度竟可能大,但高度指定为200,
    // 实际开发中我们需要根据子元素所占用的具体宽高来设置Flow大小
    return Size(double.infinity, 200.0);
  }

  
  bool shouldRepaint(FlowDelegate oldDelegate) {
    
    
    return oldDelegate != this;
  }
}

运行效果:

在这里插入图片描述

可以看到我们主要的任务就是实现paintChildren,它的主要任务是确定每个子widget位置。由于Flow不能自适应子widget的大小,我们通过在getSize返回一个固定大小来指定Flow的大小。

注意,如果我们需要自定义布局策略,一般首选的方式是通过直接继承RenderObject,然后通过重写 performLayout 的方式实现。

层叠布局(Stack、Positioned)

层叠布局和 Web 中的绝对定位、Android 中的 FrameLayout 相似,子组件可以根据距父容器四个角的位置来确定自身的位置。子组件是按照代码中声明的顺序堆叠起来。Flutter中使用Stack结合PositionedAlign这两个组件来配合实现定位。Stack允许子组件堆叠,而Positioned用于根据Stack的四个角来确定子组件的位置。

Stack

常用属性:

属性 说明
alignment 此参数决定如何去对齐没有定位(没有使用Positioned)或部分定位的子组件。
所谓部分定位,在这里特指没有在某一个轴上定位:leftright为横轴,topbottom为纵轴,只要包含某个轴上的一个定位属性就算在该轴上有定位。默认值是AlignmentDirectional.topStart
textDirection RowWraptextDirection功能一样,都用于确定alignment对齐的参考系,
即:textDirection的值为TextDirection.ltr,则alignmentstart代表左,end代表右,即从左往右的顺序;
textDirection的值为TextDirection.rtl,则alignmentstart代表右,end代表左,即从右往左的顺序
fit 此参数用于确定没有定位的子组件如何去适应Stack的大小。StackFit.loose表示使用子组件的大小,StackFit.expand表示扩伸到Stack的大小,默认是StackFit.loose
clipBehavior 此属性决定对超出Stack显示空间的部分如何剪裁,Clip枚举类中定义了剪裁的方式,默认是Clip.hardEdge 表示直接剪裁,不应用抗锯齿

Positioned

常用属性:

属性 说明
left 子元素距离左侧距离
top 子元素距离顶部的距离
right 子元素距离右侧距离
bottom 子元素距离底部的距离
child 子组件
width **子组件的高度 **
height 子组件的高度

注意,Positionedwidthheight 和其他地方的意义稍微有点区别,此处用于配合left、top 、right、 bottom来定位组件,举个例子,在水平方向时,你只能指定left、right、width三个属性中的两个,如指定leftwidth后,right会自动算出(left+width),如果同时指定三个属性则会报错,垂直方向同理。另外,宽度和高度必须是固定值,没法使用double.infinity

示例:下面代码通过对几个Text组件的定位来演示StackPositioned的特性

//通过ConstrainedBox来确保Stack占满屏幕
ConstrainedBox(
  constraints: BoxConstraints.expand(),
  child: Stack(
    alignment:Alignment.center , //指定未定位或部分定位widget的对齐方式
    //注意:相对于外部容器进行定位,如果没有外部容器就相对于整个屏幕进行定位
    children: <Widget>[
      Container(
        child: Text("Hello world",style: TextStyle(color: Colors.white)),
        color: Colors.red,
      ),
      Positioned(
        left: 18.0,
        child: Text("I am Jack"),
      ),
      Positioned(
        top: 18.0,
        child: Text("Your friend"),
      )        
    ],
  ),
);

效果:

在这里插入图片描述

  • 由于第一个子文本组件Text("Hello world")没有指定定位,并且alignment值为Alignment.center,所以它会居中显示。
  • 第二个子文本组件Text("I am Jack")只指定了水平方向的定位(left),所以属于部分定位,即垂直方向上没有定位,那么它在垂直方向的对齐方式则会按照alignment指定的对齐方式对齐,即垂直方向居中。
  • 对于第三个子文本组件Text("Your friend"),和第二个Text原理一样,只不过是水平方向没有定位,则水平方向居中。

我们给上例中的Stack指定一个fit属性,然后将三个子文本组件的顺序调整一下:

Stack(
  alignment:Alignment.center ,
  fit: StackFit.expand, //未定位widget占满Stack整个空间
  children: <Widget>[
    Positioned(
      left: 18.0,
      child: Text("I am Jack"),
    ),
    Container(child: Text("Hello world",style: TextStyle(color: Colors.white)),
      color: Colors.red,
    ),
    Positioned(
      top: 18.0,
      child: Text("Your friend"),
    )
  ],
),

效果:

在这里插入图片描述

可以看到,由于第二个子文本组件没有定位,所以fit属性会对它起作用,就会占满Stack。由于Stack子元素是堆叠的,所以第一个子文本组件被第二个遮住了,而第三个在最上层,所以可以正常显示。

StackPositioned实现固定导航案例

import 'package:flutter/material.dart';

void main() {
    
    
  runApp(MaterialApp(
    home: Scaffold(
        appBar: AppBar(title: const Text("你好Flutter")), body: const HomePage()),
  ));
}

class HomePage extends StatelessWidget {
    
    
  const HomePage({
    
    Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    
    
    //获取设备的宽度和高度
    final size = MediaQuery.of(context).size;
    return Stack(
      children: [
        ListView(
          padding: const EdgeInsets.only(top: 50),
          children: List.generate(
              40, (index) => ListTile(title: Text("我是一个列表$index"))),
        ),
        Positioned(
            left: 0,
            top: 0,
            // bottom: 0, // 改为bottom即底部固定
            width: size.width, //配置子元素的宽度和高度  没法使用double.infinity
            height: 44, //配置子元素的宽度和高度
            child: Container(
              alignment: Alignment.center,
              color: Colors.red,
              child: const Text(
                "二级导航",
                style: TextStyle(color: Colors.white),
              ),
            ))
      ],
    );
  }
}

效果:

在这里插入图片描述
上面代码中MediaQuery可以到获取屏幕宽度和高度,可以在build方法中调用:

Widget build(BuildContext context) {
    
    
	final size =MediaQuery.of(context).size;
	final width =size.width;
	final height =size.height;
	...
}

对齐与相对定位(Align)

通过StackPositioned,我们可以指定多个子元素相对于父元素各个边的精确偏移,并且可以重叠。但如果我们只想简单的调整一个子元素在父元素中的位置的话,使用 Align 组件会更简单一些。

属性 说明
alignment 需要一个AlignmentGeometry类型的值,表示子组件在父组件中的起始位置。AlignmentGeometry 是一个抽象类,它有两个常用的子类:AlignmentFractionalOffset
widthFactorheightFactor 用于确定 Align 组件本身宽高的属性;它们是两个缩放因子,会分别乘以子元素的宽、高,最终的结果就是 Align 组件的宽高。如果值为null,则组件的宽高将会占用尽可能多的空间。

简单示例:

Container(
  height: 120.0,
  width: 120.0,
  color: Colors.blue.shade50,
  child: Align(
    alignment: Alignment.topRight,
    child: FlutterLogo(size: 60),
  ),
)

效果:

在这里插入图片描述

FlutterLogo 是 Flutter SDK 提供的一个组件,内容就是 Flutter 的 logo 。在上面的例子中,我们显式指定了Container的宽、高都为 120。如果我们不显式指定宽高,而通过同时指定widthFactorheightFactor2 也是可以达到同样的效果:

Align(
  widthFactor: 2,
  heightFactor: 2,
  alignment: Alignment.topRight,
  child: FlutterLogo(size: 60),
),

因为FlutterLogo的宽高为 60,则Align的最终宽高都为2*60=120

另外,我们还通过Alignment.topRight将FlutterLogo定位在Container的右上角。那Alignment.topRight是什么呢?通过源码我们可以看到其定义如下:

static const Alignment topRight = Alignment(1.0, -1.0);

可以看到它只是Alignment的一个实例,下面我们介绍一下Alignment

Alignment

Alignment继承自AlignmentGeometry,表示矩形内的一个点,他有两个属性x、y,分别表示在水平和垂直方向的偏移,Alignment定义如下:

Alignment(this.x, this.y)

Alignment Widget会以矩形的中心点作为坐标原点,即Alignment(0.0, 0.0)x、y的值从-11分别代表矩形左边到右边的距离和顶部到底边的距离,因此2个水平(或垂直)单位则等于矩形的宽(或高),如Alignment(-1.0, -1.0) 代表矩形的左侧顶点,而Alignment(1.0, 1.0)代表右侧底部终点,而Alignment(1.0, -1.0) 则正是右侧顶点,即Alignment.topRight。为了使用方便,矩形的原点、四个顶点,以及四条边的终点在Alignment类中都已经定义为了静态常量。

在这里插入图片描述
Alignment可以通过其坐标转换公式将其坐标转为子元素的具体偏移坐标:

(Alignment.x * childWidth / 2 + childWidth / 2, Alignment.y * childHeight / 2 + childHeight / 2)

其中childWidth为子元素的宽度,childHeight为子元素高度。

现在我们再看看上面的示例,我们将Alignment(1.0, -1.0)带入上面公式,可得FlutterLogo的实际偏移坐标正是(60,0)

下面再看一个例子:

Align(
  widthFactor: 2,
  heightFactor: 2,
  alignment: Alignment(2,0.0),
  child: FlutterLogo(size: 60),
)

我们可以先想象一下运行效果:将Alignment(2,0.0)带入上述坐标转换公式,可以得到FlutterLogo的实际偏移坐标为(90,30)。实际运行如图所示:

在这里插入图片描述

FractionalOffset

FractionalOffset 继承自 Alignment,它和 Alignment唯一的区别就是坐标原点不同!FractionalOffset 的坐标原点为矩形的左侧顶点,这和布局系统的一致,所以理解起来会比较容易。FractionalOffset的坐标转换公式为:

实际偏移 = (FractionalOffse.x * childWidth, FractionalOffse.y * childHeight)

简单示例:

Container(
  height: 120.0,
  width: 120.0,
  color: Colors.blue[50],
  child: Align(
    alignment: FractionalOffset(0.2, 0.6),
    child: FlutterLogo(size: 60),
  ),
)

效果:

在这里插入图片描述

我们将FractionalOffset(0.2, 0.6)带入坐标转换公式得FlutterLogo实际偏移为(12,36),和实际运行效果吻合。

建议在需要制定一些精确的偏移时应优先使用FractionalOffset,因为它的坐标原点和布局系统相同,能更容易算出实际偏移。

Align和Stack对比

可以看到,AlignStack/Positioned都可以用于指定子元素相对于父元素的偏移,但它们还是有两个主要区别:

  1. 定位参考系统不同Stack/Positioned定位的的参考系可以是父容器矩形的四个顶点;而Align则需要先通过 alignment 参数来确定坐标原点,不同的alignment会对应不同原点,最终的偏移是需要通过alignment的转换公式来计算出。
  2. Stack可以有多个子元素,并且子元素可以堆叠,而Align只能有一个子元素,不存在堆叠。

Align结合Stack使用:

class HomePage extends StatelessWidget {
    
    
  const HomePage({
    
    Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    
    
    return Center(
      child: Container(
        height: 400,
        width: 300,
        color: Colors.red,
        child: const Stack(
          // alignment: Alignment.center,
          children: <Widget>[
            Align(
              alignment: Alignment(1,-0.2),
              child: Icon(Icons.home,size: 40,color: Colors.white),
            ),
            Align(
              alignment: Alignment.center,
              child: Icon(Icons.search,size: 30,color: Colors.white),
            ),
            Align(
              alignment: Alignment.bottomRight,
              child: Icon(Icons.settings_applications,size: 30,color:
              Colors.white),
            )
          ],
        ),
      ),
    );
  }
} 

Center组件

Center组件的源码定义如下:

class Center extends Align {
    
    
  const Center({
    
     Key? key, double widthFactor, double heightFactor, Widget? child })
    : super(key: key, widthFactor: widthFactor, heightFactor: heightFactor, child: child);
}

可以看到Center继承自Align,它比Align只少了一个alignment 参数;由于Align的构造函数中alignment 默认值为Alignment.center,所以,我们可以认为Center组件其实是对齐方式确定(Alignment.center)了的Align

上面我们讲过当widthFactorheightFactornull时组件的宽高将会占用尽可能多的空间,这一点需要特别注意,我们通过一个示例说明:

...//省略无关代码
DecoratedBox(
  decoration: BoxDecoration(color: Colors.red),
  child: Center(
    child: Text("xxx"),
  ),
),
DecoratedBox(
  decoration: BoxDecoration(color: Colors.red),
  child: Center(
    widthFactor: 1,
    heightFactor: 1,
    child: Text("xxx"),
  ),
)

效果:
在这里插入图片描述

熟悉Web开发的同学可能会发现Align组件的特性和Web开发中相对定位(position: relative)非常像,是的!在大多数时候,我们可以直接使用Align组件来实现Web中相对定位的效果。

Card 组件

Card是卡片组件块,内容可以由大多数类型的Widget构成,Card具有圆角和阴影,这让它看起来有立体感。

属性 说明
margin 外边距
child 子组件
elevation 阴影值的深度
color 背景颜色
shadowColor 阴影颜色
margin 外边距
clipBehavior 内容溢出的剪切方式
Clip.none不剪切
Clip.hardEdge裁剪但不应用抗锯齿
Clip.antiAlias裁剪而且抗锯齿
Clip.antiAliasWithSaveLayer带有抗锯齿的剪辑,并在剪辑之后立即保存saveLayer
Shape Card的阴影效果,默认的阴影效果为圆角的长方形边。
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10))
),

Card 实现一个通讯录的卡片效果:

import 'package:flutter/material.dart';

void main() {
    
    
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
    
    
  const MyApp({
    
    Key? key}) : super(key: key);

  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
    
    
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        appBar: AppBar(title: const Text("Flutter App")),
        body: const LayoutDemo(),
      ),
    );
  }
}

class LayoutDemo extends StatelessWidget {
    
    
  const LayoutDemo({
    
    Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    
    
    return ListView(
      children: [
        Card(
          shape: RoundedRectangleBorder(
              //Card的阴影效果
              borderRadius: BorderRadius.circular(10)),
          elevation: 20, //阴影值的深度
          margin: const EdgeInsets.all(10),
          child: Column(
            children: const [
              ListTile(
                title: Text("张三", style: TextStyle(fontSize: 28)),
                subtitle: Text("高级软件工程师"),
              ),
              Divider(),
              ListTile(
                title: Text("电话:152222222"),
              ),
              ListTile(
                title: Text("地址:北京市海淀区 xxx"),
              ),
            ],
          ),
        ),
        Card(
          shape: RoundedRectangleBorder(
              //Card的阴影效果
              borderRadius: BorderRadius.circular(10)),
          elevation: 20,
          margin: const EdgeInsets.all(10),
          // color:Colors.black12,  //背景颜色
          child: Column(
            children: const [
              ListTile(
                title: Text("李四", style: TextStyle(fontSize: 28)),
                subtitle: Text("Flutter高级软件工程师"),
              ),
              Divider(),
              ListTile(
                title: Text("电话:152222222"),
              ),
              ListTile(
                title: Text("地址:北京市海淀区 xxx"),
              ),
            ],
          ),
        ),
         Card(
          shape: RoundedRectangleBorder(
              //Card的阴影效果
              borderRadius: BorderRadius.circular(10)),
          elevation: 20, //阴影值的深度
          margin: const EdgeInsets.all(10),
          child: Column(
            children: const [
              ListTile(
                title: Text("张三", style: TextStyle(fontSize: 28)),
                subtitle: Text("高级软件工程师"),
              ),
              Divider(),
              ListTile(
                title: Text("电话:152222222"),
              ),
              ListTile(
                title: Text("地址:北京市海淀区 xxx"),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

在这里插入图片描述

Card 实现一个图文列表卡片效果:

import 'package:flutter/material.dart';

void main() {
    
    
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
    
    
  const MyApp({
    
    Key? key}) : super(key: key);

  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
    
    
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        appBar: AppBar(title: const Text("Flutter App")),
        body: const LayoutDemo(),
      ),
    );
  }
}

class LayoutDemo extends StatelessWidget {
    
    
  const LayoutDemo({
    
    Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    
    
    return ListView(
      children: [
        Card(
          shape: RoundedRectangleBorder(
            borderRadius:BorderRadius.circular(10)
          ),
          elevation: 20,
          margin: const EdgeInsets.all(10),
          child: Column(
            children: [
              AspectRatio(
                aspectRatio: 16 / 9,
                child: Image.network(
                    "https://www.itying.com/images/flutter/3.png",
                    fit: BoxFit.cover),
              ),
              ListTile(
                leading: ClipOval(
                  child:Image.network(
                    "https://www.itying.com/images/flutter/3.png",
                    fit: BoxFit.cover,
                    height: 40,
                    width: 40,
                ),
                ),
                title: const Text("xxxxxxxxx"),
                subtitle: const Text("xxxxxxxxx"),
              )
            ],
          ),
        ),
        Card(
          shape: RoundedRectangleBorder(
            borderRadius:BorderRadius.circular(10)
          ),
          elevation: 20,
          margin: const EdgeInsets.all(10),
          child: Column(
            children: [
              AspectRatio(
                aspectRatio: 16 / 9,
                child: Image.network(
                    "https://www.itying.com/images/flutter/3.png",
                    fit: BoxFit.cover),
              ),
              const ListTile(
                leading: CircleAvatar(
                  backgroundImage: NetworkImage("https://www.itying.com/images/flutter/4.png"),
                ),
                title: Text("xxxxxxxxx"),
                subtitle: Text("xxxxxxxxx"),
              )
            ],
          ),
        )
      ],
    );
  }
}

在这里插入图片描述

LayoutBuilder

通过 LayoutBuilder,我们可以在布局过程中拿到父组件传递的约束信息,然后我们可以根据约束信息动态的构建不同的布局。

比如我们实现一个响应式的 Column 组件 ResponsiveColumn,它的功能是当当前可用的宽度小于 200 时,将子组件显示为一列,否则显示为两列。简单来实现一下:

class ResponsiveColumn extends StatelessWidget {
    
    
  const ResponsiveColumn({
    
    Key? key, required this.children}) : super(key: key);
  final List<Widget> children;
  
  Widget build(BuildContext context) {
    
    
    // 通过 LayoutBuilder 拿到父组件传递的约束,然后判断 maxWidth 是否小于200
    return LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
    
    
        if (constraints.maxWidth < 200) {
    
     // 最大宽度小于200,显示单列 
          return Column(children: children, mainAxisSize: MainAxisSize.min);
        } else {
    
     // 大于200,显示双列 
          var _children = <Widget>[];
          for (var i = 0; i < children.length; i += 2) {
    
    
            if (i + 1 < children.length) {
    
    
              _children.add(Row(
                children: [children[i], children[i + 1]],
                mainAxisSize: MainAxisSize.min,
              ));
            } else {
    
    
              _children.add(children[i]);
            }
          }
          return Column(children: _children, mainAxisSize: MainAxisSize.min);
        }
      },
    );
  }
}

class LayoutBuilderRoute extends StatelessWidget {
    
    
  const LayoutBuilderRoute({
    
    Key? key}) : super(key: key);
  
  Widget build(BuildContext context) {
    
    
    var _children = List.filled(6, Text("A"));
    // Column在本示例中在水平方向的最大宽度为屏幕的宽度
    return Column(
      children: [
        // 限制宽度为190,小于 200
        SizedBox(width: 190, child: ResponsiveColumn(children: _children)),
        ResponsiveColumn(children: _children),
        LayoutLogPrint(child:Text("xx")) // 下面介绍
      ],
    );
  }
}

可以发现 LayoutBuilder 的使用很简单,但是不要小看它,因为它非常实用且重要,它主要有两个使用场景:

  1. 可以使用 LayoutBuilder 来根据设备的尺寸来实现响应式布局。
  2. LayoutBuilder 可以帮我们高效排查问题。比如我们在遇到布局问题或者想调试组件树中某一个节点布局的约束时 LayoutBuilder 就很有用。

打印布局时的约束信息

为了便于排错,我们封装一个能打印父组件传递给子组件约束的组件:

class LayoutLogPrint<T> extends StatelessWidget {
    
    
  const LayoutLogPrint({
    
    
    Key? key,
    this.tag,
    required this.child,
  }) : super(key: key);

  final Widget child;
  final T? tag; //指定日志tag

  
  Widget build(BuildContext context) {
    
    
    return LayoutBuilder(builder: (_, constraints) {
    
    
      // assert在编译release版本时会被去除
      assert(() {
    
    
        print('${
      
      tag ?? key ?? child}: $constraints');
        return true;
      }());
      return child;
    });
  }
}

这样,我们就可以使用 LayoutLogPrint 组件树中任意位置的约束信息,比如:

LayoutLogPrint(child:Text("xx"))

控制台输出:

flutter: Text("xx"): BoxConstraints(0.0<=w<=428.0, 0.0<=h<=823.0)

可以看到 Text("xx") 的显示空间最大宽度为 428,最大高度为 823 。

注意!我们的大前提是盒模型布局,如果是Sliver 布局,可以使用 SliverLayoutBuiler 来打印。

运行效果:

在这里插入图片描述

Flutter 的 build 和 layout

通过观察 LayoutBuilder 的示例,我们还可以发现一个关于 Flutter 构建(build)和 布局(layout)的结论:Flutter 的 build 和 layout 是可以交错执行的,并不是严格的按照先 buildlayout 的顺序。比如在上例中 ,在build过程中遇到了 LayoutBuilder 组件,而 LayoutBuilderbuilder 是在 layout 阶段执行的(layout阶段才能取到布局过程的约束信息),在 builder 中新新建了一个 widget 后,Flutter 框架随后会调用该 widgetbuild 方法,又进入了 build 阶段。

AfterLayout

1. 获取组件大小和相对于屏幕的坐标

Flutter 是响应式UI框架,而命令式UI框架最大的不同就是:大多数情况下开发者只需要关注数据的变化,数据变化后框架会自动重新构建UI而不需要开发者手动去操作每一个组件,所以我们会发现 Widget 会被定义为不可变的(immutable),并且没有提供任何操作组件的 API,因此如果我们想在 Flutter 中获取某个组件的大小和位置就会很困难,当然大多数情况下不会有这个需求,但总有一些场景会需要,而在命令式UI框架中是不会存在这个问题的。

我们知道,只有当布局完成时,每个组件的大小和位置才能确定,所以获取的时机肯定是布局完成后,那布局完成的时机如何获取呢?至少事件分发肯定是在布局完成之后的,比如:

Builder(
  builder: (context) {
    
    
    return GestureDetector(
      child: Text('flutter'),
      onTap: () => print(context.size), //打印 text 的大小
    );
  },
),

context.size 可以获取当前上下文 RenderObject 的大小,对于BuilderStatelessWidget 以及 StatefulWidget 这样没有对应 RenderObject 的组件(这些组件只是用于组合和代理组件,本身并没有布局和绘制逻辑),获取的是子代中第一个拥有 RenderObject 组件的 RenderObject 对象。

虽然事件点击时可以拿到组件大小,但有两个问题,第一是需要用户手动触发,第二是时机较晚,更多的时候我们更希望在布局一结束就去获取大小和位置信息,为了解决这个问题,我们可以自己封装一个 AfterLayout 组件,它可以在子组件布局完成后执行一个回调,并同时将 RenderObject 对象作为参数传递。

以下是 AfterLayout 实现源码:

typedef AfterLayoutCallback = Function(RenderAfterLayout ral);

/// A widget can retrieve its render object after layout.
///
/// Sometimes we need to do something after the build phase is complete,
/// for example, most of [RenderObject] methods and attributes, such as
/// `renderObject.size`、`renderObject.localToGlobal(...)` only can be used
/// after build.
///
/// Call `setState` in callback is **allowed**, it is safe!
class AfterLayout extends SingleChildRenderObjectWidget {
    
    
  const AfterLayout({
    
    Key? key, required this.callback, Widget? child,}) : super(key: key, child: child);

  
  RenderObject createRenderObject(BuildContext context) {
    
    
    return RenderAfterLayout(callback);
  }

  
  void updateRenderObject(context, RenderAfterLayout renderObject) {
    
    
    renderObject.callback = callback;
  }

  /// 组件树布局结束后会被触发,注意,并不是当前组件布局结束后触发
  /// [callback] will be triggered after the layout phase ends.
  final AfterLayoutCallback callback;
}

class RenderAfterLayout extends RenderProxyBox {
    
    
  RenderAfterLayout(this.callback);

  ValueSetter<RenderAfterLayout> callback;

  
  void performLayout() {
    
    
    super.performLayout();
    // 不能直接回调callback,在 frame 结束的时候再去触发回调。 
    SchedulerBinding.instance.addPostFrameCallback((timeStamp) => callback(this));
  }

  /// 组件在在屏幕坐标中的起始偏移坐标
  Offset get offset => localToGlobal(Offset.zero);

  /// 组件在屏幕上占有的矩形空间区域
  Rect get rect => offset & size;
}

AfterLayout 可以在布局结束后拿到子组件的代理渲染对象 (RenderAfterLayout), RenderAfterLayout 对象会代理子组件渲染对象 ,因此,通过RenderAfterLayout 对象也就可以获取到子组件渲染对象上的属性,比如件大小、位置等。

AfterLayout 使用示例:

AfterLayout(
  callback: (RenderAfterLayout ral) {
    
    
    print(ral.size); //子组件的大小
    print(ral.offset); // 子组件在屏幕中坐标
  },
  child: Text('flutter'),
),

运行后控制台输出:

flutter: Size(105.0, 17.0)
flutter: Offset(42.5, 290.0)

可以看到 Text 文本的实际长度是 105,高度是 17,它的起始位置坐标是(42.5, 290.0)。

2. 获取组件相对于某个父组件的坐标

RenderAfterLayout 类继承自 RenderBoxRenderBox 有一个 localToGlobal 方法,它可以将坐标转化为相对与指定的祖先节点的坐标,比如下面代码可以打印出 Text('A') 在 父 Container 中的坐标:

Builder(builder: (context) {
    
    
  return Container(
    color: Colors.grey.shade200,
    alignment: Alignment.center,
    width: 100,
    height: 100,
    child: AfterLayout(
      callback: (RenderAfterLayout ral) {
    
    
        Offset offset = ral.localToGlobal(
          Offset.zero,
          // 传一个父元素 Container 对应的 RenderObject 对象
          ancestor: context.findRenderObject(),
        );
        print('A 在 Container 中占用的空间范围为:${
      
      offset & ral.size}');
      },
      child: Text('A'),
    ),
  );
}),

下面是一个 AfterLayout 的完整测试示例:

class AfterLayoutRoute extends StatefulWidget {
    
    
  const AfterLayoutRoute({
    
    Key? key}) : super(key: key);

  
  _AfterLayoutRouteState createState() => _AfterLayoutRouteState();
}

class _AfterLayoutRouteState extends State<AfterLayoutRoute> {
    
    
  String _text = 'flutter 实战 ';
  Size _size = Size.zero;

  
  Widget build(BuildContext context) {
    
    
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Padding(
          padding: const EdgeInsets.all(8.0),
          child: Builder(
            builder: (context) {
    
    
              return GestureDetector(
                child: Text(
                  'Text1: 点我获取我的大小',
                  textAlign: TextAlign.center,
                  style: TextStyle(color: Colors.blue),
                ),
                onTap: () => print('Text1: ${
      
      context.size}'),
              );
            },
          ),
        ),
        AfterLayout(
          callback: (RenderAfterLayout ral) {
    
    
            print('Text2: ${
      
      ral.size}, ${
      
      ral.offset}');
          },
          child: Text('Text2:flutter@wendux'),
        ),
        Builder(builder: (context) {
    
    
          return Container(
            color: Colors.grey.shade200,
            alignment: Alignment.center,
            width: 100,
            height: 100,
            child: AfterLayout(
              callback: (RenderAfterLayout ral) {
    
    
                Offset offset = ral.localToGlobal(
                  Offset.zero,
                  ancestor: context.findRenderObject(),
                );
                print('A 在 Container 中占用的空间范围为:${
      
      offset & ral.size}');
              },
              child: Text('A'),
            ),
          );
        }),
        Divider(),
        AfterLayout(
          child: Text(_text), 
          callback: (RenderAfterLayout value) {
    
    
            setState(() {
    
    
              //更新尺寸信息
              _size = value.size;
            });
          },
        ),
        //显示上面 Text 的尺寸
        Padding(
          padding: const EdgeInsets.symmetric(vertical: 8.0),
          child: Text(
            'Text size: $_size ',
            style: TextStyle(color: Colors.blue),
          ),
        ),
        ElevatedButton(
          onPressed: () {
    
    
            setState(() {
    
    
              _text += 'flutter 实战 ';
            });
          },
          child: Text('追加字符串'),
        ),
      ],
    );
  }
}

运行效果:

在这里插入图片描述

运行后点击 Text1 就可以在日志面板看到它的大小。点击 “追加字符串” 按钮,字符串大小变化后,屏幕上上也会显示变化后的文本区域大小(按钮上方挨着)。


参考:《Flutter实战·第二版》

猜你喜欢

转载自blog.csdn.net/lyabc123456/article/details/130843115