跳到主内容

使用 Flutter 构建用户界面

Flutter 用户界面开发简介。

Flutter 组件基于一种受 React 启发的现代框架构建。其核心理念是使用组件来构建 UI。组件根据其当前配置和状态来描述视图的外观。当组件的状态发生变化时,组件会重新构建其描述,框架会将新的描述与之前的描述进行对比,从而确定为了从当前状态过渡到下一状态,底层渲染树中需要进行的最小化更改。

Hello world

#

最简单的 Flutter 应用只需调用包含组件的 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 并将其作为组件树的根。在此示例中,组件树由两个组件组成:Center 组件及其子组件 Text。框架强制根组件覆盖整个屏幕,这意味着“Hello, world”文本最终会居中显示。在此实例中需要指定文本方向;当使用 MaterialApp 组件时,系统会自动处理,稍后会演示。

编写应用时,通常会根据组件是否管理状态,来编写作为 StatelessWidget(无状态组件)或 StatefulWidget(有状态组件)子类的新组件。组件的主要工作是实现一个 build() 函数,该函数使用其他更底层的组件来描述自身。框架会依次构建这些组件,直到处理到代表底层 RenderObject(渲染对象)的组件,该对象负责计算并描述组件的几何形状。

基础组件

#

Flutter 附带了一套功能强大的基础组件,以下是常用的组件:

文本

Text 组件允许你在应用中创建一段带样式的文本。

Row, Column

这些 Flex 组件允许你在水平(Row)和垂直(Column)方向上创建灵活的布局。这些对象的设计基于 Web 的 Flexbox 布局模型。

层叠布局

Stack 组件不再是线性方向(水平或垂直),而是允许你按绘制顺序将组件相互叠加放置。你可以对 Stack 的子组件使用 Positioned 组件,以便相对于堆叠的顶部、右侧、底部或左侧边缘定位它们。Stack 基于 Web 的绝对定位布局模型。

容器

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 图标。如果你正在使用 Material 库,通常建议包含此行。

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

许多 Material Design 组件为了正常显示并继承主题数据,需要放置在 MaterialApp 内部。因此,请使用 MaterialApp 运行应用。

MyAppBar 组件创建一个高度为 56 个逻辑像素、左右内边距为 8 像素的 Container。在容器内部,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 组件、title 组件的 actions 传递组件。这种模式在整个框架中反复出现,在设计自己的组件时可以考虑使用它。

更多信息,请查看Material 组件

处理手势

#

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

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 = 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 组件遵循无状态组件的常用模式。它将构造函数中接收到的值存储在 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 = 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,这意味着该组件存储可变状态。当 ShoppingList 组件首次插入树中时,框架调用 createState() 函数来创建一个新的 _ShoppingListState 实例,并将其关联到树中的那个位置。(注意,State 的子类通常以带下划线的名称命名,以表示它们是私有的实现细节。)当此组件的父组件重新构建时,父组件会创建 ShoppingList 的新实例,但框架会复用树中已存在的 _ShoppingListState 实例,而不是再次调用 createState

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

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

响应组件生命周期事件

#

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

当不再需要状态对象时,框架会在状态对象上调用 dispose()。重写 dispose 函数以执行清理工作。例如,重写 dispose 以取消定时器或取消订阅平台服务。实现 dispose 时,通常在最后调用 super.dispose

更多信息,请查看 State

键 (Keys)

#

使用键 (Keys) 来控制组件重建时框架如何将组件与其它组件匹配。默认情况下,框架会根据 runtimeType 和它们出现的顺序来匹配当前构建和之前构建中的组件。使用键时,框架要求两个组件必须具有相同的 key 以及相同的 runtimeType

键在构建大量相同类型组件实例的场景中最有用。例如,ShoppingList 组件只构建足以填满可见区域的 ShoppingListItem 实例:

  • 如果没有键,当前构建中的第一个条目总是会与之前构建中的第一个条目同步,即使从语义上讲,列表中的第一个条目已经滚动出了屏幕,不再在视口中可见。

  • 通过为列表中的每个条目分配一个“语义”键,无限列表的效率可以更高,因为框架会使用匹配的语义键同步条目,因此具有相似(或相同)的视觉外观。此外,以语义方式同步条目意味着在有状态子组件中保留的状态会保持与同一个语义条目相关联,而不是与视口中数值位置相同的条目相关联。

更多信息,请查看 Key API。

全局键 (Global keys)

#

使用全局键 (Global keys) 来唯一标识子组件。全局键必须在整个组件层次结构中全局唯一,不像局部键只需要在兄弟组件中唯一即可。由于它们是全局唯一的,因此可以使用全局键来检索与组件关联的状态。

更多信息,请查看 GlobalKey API。