跳转至正文

使用 Flutter 构建界面

介绍如何用 Flutter 构建界面

Flutter widget 采用受 React 启发的现代框架构建。核心思想是用 widget 构建 UI。Widget 根据当前配置和状态描述其视图应有的外观。当 widget 的状态改变时,widget 会重建其描述,框架将其与先前的描述进行 diff,以确定底层渲染树从一种状态过渡到另一种状态所需的最小变更。

你好世界

#

最简 Flutter 应用只需用 widget 调用 runApp() 函数:

import 'package:flutter/material.dart';

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

runApp() 函数接收给定的 Widget 并将其设为 widget 树的根。本例中 widget 树由两个 widget 组成:Center 及其子节点 Text。框架强制根 widget 覆盖整个屏幕,因此「Hello, world」文字会显示在屏幕中央。此例需指定文字方向;使用 MaterialApp widget 时会自动处理,后文将演示。

编写应用时,你通常会编写继承 StatelessWidgetStatefulWidget 的新 widget,取决于 widget 是否管理状态。widget 的主要工作是实现 build() 函数,用更低层级的 widget 描述自身。框架依次构建这些 widget,直至底层由表示 RenderObject 的 widget 结束,由后者计算并描述 widget 的几何信息。

基础 widget

#

Flutter 自带一系列强大的基础 widget,以下是常用的一些:

Text

The Text widget lets you create a run of styled text within your application.

Text widget 让你在应用中创建一段样式化文本。

Row , Column

These flex widgets let you create flexible layouts in both the horizontal (Row) and vertical (Column) directions. The design of these objects is based on the web's flexbox layout model.

这些 flex widget 让你在水平(Row)和垂直(Column)方向创建灵活布局,其设计基于 Web 的 flexbox 布局模型。

Stack

Instead of being linearly oriented (either horizontally or vertically), a Stack widget lets you place widgets on top of each other in paint order. You can then use the Positioned widget on children of a Stack to position them relative to the top, right, bottom, or left edge of the stack. Stacks are based on the web's absolute positioning layout model.

Stack widget 不按线性方向(水平或垂直)排列,而按绘制顺序将 widget 叠放。可在 Stack 的子节点上使用 Positioned widget,相对于栈的上、右、下、左边缘定位。Stack 基于 Web 的绝对定位布局模型。

Container

The Container widget lets you create a rectangular visual element. A container can be decorated with a BoxDecoration, such as a background, a border, or a shadow. A Container can also have margins, padding, and constraints applied to its size. In addition, a Container can be transformed in three-dimensional space using a matrix.

Container widget 用于创建矩形视觉元素,可用 BoxDecoration 装饰背景、边框或阴影,也可设置外边距、内边距和尺寸约束,还可用矩阵在三维空间中变换。

下面是组合这些及其他 widget 的一些简单示例:

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.yamlflutter 段中包含 uses-material-design: true 条目。这样你才能使用预定义的 Material 图标 集。若使用 Materials 库,通常建议包含这一行。

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

许多 Material Design widget 需要放在 MaterialApp 内才能正确显示并继承主题数据。因此请用 MaterialApp 运行应用。

MyAppBar widget 创建一个高度为 56 逻辑像素的 Container,左右内边距各为 8 像素。在容器内,MyAppBar 使用 Row 布局组织子节点。中间的 title widget 标记为 Expanded,表示它会扩展以填满其他子节点未占用的剩余空间。可以有多个 Expanded 子节点,并通过 Expandedflex 参数决定它们占用可用空间的比例。

MyScaffold widget 在垂直列中组织子节点。列顶部放置 MyAppBar 实例,并向应用栏传入用作标题的 Text widget。将 widget 作为参数传给其他 widget 是一种强大技巧,可创建可在多种场景复用的通用 widget。最后,MyScaffoldExpanded 以居中消息填充剩余空间作为 body。

更多信息请参阅布局

使用 Material 组件

#

Flutter 提供多种 widget,帮助你构建符合 Material Design 的应用。Material 应用以 MaterialApp widget 开头,它在应用根节点构建多种实用 widget,包括 Navigator——管理以字符串标识的 widget 栈,即「路由」。Navigator 让你在应用各界面间平滑过渡。使用 MaterialApp widget 完全可选,但是良好实践。

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 widget,并改用 material.dart,应用开始更具 Material 风格。例如,应用栏带有阴影,标题文字会自动继承正确样式,还添加了浮动操作按钮。

注意 widget 会作为参数传给其他 widget。Scaffold widget 接收多种不同 widget 作为命名参数,各自放在 Scaffold 布局的合适位置。同样,AppBar widget 允许你为 leadingtitleactions 传入 widget。这一模式在框架中反复出现,设计自己的 widget 时也可考虑采用。

更多信息请参阅 Material 组件 widget

处理手势

#

大多数应用都包含与系统的某种用户交互。构建交互式应用的第一步是检测输入手势。通过创建一个简单按钮来了解其工作原理:

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 widget 没有视觉表现,而是检测用户做出的手势。当用户点击 Container 时,GestureDetector 会调用其 onTap() 回调,本例中向控制台打印消息。你可以用 GestureDetector 检测多种输入手势,包括点击、拖动和缩放。

许多 widget 内部使用 GestureDetector 为其他 widget 提供可选回调。例如,IconButtonElevatedButtonFloatingActionButton widget 具有 onPressed() 回调,在用户点击 widget 时触发。

更多信息请参阅 Flutter 中的手势

根据输入更改 widget

#

到目前为止,本页只使用了无状态 widget。无状态 widget 从父 widget 接收参数,并存入 final 成员变量。当要求 widget build() 时,它用这些存储的值为其创建的 widget 推导新参数。

要构建更复杂的体验——例如以更有趣的方式响应用户输入——应用通常需要持有一些状态。Flutter 用 StatefulWidget 表达这一概念。StatefulWidget 是知道如何生成 State 对象的特殊 widget,再由 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 中,这两类对象生命周期不同。Widget 是临时对象,用于构建应用在某一状态下的呈现。State 对象则在多次调用 build() 之间保持存在,从而能记住信息。

上面的示例接受用户输入并直接在 build() 方法中使用结果。在更复杂的应用中,widget 树的不同部分可能负责不同关注点;例如,一个 widget 可能展示用于收集日期或位置等特定信息的复杂界面,另一个 widget 则可能用这些信息改变整体呈现。

在 Flutter 中,变更通知通过回调沿 widget 层次结构向上流动,当前状态则向下流向负责呈现的无状态 widget。重定向这一流动的共同父级是 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())),
    ),
  );
}

注意这里创建了两个新的无状态 widget,清晰分离了_显示_计数器(CounterDisplay)与_修改_计数器(CounterIncrementor)的职责。尽管总体结果与前一示例相同,职责分离使各 widget 能封装更复杂的逻辑,同时保持父 widget 简洁。

更多信息请参阅:

综合示例

#

下面是一个更完整的示例,综合上述概念:假设某购物应用展示待售商品,并维护意向购买的购物车。先从定义呈现类 ShoppingListItem 开始:

import 'package:flutter/material.dart';

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

  final String name;
}

typedef CartChangedCallback = void 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 widget 遵循无状态 widget 的常见模式:将构造函数接收的值存入 final 成员变量,并在 build() 函数中使用。例如,inCart 布尔值在两种视觉外观间切换:一种使用当前主题的主色,另一种使用灰色。

当用户点击列表项时,widget 不会直接修改 inCart 值,而是调用从父 widget 收到的 onCartChanged 函数。这一模式让你能把状态保存在 widget 层次结构更高处,使状态持续更久。极端情况下,传给 runApp() 的 widget 上保存的状态会贯穿整个应用生命周期。

当父级收到 onCartChanged 回调时,会更新内部状态,从而触发父级重建并创建带有新 inCart 值的 ShoppingListItem 新实例。尽管父级重建时会创建新的 ShoppingListItem 实例,但这一操作开销很小,因为框架会将新构建的 widget 与先前构建的 widget 比较,并仅将差异应用到底层 RenderObject

下面是一个保存可变状态的父 widget 示例:

import 'package:flutter/material.dart';

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

  final String name;
}

typedef CartChangedCallback = void 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,表示该 widget 保存可变状态。当 ShoppingList widget 首次插入树时,框架调用 createState() 创建新的 _ShoppingListState 实例并与树中该位置关联。(注意 State 的子类通常以下划线开头,表示它们是私有实现细节。)当该 widget 的父级重建时,父级会创建新的 ShoppingList 实例,但框架会复用树中已有的 _ShoppingListState 实例,而不会再次调用 createState

要访问当前 ShoppingList 的属性,_ShoppingListState 可使用其 widget 属性。若父级重建并创建新的 ShoppingList_ShoppingListState 会用新的 widget 值重建。若希望在 widget 属性变化时收到通知,可重写 didUpdateWidget() 函数,它会传入 oldWidget 以便你将旧 widget 与当前 widget 比较。

处理 onCartChanged 回调时,_ShoppingListState 通过向 _shoppingCart 添加或移除商品来变更内部状态。为向框架表明内部状态已改变,这些调用应包在 setState() 中。调用 setState 会将该 widget 标记为 dirty,并在应用下次需要更新屏幕时安排重建。若在修改 widget 内部状态时忘记调用 setState,框架不会知道 widget 已 dirty,可能不会调用 widget 的 build() 函数,界面也就可能不会反映变更后的状态。用这种方式管理状态时,你无需为创建和更新子 widget 分别编写代码,只需实现 build 函数,它可同时处理两种情况。

响应 widget 生命周期事件

#

StatefulWidget 上调用 createState() 之后,框架将新的 state 对象插入树,然后在该 state 对象上调用 initState()State 的子类可重写 initState 以执行只需进行一次的工作,例如配置动画或订阅平台服务。initState 的实现必须先调用 super.initState

当不再需要 state 对象时,框架会在该 state 对象上调用 dispose()。可重写 dispose 函数进行清理,例如取消定时器或取消订阅平台服务。dispose 的实现通常以调用 super.dispose 结束。

更多信息请参阅 State

#

使用 key 可控制 widget 重建时框架将哪些 widget 相互匹配。默认情况下,框架根据 runtimeType 及出现顺序匹配当前构建与先前构建中的 widget。有了 key,框架还要求两个 widget 具有相同的 key 以及相同的 runtimeType

key 在构建大量同类型 widget 实例时最有用。例如 ShoppingList widget 会构建刚好填满可见区域的 ShoppingListItem 实例:

  • Without keys, the first entry in the current build would always sync with the first entry in the previous build, even if, semantically, the first entry in the list just scrolled off screen and is no longer visible in the viewport.

  • 没有 key 时,当前构建中的第一项总会与先前构建中的第一项同步,即使从语义上讲列表第一项已滚出屏幕、在视口中不再可见。

  • By assigning each entry in the list a "semantic" key, the infinite list can be more efficient because the framework syncs entries with matching semantic keys and therefore similar (or identical) visual appearances. Moreover, syncing the entries semantically means that state retained in stateful child widgets remains attached to the same semantic entry rather than the entry in the same numerical position in the viewport.

  • 为列表中每项分配「语义」key 后,无限列表可以更高效,因为框架会同步具有匹配语义 key 的项,从而保持相似(或相同)的视觉外观。此外,按语义同步项意味着有状态子 widget 中保留的状态会附着在相同语义项上,而不是视口中相同数值位置的项上。

更多信息请参阅 Key API。

全局键

#

使用 global key 可唯一标识子 widget。global key 必须在整个 widget 层次结构中全局唯一,而 local key 只需在兄弟节点间唯一。由于全局唯一,global key 可用于获取与 widget 关联的 state。

更多信息请参阅 GlobalKey API。