构建 Flutter 布局

本教程介绍如何在 Flutter 中设计和构建布局。

如果你使用提供的示例代码,则可以构建以下应用。

The finished app.
完成的应用。

照片由 Dino ReichmuthUnsplash 上提供。文本由 瑞士旅游局 提供。

要更好地了解布局机制,请从 Flutter 的布局方法 开始。

绘制布局图

在本部分中,考虑你希望为应用用户提供哪种类型的用户体验。

考虑如何定位用户界面的组件。布局由这些定位的最终结果组成。考虑规划布局以加快编码速度。使用视觉提示来了解屏幕上某项内容的位置会很有帮助。

使用你喜欢的任何方法,比如界面设计工具或铅笔和一张纸。在编写代码之前,先确定要在屏幕上放置元素的位置。这是格言“测量两次,切割一次”的编程版本。

  1. 提出这些问题,将布局分解为基本元素。

    • 你能识别出行和列吗?
    • 布局是否包含网格?
    • 是否有重叠元素?
    • UI 是否需要选项卡?
    • 你需要对齐、填充或设置边框的是什么?
  2. 识别较大的元素。在此示例中,你将图像、标题、按钮和描述排列成一列。

    Major elements in the layout: image, row, row, and text block
    布局中的主要元素:图像、行、行和文本块
  3. 对每一行绘制图表。

    1. 第 1 行,即标题部分,有三个子元素:一列文本、一个星形图标和一个数字。其第一个子元素(列)包含两行文本。第一列可能需要更多空间。

      Title section with text blocks and an icon
      带有文本块和图标的标题部分
    2. 第 2 行,即按钮部分,有三个子元素:每个子元素都包含一列,然后一列包含一个图标和文本。

      The Button section with three labeled buttons
      带有三个标记按钮的按钮部分

在绘制布局图表后,考虑如何对其进行编码。

你是否会将所有代码都写在一个类中?或者,你是否会为布局的每个部分创建一个类?

要遵循 Flutter 最佳实践,请创建一个类或小部件来包含布局的每个部分。当 Flutter 需要重新渲染 UI 的一部分时,它会更新更改的最小部分。这就是 Flutter 使“所有内容都成为小部件”的原因。如果仅在 Text 小部件中更改文本,则 Flutter 仅重新绘制该文本。Flutter 会根据用户输入尽可能最少地更改 UI。

对于本教程,将你识别的每个元素写成它自己的小部件。

创建应用基础代码

在本部分中,剥离基本的 Flutter 应用代码以启动你的应用。

  1. 设置你的 Flutter 环境.

  2. 创建一个新的 Flutter 应用.

  3. 使用以下代码替换 lib/main.dart 的内容。此应用使用应用标题和应用 appBar 上显示的标题的参数。此决策简化了代码。

    lib/main.dart(全部)
    import 'package:flutter/material.dart';
    
    void main() => runApp(const MyApp());
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        const String appTitle = 'Flutter layout demo';
        return MaterialApp(
          title: appTitle,
          home: Scaffold(
            appBar: AppBar(
              title: const Text(appTitle),
            ),
            body: const Center(
              child: Text('Hello World'),
            ),
          ),
        );
      }
    }

添加标题部分

在本部分中,创建一个类似于以下布局的 TitleSection 小部件。

The Title section as sketch and prototype UI
标题部分作为草图和原型 UI

添加 TitleSection 小组件

MyApp 类之后添加以下代码。

step2/lib/main.dart (titleSection)
class TitleSection extends StatelessWidget {
  const TitleSection({
    super.key,
    required this.name,
    required this.location,
  });

  final String name;
  final String location;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(32),
      child: Row(
        children: [
          Expanded(
            /*1*/
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                /*2*/
                Padding(
                  padding: const EdgeInsets.only(bottom: 8),
                  child: Text(
                    name,
                    style: const TextStyle(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
                Text(
                  location,
                  style: TextStyle(
                    color: Colors.grey[500],
                  ),
                ),
              ],
            ),
          ),
          /*3*/
          Icon(
            Icons.star,
            color: Colors.red[500],
          ),
          const Text('41'),
        ],
      ),
    );
  }
}
  1. 要使用行中所有剩余的可用空间,请使用 Expanded 小组件来拉伸 Column 小组件。要将列放置在行的开头,请将 crossAxisAlignment 属性设置为 CrossAxisAlignment.start
  2. 要在文本行之间添加空格,请将这些行放入 Padding 小组件中。
  3. 标题行以红色星形图标和文本 41 结尾。整行都位于 Padding 小组件中,并用 32 像素填充每条边。

将应用主体更改为滚动视图

body 属性中,用 SingleChildScrollView 小组件替换 Center 小组件。在 SingleChildScrollView 小组件中,用 Column 小组件替换 Text 小组件。

{../base → step2}/lib/main.dart
@@ -21,2 +17,3 @@
21
- body: const Center(
22
- child: Text('Hello World'),
17
+ body: const SingleChildScrollView(
18
+ child: Column(
19
+ children: [

这些代码更新以以下方式更改应用。

  • SingleChildScrollView 小组件可以滚动。这允许显示不适合当前屏幕的元素。
  • 一个 Column 小组件会按其 children 属性中列出的顺序显示其内的任何元素。在 children 列表中列出的第一个元素会显示在列表顶部。 children 列表中的元素会按数组顺序从上到下显示在屏幕上。

更新应用以显示标题部分

TitleSection 小组件添加为 children 列表中的第一个元素。这会将其置于屏幕顶部。将提供的名称和位置传递给 TitleSection 构造函数。

{../base → step2}/lib/main.dart
@@ -23 +19,6 @@
19
+ children: [
20
+ TitleSection(
21
+ name: 'Oeschinen Lake Campground',
22
+ location: 'Kandersteg, Switzerland',
23
+ ),
24
+ ],

添加按钮部分

在此部分中,添加将为您的应用添加功能的按钮。

按钮部分包含三列,它们使用相同的布局:一行文本上的一个图标。

The Button section as sketch and prototype UI
按钮部分作为草图和原型 UI

计划将这些列分布在一行中,以便每列占用相同数量的空间。使用主色绘制所有文本和图标。

添加 ButtonSection 小组件

TitleSection 小组件之后添加以下代码,以包含用于构建按钮行的代码。

lib/main.dart (ButtonStart)
class ButtonSection extends StatelessWidget {
  const ButtonSection({super.key});

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

创建一个小组件来制作按钮

由于每列的代码可以使用相同的语法,因此创建一个名为 ButtonWithText 的小组件。小组件的构造函数接受一个颜色、图标数据和按钮的标签。使用这些值,小组件会构建一个 Column,其子项为 Icon 和样式化的 Text 小组件。为了帮助分隔这些子项,一个 Padding 小组件与一个 Padding 小组件一起包装 Text 小组件。

ButtonSection 类之后添加以下代码。

lib/main.dart(ButtonWithText)
class ButtonSection extends StatelessWidget {
  const ButtonSection({super.key});
// ···
}

class ButtonWithText extends StatelessWidget {
  const ButtonWithText({
    super.key,
    required this.color,
    required this.icon,
    required this.label,
  });

  final Color color;
  final IconData icon;
  final String label;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(icon, color: color),
        Padding(
          padding: const EdgeInsets.only(top: 8),
          child: Text(
            label,
            style: TextStyle(
              fontSize: 12,
              fontWeight: FontWeight.w400,
              color: color,
            ),
          ),
        ),
      ],
    );
  }

使用 Row 小组件定位按钮

将以下代码添加到 ButtonSection 小组件中。

  1. 为每个按钮添加三个 ButtonWithText 小组件实例。
  2. 传递特定按钮的颜色、Icon 和文本。
  3. 使用 MainAxisAlignment.spaceEvenly 值沿着主轴对齐列。 Row 小组件的主轴是水平的,而 Column 小组件的主轴是垂直的。然后,此值告诉 Flutter 在 Row 沿每个列之前、之间和之后以相等量排列自由空间。
lib/main.dart(ButtonSection)
class ButtonSection extends StatelessWidget {
  const ButtonSection({super.key});

  @override
  Widget build(BuildContext context) {
    final Color color = Theme.of(context).primaryColor;
    return SizedBox(
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
        children: [
          ButtonWithText(
            color: color,
            icon: Icons.call,
            label: 'CALL',
          ),
          ButtonWithText(
            color: color,
            icon: Icons.near_me,
            label: 'ROUTE',
          ),
          ButtonWithText(
            color: color,
            icon: Icons.share,
            label: 'SHARE',
          ),
        ],
      ),
    );
  }
}

class ButtonWithText extends StatelessWidget {
  const ButtonWithText({
    super.key,
    required this.color,
    required this.icon,
    required this.label,
  });

  final Color color;
  final IconData icon;
  final String label;

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

更新应用以显示按钮部分

将按钮部分添加到 children 列表中。

{step2 → step3}/lib/main.dart (addWidget)
@@ -5,6 +5,7 @@
5
5
  name: 'Oeschinen Lake Campground',
6
6
  location: 'Kandersteg, Switzerland',
7
7
  ),
8
+ ButtonSection(),
8
9
  ],
9
10
  ),
10
11
  ),

添加文本部分

在此部分中,向此应用添加文本描述。

The text block as sketch and prototype UI
文本块作为草图和原型 UI

添加 TextSection 小组件

ButtonSection 小组件之后添加以下代码作为单独的小组件。

step4/lib/main.dart (TextSection)
class TextSection extends StatelessWidget {
  const TextSection({
    super.key,
    required this.description,
  });

  final String description;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(32),
      child: Text(
        description,
        softWrap: true,
      ),
    );
  }
}

通过将 softWrap 设置为 true,文本行会在单词边界换行之前填充列宽。

更新应用以显示文本部分

ButtonSection 之后添加一个新的 TextSection 小组件作为子组件。在添加 TextSection 小组件时,将其 description 属性设置为位置说明的文本。

{step3 → step4}/lib/main.dart (addWidget)
@@ -6,6 +6,16 @@
6
6
  location: 'Kandersteg, Switzerland',
7
7
  ),
8
8
  ButtonSection(),
9
+ TextSection(
10
+ description:
11
+ 'Lake Oeschinen lies at the foot of the Blüemlisalp in the '
12
+ 'Bernese Alps. Situated 1,578 meters above sea level, it '
13
+ 'is one of the larger Alpine Lakes. A gondola ride from '
14
+ 'Kandersteg, followed by a half-hour walk through pastures '
15
+ 'and pine forest, leads you to the lake, which warms to 20 '
16
+ 'degrees Celsius in the summer. Activities enjoyed here '
17
+ 'include rowing, and riding the summer toboggan run.',
18
+ ),
9
19
  ],
10
20
  ),
11
21
  ),

添加图片部分

在此部分中,添加图像文件以完成布局。

配置应用以使用提供的图像

要配置应用以引用图像,请修改其 pubspec.yaml 文件。

  1. 在项目顶部创建一个 images 目录。

  2. 下载 lake.jpg 图像,并将其添加到新的 images 目录中。

  3. 要包含图片,请在应用根目录下的 pubspec.yaml 文件中添加 assets 标记。添加 assets 时,它作为代码可用的图片指针集。

    {step4 → step5}/pubspec.yaml
    @@ -19,3 +19,5 @@
    19
    19
      flutter:
    20
    20
      uses-material-design: true
    21
    + assets:
    22
    + - images/lake.jpg

创建 ImageSection 窗口小部件

在其他声明之后定义以下 ImageSection 窗口小部件。

step5/lib/main.dart (ImageSection)
class ImageSection extends StatelessWidget {
  const ImageSection({super.key, required this.image});

  final String image;

  @override
  Widget build(BuildContext context) {
    return Image.asset(
      image,
      width: 600,
      height: 240,
      fit: BoxFit.cover,
    );
  }
}

BoxFit.cover 值告诉 Flutter 以两个约束显示图片。首先,尽可能小地显示图片。其次,覆盖布局分配的所有空间,称为渲染框。

更新应用以显示图片部分

children 列表中添加 ImageSection 窗口小部件作为第一个子项。将 image 属性设置为在 配置应用以使用提供的图片 中添加的图片路径。

{step4 → step5}/lib/main.dart (addWidget)
@@ -1,6 +1,9 @@
1
1
  body: const SingleChildScrollView(
2
2
  child: Column(
3
3
  children: [
4
+ ImageSection(
5
+ image: 'images/lake.jpg',
6
+ ),
4
7
  TitleSection(
5
8
  name: 'Oeschinen Lake Campground',
6
9
  location: 'Kandersteg, Switzerland',

恭喜

就是这样!当你热重载应用时,你的应用看起来应该像这样。

The finished app
完成的应用

资源

你可以从以下位置访问本教程中使用的资源

Dart 代码: main.dart
图片: ch-photo
Pubspec: pubspec.yaml

下一步

要为这个布局添加交互性,请按照交互性教程操作。