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