Skip to content

布局构建2

布局构建教程

这是一份如何在 Flutter 中构建布局的指南。你将为如下 app 创建布局:

这份指南之前溯源一步解释了 Flutter 中的布局方式,以及展示了如何在屏幕中放置单个 widget。经过了如何水平以及竖直放置 widgets 的讨论之后,一些最常使用的 widgets 都涉及到了。

如果你想对布局机制有个”全局”的理解,可以先从 Flutter 中的布局 开始.

创建 app 基础代码

确保你已经 安装和配置 好了你的环境,然后做如下步骤:

  1. 创建一个简单的 Flutter app ——”Hello World”。(https://flutter.cn/docs/get-started/codelab#step-1-create-the-starter-flutter-app)
  2. 按照如下方法修改 app 标题栏的标题以及 app 的标题:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Copyright 2018 The Flutter team. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter layout demo',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Flutter layout demo'),
        ),
        body: const Center(
          child: Text('Hello World'),
        ),
      ),
    );
  }
}

对布局进行图形分解

第一步需要将布局分解成它的各个基础元素:

  • 识别出它的行和列。
  • 这个布局是否包含网格布局?
  • 是否有重叠的元素?
  • 界面是否需要选项卡?
  • 留意需要对齐、内间距、或者边界的区域。

首先,识别出稍大的元素。在这个例子中,四个元素排成一列:一个图像,两个行区域,和一个文本区域。

接着,对每一行进行图解。
第一行,也就是标题区域,有三个子元素:一个文本列,一个星形图标,和一个数字。
它的第一个子元素,文本列,包含两行文本。第一列占据大量空间,因此它应当被封装在一个 Expanded widget 当中。

第二行,也就是按钮区域,同样有三个子元素:每个子元素是一个包含图标和文本的列。

一旦图解好布局,采取自下而上的方法来实现它就变得尤为轻松了。
为了最大程度减少,深层嵌套的布局代码带来的视觉混乱,需要用一些变量和函数来替代某些实现。

实现标题行

首先,你可以构建标题部分左侧列。添加如下代码到 MyApp 类的 build() 方法内顶部。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Widget titleSection = Container(
  padding: const EdgeInsets.all(32),
  child: Row(
    children: [
      Expanded(
        /*1*/
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            /*2*/
            Container(
              padding: const EdgeInsets.only(bottom: 8),
              child: const Text(
                'Oeschinen Lake Campground',
                style: TextStyle(
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
            Text(
              'Kandersteg, Switzerland',
              style: TextStyle(
                color: Colors.grey[500],
              ),
            ),
          ],
        ),
      ),
      /*3*/
      Icon(
        Icons.star,
        color: Colors.red[500],
      ),
      const Text('41'),
    ],
  ),
);

/*1*/ 将 Column 元素放到 Expanded widget 中可以拉伸该列,以利用该行中所有剩余的闲置空间。设置 crossAxisAlignment 属性值为 CrossAxisAlignment.start,这会将该列放置在行的起始位置。

/*2*/ 将第一行文本放入 Container 容器中使得你可以增加内间距。列中的第二个子元素,同样为文本,显示为灰色。

/*3*/ 标题行中的最后两项是一个红色星形图标,和文字”41”。整行都在一个 Container 容器布局中,而且每条边都有 32 像素的内间距。

如下添加标题部分到 app body 中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter layout demo',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Flutter layout demo'),
        ),
        body: Column(
          children: [
            titleSection,
          ],
        ),
      ),
    );
  }
}

实现按钮行

按钮区域包含三列使用相同布局-一行文本上面一个图标。
此行的各列被等间隙放置,文本和图标被着以初始色。

由于构建每列的代码基本相同,因此可以创建一个名为 buildButtonColumn() 的私有辅助函数,以颜色、图标和文本为入参,返回一个以指定颜色绘制自身 widgets 的一个 column 列对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // ···
  }

  Column _buildButtonColumn(Color color, IconData icon, String label) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(icon, color: color),
        Container(
          margin: const EdgeInsets.only(top: 8),
          child: Text(
            label,
            style: TextStyle(
              fontSize: 12,
              fontWeight: FontWeight.w400,
              color: color,
            ),
          ),
        ),
      ],
    );
  }
}

这个函数直接将图标添加到这列里。
文本在以一个仅有上间距的 Container 容器中,使得文本与图标分隔开。

通过调用函数并传递针对某列的颜色,Icon 图标和文本,来构建包含这些列的行。
然后在行的主轴方向通过使用 MainAxisAlignment.spaceEvenly,将剩余的空间均分到每列各自的前后及中间。
只需在 build() 方法中的 titleSection 声明下添加如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Color color = Theme.of(context).primaryColor;

Widget buttonSection = Row(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  children: [
    _buildButtonColumn(color, Icons.call, 'CALL'),
    _buildButtonColumn(color, Icons.near_me, 'ROUTE'),
    _buildButtonColumn(color, Icons.share, 'SHARE'),
  ],
);

添加按钮部分到 body 属性中去:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    Color color = Theme.of(context).primaryColor;

    Widget buttonSection = Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        _buildButtonColumn(color, Icons.call, 'CALL'),
        _buildButtonColumn(color, Icons.near_me, 'ROUTE'),
        _buildButtonColumn(color, Icons.share, 'SHARE'),
      ],
    );
    return MaterialApp(
      title: 'Flutter layout demo',
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Flutter layout demo'),
        ),
        body: Column(
          children: [titleSection, buttonSection],
        ),
      ),
    );
  }
  ...
}

实现文本区域

将文本区域定义为一个变量,将文本放置到一个 Container 容器中,然后为每条边添加内边距。只需在 buttonSection 声明下添加如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Widget textSection = const Padding(
  padding: EdgeInsets.all(32),
  child: Text(
    'Lake Oeschinen lies at the foot of the Blüemlisalp in the Bernese '
    'Alps. Situated 1,578 meters above sea level, it is one of the '
    'larger Alpine Lakes. A gondola ride from Kandersteg, followed by a '
    'half-hour walk through pastures and pine forest, leads you to the '
    'lake, which warms to 20 degrees Celsius in the summer. Activities '
    'enjoyed here include rowing, and riding the summer toboggan run.',
    softWrap: true,
  ),
);

通过设置 softwrap 为 true,文本将在填充满列宽后在单词边界处自动换行。

添加文本部分到 body 属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
return MaterialApp(
    title: 'Flutter layout demo',
    home: Scaffold(
    appBar: AppBar(
        title: const Text('Flutter layout demo'),
    ),
    body: Column(
        children: [titleSection, buttonSection, textSection],
    ),
    ),
);

实现图片区域

四个列元素中的三个已经完成了,只剩下图片部分了。
如下添加图片文件到示例工程中:

  • Create an images directory at the top of the project.
  • 添加 lake.jpg: images/lake.jpeg(https://raw.githubusercontent.com/flutter/website/master/examples/layout/lakes/step5/images/lake.jpg)
  • 更新 pubspec.yaml 文件,添加一个 assets 标签。这使得在你的代码中可以访问到该图片。
1
2
3
4
flutter:
  uses-material-design: true
  assets:
    - images/lake.jpeg

现在你可以在你的代码中引用该图片了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
return MaterialApp(
    title: 'Flutter layout demo',
    home: Scaffold(
    appBar: AppBar(
        title: const Text('Flutter layout demo'),
    ),
    body: Column(
        children: [
        Image.asset('images/lake.jpeg',
            width: 600, height: 240, fit: BoxFit.cover),
        titleSection,
        buttonSection,
        textSection
        ],
    ),
    ),
);

BoxFit.cover 告诉系统图片应当尽可能等比缩小到刚好能够覆盖住整个渲染 box。

最终的收尾

在最后的步骤中,需要在一个 ListView 中排列好所有的元素,而不是在一个 Column 中,因为当 app 运行在某个小设备上时,ListView 支持 app body 的滚动。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
return MaterialApp(
    title: 'Flutter layout demo',
    home: Scaffold(
    appBar: AppBar(
        title: const Text('Flutter layout demo'),
    ),
    body: ListView(
        children: [
        Image.asset('images/lake.jpeg',
            width: 600, height: 240, fit: BoxFit.cover),
        titleSection,
        buttonSection,
        textSection
        ],
    ),
    ),
);

大功告成!当你热加载 app 时,你应当可以看到和本页开头截图一样的 app 布局了。

创建响应式和自适应的应用

Flutter 的首要目标,是构建一个可以使用单一代码来源,开发在所有平台上都有着良好的视觉和体验的应用的框架。

这意味着你的应用可能会在不同大小的屏幕上使用,从智能手表,到可折叠的双屏设备,再到高清显示器。

通常这样的考量被分为两种概念:自适应响应式
理想条件下,你的应用应该两者兼具,但是它们究竟代表了什么?
这两种概念有些类似,但并不是同一种含义。

自适应应用和响应式应用的区别

自适应响应式 可以看作应用里的两种维度:你的应用可能是自适应的,但不是响应式的,又或是反行其道。
当然,你的应用可能既自适应又为响应式,也可能两者均未实现。

响应式
通常来说,一个 响应式 应用的布局会根据可用的屏幕大小而调整。
常见的场景是在用户重新调整窗口大小或旋转屏幕时,重新布局 UI。
对于需要在多种设备(手表、手机、平板、笔记本或台式机)上运行的应用而言,这是必要的要素。

自适应
应用以 自适应 的方式在不同的设备上(移动端和桌面端)运行,需要同时处理鼠标、键盘和触控输入。
这也意味着应用的视觉密度、组件的选择(层级菜单或底部抽屉)、平台特定的行为(例如置顶的窗口)等内容将在不同的平台上有一定的差异。

构建一个响应式的 Flutter 应用

Flutter 让你能够构建自动适配屏幕大小和旋转方向的应用。

构建响应式设计的 Flutter 应用,有以下两种较为基础的方式:

使用 LayoutBuilder 类
通过它的 builder 属性,你可以得到一个 BoxConstraints 对象。
你可以检查约束里的属性,来决定如何进行显示。
例如,如果约束里的 maxWidth 超过了你的宽度分界点,你可以返回一个 Scaffold,它包含一列内容,左侧是一个列表。
如果约束更小,则返回一个列表在抽屉里的 Scaffold。你也可以根据你的设备高度、屏幕的比例或者其他的属性,来调整你的显示。
当约束改变时(例如用户旋转了手机,或是在 Android N 上将应用放置到 tile UI 中)构建方法会运行。

在构建方法中使用 MediaQuery.of() 方法
这个方法可以获取到当前应用(基于上下文)的尺寸、旋转方向等信息。
如果你需要基于完整的上下文信息进行布局决策,而不是基于特定的 widget,这个方法将非常有用。
同样的,如果应用的尺寸发生了改变,构建方法也会自动执行。

以下是其他有助于构建响应式界面的 widget:

  • AspectRatio
  • CustomSingleChildLayout
  • CustomMultiChildLayout
  • FittedBox
  • FractionallySizedBox
  • LayoutBuilder
  • MediaQuery
  • MediaQueryData
  • OrientationBuilder

处理边界约束 (Box constraints) 的问题

Flutter 中的 widget 由在其底层的 RenderBox 对象渲染而成。
渲染框由其父级 widget 给出约束,并根据这些约束调整自身尺寸大小。
约束是由最小宽度、最大宽度、最小高度、最大高度四个方面构成;
尺寸大小则由特定的宽度和高度两个方面构成。

一般来说,从如何处理约束的角度来看,有以下三种类型的渲染框:

  • 尽可能大。比如 Center 和 ListView 的渲染框。
  • 与子 widget 一样大,比如 Transform 和 Opacity 的渲染框。
  • 特定大小,比如 Image 和 Text 的渲染框。

对于一些诸如 Container 的 widget,其尺寸会因构造方法的参数而异,就 Container 来说,它默认是尽可能大的,而一旦给它一个特定的宽度,那么它就会遵照这个特定的宽度来调整自身尺寸。

其它一些像 Row and Column (flex boxes)这样的 widget ,其尺寸会因给定的约束而异,具体细节见后文 “Flex” 部分;

约束有时是”紧密的”,这意味着这些约束严格地限定了渲染框在定夺自身尺寸方面的空间(例如:当约束的最小宽度和最大宽度相同时,这种情况下,我们称这个约束有紧密宽度),这方面的主要例子是 App Widget,它是 RenderView 类里面的一个 widget: 由应用程序的 build 函数返回的子 widget 渲染框被指定了一个约束,该约束强制 App Widget 精确填充应用程序的内容区域(通常是整个屏幕)。
Flutter 中的许多渲染框,特别是那些只包含单个 widget 的渲染框,都会将自身的约束传递给他们的子级 widget。
这意味着如果你在应用程序渲染树的根部嵌套了一些渲染框,这些框将会在受到约束的影响下相互适应彼此。

有些渲染框放松了约束,即:约束中只有最大宽度,最大高度,但没有最小宽度,最小高度,例如 Center。

无边界约束

在某些情况下,传递给框的约束是 无边界 的或无限的。这意味着约束的最大宽度或最大高度为 double.infinity。

当传递无边界约束给类型为尽可能大的框时会失效,在 debug 模式下,则会抛出异常,该异常信息会把你引导到本页面。

渲染框具有无边界约束的最常见情况是:当其被置于 flex boxes (Row 和 Column)内以及 可滚动区域(ListView 和其它 ScrollView 的子类)内时。

特别是 ListView 会试图扩展以适应其交叉方向可用空间 (比如说,如果它是一个垂直滚动块,它将试图扩充到与其父 widget 一样宽)。
如果让垂直滚动的 ListView 嵌套在水平滚动的 ListView 内,那么被嵌套在里面的垂直滚动的 ListView 将会试图尽可能宽,直到无限宽,因为将其嵌套的是一个水平滚动的ListView,它可以在水平方向上一直滚动。

Flex

Flex 框本身(Row 和 Column)的行为会有所不同,这取决于其在给定方向上是处于有边界约束还是无边界约束。

在有边界约束条件下,它们在给定方向上会尽可能大。

在无边界约束条件下,它们试图让其子 widget 自适应这个给定的方向。
在这种情况下,不能将子 widget 的flex属性设置为 0(默认值)以外的任何值。这意味着在 widget 库中,当一个 flex 框嵌套在另外一个 flex 框或者嵌套在可滚动区域内时,不能使用 Expanded。如果这样做了,就会收到异常,该异常信息会把你引导到本页面。

交叉 方向上,如 Column(垂直的 flex)的宽度和 Row(水平的 flex)的高度,它们必将不能是无界的,否则它们将无法合理地对齐它们的子 widget。