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(),
);
}
}