Flutter 基础 | 控件 & 布局(一)

7,283 阅读10分钟

Flutter 中一切皆控件。这一篇会讲述基础控件的使用方法,并以一个实例展示如何综合运用它们。

了解 Dart 的基础语法才能无障碍地阅读本篇内容,具体介绍可以点击Flutter 基础 | Dart 语法

界面入口

当你新建一个 Flutter app 之后,就能深刻地感受到一切皆控件。

Flutter App 程序的入口是在 lib 目录下main.dart文件中的main()方法:

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

其中MyApp就是一个控件:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: Scaffold(
        appBar: AppBar(title: const Text('Welcome to Flutter')),
        body: , // 可以在这里填充想展示的控件
      ),
    );
  }
}

MyApp 继承自StatelessWidget,它是一个自定义的无状态控件。自定义控件时得重写build(),该方法返回一个Widget,它是所有控件的基类。

这次返回的是 MaterialApp,它是一个符合 material design 的控件。给他设置了 3 个属性,其中home属性的类型也是一个控件,构建了一个包含 AppBar 和 body 的控件Scaffold,应用程序的业务界面大多都往 body 中填充。

可以看出 Flutter 中的控件是直接 new 出来的,构建布局是声明式的,布局和逻辑是混合在一起的。

文本

从最基本的文本开始:

Text(
  'abc', //内容
  style: TextStyle(
    fontSize: 12, // 字号
    fontWeight: FontWeight.w400, // 字重
    color: Colors.blue, // 字色
    fontFamily: 'pingfang' // 字体
  ),
  maxLines: 1 // 最大行数
);

自定义字体

上述代码构建了一个文本控件,且为文本定制了样式。其中引用的自定义字体得先将字体文件存放在app/fonts目录下(若没有则新建):

微信截图_20211113190959.png

然后在pubspec.yaml文件中加载字体,然后就能在 dart 中引用了:

微信截图_20211113191151.png

自定义颜色

除了使用系统预定义的Colors.xxx颜色外,也可以使用自定义颜色:

Text(
  'abc', 
  style: TextStyle(
    color: Color(0xFFB9BEC5), // 自定义颜色
  ),
);

直接构造一个Color对象并传入色值的十六进制。

富文本

// ‘我是程序员’被分割成两段,以用不同字号及颜色展示
Text.rich(TextSpan(children: [
  TextSpan(
      text: '我是',
      style: TextStyle(
          fontSize: 10, 
          color: Colors.blue
      )
  ),
  TextSpan(
      text: '程序员', 
      style: TextStyle(
          color: Colors.red, 
          fontSize: 12
      )
  )
]))

Text.rich()是 Text 控件的一个命名构造方法,构造的同时传入了一个TextSpan对象,它有一个children属性,表示可以传入若干个TextSpan对象,每个都可独立地设置文本属性,这样可以将一段文字,分解成若干个 TextSpan 对象,以实现富文本效果。

图片

加载本地图片

Image.asset(
  'images/hot_week.webp', // 本地图片名
),

上述代码构建了一个Image控件用于展示本地图片,其中图片资源 images/hot_week.webp 得先存放在app/images目录下(没有就新建): 微信截图_20211113192702.png

然后在pubspec.yaml文件中加载图片,就能在 dart 中引用了:

微信截图_20211113192826.png

加载网络图片

Image.network(
  circle?.url ?? "",
  fit: BoxFit.cover,
)

Image 控件有一个方便的命名方法network()只需传入图片 url 就能完成异步加载图片。

其中的fit属性表示,图片应该怎么样去适应控件的大小。BoxFit.cover 表示等比例缩放图片,直到将控件宽高填满。

控件尺寸

Flutter 中控件尺寸不由其自身决定的,而是由它的父控件。

这是在 Flutter 中布局时必须铭记在心的一句谚语。

所以如果想展示一个 200 * 200 的图片,只能这样写:

Container(
  width: 200,
  height: 200,
  child: Image.asset('images/hot_week.webp')
)

对!在外面包一层Container,然后再指定 Container 的宽高。

试着将上述代码继承到 MaterialApp 中:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: Container(
        width: 200,
        height: 200,
        child: Image.asset('images/hot_week.webp')
      )
    );
  }
}

运行代码,会惊奇地发现图片撑满了整个屏幕。那条谚语生效了。并不是 Container 说自己想要多大就能展示多大的,这得由它的父控件说了算,很不巧它的父控件 MaterialApp 对孩子有约束,它要求子控件必须撑满整个父控件,所以给 Container 指定的宽高就不生效了。

换一个写法:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(primarySwatch: Colors.blue),
      home: Scaffold(
        appBar: AppBar(title: const Text('Welcome to Flutter')),
        body: Container(
          width: 200,
          height: 200,
          color: Colors.red
        ),
      ),
    );
  }
}

这次让 200 * 200 的图片成为 Scaffold 的孩子,效果就符合预期了。因为 Scaffold 对孩子的约束是“孩子想多大就多大,但不能超过我”。

这就引出了 Flutter 布局的第二句谚语:

父控件总是会施加一个约束给孩子,这个约束会决定孩子宽高的取值范围以及相对位置。

边距 背景 圆角

Container 控件除了能指定尺寸外,还能指定内外边距、背景色、圆角。

Container(
  decoration: BoxDecoration(
      borderRadius: BorderRadius.all(Radius.circular(8)), // 圆角
      color:Colors.red // 填充色
  ),
  padding: EdgeInsets.fromLTRB(10, 5, 10, 5),// 内边距,分别指定左上右下
  margin: EdgeInsets.all(20),// 外边距,统一指定上下左右都是 20
  child: Text("这是一个带圆角的文本"),
)

上述代码展示如下:

微信截图_20211113201441.png

“通过包裹一层父控件来决定边距、背景、圆角这些属性”,这个思路和 Android 原生构建界面的思路不太一样。原生的世界里面这些都是控件自身的属性。这样做有一个显而易见的好处就是解耦:边距、背景、圆角,这些特性可以无障碍地附着在任何一个控件上,而不像原生中每自定义一个控件都需要独自考虑边距、背景、圆角问题。

线性容器

Flutter 中有两种不带滚动效果的线性容器,横向的Row,纵向的Column

Row(
  children: [
    Text('look at here--'),
    Text('123--'),
    Text('and--'),
    Text('more--'),
    Text('gift--'),
  ],
)

上述代码的展示效果如下:

微信截图_20211113205045.png 上图中的蓝色线条表示控件的边界,它是 Flutter 自带的界面调试工具,只需要在 main() 方法中设置debugPaintSizeEnabled为 true,然后重新运行 app 即可(hot restart 不生效):

void main() {
  debugPaintSizeEnabled = true; 
  runApp(MyApp());
}

可以看到 Column 的父控件要求它横向撑满屏幕,而 Column 对自己的孩子没有任何约束,它们可以自己决定自己的尺寸。

换成纵向容器,写法是类似的:

Column(
  children: [
    Text('look at here--'),
    Text('123--'),
    Text('and--'),
    Text('more--'),
    Text('gift--'),
  ],
)

上述代码的展示效果如下:

微信截图_20211113205508.png

同样的,Column 也被父控件要求纵向撑满整个屏幕,但并未对子控件的尺寸添加约束。

Expanded

如果某个线性容器的子控件希望撑满容器的剩余空间,可以这样写:

 Row(
  children: [
    Expanded(child: Text('look at here--')),// 撑满容器剩余空间
    Text('123--'),
    Text('and--'),
    Text('more--'),
    Text('gift--'),
  ],
)

上述代码的效果如下:

微信截图_20211113205838.png 如果线性容器的每个孩子都被 Expanded 包裹,则可以通过flex属性来决定他们的比例:

Row(
  children: [
    Expanded(child: Text('look at here--'), flex: 2),
    Expanded( child: Text('123--'), flex: 3, ),
    Expanded( child: Text('and--'), flex: 4, ),
    Expanded( child: Text('more--'), flex: 1, ),
    Expanded( child: Text('gift--'), flex: 2, ),
  ],
)

上述代码展示效果如下: 微信截图_20211113210325.png

对齐方式

线性容器的对齐方式分为两个轴,主轴mainAxisAlignment和交叉轴crossAxisAlignment

对于横向容器,主轴是水平方向的,与水平方向十字交叉的轴是垂直方向的。

对于纵向容器,主轴是垂直方向的,与垂直方向十字交叉的轴是水平方向的。

Column(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,// 在主轴上平分
  children: [
    Text('look at here--'),
    Text('123--'),
    Text('and--'),
    Text('more--'),
    Text('gift--'),
  ],
)

上述代码的效果如下:

微信截图_20211113204937.png

因为父控件要求 Column 纵向撑满整个屏幕,所以在主轴上平分布局的就让子控件平均地分布在屏幕的垂直方向上,从蓝色边框可以看出控件本身的尺寸并未发生变化,只是相对于父控件的位置变化了。

若希望在上图的基础上让子控件左对齐,则可以这样写:

Column(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,// 在主轴上平分
  crossAxisAlignment: CrossAxisAlignment.start, // 在交叉轴上左对齐
  children: [
    Text('look at here--'),
    Text('123--'),
    Text('and--'),
    Text('more--'),
    Text('gift--'),
  ],
)

上述代码展示效果如下:

微信截图_20211113211028.png

溢出

因为 Column 和 Row 都是不可滚动的控件,所以如果主轴上子控件太多,则会导致无法完全展示,这叫溢出:

Row(
  children: [
    Text('look at here--'),
    Text('123--'),
    Text('and--'),
    Text('more--'),
    Text('gift--'),
    Text('gift--'),
    Text('gift--'),
    Text('gift--'),
    Text('gift--'),
    Text('gift--'),
    Text('gift--'),
  ],
)

故意多加了几个控件,想在水平方向上产生溢出:

微信截图_20211113211945.png

当溢出发生时,会展示黄黑相间的警告。IDE 也会报错A RenderFlex overflowed by 28 pixels on the right.

再来看一个报错的例子:

 Row(
  children: [
    Text('look at here--'),
    Text('123--'),
    Text('and--'),
    Text('more--'),
    Text('gift--'),
    ListView(scrollDirection: Axis.horizontal),// 横向列表
  ],
)

在横向容器中添加了一个横向列表。demo 也跑步起来后 IDE 报错如下Horizontal viewport was given unbounded width.,即横向视窗没有水平方向上尺寸的约束。

因为 Column 不会约束子控件,任其在水平和垂直方向上生长,很巧的是其中一个孩子是ListView,它也是一个可以自由生长的控件,并且生长方向是水平。两个在同一方向上自由生长的控件互为父子的时候,父控件就不知道自己的尺寸到底应该有多大了。

解决办法如下:

Container(
  color: Colors.red,
  child: Row(
    children: [
      Text('look at here--'),
      Text('123--'),
      Text('and--'),
      Text('more--'),
      Text('gift--'),
      ListView(
        scrollDirection: Axis.horizontal,
        shrinkWrap: true,
      ),
    ],
  ),
)

增加一个属性shrinkWrap表示 ListView 的尺寸尽可能地小,能正好包裹子控件就好,这样 ListView 的尺寸就确定下来了,从而 Column 的尺寸也就确定了。

为了清晰地看清 Row 的边界,特意用 Container 包裹它并添加了红色背景,运行一下:

6eb15ac2-8044-4bf9-a10a-3c416dd4d6f5.png

报错是没有了,但效果也和预期不太相符。本来父容器只是让 Row 在水平方向上撑满屏幕,现在因 ListView 的加入,Row 在垂直方向上也撑满了屏幕。

这是因为 ListView 对子控件的高没有约束,并且自己的高就是子控件高的最大值,Row 也是同理。这样的话,只要 ListView 子控件足够高,则 Row 有超出屏幕底部的可能,但为啥这时没有溢出警告?因为父容器对 Row 的约束是“横向撑满屏幕,纵向你爱多高就多高,但最高不能超过我”。

这时 Flutter 布局的第三句谚语就要来了:

子控件尺寸和位置虽然受到父控件的约束,但子控件的尺寸有时候也可以影响到父控件的尺寸。

综合运用

来看一个综合运用的例子:导航栏

微信截图_20211109123050.png

导航栏包含了 3 个横向铺开的按钮,每个按钮又是一个 Image 和 Text 的纵向组合。所以导航栏应该是一个 Row 包含了 三个 Column,先看下每个 Column 怎么写:

Column(
  mainAxisSize: MainAxisSize.min, // 限制列高度,让它总是最小化,正好包裹子控件就好
  mainAxisAlignment: MainAxisAlignment.center, // 子控件居中
  children: [
    Image.asset('images/call.webp'), // 图标
    Container( // 容器(为了增加图标和文字间距)
      margin: EdgeInsets.only(top: 8), // 间距
      child: Text( // 文字
        "CALL",
        style: TextStyle(
          fontSize: 12,
          fontWeight: FontWeight.w400,
          color: Colors.blue,
        ),
      ),
    ),
  ],
)

上述代码展示效果如下:

微信截图_20211114152453.png

然后将 3 个 Column 嵌入 Row

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Welcome to Flutter',

      home: Scaffold(
        appBar: AppBar(
          title: const Text('Welcome to Flutter'),
        ),
        body: Row(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            // 电话列
            Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Image.asset('images/call.webp'),
                Container(
                  margin: EdgeInsets.only(top: 8),
                  child: Text(
                    "CALL",
                    style: TextStyle(
                      fontSize: 12,
                      fontWeight: FontWeight.w400,
                      color: Colors.blue,
                    ),
                  ),
                ),
              ],
            ),
            // 路由列
            Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Image.asset('images/route.webp'),
                Container(
                  margin: EdgeInsets.only(top: 8),
                  child: Text(
                    "ROUTE",
                    style: TextStyle(
                      fontSize: 12,
                      fontWeight: FontWeight.w400,
                      color: Colors.blue,
                    ),
                  ),
                ),
              ],
            ),
            // 分享列
            Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Image.asset('images/share.webp'),
                Container(
                  margin: EdgeInsets.only(top: 8),
                  child: Text(
                    "SHARE",
                    style: TextStyle(
                      fontSize: 12,
                      fontWeight: FontWeight.w400,
                      color: Colors.blue,
                    ),
                  ),
                ),
              ],
            )
          ],
        ),
      ),
    );
  }
}

预告

Flutter 中的控件叫 Widget,它分为有状态StatefulWidget和无状态StatelessWidgets。有状态的意思是内容会改变的控件,无状态的就是静态不可改变的。

下一篇会分别自定义一个有状态和无状态控件,在自定义的过程中更好地理解它们的区别。

参考

Flutter 官方所有控件列表

Layouts in Flutter | Flutter

推荐阅读