使用 Flutter 构建用户界面

Flutter 小组件使用一个现代框架构建,该框架从 React 中汲取灵感。核心思想是使用小组件构建 UI。小组件描述了在给定其当前配置和状态的情况下其视图应如何显示。当小组件的状态发生变化时,小组件会重新构建其描述,框架会将该描述与之前的描述进行比较,以确定从一种状态过渡到另一种状态所需的基本更改。

你好,世界

最小的 Flutter 应用只需使用小组件调用 runApp() 函数

import 'package:flutter/material.dart';

void main() {
  runApp(
    const Center(
      child: Text(
        'Hello, world!',
        textDirection: TextDirection.ltr,
      ),
    ),
  );
}

runApp() 函数获取给定的 Widget,并将其作为小组件树的根。在此示例中,小组件树由两个小组件组成,即 Center 小组件及其子项 Text 小组件。框架强制根小组件覆盖屏幕,这意味着文本“你好,世界”最终居中显示在屏幕上。在此实例中需要指定文本方向;当使用 MaterialApp 小组件时,会为你处理此问题,如下所示。

在编写应用时,你通常会编写新的小部件,它们是 StatelessWidgetStatefulWidget 的子类,具体取决于你的小部件是否管理任何状态。小部件的主要工作是实现一个 build() 函数,该函数根据其他更低级的小部件来描述小部件。框架依次构建这些小部件,直到该过程在表示底层 RenderObject 的小部件中结束,该对象计算并描述小部件的几何形状。

基本小部件

Flutter 附带了一套功能强大的基本小部件,其中以下小部件是常用的

文本
Text 小部件允许你在应用程序中创建一段带样式的文本。
RowColumn
这些 flex 小部件允许你在水平 (Row) 和垂直 (Column) 方向上创建灵活的布局。这些对象的设计基于 Web 的 flexbox 布局模型。
Stack
与线性定向(水平或垂直)不同,Stack 小部件允许你按绘制顺序将小部件放在彼此之上。然后,你可以在 Stack 的子项上使用 Positioned 小部件,以相对于堆栈的顶部、右侧、底部或左侧边缘对它们进行定位。堆栈基于 Web 的绝对定位布局模型。
Container
Container 小部件允许你创建一个矩形可视元素。容器可以用 BoxDecoration 进行装饰,例如背景、边框或阴影。Container 还可以对它的尺寸应用边距、内边距和约束。此外,可以使用矩阵在三维空间中变换 Container

下面是一些结合了这些和其他小部件的简单小部件

import 'package:flutter/material.dart';

class MyAppBar extends StatelessWidget {
  const MyAppBar({required this.title, super.key});

  // Fields in a Widget subclass are always marked "final".

  final Widget title;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 56, // in logical pixels
      padding: const EdgeInsets.symmetric(horizontal: 8),
      decoration: BoxDecoration(color: Colors.blue[500]),
      // Row is a horizontal, linear layout.
      child: Row(
        children: [
          const IconButton(
            icon: Icon(Icons.menu),
            tooltip: 'Navigation menu',
            onPressed: null, // null disables the button
          ),
          // Expanded expands its child
          // to fill the available space.
          Expanded(
            child: title,
          ),
          const IconButton(
            icon: Icon(Icons.search),
            tooltip: 'Search',
            onPressed: null,
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    // Material is a conceptual piece
    // of paper on which the UI appears.
    return Material(
      // Column is a vertical, linear layout.
      child: Column(
        children: [
          MyAppBar(
            title: Text(
              'Example title',
              style: Theme.of(context) //
                  .primaryTextTheme
                  .titleLarge,
            ),
          ),
          const Expanded(
            child: Center(
              child: Text('Hello, world!'),
            ),
          ),
        ],
      ),
    );
  }
}

void main() {
  runApp(
    const MaterialApp(
      title: 'My app', // used by the OS task switcher
      home: SafeArea(
        child: MyScaffold(),
      ),
    ),
  );
}

确保在 pubspec.yaml 文件的 flutter 部分中有一个 uses-material-design: true 条目。它允许你使用预定义的 Material 图标。如果你正在使用 Materials 库,通常最好包含此行。

name: my_app
flutter:
  uses-material-design: true

许多 Material Design 小组件需要在 MaterialApp 中才能正确显示,以便继承主题数据。因此,使用 MaterialApp 运行应用程序。

MyAppBar 小组件创建一个 Container,其高度为 56 个与设备无关的像素,内部填充为 8 个像素,左右两侧均如此。在容器内,MyAppBar 使用 Row 布局来组织其子级。中间子级(title 小组件)标记为 Expanded,这意味着它会扩展以填充其他子级尚未占用的任何剩余可用空间。你可以拥有多个 Expanded 子级,并使用 Expandedflex 参数来确定它们消耗可用空间的比率。

MyScaffold 小组件以垂直列的形式组织其子级。它在列的顶部放置 MyAppBar 的一个实例,并向应用程序栏传递一个 Text 小组件以用作其标题。将小组件作为参数传递给其他小组件是一项强大的技术,它使你可以创建可以在各种方式中重复使用的通用小组件。最后,MyScaffold 使用 Expanded 用其主体填充剩余空间,主体由一个居中的消息组成。

有关更多信息,请查看 布局

使用 Material 组件

Flutter 提供了许多小组件,帮助你构建遵循 Material Design 的应用程序。Material 应用程序从 MaterialApp 小组件开始,该小组件在应用程序的根目录下构建许多有用的组件,包括 Navigator,它管理一个由字符串(也称为“路由”)标识的小组件堆栈。 Navigator 使你可以在应用程序的屏幕之间平滑过渡。使用 MaterialApp 小组件是完全可选的,但这是一个好习惯。

import 'package:flutter/material.dart';

void main() {
  runApp(
    const MaterialApp(
      title: 'Flutter Tutorial',
      home: TutorialHome(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    // Scaffold is a layout for
    // the major Material Components.
    return Scaffold(
      appBar: AppBar(
        leading: const IconButton(
          icon: Icon(Icons.menu),
          tooltip: 'Navigation menu',
          onPressed: null,
        ),
        title: const Text('Example title'),
        actions: const [
          IconButton(
            icon: Icon(Icons.search),
            tooltip: 'Search',
            onPressed: null,
          ),
        ],
      ),
      // body is the majority of the screen.
      body: const Center(
        child: Text('Hello, world!'),
      ),
      floatingActionButton: const FloatingActionButton(
        tooltip: 'Add', // used by assistive technologies
        onPressed: null,
        child: Icon(Icons.add),
      ),
    );
  }
}

现在代码已从 MyAppBarMyScaffold 切换到 AppBarScaffold 小组件,并且从 material.dart 切换,该应用开始看起来更像 Material。例如,应用栏具有阴影,标题文本会自动继承正确的样式。还添加了一个浮动操作按钮。

请注意,小组件作为参数传递给其他小组件。 Scaffold 小组件将多个不同的组件作为命名参数,每个组件都放置在 Scaffold 布局的适当位置。类似地, AppBar 小组件允许您为 leading 小组件和 actions 小组件的 title 传递小组件。此模式在整个框架中反复出现,在设计您自己的小组件时,您可能会考虑这一点。

有关更多信息,请查看 Material Components 小组件

处理手势

大多数应用程序都包含某种形式的用户与系统交互。构建交互式应用程序的第一步是检测输入手势。通过创建一个简单的按钮,了解它是如何工作的

import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        print('MyButton was tapped!');
      },
      child: Container(
        height: 50,
        padding: const EdgeInsets.all(8),
        margin: const EdgeInsets.symmetric(horizontal: 8),
        decoration: BoxDecoration(
          borderRadius: BorderRadius.circular(5),
          color: Colors.lightGreen[500],
        ),
        child: const Center(
          child: Text('Engage'),
        ),
      ),
    );
  }
}

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Center(
          child: MyButton(),
        ),
      ),
    ),
  );
}

GestureDetector 小组件没有可视表示,而是检测用户做出的手势。当用户点击 Container 时, GestureDetector 调用其 onTap() 回调,在本例中,向控制台打印一条消息。您可以使用 GestureDetector 检测各种输入手势,包括点击、拖动和缩放。

许多小部件使用 GestureDetector 为其他小部件提供可选回调。例如,IconButtonElevatedButtonFloatingActionButton 小部件具有 onPressed() 回调,当用户点击小部件时触发这些回调。

有关更多信息,请查看 Flutter 中的手势

根据输入更改小部件

到目前为止,本页仅使用了无状态小部件。无状态小部件从其父小部件接收参数,这些参数存储在 final 成员变量中。当要求小部件 build() 时,它使用这些存储的值来导出其创建的小部件的新参数。

为了构建更复杂的用户体验(例如,以更有趣的方式对用户输入做出反应),应用程序通常会携带一些状态。Flutter 使用 StatefulWidgets 来捕获此想法。 StatefulWidgets 是特殊的小部件,它们知道如何生成 State 对象,然后使用这些对象来保存状态。考虑这个基本示例,它使用前面提到的 ElevatedButton

import 'package:flutter/material.dart';

class Counter extends StatefulWidget {
  // This class is the configuration for the state.
  // It holds the values (in this case nothing) provided
  // by the parent and used by the build  method of the
  // State. Fields in a Widget subclass are always marked
  // "final".

  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      // This call to setState tells the Flutter framework
      // that something has changed in this State, which
      // causes it to rerun the build method below so that
      // the display can reflect the updated values. If you
      // change _counter without calling setState(), then
      // the build method won't be called again, and so
      // nothing would appear to happen.
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    // This method is rerun every time setState is called,
    // for instance, as done by the _increment method above.
    // The Flutter framework has been optimized to make
    // rerunning build methods fast, so that you can just
    // rebuild anything that needs updating rather than
    // having to individually changes instances of widgets.
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        ElevatedButton(
          onPressed: _increment,
          child: const Text('Increment'),
        ),
        const SizedBox(width: 16),
        Text('Count: $_counter'),
      ],
    );
  }
}

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Counter(),
        ),
      ),
    ),
  );
}

您可能想知道为什么 StatefulWidgetState 是独立的对象。在 Flutter 中,这两种类型的对象具有不同的生命周期。 Widgets 是临时对象,用于构建应用程序当前状态的表示形式。另一方面,State 对象在对 build() 的调用之间是持久的,允许它们记住信息。

上面的示例接受用户输入,并在其 build() 方法中直接使用结果。在更复杂应用程序中,小部件层次结构的不同部分可能负责不同的关注点;例如,一个小部件可能会显示一个复杂的用户界面,目的是收集特定信息,例如日期或位置,而另一个小部件可能会使用该信息来更改整体表示形式。

在 Flutter 中,更改通知通过回调的方式“向上”流向小部件层次结构,而当前状态则“向下”流向执行演示的无状态小部件。重新定向此流的公共父级是 State。以下稍复杂的示例展示了此操作在实践中的工作方式

import 'package:flutter/material.dart';

class CounterDisplay extends StatelessWidget {
  const CounterDisplay({required this.count, super.key});

  final int count;

  @override
  Widget build(BuildContext context) {
    return Text('Count: $count');
  }
}

class CounterIncrementor extends StatelessWidget {
  const CounterIncrementor({required this.onPressed, super.key});

  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onPressed,
      child: const Text('Increment'),
    );
  }
}

class Counter extends StatefulWidget {
  const Counter({super.key});

  @override
  State<Counter> createState() => _CounterState();
}

class _CounterState extends State<Counter> {
  int _counter = 0;

  void _increment() {
    setState(() {
      ++_counter;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        CounterIncrementor(onPressed: _increment),
        const SizedBox(width: 16),
        CounterDisplay(count: _counter),
      ],
    );
  }
}

void main() {
  runApp(
    const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Counter(),
        ),
      ),
    ),
  );
}

注意创建了两个新的无状态小部件,清楚地分开了显示计数器 (CounterDisplay) 和更改计数器 (CounterIncrementor) 的关注点。尽管最终结果与之前的示例相同,但职责分离允许在各个小部件中封装更大的复杂性,同时保持父级中的简单性。

有关更多信息,请查看

将所有内容汇集在一起

以下是一个更完整的示例,它汇集了这些概念:一个假设的购物应用程序显示了各种待售产品,并维护一个用于预期购买的购物车。首先定义演示类,ShoppingListItem

import 'package:flutter/material.dart';

class Product {
  const Product({required this.name});

  final String name;
}

typedef CartChangedCallback = Function(Product product, bool inCart);

class ShoppingListItem extends StatelessWidget {
  ShoppingListItem({
    required this.product,
    required this.inCart,
    required this.onCartChanged,
  }) : super(key: ObjectKey(product));

  final Product product;
  final bool inCart;
  final CartChangedCallback onCartChanged;

  Color _getColor(BuildContext context) {
    // The theme depends on the BuildContext because different
    // parts of the tree can have different themes.
    // The BuildContext indicates where the build is
    // taking place and therefore which theme to use.

    return inCart //
        ? Colors.black54
        : Theme.of(context).primaryColor;
  }

  TextStyle? _getTextStyle(BuildContext context) {
    if (!inCart) return null;

    return const TextStyle(
      color: Colors.black54,
      decoration: TextDecoration.lineThrough,
    );
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      onTap: () {
        onCartChanged(product, inCart);
      },
      leading: CircleAvatar(
        backgroundColor: _getColor(context),
        child: Text(product.name[0]),
      ),
      title: Text(product.name, style: _getTextStyle(context)),
    );
  }
}

void main() {
  runApp(
    MaterialApp(
      home: Scaffold(
        body: Center(
          child: ShoppingListItem(
            product: const Product(name: 'Chips'),
            inCart: true,
            onCartChanged: (product, inCart) {},
          ),
        ),
      ),
    ),
  );
}

ShoppingListItem 小部件遵循无状态小部件的常见模式。它在 final 成员变量中存储在构造函数中接收的值,然后在 build() 函数期间使用这些值。例如,inCart 布尔值在两种视觉外观之间切换:一种使用当前主题中的主色,另一种使用灰色。

当用户点击列表项时,小部件不会直接修改其 inCart 值。相反,小部件调用从其父小部件接收的 onCartChanged 函数。此模式允许您将状态存储在小部件层次结构中较高的位置,这会导致状态持续更长的时间。极端情况下,传递给 runApp() 的小部件上存储的状态会持续到应用程序的生命周期。

当父组件收到 onCartChanged 回调时,父组件会更新其内部状态,从而触发父组件重建并使用新的 inCart 值创建一个 ShoppingListItem 的新实例。虽然父组件在重建时会创建一个 ShoppingListItem 的新实例,但该操作开销很小,因为框架会将新构建的小组件与之前构建的小组件进行比较,并仅将差异应用于底层的 RenderObject

下面是一个存储可变状态的父小组件示例

import 'package:flutter/material.dart';

class Product {
  const Product({required this.name});

  final String name;
}

typedef CartChangedCallback = Function(Product product, bool inCart);

class ShoppingListItem extends StatelessWidget {
  ShoppingListItem({
    required this.product,
    required this.inCart,
    required this.onCartChanged,
  }) : super(key: ObjectKey(product));

  final Product product;
  final bool inCart;
  final CartChangedCallback onCartChanged;

  Color _getColor(BuildContext context) {
    // The theme depends on the BuildContext because different
    // parts of the tree can have different themes.
    // The BuildContext indicates where the build is
    // taking place and therefore which theme to use.

    return inCart //
        ? Colors.black54
        : Theme.of(context).primaryColor;
  }

  TextStyle? _getTextStyle(BuildContext context) {
    if (!inCart) return null;

    return const TextStyle(
      color: Colors.black54,
      decoration: TextDecoration.lineThrough,
    );
  }

  @override
  Widget build(BuildContext context) {
    return ListTile(
      onTap: () {
        onCartChanged(product, inCart);
      },
      leading: CircleAvatar(
        backgroundColor: _getColor(context),
        child: Text(product.name[0]),
      ),
      title: Text(
        product.name,
        style: _getTextStyle(context),
      ),
    );
  }
}

class ShoppingList extends StatefulWidget {
  const ShoppingList({required this.products, super.key});

  final List<Product> products;

  // The framework calls createState the first time
  // a widget appears at a given location in the tree.
  // If the parent rebuilds and uses the same type of
  // widget (with the same key), the framework re-uses
  // the State object instead of creating a new State object.

  @override
  State<ShoppingList> createState() => _ShoppingListState();
}

class _ShoppingListState extends State<ShoppingList> {
  final _shoppingCart = <Product>{};

  void _handleCartChanged(Product product, bool inCart) {
    setState(() {
      // When a user changes what's in the cart, you need
      // to change _shoppingCart inside a setState call to
      // trigger a rebuild.
      // The framework then calls build, below,
      // which updates the visual appearance of the app.

      if (!inCart) {
        _shoppingCart.add(product);
      } else {
        _shoppingCart.remove(product);
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Shopping List'),
      ),
      body: ListView(
        padding: const EdgeInsets.symmetric(vertical: 8),
        children: widget.products.map((product) {
          return ShoppingListItem(
            product: product,
            inCart: _shoppingCart.contains(product),
            onCartChanged: _handleCartChanged,
          );
        }).toList(),
      ),
    );
  }
}

void main() {
  runApp(const MaterialApp(
    title: 'Shopping App',
    home: ShoppingList(
      products: [
        Product(name: 'Eggs'),
        Product(name: 'Flour'),
        Product(name: 'Chocolate chips'),
      ],
    ),
  ));
}

ShoppingList 类扩展 StatefulWidget,这意味着此小组件存储可变状态。当 ShoppingList 小组件首次插入到树中时,框架会调用 createState() 函数,以创建 _ShoppingListState 的新实例,并将其与树中的该位置关联。(请注意,State 的子类通常以以下划线开头命名,以表明它们是私有实现细节。)当此小组件的父组件重建时,父组件会创建一个 ShoppingList 的新实例,但框架会重复使用树中已有的 _ShoppingListState 实例,而不是再次调用 createState

要访问当前 ShoppingList 的属性,_ShoppingListState 可以使用其 widget 属性。如果父组件重建并创建新的 ShoppingList_ShoppingListState 会使用新的小组件值重建。如果您希望在 widget 属性更改时收到通知,请重写 didUpdateWidget() 函数,该函数会传递一个 oldWidget,以便您可以将旧小组件与当前小组件进行比较。

在处理 onCartChanged 回调时,_ShoppingListState 通过向 _shoppingCart 添加或移除产品来改变其内部状态。为了向框架发出其内部状态已更改的信号,它将这些调用包装在 setState() 调用中。调用 setState 会将此小组件标记为脏,并计划在下一次应用需要更新屏幕时对其进行重建。如果您在修改小组件的内部状态时忘记调用 setState,则框架将不知道您的组件已变脏,并且可能不会调用组件的 build() 函数,这意味着用户界面可能不会更新以反映已更改的状态。通过这种方式管理状态,您无需编写用于创建和更新子组件的单独代码。相反,您只需实现 build 函数,它可以处理这两种情况。

响应小组件生命周期事件

StatefulWidget 上调用 createState() 后,框架将新的状态对象插入到树中,然后在状态对象上调用 initState()State 的子类可以覆盖 initState 来执行仅需执行一次的工作。例如,覆盖 initState 来配置动画或订阅平台服务。initState 的实现需要以调用 super.initState 开始。

当不再需要状态对象时,框架将在状态对象上调用 dispose()。覆盖 dispose 函数来执行清理工作。例如,覆盖 dispose 来取消计时器或取消订阅平台服务。dispose 的实现通常以调用 super.dispose 结束。

有关更多信息,请查看 State

使用键来控制框架在小组件重建时将哪些小组件与其他小组件匹配。默认情况下,框架会根据小组件的 runtimeType 和它们出现的顺序来匹配当前和先前版本中的小组件。使用键后,框架要求这两个小组件具有相同的 key 和相同的 runtimeType

键在构建大量相同类型小组件实例的小组件中最为有用。例如,ShoppingList 小组件,它仅构建足够多的 ShoppingListItem 实例来填充其可见区域

  • 如果没有键,当前版本中的第一个条目将始终与前一个版本中的第一个条目同步,即使从语义上讲,列表中的第一个条目刚刚滚动出屏幕,并且在视口中不再可见。

  • 通过为列表中的每个条目分配一个“语义”键,无限列表可以变得更有效,因为框架会同步具有匹配语义键的条目,因此具有类似(或相同)的可视外观。此外,从语义上同步条目意味着保留在有状态子小组件中的状态会附加到相同的语义条目,而不是视口中相同数字位置的条目。

有关更多信息,请查看 Key API。

全局键

使用全局键来唯一标识子小组件。全局键在整个小组件层次结构中必须是全局唯一的,而本地键只需要在兄弟姐妹之间唯一即可。由于它们是全局唯一的,因此可以使用全局键来检索与小组件关联的状态。

有关更多信息,请查看 GlobalKey API。