致 UIKit 开发者的 Flutter 指南
了解在构建 Flutter 应用时如何应用 iOS 和 UIKit 开发知识。
拥有使用 UIKit 经验并希望使用 Flutter 编写移动应用的 iOS 开发者应当阅读本指南。它解释了如何将现有的 UIKit 知识应用到 Flutter 中。
Flutter 是一个使用 Dart 编程语言构建跨平台应用的框架。要了解使用 Dart 编程与使用 Swift 编程之间的一些差异,请查看 作为 Swift 开发者学习 Dart 以及 致 Swift 开发者的 Flutter 并发指南。
你的 iOS 和 UIKit 知识与经验在进行 Flutter 开发时非常有价值。Flutter 在 iOS 上运行时也会对应用行为进行多项适配。要了解如何适配,请参阅 平台适配。
请将此指南作为参考手册使用。你可以随意跳转并查找解决你最迫切需求的问题。
概述
#作为入门,请观看以下视频。它概述了 Flutter 在 iOS 上的工作原理,以及如何使用 Flutter 构建 iOS 应用。
View vs. Widget
#在 UIKit 中,你在 UI 中创建的大部分内容都是使用视图对象完成的,这些对象是 UIView 类的实例。它们可以作为其他 UIView 类的容器,从而构成你的布局。
在 Flutter 中,与 UIView 大致对应的概念是 Widget。Widget 并不完全等同于 iOS 的视图,但在你熟悉 Flutter 的工作原理时,可以将其视为“声明和构造 UI 的方式”。
然而,它们与 UIView 有一些区别。首先,Widget 具有不同的生命周期:它们是不可变的,仅在需要更改之前存在。每当 Widget 或其状态发生变化时,Flutter 框架都会创建一个新的 Widget 实例树。相比之下,UIKit 视图在更改时不会被重新创建,它是一个可变实体,绘制一次后除非使用 setNeedsDisplay() 使其失效,否则不会重新绘制。
此外,与 UIView 不同,Flutter 的 Widget 是轻量级的,部分原因是它们的不可变性。因为它们本身不是视图,也不直接绘制任何内容,而是对 UI 及其语义的描述,在底层被“充实”成真正的视图对象。
Flutter 包含了 Material Components 库。这些是实现了 Material Design 指南 的 Widget。Material Design 是一套灵活的设计系统,针对包括 iOS 在内的所有平台进行了优化。
但 Flutter 足够灵活且具有表现力,可以实现任何设计语言。在 iOS 上,你可以使用 Cupertino widgets 库来生成看起来像 Apple iOS 设计语言 的界面。
更新 Widget
#在 UIKit 中更新视图时,你会直接修改它们。在 Flutter 中,Widget 是不可变的,不能直接更新。相反,你必须操作 Widget 的状态(State)。
这就是有状态(Stateful)与无状态(Stateless)Widget 概念的来源。StatelessWidget 就像它的名字一样——是一个没有关联状态的 Widget。
当你描述的界面部分不依赖于 Widget 初始配置信息以外的任何内容时,StatelessWidget 非常有用。
例如,在 UIKit 中,这类似于放置一个以 logo 作为 image 的 UIImageView。如果 logo 在运行时不会改变,请在 Flutter 中使用 StatelessWidget。
如果你想根据发起 HTTP 调用后接收到的数据动态更改 UI,请使用 StatefulWidget。HTTP 调用完成后,通知 Flutter 框架该 Widget 的 State 已更新,以便它可以更新 UI。
无状态和有状态 Widget 之间的重要区别在于,StatefulWidget 有一个 State 对象,该对象存储状态数据并在树重建过程中保留它,因此数据不会丢失。
如果你有疑问,请记住这条规则:如果一个 Widget 在 build 方法之外发生变化(例如由于运行时的用户交互),那么它就是有状态的。如果该 Widget 在构建后永远不会改变,那么它是无状态的。然而,即使一个 Widget 是有状态的,如果包含它的父 Widget 本身不对这些变化(或其他输入)做出反应,父 Widget 仍然可以是无状态的。
以下示例显示了如何使用 StatelessWidget。最常用的 StatelessWidget 是 Text。如果你查看 Text 的实现,会发现它是 StatelessWidget 的子类。
Text(
'I like Flutter!',
style: TextStyle(fontWeight: FontWeight.bold),
);
查看上面的代码,你可能会注意到 Text Widget 没有携带显式状态。它仅渲染其构造函数中传递的内容,此外别无他物。
但是,如果你想让 "I Like Flutter" 动态变化,例如在点击 FloatingActionButton 时,该怎么办?
为了实现这一点,将 Text Widget 包装在一个 StatefulWidget 中,并在用户点击按钮时更新它。
例如
class SampleApp extends StatelessWidget {
// This widget is the root of your application.
const SampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(title: 'Sample App', home: SampleAppPage());
}
}
class SampleAppPage extends StatefulWidget {
const SampleAppPage({super.key});
@override
State<SampleAppPage> createState() => _SampleAppPageState();
}
class _SampleAppPageState extends State<SampleAppPage> {
// Default placeholder text
String textToShow = 'I Like Flutter';
void _updateText() {
setState(() {
// Update the text
textToShow = 'Flutter is Awesome!';
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample App')),
body: Center(child: Text(textToShow)),
floatingActionButton: FloatingActionButton(
onPressed: _updateText,
tooltip: 'Update Text',
child: const Icon(Icons.update),
),
);
}
}
Widget 布局
#在 UIKit 中,你可能会使用 Storyboard 文件来组织视图并设置约束,或者在视图控制器中通过代码设置约束。在 Flutter 中,你通过组合 Widget 树在代码中声明布局。
以下示例展示了如何显示一个带填充的简单 Widget
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample App')),
body: Center(
child: CupertinoButton(
onPressed: () {},
padding: const EdgeInsets.only(left: 10, right: 10),
child: const Text('Hello'),
),
),
);
}
你可以为任何 Widget 添加内边距(Padding),这模仿了 iOS 中约束的功能。
你可以在 Widget 目录中查看 Flutter 提供的各种布局。
移除 Widget
#在 UIKit 中,你在父视图上调用 addSubview() 或在子视图上调用 removeFromSuperview() 来动态添加或移除子视图。在 Flutter 中,由于 Widget 是不可变的,没有直接等同于 addSubview() 的操作。相反,你可以向父 Widget 传递一个返回 Widget 的函数,并通过一个布尔标记来控制该子 Widget 的创建。
以下示例展示了当用户点击 FloatingActionButton 时如何在两个 Widget 之间切换
class SampleApp extends StatelessWidget {
// This widget is the root of your application.
const SampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(title: 'Sample App', home: SampleAppPage());
}
}
class SampleAppPage extends StatefulWidget {
const SampleAppPage({super.key});
@override
State<SampleAppPage> createState() => _SampleAppPageState();
}
class _SampleAppPageState extends State<SampleAppPage> {
// Default value for toggle.
bool toggle = true;
void _toggle() {
setState(() {
toggle = !toggle;
});
}
Widget _getToggleChild() {
if (toggle) {
return const Text('Toggle One');
}
return CupertinoButton(onPressed: () {}, child: const Text('Toggle Two'));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample App')),
body: Center(child: _getToggleChild()),
floatingActionButton: FloatingActionButton(
onPressed: _toggle,
tooltip: 'Update Text',
child: const Icon(Icons.update),
),
);
}
}
动画
#在 UIKit 中,你通过在视图上调用 animate(withDuration:animations:) 方法来创建动画。在 Flutter 中,使用动画库将 Widget 包装在动画 Widget 内部。
在 Flutter 中,使用 AnimationController,它是一个可以暂停、寻道、停止和反转动画的 Animation<double>。它需要一个 Ticker,用于在发生垂直同步(vsync)时发出信号,并在运行时每一帧产生一个 0 到 1 之间的线性插值。然后,你创建一个或多个 Animation 并将其附加到控制器上。
例如,您可以使用 `CurvedAnimation` 来实现沿着插值曲线的动画。从这个意义上说,控制器是动画进度的“主”源,而 `CurvedAnimation` 计算替换控制器默认线性运动的曲线。像组件一样,Flutter 中的动画也通过组合工作。
在构建 Widget 树时,你将 Animation 分配给 Widget 的动画属性,例如 FadeTransition 的不透明度,并通知控制器开始动画。
以下示例展示了如何编写一个 FadeTransition,当您按下 FloatingActionButton 时,该转换会将 Widget 淡入为徽标
import 'package:flutter/material.dart';
class SampleApp extends StatelessWidget {
// This widget is the root of your application.
const SampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
title: 'Fade Demo',
home: MyFadeTest(title: 'Fade Demo'),
);
}
}
class MyFadeTest extends StatefulWidget {
const MyFadeTest({super.key, required this.title});
final String title;
@override
State<MyFadeTest> createState() => _MyFadeTest();
}
class _MyFadeTest extends State<MyFadeTest>
with SingleTickerProviderStateMixin {
late AnimationController controller;
late CurvedAnimation curve;
@override
void initState() {
super.initState();
controller = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
curve = CurvedAnimation(parent: controller, curve: Curves.easeIn);
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: Center(
child: FadeTransition(
opacity: curve,
child: const FlutterLogo(size: 100),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
controller.forward();
},
tooltip: 'Fade',
child: const Icon(Icons.brush),
),
);
}
}
有关更多信息,请参阅动画与动作 Widgets、动画教程和动画概述。
在屏幕上绘制
#在 UIKit 中,你使用 CoreGraphics 在屏幕上绘制线条和形状。Flutter 有一套基于 Canvas 类的不同 API,并有两个辅助绘制的类:CustomPaint 和 CustomPainter,后者负责实现绘制到画布的算法。
要了解如何在 Flutter 中实现签名绘制板,请查看 Collin 在 StackOverflow 上的回答。
import 'package:flutter/material.dart';
void main() => runApp(const MaterialApp(home: DemoApp()));
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) => const Scaffold(body: Signature());
}
class Signature extends StatefulWidget {
const Signature({super.key});
@override
State<Signature> createState() => SignatureState();
}
class SignatureState extends State<Signature> {
List<Offset?> _points = <Offset?>[];
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanUpdate: (details) {
setState(() {
RenderBox? referenceBox = context.findRenderObject() as RenderBox;
Offset localPosition = referenceBox.globalToLocal(
details.globalPosition,
);
_points = List.from(_points)..add(localPosition);
});
},
onPanEnd: (details) => _points.add(null),
child: CustomPaint(
painter: SignaturePainter(_points),
size: Size.infinite,
),
);
}
}
class SignaturePainter extends CustomPainter {
SignaturePainter(this.points);
final List<Offset?> points;
@override
void paint(Canvas canvas, Size size) {
final Paint paint = Paint()
..color = Colors.black
..strokeCap = StrokeCap.round
..strokeWidth = 5;
for (int i = 0; i < points.length - 1; i++) {
if (points[i] != null && points[i + 1] != null) {
canvas.drawLine(points[i]!, points[i + 1]!, paint);
}
}
}
@override
bool shouldRepaint(SignaturePainter oldDelegate) =>
oldDelegate.points != points;
}
Widget 透明度
#在 UIKit 中,所有内容都有 .opacity 或 .alpha 属性。在 Flutter 中,大多数情况下你需要将 Widget 包装在 Opacity Widget 中来实现这一点。
自定义 Widget
#在 UIKit 中,你通常继承 UIView 或使用现有视图,重写并实现相关方法以实现所需行为。在 Flutter 中,通过组合更小的 Widget(而不是扩展它们)来构建自定义 Widget。
例如,如何构建一个在构造函数中接受标签的 CustomButton?通过组合带标签的 ElevatedButton 来创建 CustomButton,而不是通过扩展 ElevatedButton
class CustomButton extends StatelessWidget {
const CustomButton(this.label, {super.key});
final String label;
@override
Widget build(BuildContext context) {
return ElevatedButton(onPressed: () {}, child: Text(label));
}
}
然后像使用任何其他 Flutter Widget 一样使用 CustomButton
@override
Widget build(BuildContext context) {
return const Center(child: CustomButton('Hello'));
}
管理依赖
#在 iOS 中,你通过在 Podfile 中添加内容来使用 CocoaPods 管理依赖。Flutter 使用 Dart 的构建系统和 Pub 包管理器来处理依赖。工具会将原生 Android 和 iOS 包装应用的构建委托给各自的构建系统。
虽然 Flutter 项目的 iOS 文件夹中有一个 Podfile,但仅在添加平台特定集成所需的原生依赖时才使用它。通常情况下,使用 pubspec.yaml 声明 Flutter 中的外部依赖。寻找优质 Flutter 扩展包的好地方是 pub.dev。
导航
#文档的本节讨论应用页面间的导航、Push 和 Pop 机制等。
在页面间导航
#在 UIKit 中,要在视图控制器之间跳转,可以使用管理视图控制器展示栈的 UINavigationController。
Flutter 有类似的实现,使用 Navigator 和 Routes。Route 是应用“屏幕”或“页面”的抽象,而 Navigator 是一个管理路由的 Widget。一个路由大致对应一个 UIViewController。导航器的工作方式与 iOS 的 UINavigationController 类似,它可以根据你想进入还是返回某个视图来执行 push() 和 pop() 路由操作。
要在页面间导航,你有几个选择
- 指定一个路由名称的
Map。 - 直接导航到某个路由。
以下示例构建了一个 Map。
void main() {
runApp(
CupertinoApp(
home: const MyAppHome(), // becomes the route named '/'
routes: <String, WidgetBuilder>{
'/a': (context) => const MyPage(title: 'page A'),
'/b': (context) => const MyPage(title: 'page B'),
'/c': (context) => const MyPage(title: 'page C'),
},
),
);
}
通过将路由名称 push 到 Navigator 来导航到该路由。
Navigator.of(context).pushNamed('/b');
Navigator 类处理 Flutter 中的路由,并用于从你推入栈中的路由获取返回结果。这通过 await 等待 push() 返回的 Future 来完成。
例如,要启动一个允许用户选择位置的 location 路由,你可以执行以下操作
Object? coordinates = await Navigator.of(context).pushNamed('/location');
然后,在你的 location 路由内部,一旦用户选择了位置,就带着结果 pop() 出栈
Navigator.of(context).pop({'lat': 43.821757, 'long': -79.226392});
导航至其他应用
#在 UIKit 中,要将用户引导至另一个应用程序,你需要使用特定的 URL 方案。对于系统级应用,方案取决于应用本身。要在 Flutter 中实现此功能,请创建原生平台集成,或使用现有的插件,例如 url_launcher。
手动返回(Pop)
#从 Dart 代码中调用 SystemNavigator.pop() 会触发以下 iOS 代码
UIViewController* viewController = [UIApplication sharedApplication].keyWindow.rootViewController;
if ([viewController isKindOfClass:[UINavigationController class]]) {
[((UINavigationController*)viewController) popViewControllerAnimated:NO];
}
如果这不能满足你的需求,你可以创建自己的 平台通道 来调用任意 iOS 代码。
处理本地化
#与拥有 Localizable.strings 文件的 iOS 不同,Flutter 目前还没有专门处理字符串的系统。目前的最佳实践是在类中将文本声明为静态字段并从那里访问。例如
class Strings {
static const String welcomeMessage = 'Welcome To Flutter';
}
你可以按如下方式访问字符串
Text(Strings.welcomeMessage);
默认情况下,Flutter 的字符串仅支持美式英语。如果你需要添加对其他语言的支持,请引入 flutter_localizations 包。你可能还需要添加 Dart 的 intl 包来使用 i10n 机制,例如日期/时间格式化。
dependencies:
flutter_localizations:
sdk: flutter
intl: any # Use version of intl from flutter_localizations.
要使用 flutter_localizations 包,请在 app widget 上指定 localizationsDelegates 和 supportedLocales
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
class MyWidget extends StatelessWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
localizationsDelegates: <LocalizationsDelegate<dynamic>>[
// Add app-specific localization delegate[s] here
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: <Locale>[
Locale('en', 'US'), // English
Locale('he', 'IL'), // Hebrew
// ... other locales the app supports
],
);
}
}
Delegates(代理)包含实际的本地化值,而 supportedLocales 定义了应用支持的语言环境。上面的示例使用了 MaterialApp,因此它既有用于基础 Widget 本地化值的 GlobalWidgetsLocalizations,也有用于 Material Widget 本地化的 MaterialWidgetsLocalizations。如果你使用 WidgetsApp,则不需要后者。请注意,这两个代理包含“默认”值,但如果你希望自己应用的文本也能本地化,则需要为一个或多个代理提供你自己应用的本地化副本。
初始化时,WidgetsApp(或 MaterialApp)会根据你指定的代理为你创建一个 Localizations Widget。设备的当前语言环境始终可以从当前 context 的 Localizations Widget(以 Locale 对象的形式)或使用 Window.locale 访问。
要访问本地化资源,请使用 Localizations.of() 方法访问由给定代理提供的特定本地化类。使用 intl_translation 包将可翻译副本提取到 arb 文件进行翻译,并将其导回应用以配合 intl 使用。
有关 Flutter 国际化和本地化的更多详细信息,请参阅 国际化指南,其中包含使用和不使用 intl 包的示例代码。
ViewController
#本节讨论 Flutter 中与 ViewController 对应的概念以及如何监听生命周期事件。
Flutter 中与 ViewController 对应的概念
#在 UIKit 中,ViewController 代表用户界面的一部分,最常用于屏幕或部分。它们组合在一起构建复杂的用户界面,并帮助扩展应用的 UI。在 Flutter 中,这项工作由 Widget 承担。正如在导航章节中提到的,由于“万物皆 Widget”,Flutter 中的屏幕由 Widget 表示。使用 Navigator 在代表不同屏幕或页面、或同一数据的不同状态或渲染效果的不同 Route 之间移动。
监听生命周期事件
#在 UIKit 中,你可以重写 ViewController 的方法来捕捉视图本身的生命周期方法,或在 AppDelegate 中注册生命周期回调。在 Flutter 中,你没有这两个概念,但可以通过挂载到 WidgetsBinding 观察者并监听 didChangeAppLifecycleState() 更改事件来监听生命周期事件。
可观察的生命周期事件有
inactive(非活动)应用程序处于非活动状态且未接收
用户输入。此事件仅在 iOS 上有效,因为 Android 上没有对应的事件。
paused(已暂停)应用程序当前对用户不可见,
不响应用户输入,但在后台运行。
resumed(已恢复)应用程序可见并响应用户输入。
suspending(挂起中)应用程序暂时挂起。
iOS 平台没有对应的事件。
有关这些状态含义的更多详情,请参阅 AppLifecycleState 文档。
布局
#本节讨论 Flutter 中的不同布局,以及它们与 UIKit 的比较。
显示列表视图
#在 UIKit 中,你可能会在 UITableView 或 UICollectionView 中显示列表。在 Flutter 中,你可以使用 ListView 进行类似的实现。在 UIKit 中,这些视图有代理方法用于决定行数、每个索引路径的单元格以及单元格的大小。
由于 Flutter 的不可变 Widget 模式,你向 ListView 传递一个 Widget 列表,Flutter 会负责确保滚动快速且流畅。
import 'package:flutter/material.dart';
void main() {
runApp(const SampleApp());
}
class SampleApp extends StatelessWidget {
const SampleApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return const MaterialApp(title: 'Sample App', home: SampleAppPage());
}
}
class SampleAppPage extends StatefulWidget {
const SampleAppPage({super.key});
@override
State<SampleAppPage> createState() => _SampleAppPageState();
}
class _SampleAppPageState extends State<SampleAppPage> {
List<Widget> _getListData() {
final List<Widget> widgets = [];
for (int i = 0; i < 100; i++) {
widgets.add(
Padding(padding: const EdgeInsets.all(10), child: Text('Row $i')),
);
}
return widgets;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample App')),
body: ListView(children: _getListData()),
);
}
}
检测点击内容
#在 UIKit 中,你实现代理方法 tableView:didSelectRowAtIndexPath:。在 Flutter 中,使用传入 Widget 提供的触摸处理。
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
void main() {
runApp(const SampleApp());
}
class SampleApp extends StatelessWidget {
const SampleApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return const MaterialApp(title: 'Sample App', home: SampleAppPage());
}
}
class SampleAppPage extends StatefulWidget {
const SampleAppPage({super.key});
@override
State<SampleAppPage> createState() => _SampleAppPageState();
}
class _SampleAppPageState extends State<SampleAppPage> {
List<Widget> _getListData() {
List<Widget> widgets = [];
for (int i = 0; i < 100; i++) {
widgets.add(
GestureDetector(
onTap: () {
developer.log('row tapped');
},
child: Padding(
padding: const EdgeInsets.all(10),
child: Text('Row $i'),
),
),
);
}
return widgets;
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample App')),
body: ListView(children: _getListData()),
);
}
}
动态更新 ListView
#在 UIKit 中,你更新列表视图的数据,并使用 reloadData 方法通知表格或集合视图。
在 Flutter 中,如果你在 setState() 内部更新 Widget 列表,你会很快发现数据在视觉上没有变化。这是因为当 setState() 被调用时,Flutter 渲染引擎会查看 Widget 树以查看是否发生了变化。当它到达 ListView 时,会执行 == 检查,并判定两个 ListView 是相同的。由于没有发生变化,因此不需要更新。
为了简单地更新您的 `ListView`,在 `setState()` 内部创建一个新的 `List`,并将数据从旧列表复制到新列表。虽然这种方法很简单,但不建议用于大型数据集,如以下示例所示。
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
void main() {
runApp(const SampleApp());
}
class SampleApp extends StatelessWidget {
const SampleApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return const MaterialApp(title: 'Sample App', home: SampleAppPage());
}
}
class SampleAppPage extends StatefulWidget {
const SampleAppPage({super.key});
@override
State<SampleAppPage> createState() => _SampleAppPageState();
}
class _SampleAppPageState extends State<SampleAppPage> {
List<Widget> widgets = <Widget>[];
@override
void initState() {
super.initState();
for (int i = 0; i < 100; i++) {
widgets.add(getRow(i));
}
}
Widget getRow(int i) {
return GestureDetector(
onTap: () {
setState(() {
widgets = List.from(widgets);
widgets.add(getRow(widgets.length));
developer.log('row $i');
});
},
child: Padding(padding: const EdgeInsets.all(10), child: Text('Row $i')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample App')),
body: ListView(children: widgets),
);
}
}
构建列表的推荐、高效且有效的方法是使用 ListView.Builder。当你有一个动态列表或包含大量数据的列表时,此方法非常棒。
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
void main() {
runApp(const SampleApp());
}
class SampleApp extends StatelessWidget {
const SampleApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return const MaterialApp(title: 'Sample App', home: SampleAppPage());
}
}
class SampleAppPage extends StatefulWidget {
const SampleAppPage({super.key});
@override
State<SampleAppPage> createState() => _SampleAppPageState();
}
class _SampleAppPageState extends State<SampleAppPage> {
List<Widget> widgets = [];
@override
void initState() {
super.initState();
for (int i = 0; i < 100; i++) {
widgets.add(getRow(i));
}
}
Widget getRow(int i) {
return GestureDetector(
onTap: () {
setState(() {
widgets.add(getRow(widgets.length));
developer.log('row $i');
});
},
child: Padding(padding: const EdgeInsets.all(10), child: Text('Row $i')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample App')),
body: ListView.builder(
itemCount: widgets.length,
itemBuilder: (context, position) {
return getRow(position);
},
),
);
}
}
不要创建普通的 ListView,而是创建一个 ListView.builder,它接收两个关键参数:列表的初始长度和一个 ItemBuilder 函数。
ItemBuilder 函数类似于 iOS 表格或集合视图中的 cellForItemAt 代理方法,因为它接收一个位置(index),并返回你希望在该位置渲染的单元格。
最后但最重要的一点是,请注意 onTap() 函数不再重新创建列表,而是向其执行 .add 操作。
创建滚动视图
#在 UIKit 中,你将视图包装在 ScrollView 中,以便用户在需要时滚动内容。
在 Flutter 中,最简单的方法是使用 ListView Widget。它既充当 ScrollView 又充当 iOS 的 TableView,因为你可以以垂直格式布局 Widget。
@override
Widget build(BuildContext context) {
return ListView(
children: const <Widget>[
Text('Row One'),
Text('Row Two'),
Text('Row Three'),
Text('Row Four'),
],
);
}
有关如何在 Flutter 中布局 Widget 的更详细文档,请参阅 布局教程。
手势检测和触摸事件处理
#本节讨论如何在 Flutter 中检测手势和处理不同事件,以及它们与 UIKit 的比较。
添加点击监听器
#在 UIKit 中,你为视图附加 GestureRecognizer 来处理点击事件。在 Flutter 中,有两种添加触摸监听器的方法
- 如果 Widget 支持事件检测,向其传递一个函数,并在该函数中处理事件。例如,
ElevatedButtonWidget 有一个onPressed参数
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
developer.log('click');
},
child: const Text('Button'),
);
}
- 如果 Widget 不支持事件检测,请将该 Widget 包装在 GestureDetector 中,并将函数传递给
onTap参数。
class SampleTapApp extends StatelessWidget {
const SampleTapApp({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: GestureDetector(
onTap: () {
developer.log('tap');
},
child: const FlutterLogo(size: 200),
),
),
);
}
}
处理其他手势
#使用 GestureDetector,你可以监听广泛的手势,例如
-
点击(Tapping)
onTapDown可能引起点击的指针已在特定位置接触
屏幕。
onTapUp触发点击的指针已停止接触
屏幕。
onTap点击已发生。
onTapCancel此前触发
onTapDown的指针
不会引起点击。
-
双击(Double tapping)
onDoubleTap用户在极短时间内两次点击屏幕
同一位置。
-
长按(Long pressing)
onLongPress指针在屏幕同一位置保持接触
较长时间。
-
垂直拖动(Vertical dragging)
onVerticalDragStart指针已接触屏幕并可能开始
垂直移动。
onVerticalDragUpdate接触屏幕的指针
在垂直方向上发生了进一步移动。
onVerticalDragEnd此前接触屏幕并垂直移动的指针
已不再接触屏幕,且在停止接触时正以特定速度移动。
-
水平拖动(Horizontal dragging)
onHorizontalDragStart指针已接触屏幕并可能开始
水平移动。
onHorizontalDragUpdate接触屏幕的指针
在水平方向上发生了进一步移动。
onHorizontalDragEnd此前接触屏幕并垂直移动的指针
屏幕且水平移动的指针已不再接触屏幕。
以下示例展示了一个 GestureDetector,它在双击时旋转 Flutter 徽标
class SampleApp extends StatefulWidget {
const SampleApp({super.key});
@override
State<SampleApp> createState() => _SampleAppState();
}
class _SampleAppState extends State<SampleApp>
with SingleTickerProviderStateMixin {
late AnimationController controller;
late CurvedAnimation curve;
@override
void initState() {
super.initState();
controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
curve = CurvedAnimation(parent: controller, curve: Curves.easeIn);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: GestureDetector(
onDoubleTap: () {
if (controller.isCompleted) {
controller.reverse();
} else {
controller.forward();
}
},
child: RotationTransition(
turns: curve,
child: const FlutterLogo(size: 200),
),
),
),
);
}
}
主题、样式和媒体
#Flutter 应用易于设置样式;你可以在浅色和深色主题之间切换、更改文本和 UI 组件的样式等。本节涵盖了设置 Flutter 应用样式的各个方面,并比较了在 UIKit 中如何实现同样的效果。
使用主题
#Flutter 开箱即用地提供了精美的 Material Design 实现,它涵盖了你通常需要进行的大量样式和主题设置需求。
为了在应用中充分利用 Material Components,请声明一个顶层 Widget MaterialApp 作为应用的入口。MaterialApp 是一个便利的 Widget,它包装了实现 Material Design 的应用通常需要的许多 Widget。它在 WidgetsApp 的基础上增加了 Material 特有的功能。
但 Flutter 足够灵活且具有表现力,可以实现任何设计语言。在 iOS 上,你可以使用 Cupertino 库 来生成符合 人机交互指南 (Human Interface Guidelines) 的界面。有关这些 Widget 的完整集合,请参阅 Cupertino widgets 展示页。
您还可以使用 WidgetsApp 作为您的应用 Widget,它提供了一些相同的功能,但不如 MaterialApp 丰富。
要自定义任何子组件的颜色和样式,请向 MaterialApp Widget 传递一个 ThemeData 对象。例如,在下面的代码中,种子颜色方案被设置为 deepPurple,分割线颜色为 grey。
import 'package:flutter/material.dart';
class SampleApp extends StatelessWidget {
const SampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Sample App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
dividerColor: Colors.grey,
),
home: const SampleAppPage(),
);
}
}
使用自定义字体
#在 UIKit 中,你将所有 ttf 字体文件导入项目并在 info.plist 文件中创建引用。在 Flutter 中,将字体文件放入文件夹并在 pubspec.yaml 文件中引用,类似于导入图片的方式。
fonts:
- family: MyCustomFont
fonts:
- asset: fonts/MyCustomFont.ttf
- style: italic
然后将字体分配给您的 Text Widget
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample App')),
body: const Center(
child: Text(
'This is a custom font text',
style: TextStyle(fontFamily: 'MyCustomFont'),
),
),
);
}
文本样式
#除了字体之外,您还可以在 Text Widget 上自定义其他样式元素。Text Widget 的 style 参数接受一个 TextStyle 对象,您可以在其中自定义许多参数,例如
colordecorationdecorationColordecorationStylefontFamilyfontSizefontStylefontWeighthashCodeheightinheritletterSpacingtextBaselinewordSpacing
在应用中打包图片
#虽然 iOS 将图片和资源(assets)视为不同的项目,但 Flutter 应用只有资源。在 iOS 上放置在 Images.xcasset 文件夹中的资源,在 Flutter 中被放置在 assets 文件夹中。与 iOS 一样,assets 可以是任何类型的文件,不仅仅是图片。例如,你可能在 my-assets 文件夹中有一个 JSON 文件
my-assets/data.json
在 pubspec.yaml 文件中声明资源
assets:
- my-assets/data.json
然后在代码中使用 AssetBundle 访问它
import 'dart:async' show Future;
import 'package:flutter/services.dart' show rootBundle;
Future<String> loadAsset() async {
return await rootBundle.loadString('my-assets/data.json');
}
对于图片,Flutter 遵循类似于 iOS 的简单基于密度的格式。图片资源可以是 1.0x、2.0x、3.0x 或任何其他倍数。Flutter 的 devicePixelRatio 表示单个逻辑像素中物理像素的比例。
资源可以放置在任何文件夹中——Flutter 没有预定义的文件夹结构。你在 pubspec.yaml 文件中声明资源(带路径),Flutter 就会识别它们。
例如,要将名为 my_icon.png 的图片添加到 Flutter 项目,你可能决定将其存储在名为 images 的文件夹中。将基础图片 (1.0x) 放在 images 文件夹中,并将其他变体放在以相应比例倍数命名的子文件夹中
images/my_icon.png // Base: 1.0x image
images/2.0x/my_icon.png // 2.0x image
images/3.0x/my_icon.png // 3.0x image
接下来,在 pubspec.yaml 文件中声明这些图片
assets:
- images/my_icon.png
你现在可以使用 AssetImage 访问你的图片
image: AssetImage('images/a_dot_burr.png'),
或者直接在 Image Widget 中使用
@override
Widget build(BuildContext context) {
return Image.asset('images/my_image.png');
}
更多详情,请参阅 在 Flutter 中添加资源和图片。
表单输入
#本节讨论如何在 Flutter 中使用表单,以及它们与 UIKit 的比较。
获取用户输入
#鉴于 Flutter 使用带有独立状态的不可变 Widget,你可能会好奇用户输入是如何处理的。在 UIKit 中,当你准备提交用户输入或对其采取行动时,通常会向 Widget 查询其当前值。在 Flutter 中这是如何工作的?
在实践中,表单就像 Flutter 中的所有内容一样,由专门的 Widget 处理。如果你有一个 TextField 或 TextFormField,你可以提供一个 TextEditingController 来获取用户输入
class _MyFormState extends State<MyForm> {
// Create a text controller and use it to retrieve the current value.
// of the TextField!
final myController = TextEditingController();
@override
void dispose() {
// Clean up the controller when disposing of the Widget.
myController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Retrieve Text Input')),
body: Padding(
padding: const EdgeInsets.all(16),
child: TextField(controller: myController),
),
floatingActionButton: FloatingActionButton(
// When the user presses the button, show an alert dialog with the
// text the user has typed into our text field.
onPressed: () {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
// Retrieve the text the user has typed in using our
// TextEditingController.
content: Text(myController.text),
);
},
);
},
tooltip: 'Show me the value!',
child: const Icon(Icons.text_fields),
),
);
}
}
你可以在 获取文本框的值 中找到更多信息和完整代码列表。
文本框中的占位符
#在 Flutter 中,你可以通过为 Text Widget 的 decoration 构造参数添加 InputDecoration 对象,轻松地为字段显示“提示”或占位符文本
Center(
child: TextField(decoration: InputDecoration(hintText: 'This is a hint')),
)
显示校验错误
#就像添加“提示”一样,将 InputDecoration 对象传递给 Text Widget 的 decoration 构造函数。
但是,您不希望一开始就显示错误。相反,当用户输入无效数据时,更新状态,并传递一个新的 InputDecoration 对象。
import 'package:flutter/material.dart';
void main() {
runApp(const SampleApp());
}
class SampleApp extends StatelessWidget {
const SampleApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return const MaterialApp(title: 'Sample App', home: SampleAppPage());
}
}
class SampleAppPage extends StatefulWidget {
const SampleAppPage({super.key});
@override
State<SampleAppPage> createState() => _SampleAppPageState();
}
class _SampleAppPageState extends State<SampleAppPage> {
String? _errorText;
bool isEmail(String em) {
String emailRegexp =
r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|'
r'(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|'
r'(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$';
RegExp regExp = RegExp(emailRegexp);
return regExp.hasMatch(em);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample App')),
body: Center(
child: TextField(
onSubmitted: (text) {
setState(() {
if (!isEmail(text)) {
_errorText = 'Error: This is not an email';
} else {
_errorText = null;
}
});
},
decoration: InputDecoration(
hintText: 'This is a hint',
errorText: _errorText,
),
),
),
);
}
}
线程与异步
#本节讨论 Flutter 中的并发,以及它与 UIKit 的比较。
编写异步代码
#Dart 采用单线程执行模型,支持 Isolate(在另一个线程上运行 Dart 代码的一种方式)、事件循环(event loop)和异步编程。除非你派生一个 Isolate,否则你的 Dart 代码将在主 UI 线程中运行,并由事件循环驱动。Flutter 的事件循环相当于 iOS 的主循环——即附加到主线程的 Looper。
Dart 的单线程模型并不意味着你必须将所有内容作为导致 UI 冻结的阻塞操作来运行。相反,应使用 Dart 语言提供的异步设施(如 async/await)来执行异步工作。
例如,您可以使用 async/await 运行网络代码,而不会导致 UI 卡顿,让 Dart 完成繁重的工作
Future<void> loadData() async {
final Uri dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
final http.Response response = await http.get(dataURL);
setState(() {
data = (jsonDecode(response.body) as List).cast<Map<String, Object?>>();
});
}
一旦 await 的网络调用完成,通过调用 setState() 更新 UI,这将触发 Widget 子树的重建并更新数据。
以下示例异步加载数据并将其显示在 ListView 中
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
void main() {
runApp(const SampleApp());
}
class SampleApp extends StatelessWidget {
const SampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(title: 'Sample App', home: SampleAppPage());
}
}
class SampleAppPage extends StatefulWidget {
const SampleAppPage({super.key});
@override
State<SampleAppPage> createState() => _SampleAppPageState();
}
class _SampleAppPageState extends State<SampleAppPage> {
List<Map<String, Object?>> data = [];
@override
void initState() {
super.initState();
loadData();
}
Future<void> loadData() async {
final Uri dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
final http.Response response = await http.get(dataURL);
setState(() {
data = (jsonDecode(response.body) as List).cast<Map<String, Object?>>();
});
}
Widget getRow(int index) {
return Padding(
padding: const EdgeInsets.all(10),
child: Text('Row ${data[index]['title']}'),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample App')),
body: ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) {
return getRow(index);
},
),
);
}
}
请参阅下一节,了解有关在后台执行工作以及 Flutter 与 iOS 区别的更多信息。
移动到后台线程
#由于 Flutter 是单线程并运行事件循环(类似于 Node.js),你无需担心线程管理或派生后台线程。如果你在执行 I/O 密集型工作(如磁盘访问或网络调用),只需放心地使用 async/await 即可。另一方面,如果你需要执行让 CPU 保持繁忙的计算密集型工作,你需要将其移动到 Isolate 以避免阻塞事件循环。
对于 I/O 密集型工作,将函数声明为 async 函数,并在函数内部 await 长时间运行的任务
Future<void> loadData() async {
final Uri dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
final http.Response response = await http.get(dataURL);
setState(() {
data = (jsonDecode(response.body) as List).cast<Map<String, Object?>>();
});
}
这就是你通常进行网络或数据库调用的方式,这两者都是 I/O 操作。
但是,有时您可能会处理大量数据并且 UI 挂起。在 Flutter 中,使用 Isolate 来利用多个 CPU 核心来执行长时间运行或计算密集型任务。
Isolate 是独立的执行线程,不与主执行内存堆共享任何内存。这意味着你无法从主线程访问变量,也无法通过调用 setState() 更新 UI。Isolate 名副其实(隔离),无法共享内存(例如以静态字段的形式)。
以下示例以一个简单的 isolate 形式展示了如何将数据共享回主线程以更新 UI。
Future<void> loadData() async {
final ReceivePort receivePort = ReceivePort();
await Isolate.spawn(dataLoader, receivePort.sendPort);
// The 'echo' isolate sends its SendPort as the first message.
final SendPort sendPort = await receivePort.first as SendPort;
final List<Map<String, dynamic>> msg = await sendReceive(
sendPort,
'https://jsonplaceholder.typicode.com/posts',
);
setState(() {
data = msg;
});
}
// The entry point for the isolate.
static Future<void> dataLoader(SendPort sendPort) async {
// Open the ReceivePort for incoming messages.
final ReceivePort port = ReceivePort();
// Notify any other isolates what port this isolate listens to.
sendPort.send(port.sendPort);
await for (final dynamic msg in port) {
final String url = msg[0] as String;
final SendPort replyTo = msg[1] as SendPort;
final Uri dataURL = Uri.parse(url);
final http.Response response = await http.get(dataURL);
// Lots of JSON to parse
replyTo.send(jsonDecode(response.body) as List<Map<String, dynamic>>);
}
}
Future<List<Map<String, dynamic>>> sendReceive(SendPort port, String msg) {
final ReceivePort response = ReceivePort();
port.send(<dynamic>[msg, response.sendPort]);
return response.first as Future<List<Map<String, dynamic>>>;
}
在这里,dataLoader() 是在独立执行线程中运行的 Isolate。在 isolate 中,你可以执行更密集的 CPU 处理(例如解析大型 JSON),或执行计算密集型数学运算,如加密或信号处理。
您可以在下面运行完整示例
import 'dart:async';
import 'dart:convert';
import 'dart:isolate';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
void main() {
runApp(const SampleApp());
}
class SampleApp extends StatelessWidget {
const SampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(title: 'Sample App', home: SampleAppPage());
}
}
class SampleAppPage extends StatefulWidget {
const SampleAppPage({super.key});
@override
State<SampleAppPage> createState() => _SampleAppPageState();
}
class _SampleAppPageState extends State<SampleAppPage> {
List<Map<String, Object?>> data = [];
@override
void initState() {
super.initState();
loadData();
}
bool get showLoadingDialog => data.isEmpty;
Future<void> loadData() async {
final ReceivePort receivePort = ReceivePort();
await Isolate.spawn(dataLoader, receivePort.sendPort);
// The 'echo' isolate sends its SendPort as the first message.
final SendPort sendPort = await receivePort.first as SendPort;
final List<Map<String, dynamic>> msg = await sendReceive(
sendPort,
'https://jsonplaceholder.typicode.com/posts',
);
setState(() {
data = msg;
});
}
// The entry point for the isolate.
static Future<void> dataLoader(SendPort sendPort) async {
// Open the ReceivePort for incoming messages.
final ReceivePort port = ReceivePort();
// Notify any other isolates what port this isolate listens to.
sendPort.send(port.sendPort);
await for (final dynamic msg in port) {
final String url = msg[0] as String;
final SendPort replyTo = msg[1] as SendPort;
final Uri dataURL = Uri.parse(url);
final http.Response response = await http.get(dataURL);
// Lots of JSON to parse
replyTo.send(jsonDecode(response.body) as List<Map<String, dynamic>>);
}
}
Future<List<Map<String, dynamic>>> sendReceive(SendPort port, String msg) {
final ReceivePort response = ReceivePort();
port.send(<dynamic>[msg, response.sendPort]);
return response.first as Future<List<Map<String, dynamic>>>;
}
Widget getBody() {
bool showLoadingDialog = data.isEmpty;
if (showLoadingDialog) {
return getProgressDialog();
} else {
return getListView();
}
}
Widget getProgressDialog() {
return const Center(child: CircularProgressIndicator());
}
ListView getListView() {
return ListView.builder(
itemCount: data.length,
itemBuilder: (context, position) {
return getRow(position);
},
);
}
Widget getRow(int i) {
return Padding(
padding: const EdgeInsets.all(10),
child: Text("Row ${data[i]["title"]}"),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample App')),
body: getBody(),
);
}
}
发起网络请求
#使用流行的 http 包 在 Flutter 中发起网络请求非常简单。它抽象出了你通常可能需要自己实现的大量网络操作,使发起网络请求变得简单。
要将 http 包添加为依赖项,请运行 flutter pub add。
flutter pub add http
要发起网络请求,请对异步函数 http.get() 调用 await
Future<void> loadData() async {
final Uri dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
final http.Response response = await http.get(dataURL);
setState(() {
data = (jsonDecode(response.body) as List).cast<Map<String, Object?>>();
});
}
展示耗时任务的进度
#在 UIKit 中,你在后台执行耗时任务时通常使用 UIProgressView。
在 Flutter 中,使用 ProgressIndicator Widget。通过布尔标志控制何时渲染它来以编程方式显示进度。在长时间运行的任务开始之前通知 Flutter 更新其状态,并在任务结束后将其隐藏。
在下面的示例中,build 函数被拆分为三个不同的函数。如果 showLoadingDialog 为 true(当 widgets.length == 0 时),则渲染 ProgressIndicator。否则,渲染带有网络调用返回数据的 ListView。
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
void main() {
runApp(const SampleApp());
}
class SampleApp extends StatelessWidget {
const SampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(title: 'Sample App', home: SampleAppPage());
}
}
class SampleAppPage extends StatefulWidget {
const SampleAppPage({super.key});
@override
State<SampleAppPage> createState() => _SampleAppPageState();
}
class _SampleAppPageState extends State<SampleAppPage> {
List<Map<String, Object?>> data = [];
@override
void initState() {
super.initState();
loadData();
}
bool get showLoadingDialog => data.isEmpty;
Future<void> loadData() async {
final Uri dataURL = Uri.parse('https://jsonplaceholder.typicode.com/posts');
final http.Response response = await http.get(dataURL);
setState(() {
data = (jsonDecode(response.body) as List).cast<Map<String, Object?>>();
});
}
Widget getBody() {
if (showLoadingDialog) {
return getProgressDialog();
}
return getListView();
}
Widget getProgressDialog() {
return const Center(child: CircularProgressIndicator());
}
ListView getListView() {
return ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) {
return getRow(index);
},
);
}
Widget getRow(int i) {
return Padding(
padding: const EdgeInsets.all(10),
child: Text("Row ${data[i]["title"]}"),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sample App')),
body: getBody(),
);
}
}