Flutter 给 UIKit 开发者的指南
了解如何在构建 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 应用。
视图 (Views) vs. 组件 (Widgets)
#在 UIKit 中,你在 UI 中创建的大多数内容都是通过视图对象完成的,即 UIView 类的实例。它们可以作为其他 UIView 类的容器,从而形成布局。
在 Flutter 中,与 UIView 大致等价的是 Widget。虽然 Widget 并不完全对应 iOS 视图,但在你熟悉 Flutter 的工作方式时,可以将它们视为“声明和构建 UI 的方式”。
然而,它们与 UIView 有几点不同。首先,组件的生命周期不同:它们是不可变的,仅在需要更改时存在。每当组件或其状态发生变化,Flutter 框架就会创建一个新的组件实例树。相比之下,UIKit 视图在改变时不会被重建,它是一个可变实体,在调用 setNeedsDisplay() 使其无效之前,它会被绘制一次且不再重绘。
此外,与 UIView 不同,Flutter 的组件非常轻量,部分原因是它们的不可变性。因为它们本身不是视图,也不直接绘制任何内容,而是对 UI 及其语义的描述,这些描述在底层被“注入 (inflated)”为实际的视图对象。
Flutter 包含 Material 组件库。这些组件实现了 Material Design 指南。Material Design 是一个针对所有平台(包括 iOS)优化的灵活设计系统。
但 Flutter 足够灵活且富有表现力,可以实现任何设计语言。在 iOS 上,你可以使用 Cupertino 组件库来生成看起来像 Apple iOS 设计语言的界面。
更新组件
#在 UIKit 中更新视图时,你需要直接修改它们。在 Flutter 中,组件是不可变的,不能直接更新。相反,你必须操纵组件的状态。
这就是有状态 (Stateful) 与无状态 (Stateless) 组件概念引入的原因。StatelessWidget 字如其名——一个没有关联状态的组件。
当你描述的用户界面部分不依赖于组件初始配置信息之外的任何东西时,StatelessWidget 非常有用。
例如,在 UIKit 中,这类似于放置一个以你的 logo 作为 image 的 UIImageView。如果 logo 在运行时不会改变,请在 Flutter 中使用 StatelessWidget。
如果你想根据 HTTP 调用接收到的数据动态更改 UI,请使用 StatefulWidget。HTTP 调用完成后,告知 Flutter 框架组件的 State 已更新,以便它能更新 UI。
无状态和有状态组件之间的重要区别在于,StatefulWidget 拥有一个 State 对象,该对象存储状态数据并在树重建时保持该数据,因此数据不会丢失。
如有疑问,请记住这条规则:如果一个组件在 build 方法之外发生变化(例如,由于运行时用户交互),则它是状态化的。如果组件在构建后从未改变,则它是无状态的。然而,即使一个组件是有状态的,如果其父组件本身不对这些更改(或其他输入)做出反应,它仍然可以是无状态的。
以下示例展示了如何使用 StatelessWidget。常见的 StatelessWidget 是 Text 组件。如果你查看 Text 组件的实现,你会发现它继承自 StatelessWidget。
Text(
'I like Flutter!',
style: TextStyle(fontWeight: FontWeight.bold),
);
如果你查看上面的代码,你可能会注意到 Text 组件本身不携带显式状态。它仅渲染通过构造函数传递的内容,仅此而已。
但是,如果你想让“I Like Flutter”动态改变,例如在点击 FloatingActionButton 时?
要实现这一点,请将 Text 组件包裹在 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),
),
);
}
}
组件布局
#在 UIKit 中,你可以使用 Storyboard 文件来组织视图并设置约束,或者在视图控制器中以编程方式设置约束。在 Flutter 中,通过组合组件树在代码中声明布局。
以下示例展示了如何显示一个带填充的简单 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'),
),
),
);
}
你可以为任何组件添加内边距 (padding),这模拟了 iOS 中约束的功能。
你可以在 组件目录 中查看 Flutter 提供的布局。
移除组件
#在 UIKit 中,你可以在父视图上调用 addSubview(),或者在子视图上调用 removeFromSuperview() 来动态添加或移除子视图。在 Flutter 中,由于组件是不可变的,因此没有直接等同于 addSubview() 的方法。相反,你可以向父组件传递一个返回组件的函数,并使用布尔标志来控制该子组件的创建。
以下示例展示了如何在用户点击 FloatingActionButton 时在两个组件之间进行切换。
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 中,使用动画库将组件包裹在动画组件内。
在 Flutter 中,使用 AnimationController,这是一个可以暂停、定位、停止和反转动画的 Animation<double>。它需要一个 Ticker,用于发出 vsync 信号,并在运行时在每一帧上产生 0 到 1 之间的线性插值。然后,你创建一个或多个 Animation 并将其附加到控制器上。
例如,您可以使用 `CurvedAnimation` 来实现沿着插值曲线的动画。从这个意义上说,控制器是动画进度的“主”源,而 `CurvedAnimation` 计算替换控制器默认线性运动的曲线。像组件一样,Flutter 中的动画也通过组合工作。
构建组件树时,将 Animation 分配给组件的动画属性(例如 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 中实现签名绘制器,请查看 StackOverflow 上 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;
}
组件透明度
#在 UIKit 中,所有内容都有 .opacity 或 .alpha。在 Flutter 中,大多数情况下,你需要将组件包裹在 Opacity 组件中来实现这一点。
自定义组件
#在 UIKit 中,你通常会继承 UIView,或使用现有的视图来覆盖和实现方法以达到预期的行为。在 Flutter 中,通过组合较小的组件(而不是扩展它们)来构建自定义组件。
例如,如何构建一个在构造函数中接受标签的 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。
导航
#本节文档讨论应用页面之间的导航、推入和弹出机制等。
页面间导航
#在 UIKit 中,要在视图控制器之间跳转,可以使用 UINavigationController 来管理要显示的视图控制器堆栈。
Flutter 使用 Navigator 和 Routes 实现了类似的逻辑。Route 是应用“屏幕”或“页面”的抽象,Navigator 是一个管理路由的组件。路由大致映射为 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 中的路由,并用于从堆栈中 push 的路由获取结果。这是通过 await push() 返回的 Future 来完成的。
例如,要启动一个让用户选择位置的 location 路由,你可以执行以下操作
Object? coordinates = await Navigator.of(context).pushNamed('/location');
然后,在你的 location 路由内部,一旦用户选择了他们的位置,使用结果 pop() 堆栈
Navigator.of(context).pop({'lat': 43.821757, 'long': -79.226392});
跳转至其他 App
#在 UIKit 中,要将用户发送到另一个应用程序,需要使用特定的 URL 方案。对于系统级应用,方案取决于具体应用。要在 Flutter 中实现此功能,请创建原生平台集成,或使用现有的插件,例如 url_launcher。
手动返回
#在你的 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 包,请在应用组件上指定 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
],
);
}
}
委托包含实际的本地化值,而 supportedLocales 定义了应用支持的区域设置。上面的示例使用了 MaterialApp,因此它同时拥有用于基础组件本地化值的 GlobalWidgetsLocalizations 和用于 Material 组件本地化的 MaterialWidgetsLocalizations。如果你的应用使用 WidgetsApp,则不需要后者。请注意,这两个委托包含“默认”值,但如果你希望自己的应用文案也能本地化,则需要为自己的应用提供一个或多个委托。
初始化时,WidgetsApp(或 MaterialApp)会为你创建一个 Localizations 组件,其中包含你指定的委托。设备当前的区域设置始终可以从当前上下文的 Localizations 组件(以 Locale 对象形式)中访问,或者使用 Window.locale 访问。
要访问本地化资源,请使用 Localizations.of() 方法来访问给定委托提供的特定本地化类。使用 intl_translation 包将可翻译文案提取到 arb 文件中进行翻译,然后将其导回应用中,以便通过 intl 使用它们。
有关 Flutter 中国际化和本地化的更多详情,请参阅国际化指南,其中提供了使用和不使用 intl 包的示例代码。
视图控制器 (ViewControllers)
#本节文档讨论 Flutter 中 ViewController 的等价物以及如何监听生命周期事件。
Flutter 中 ViewController 的等价物
#在 UIKit 中,ViewController 表示用户界面的一部分,最常用于屏幕或部分区域。它们被组合在一起以构建复杂的用户界面,并帮助扩展应用的 UI。在 Flutter 中,这项工作由组件完成。正如导航部分所述,Flutter 中的屏幕由组件表示,因为“一切皆组件!”使用 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 的不可变组件模式,你只需将组件列表传递给 ListView,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 中,使用传入的组件所提供的触摸处理。
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() 中更新组件列表,你会很快发现数据在视觉上没有变化。这是因为当调用 setState() 时,Flutter 渲染引擎会查看组件树以查看是否有任何变化。当它到达 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 委托方法,因为它接收一个位置,并返回你想要在该位置渲染的单元格。
最后,最重要的一点是,请注意 onTap() 函数不再重建列表,而是向其 .add 内容。
创建滚动视图
#在 UIKit 中,如果需要,你可以将视图包裹在允许用户滚动内容的 ScrollView 中。
在 Flutter 中,最简单的方法是使用 ListView 组件。它既充当 ScrollView 又充当 iOS TableView,因为你可以以垂直格式布局组件。
@override
Widget build(BuildContext context) {
return ListView(
children: const <Widget>[
Text('Row One'),
Text('Row Two'),
Text('Row Three'),
Text('Row Four'),
],
);
}
有关如何在 Flutter 中布局组件的更详细文档,请参阅 布局教程。
手势检测和触摸事件处理
#本节讨论如何在 Flutter 中检测手势和处理不同事件,以及它们与 UIKit 的对比。
添加点击监听器
#在 UIKit 中,你可以将 GestureRecognizer 附加到视图来处理点击事件。在 Flutter 中,有两种添加触摸监听器的方法
- 如果组件支持事件检测,则将函数传递给它,并在该函数中处理事件。例如,
ElevatedButton组件有一个onPressed参数
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
developer.log('click');
},
child: const Text('Button'),
);
}
- 如果组件不支持事件检测,请将组件包裹在
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 组件,请声明一个顶层组件 MaterialApp 作为应用的入口点。MaterialApp 是一个便利组件,它封装了实现 Material Design 的应用通常所需的多个组件。它通过添加 Material 特定的功能构建在 WidgetsApp 之上。
但 Flutter 足够灵活且富有表现力,可以实现任何设计语言。在 iOS 上,你可以使用 Cupertino 库来生成符合 人机界面指南的界面。有关这些组件的完整集合,请参阅 Cupertino 组件库。
您还可以使用 WidgetsApp 作为您的应用 Widget,它提供了一些相同的功能,但不如 MaterialApp 丰富。
要自定义任何子组件的颜色和样式,请将 ThemeData 对象传递给 MaterialApp 组件。例如,在下面的代码中,种子颜色方案被设置为 deepPurple,分隔符颜色为灰色。
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
在 App 中打包图片
#虽然 iOS 将图片和资源视为不同的项目,但 Flutter 应用只有资源。放置在 iOS Images.xcasset 文件夹中的资源,在 Flutter 中被放置在资源文件夹中。与 iOS 一样,资源可以是任何类型的文件,不仅仅是图片。例如,你可能有一个位于 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 组件中使用
@override
Widget build(BuildContext context) {
return Image.asset('images/my_image.png');
}
更多详情,请参阅 在 Flutter 中添加资源和图片。
表单输入
#本节讨论如何在 Flutter 中使用表单以及它们与 UIKit 的对比。
获取用户输入
#鉴于 Flutter 如何使用带有独立状态的不可变组件,你可能想知道用户输入是如何融入其中的。在 UIKit 中,你通常在需要提交用户输入或对其采取行动时查询组件的当前值。这在 Flutter 中是如何工作的?
实际上,表单的处理方式(像 Flutter 中的所有东西一样)是通过专门的组件来完成的。如果你有 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 组件的 decoration 构造函数参数添加 InputDecoration 对象,轻松为你的字段显示“提示”或占位符文本
Center(
child: TextField(decoration: InputDecoration(hintText: 'This is a hint')),
)
显示验证错误
#就像对待“提示”一样,将 InputDecoration 对象传递给 Text 组件的 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 代码的方式)、事件循环和异步编程。除非你生成一个 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,这将触发组件子树的重建并更新数据。
以下示例异步加载数据并将其显示在 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
要进行网络调用,请在 async 函数 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(),
);
}
}