跳到主内容

构建 Flutter 布局

学习如何在 Flutter 中构建布局。

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

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

The finished app.

最终成品应用。

摄影:Dino Reichmuth (来自 Unsplash)。文本:瑞士旅游局

若要更全面地了解布局机制,请先从 Flutter 的布局方案开始。

绘制布局草图

#

在这一部分,请思考您想要为应用用户提供什么样的用户体验。

思考如何定位用户界面的组件。布局即这些定位的总和。考虑提前规划布局以加快编码速度。使用视觉线索来确定屏幕元素的位置会有很大帮助。

无论您喜欢哪种方式(例如界面设计工具或纸笔),请在编写代码之前先确定要在屏幕上放置元素的位置。这就是编程版的谚语:“三思而后行”。

  1. 问自己以下问题,将布局拆解为基础元素。

    • 你能识别出哪些是行,哪些是列吗?
    • 布局是否包含网格?
    • 是否有重叠的元素?
    • UI 是否需要标签页(tabs)?
    • 你需要对齐、添加内边距(padding)或边框的地方在哪里?
  2. 识别出较大的元素。在这个示例中,您将图像、标题、按钮和描述排列成一个列(Column)。

    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 中。这种方式简化了代码。

    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 类之后添加以下代码。

dart
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 属性中,将 Center 组件替换为 SingleChildScrollView 组件。在 SingleChildScrollView 组件内,将 Text 组件替换为 Column 组件。

dart
body: const Center(
  child: Text('Hello World'),
body: const SingleChildScrollView(
  child: Column(
    children: [

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

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

更新应用以显示标题部分

#

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

dart
children: [
  TitleSection(
    name: 'Oeschinen Lake Campground',
    location: 'Kandersteg, Switzerland',
  ),
],

添加按钮部分

#

在这一部分,添加将为您的应用增加功能的按钮。

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

The Button section as sketch and prototype UI

按钮部分的草图与 UI 原型

计划将这些列分布在同一行中,以便每个列占用相同的空间。将所有文本和图标设为主题色(primary color)。

添加 ButtonSection 组件

#

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

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

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

}

创建一个用于生成按钮的组件

#

由于每一列的代码可以使用相同的语法,因此创建一个名为 ButtonWithText 的组件。该组件的构造函数接受颜色、图标数据和按钮标签。利用这些值,该组件构建了一个包含 Icon 和样式化的 Text 组件作为其子元素的 Column。为了分隔这些子元素,Text 组件被包裹在一个 Padding 组件中。

ButtonSection 类之后添加以下代码。

dart
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 中每一列的前面、中间和后面均匀地分配空白空间。
dart
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 列表中。

dart
  TitleSection(
    name: 'Oeschinen Lake Campground',
    location: 'Kandersteg, Switzerland',
  ),
  ButtonSection(),
],

添加文本部分

#

在这一部分,向应用添加文本描述。

The text block as sketch and prototype UI

文本块的草图与 UI 原型

添加 TextSection 组件

#

将以下代码作为独立组件添加到 ButtonSection 组件之后。

dart
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 属性设置为地点描述文本。

dart
    location: 'Kandersteg, Switzerland',
  ),
  ButtonSection(),
  TextSection(
    description:
        '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.',
  ),
],

添加图像部分

#

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

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

#

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

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

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

  3. 若要包含图像,请在应用根目录的 pubspec.yaml 文件中添加 assets 标签。当您添加 assets 时,它将充当指向代码可用图像的指针集。

    pubspec.yaml
    yaml
    flutter:
      uses-material-design: true
      assets:
        - images/lake.jpg
    

创建 ImageSection 组件

#

在其他声明之后定义以下 ImageSection 组件。

dart
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 在两个约束条件下显示图像。首先,尽可能显示图像。其次,覆盖布局分配的所有空间,即所谓的渲染框(render box)。

更新应用以显示图像部分

#

ImageSection 组件添加为 children 列表中的第一个子项。将 image 属性设置为您在配置应用以使用提供的图像中添加的图像路径。

dart
children: [
  ImageSection(
    image: 'images/lake.jpg',
  ),
  TitleSection(
    name: 'Oeschinen Lake Campground',
    location: 'Kandersteg, Switzerland',

恭喜

#

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

The finished app

最终成品应用

资源

#

您可以从以下位置获取本教程中使用的资源:

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

后续步骤

#

若要为该布局添加交互性,请阅读交互性教程