歡迎閱讀我第一篇 Flutter 教學文章。我之前從未寫過跨平台或 Hybrid App 框架的文章,不過 Flutter 改變了我的想法。
過去我用過 React Native、Cordova、Phone Gap、Ionic 來開發 App,這些對我來說真的蠻夠用的,直到我發現了 Flutter,以及其龐大的開發者社群和案例 App。
甚麼是 Flutter?
簡單來說,Flutter 是一個多層系統 (Multi-Layered System),當中較高的層級使用起來比較簡單,並讓你用較少的程式碼來呈現更多的東西;而較低的層級讓你可以控制更多的東西,但代價就是複雜性會更高。
Flutter 框架是完全以 Dart 語言撰寫的。大部分的程式引擎都是由 C++ 語言以撰寫的,當中 Android 特定的功能部分以 Java 語言撰寫,而 iOS 特定的功能部分則是以 Objective-C 語言撰寫的。就像 React Native,Flutter 也提供了響應式視圖 (reactive-style view),不過它用了一個不同的方法,來避免因使用編譯式程式語言(也就是 Dart)需要 JavaScript 橋接而導致的效能問題。
Dart 會「提前」(Ahead Of Time, AOT) 編譯為多個平台的原生程式碼,這樣一來,Flutter 不用透過 JavaScript 橋接進行內容轉換,都可以與平台溝通。它也會編譯成原生程式碼,進而改善 App 啟動時間。
在 Flutter,一切都在於 Widget,Widget 是影響和控制 App 視圖及介面的元素。
Flutter 渲染 Widget 樹,並將其繪製在平台的畫布上,過程非常好而簡單(還有很快)。它的 Hot-Reload 功能可以提供實時開發體驗。
你可以看看這篇文章,來了解更多 Flutter 的資訊及優點。
開始
今天,我們會建立一個非常簡單的 Flutter App,可以同時發佈到 iOS 及 Android 上,名為 Contactly。這是一個簡單的通訊簿 App,可以展示 Flutter 的能力,包含:
- TextField 與驗證
- 按鈕點擊
- 導航列
- 圖形渲染(本地端與線上)
- 錯誤警告提示
- 可滾動的列表視圖 (List View)
- 列表視圖搜尋功能
- JSON 檔案解析
- JSON 轉換物件
- 開啟外部網頁瀏覽器
這個 App 完成後應該像這樣:
它包括了這些功能:
- 輸入 Pin 碼來登入
- 從 JSON 檔案讀取聯絡資訊
- 搜尋特定的聯絡人
- 點擊查看聯絡人資料
- 點擊開啟外部瀏覽器來瀏覽聯絡資訊
Flutter 專案架構
雖然你沒有用過 Flutter 來建立任何 App,不過讓我們快速導覽一下這個專案的架構。稍後當你建立一個 Flutter 專案後,你應該看到這樣的專案架構:
- android:用來建立 Android App。而且,如果你需要添加 Android 特定的功能時,就會在這裡進行實作。
- assets:用來儲存圖形、資料檔案等。
- ios:用來建立 iOS App。而且,如果你需要添加 iOS 特定的功能時,就會在這裡進行實作。
- lib:包含了 App 的主程式碼。稍後,你會看到我們在這裡建立所有程式碼檔案。
- test:用來做 Unit Testing,不過我們不會在這篇教學詳述這部分。
看到這裡,你一定急不及待想要試用 Flutter。讓我們來深入了解 Flutter,並設定機器上所有必須的工具吧!
安裝 Flutter
在撰寫本篇文章時,我正在使用以下的機器設定及軟體版本:
- 執行 macOS High Sierra 的 Macbook Pro
- Android Studio 3.2.1
- Xcode 10.1
- Flutter 1.0
我無法保證這篇教學適用於所有環境及平台,因此我不會在教學中談及配置的除錯,好讓文章主旨清晰。
首先,前往 Flutter Installation 頁面安裝 Flutter。因為文件內容描述非常詳細,我會跳過這個步驟。
執行 flutter doctor
後,當第一到第四項都打勾通過時,你就可以繼續了!Connected Devices
並不是必要條件。
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, v1.0.0, on Mac OS X 10.13.6 17G4015, locale en-SG)
[✓] Android toolchain - develop for Android devices (Android SDK 28.0.3)
[✓] iOS toolchain - develop for iOS devices (Xcode 10.1)
[✓] Android Studio (version 3.2)
[✓] Connected device (2 available)
如果你遇到下面的錯誤,可以參考建議解決方法來修復。例如,如果你的 Mac 沒有安裝 Android Studio 的話,請前往這個網站來下載軟體。記得必須要確定前四項打勾通過了,才能繼續下去。
建立一個新的 Flutter 專案
安裝完 Flutter,讓我們來開始建立你的第一個 Flutter 專案吧。
首先,開啟 Android Studio,然後點擊 Start a new Flutter Project。
接下來,選擇 Flutter Application 並點擊 Next。
然後,在 Project name 裡填入 Contactly,你也可以為 App 改一個想要的名字。一般情況下,它應該會顯示 Flutter 的預設路徑。如果不喜歡的話,你可以指定自己的 Flutter SDK 路徑。此外,你也可以改變專案位置,並寫一個簡單的敘述。然後,點擊 Next。
最後,填上一個 Company domain,這會複製到你的 Bundle Identifier (iOS) & Package Name (Android)。在我的情況而言,我同時勾選了 Kotlin 和 Swift 支援。然後,點擊 Finish。
在 iOS 模擬器上測試 App
當啟動了你的 Flutter App 後,版面就會自動產生一個示例 App 的模版程式碼,你可以點擊當中的按鈕並更新文字。在我們進行任何程式碼更動前,我們應該先在 iOS 模擬器上試著執行它。
為了執行 App,點擊右上角的顯示著 <no devices> 的下拉式選單 ,然後選擇 Open iOS Simulator。
你上一次選擇的模擬器硬體會被挑選,以我的例子就是 iPhone XR。
點擊綠色三角形的 Run,然後 App 應該會在你的模擬器上開啟。你應該能夠與 Demo App 互動、並按按幾個按鈕!
製作主頁 (Main Page)
既然 Demo App 成功執行,那我們現在就可以開始建立我們的第一個 Flutter App 了!
首先,刪除在 main.dart
裡的所有程式碼。按下 command-a
全選所有程式碼,然後按 Delete 就可以了。
我們將要從頭撰寫程式碼了!先加入下列程式碼來引入 material
程式包 (Package):
import 'package:flutter/material.dart';
要建立 App 的 UI,這個程式包是不可缺少的工具。為了確保 App 完成載入後知道要執行甚麼,加入 main()
:
void main() => runApp(ContactlyApp());
將檔案組織到單獨的程式包、並獨立放置常數,是個非常好的習慣。所以,讓我們來建立 helper
程式包和 Constants.dart
檔案,來放置一些我們在這個 App 會用到的常數數值。
右擊在 lib
資料夾,並選擇 New > Package,把程式包命名為 helpers
。
在 Constants.dart
裡插入以下程式碼:
import 'package:flutter/material.dart';
// Colors
Color appDarkGreyColor = Color.fromRGBO(58, 66, 86, 1.0);
// Strings
const appTitle = "Contactly";
我們在這裡匯入同一個 material
程式包,讓我們能夠使用 Color
宣告,並宣告一個全域 (app-wide) 的 appTitle
。
現在回到 main.dart
,然後在匯入程式碼的第一句後添加以下的匯入陳述。
import 'helpers/Constants.dart';
讓我們加入以下程式碼,以開始建立主頁:
class ContactlyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: appTitle,
theme: new ThemeData(
primaryColor: appDarkGreyColor,
),
);
}
}
MaterialApp 是一個蠻方便的 Widget,允許你客製不同功能,像是加入導航路徑、appBar 等。將 debugShowCheckedModeBanner
設定為 false
,就可以消掉右上的紅色錯誤訊息。這裡,我們使用自己在常數檔案裡宣告的 appTitle
來給 App 標題。接著,我們來設定 primaryColor
。
這裡所有的程式碼看起來都不錯,你可能想要試試執行它了。但如果你真的做了,就會看到一個大大的紅色錯誤視窗!
因為我們還沒準備好繪製畫布。
在大部分教學中,他們都會指引你在 main.dart
撰寫所有東西的程式碼。但我發現我們可以拆開每個頁面到個別檔案,從而讓程式碼更乾淨。
同時,Android Studio 應該在 widget_test.dart
檔案裡指出一個錯誤。因為我們將類別名稱從 MyApp
更改了為 ContactlyApp
,所以你應該將以下的程式碼:
await tester.pumpWidget(MyApp());
更改為:
await tester.pumpWidget(ContactlyApp());
製作登入頁面 (Login Page)
現在,讓我們來建立 LoginPage.dart
這個新頁面,並且將它放在 lib
底下。然後,同樣地引入 material package
。
這裡我們會建立一個 Stateless Widget,因為我們不需要儲存任何類型的資料。你可以在 Stateless VS Stateful 這篇文章中了解更多資訊。
在我們輸入程式碼前,先來看看登入頁面的製成品吧:
如你所見,這個畫面有以下元件:
- 一個圖片 Logo
- 一個有替代字符的 TextField
- 一個登入按鈕
為了實作畫面元件,插入下面的程式碼。先複製貼上,我們等下會一個一個來看!
import 'package:flutter/material.dart';
import 'helpers/Constants.dart';
// 1
class LoginPage extends StatelessWidget {
// 2
final _pinCodeController = TextEditingController();
// 3
@override
Widget build(BuildContext context) {
// 3a
final logo = CircleAvatar(
backgroundColor: Colors.transparent,
radius: bigRadius,
child: appLogo,
);
// 3b
final pinCode = TextFormField(
controller: _pinCodeController,
keyboardType: TextInputType.phone,
maxLength: 4,
maxLines: 1,
autofocus: true,
decoration: InputDecoration(
hintText: pinCodeHintText,
contentPadding: EdgeInsets.fromLTRB(20.0, 10.0, 20.0, 10.0),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(32.0),
),
hintStyle: TextStyle(
color: Colors.white
)
),
style: TextStyle(
color: Colors.white,
),
);
// 3c
final loginButton = Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: RaisedButton(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
onPressed: () {},
padding: EdgeInsets.all(12),
color: appGreyColor,
child: Text(loginButtonText, style: TextStyle(color: Colors.white)),
),
);
// 3d
return Scaffold(
backgroundColor: appDarkGreyColor,
body: Center(
child: ListView(
shrinkWrap: true,
padding: EdgeInsets.only(left: 24.0, right: 24.0),
children: <Widget>[
logo,
SizedBox(height: bigRadius),
pinCode,
SizedBox(height: buttonHeight),
loginButton
],
),
),
);
}
}
同時,在 Constants.dart
檔案裡,請以下列方式進行更新,來添加我們在 build
方法中使用的一些常數:
import 'package:flutter/material.dart'; // Colors Color appDarkGreyColor = Color.fromRGBO(58, 66, 86, 1.0); Color appGreyColor = Color.fromRGBO(64, 75, 96, .9); // Strings const appTitle = "Contactly"; const pinCodeHintText = "Pin Code"; const loginButtonText = "Login"; // Images Image appLogo = Image.asset('assets/images/flutter-logo-round.png'); // Sizes const bigRadius = 66.0; const buttonHeight = 24.0;
啊!程式碼實在是太多了呀!不過別擔心,這是我們第一次真正深入了解這麼多 Dart 程式碼。相信我,一起看過一遍後,你就會更熟悉 Flutter 的運作。
我將程式碼拆解成三個部分,讓我們可以更簡單地消化它們:
- 就如早前提到的,我們會為
LoginPage
建立一個Stateless Widget
。這就是我們要從StatelessWidget
擴充類別的原因。 - 因為 App 已經有一個文字檔案供使用者輸入,所以我們會初始化一個
TextEditingController
實例,主要負責處理所有文字編輯的邏輯。 - 在我們擴充
StatelessWidget
來繪製頁面的 UI 畫布時,必須要實作build
方法。如果我們沒有實作這個方法,就會發生錯誤。此外,我們還會在這裡創建四個變數: - 一開始,我們會有個
logo
,Circular Frame 利用 CircularAvatar 類別把logo
嵌入其中。而logo
也有一個appLogo
圖像 (image)。如果你現在執行這個 App,可能會出現一個錯誤說 Image Asset 無法被讀取。我們知道圖像的讀取路徑,不過還缺了兩個部分:圖像本身、和我們需要在
pubspec.yaml
裡包含的路徑。首先,你可以從這裡取得我所用的 Logo 圖像。然後在根目錄裡建立名為
assets
的新資料夾 ,接著建立images
子資料夾。你應該要把圖像放置在
root/assets/images
裡。然後,前往
pubspec.yaml
並加入以下的程式碼,來通知 App 在執行時要將哪些 Asset 同捆在一起,以便載入。assets: - assets/images/flutter-logo-round.png
請注意,你必須將上面的程式碼加到
flutter:
區塊裡面,像是這樣:flutter: assets: - assets/images/flutter-logo-round.png
- 可以正確地讀取 Logo 圖像後,下一個 UI 項目就是
pinCode
。這是一個 TextFormField。我們在這個 TextFormField 底下設置一個_pinCodeController
,它的前綴字_
是告訴編譯器這個變數是private
。你可以閱讀settings
,因為大多都很清晰,像是keyboardType
是phone
、maxLength
是4
個字元、maxLines
是1
、autoFocus
設定為 true,好在頁面顯示時會馬上觸發鍵盤。我們也使用decoration
及style
來給 TextField 一個簡單的樣式。 - 接著,來看看我們的
loginButton
。我們使用 Symmetric 在所有邊上 (上、下、左、右)設定 Padding。我們也使用 RaisedButton,當使用者與其互動時,它可以自動地提升 UI 效果。 - 最後,我們回傳主 UI 結構類別 Scaffold,它可以將我們新創建的 UI 元件在 ListView 中組合在一起。
這裡的程式碼真多啊!寫 UI 程式碼實在是艱辛啊 😭
在我們執行 App 之前,我們也需要告訴 main()
來執行 LoginPage
為首頁。所以,回到 main.dart
,把 home: LoginPage()
添加到 theme
之後。你的程式碼看起來應該會是這樣的:
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: appTitle,
theme: new ThemeData(
primaryColor: appDarkGreyColor,
),
home: LoginPage() // just added
);
}
同時,你也需要在檔案最開頭的地方匯入 LoginPage.dart
:
import 'LoginPage.dart';
現在來執行 App 吧!你應該會看到像這樣的登入畫面:
很酷對吧?那麼繼續完成剩下的畫面吧。
建立聯絡人列表頁面 (Contacts List Page)
熱身了一會後,讓我們加快速度。我們會建置這個 App 的主要功能:聯絡人列表頁面。我們會建立一個名為 HomePage.dart
的新檔案。建立檔案後,請確認一下你已經匯入了 material
程式包:
import 'package:flutter/material.dart';
聯絡人列表頁面是一個 Stateful Widget,因為我們需要維護聯絡資料的狀態。所以,添加這幾行樣板程式碼:
class HomePage extends StatefulWidget {
@override
_HomePageState createState() {
return _HomePageState();
}
}
class _HomePageState extends State<HomePage> {
}
第一個類別 HomePage
會被呼叫,並會在導航/跳入頁面時被使用。每當 HomePage
被呼叫時,私有類別 _HomePageState
也會被呼叫。這也是我們在呼叫頁面時會維護的可變動狀態物件。
在我們再次深入程式碼前,先來看看我們的聯絡人列表頁面的製成品:
在這裡我們有很多事情要做:
- 允許從
LoginPage
導航到HomePage
(路由) - 填入 JSON 資料及映射 (Map) 到 ListView
- 顯示聯絡人列表
- 加入搜尋功能
設定路由
讓我們把 LoginPage
和 HomePage
導航路徑掛勾起來。前往 Constants.dart
並添加這些標籤:
// Pages
const loginPageTag = 'Login Page';
const homePageTag = 'Home Page';
接著,前往 main.dart
,然後加入以下程式碼到 build
函式前面:
final routes = <String, WidgetBuilder>{
loginPageTag: (context) => LoginPage(),
homePageTag: (context) => HomePage(),
};
你也需要匯入 HomePage.dart
檔案:
import 'HomePage.dart';
上面的程式碼讓我們使用標籤,來讓每個獨立頁面產生關聯。最後,把路由加入到 build
函式裡的 home
之後。
Widget build(BuildContext context) {
...
home: LoginPage(),
routes: routes
);
}
我們還不能測試這個功能,因為還沒為 ListView 實作 UI,現在就來實作吧!
填入 JSON 資料及映射到 ListView
為了這個範例,我將所有的聯絡人資料儲存在一個 JSON 檔案裡。你可以下載範例 JSON 檔案,並在 assets
底下建立 data
資料夾,將 records.json
檔案放進資料夾內。然後,以下面的配置更新 pubspec.yaml
:
assets: - assets/images/flutter-logo-round.png - assets/data/records.json
現在我們已經準備好了 JSON 資料,我們還需要建立:
Record
類別來持有每個項目的資料RecordList
類別來持有資料列表RecordService
類別來進行讀取工作
建立 Record 類別來持有聯絡人資料
首先,讓我們在 lib
底下建立一個新的 models
程式包,並建立一個名為 Record.dart
的新檔案。你可以把下列程式碼插入到檔案裡:
class Record {
String name;
String address;
String contact;
String photo;
String url;
Record({
this.name,
this.address,
this.contact,
this.photo,
this.url
});
factory Record.fromJson(Map<String, dynamic> json){
return new Record(
name: json['name'],
address: json['address'],
contact: json ['contact'],
photo: json['photo'],
url: json['url']
);
}
}
Dart 提供工廠建構式 (Factory Constructors) 來支援工廠模式 (Factory Pattern),Factory
建構式能夠回傳數值(物件)。它在這裡解譯提供的 JSON 字串,並回傳一個 Record
實例,也就是一個聯絡人。
建立 RecordList 類別來持有聯絡人列表
在相同的 models
程式包裡,建立另一個叫做 RecordList.dart
的檔案。然後,插入下列程式碼:
import 'Record.dart';
class RecordList {
List<Record> records = new List();
RecordList({
this.records
});
factory RecordList.fromJson(List<dynamic> parsedJson) {
List<Record> records = new List<Record>();
records = parsedJson.map((i) => Record.fromJson(i)).toList();
return new RecordList(
records: records,
);
}
}
建立 RecordService 類別來進行讀取工作
最後,在同一個程式包裡建立另一個 RecordService.dart
檔案,並插入以下程式碼:
import 'RecordList.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'dart:convert';
class RecordService {
Future<String> _loadRecordsAsset() async {
return await rootBundle.loadString('assets/data/records.json');
}
Future<RecordList> loadRecords() async {
String jsonString = await _loadRecordsAsset();
final jsonResponse = json.decode(jsonString);
RecordList records = new RecordList.fromJson(jsonResponse);
return records;
}
}
這裡的 loadRecords()
函式解譯 records.json
檔案,並映射到 RecordList
物件上來持有 Record
物件列表。如果你不熟悉 Dart 的話,關鍵字 Future
對你來說可能會有點陌生。為了在 Dart 進行非同步操作,我們會使用 futures,它代表非同步操作的結果。
實作從首頁導航到聯絡人列表
現在,來使用我們在 HomePage
實作好的東西吧。開啟 HomePage.dart
,在開頭的地方匯入以下引入陳述:
import 'models/Record.dart'; import 'models/RecordList.dart'; import 'models/RecordService.dart';
除了列出聯絡人記錄次外,首頁還有搜尋功能讓使用者搜尋聯絡人。所以,讓我們先在 HomePage.dart
檔案裡的 _HomePageState
類別中宣告以下變數:
final TextEditingController _filter = new TextEditingController(); RecordList _records = new RecordList(); RecordList _filteredRecords = new RecordList(); String _searchText = ""; Icon _searchIcon = new Icon(Icons.search); Widget _appBarTitle = new Text(appTitle);
讓我們看看每個變數的目的:
- 我們宣告
_filter
讓我們能夠實作搜尋的監聽者。 - 我們宣告
records
來維持原始資料的狀態,同時也宣告filteredRecords
來維持搜尋結果的狀態。 - 我們使用
_searchText
來驗證搜尋內容。 - 我們宣告
_searchIcon
來呈現圖示 _appBarTitle
只是一個我們會廣泛使用的文字 Widget
因為這是個 Stateful
Widget,所以我們可以在狀態被初始化時添加一些小設定:
@override void initState() { super.initState(); _records.records = new List(); _filteredRecords.records = new List(); _getRecords(); } void _getRecords() async { RecordList records = await RecordService().loadRecords(); setState(() { for (Record record in records.records) { this._records.records.add(record); this._filteredRecords.records.add(record); } }); }
在 HomePage 的 init 狀態中,我們清空資料記錄,並從 JSON 檔案取得新的資料。這裡我們不需要真的使用 Async Call,但如果你要從伺服器端取得資料的話,你可以看看這篇文章來了解它的概念和呼叫它的方法。
還記得在先前的段落中,我們在 build
函式裡回傳一個 Scaffold
作為主 UI 結構。那麼,我們現在繼續插入下列程式碼來完成 UI 結構:
@override Widget build(BuildContext context) { return Scaffold( appBar: _buildBar(context), backgroundColor: appDarkGreyColor, body: _buildList(context), resizeToAvoidBottomPadding: false, ); }
一如大多數行動 App 的 ListView
,最頂部會有一個導航列。在上面的程式碼中,appBar
就是一個導航列。我們指定呼叫 _buildBar(context)
來產生這個導航列,然而我們還沒實作函式!所以,繼續插入以下程式碼:
Widget _buildBar(BuildContext context) { return new AppBar( elevation: 0.1, backgroundColor: appDarkGreyColor, centerTitle: true, title: _appBarTitle, leading: new IconButton( icon: _searchIcon ) ); }
接著是 body
的部分。因為我們還未實作 _buildList(context)
函式,請繼續添加下面的程式碼:
Widget _buildList(BuildContext context) { if (!(_searchText.isEmpty)) { _filteredRecords.records = new List(); for (int i = 0; i < _records.records.length; i++) { if (_records.records[i].name.toLowerCase().contains(_searchText.toLowerCase()) || _records.records[i].address.toLowerCase().contains(_searchText.toLowerCase())) { _filteredRecords.records.add(_records.records[i]); } } } return ListView( padding: const EdgeInsets.only(top: 20.0), children: this._filteredRecords.records.map((data) => _buildListItem(context, data)).toList(), ); }
接著,我們來處理 RecordList
資料映射到 ListVew
的部分,並一併處理任何的搜尋。
ListView
的最後部分是每個 ListViewItem
的 UI。讓我們來建立 _buildListItem
函式:
Widget _buildListItem(BuildContext context, Record record) { return Card( key: ValueKey(record.name), elevation: 8.0, margin: new EdgeInsets.symmetric(horizontal: 10.0, vertical: 6.0), child: Container( decoration: BoxDecoration(color: Color.fromRGBO(64, 75, 96, .9)), child: ListTile( contentPadding: EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0), leading: Container( padding: EdgeInsets.only(right: 12.0), decoration: new BoxDecoration( border: new Border( right: new BorderSide(width: 1.0, color: Colors.white24))), child: Hero( tag: "avatar_" + record.name, child: CircleAvatar( radius: 32, backgroundImage: NetworkImage(record.photo), ) ) ), title: Text( record.name, style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ), subtitle: Row( children: <Widget>[ new Flexible( child: new Column( crossAxisAlignment: CrossAxisAlignment.start, children: <Widget>[ RichText( text: TextSpan( text: record.address, style: TextStyle(color: Colors.white), ), maxLines: 3, softWrap: true, ) ])) ], ), trailing: Icon(Icons.keyboard_arrow_right, color: Colors.white, size: 30.0), onTap: () {}, ), ), ); }
這是一段很長的程式碼,讓我們逐步拆解並消化:
實作完這些後,差不多可以執行 App 及測試它了!在讓它順利執行前,最後一件事就是處理登入按鈕的 onPressed
事件,我們先前還沒指定任何實作。現在前往 LoginPage.dart
,然後更改 loginButton
變數的 onPressed
事件為:
onPressed: () { Navigator.of(context).pushNamed(homePageTag); },
這樣就完成了!點擊 Run 按鈕,試著從登入頁面導航到首頁吧!
加入搜尋功能
為了讓 App 擁有搜尋能力,我們必須啟用文字編輯器的監聽者。在 HomePage.dart
檔案裡的 _buildListItem
方法後面插入下列程式碼:
_HomePageState() { _filter.addListener(() { if (_filter.text.isEmpty) { setState(() { _searchText = ""; _resetRecords(); }); } else { setState(() { _searchText = _filter.text; }); } }); } void _resetRecords() { this._filteredRecords.records = new List(); for (Record record in _records.records) { this._filteredRecords.records.add(record); } }
搜尋的流程是從點擊搜尋圖示開始的,當搜尋被觸發時,我們會做些 UI 改變:
- 搜尋圖示會變成
close
圖示。 appTitle
會變成搜尋輸入框。- 當我們輸入搜尋文字時,列表會重新載入,並重新渲染出過濾後的結果。
所以,以下就是你需要的程式碼。繼續添加以下的程式碼來處理搜尋功能:
void _searchPressed() { setState(() { if (this._searchIcon.icon == Icons.search) { this._searchIcon = new Icon(Icons.close); this._appBarTitle = new TextField( controller: _filter, style: new TextStyle(color: Colors.white), decoration: new InputDecoration( prefixIcon: new Icon(Icons.search, color: Colors.white), fillColor: Colors.white, hintText: 'Search by name', hintStyle: TextStyle(color: Colors.white), ), ); } else { this._searchIcon = new Icon(Icons.search); this._appBarTitle = new Text(appTitle); _filter.clear(); } }); }
為了要觸發 _searchPressed()
,請把 onPressed
裡的這個方法添加至 _buildBar
:
Widget _buildBar(BuildContext context) { ... icon: _searchIcon, onPressed: _searchPressed ... }
準備好了!試著執行 App 並做些搜尋吧!像是輸入 “Mark”:
建立聯絡人資料頁面
為了完成 Contactly App
,讓我們來建立最後的 Details
頁面,讓 App 顯示更多關於聯絡人的資訊吧。首先來看看完成後的頁面:
頁面顯示了聯絡人的大頭貼、名字、地址、及電話號碼,另外還是一個隱藏功能,就是允許使用者導航至外部網頁瀏覽器來瀏覽技術網站。讓我們開始吧!
在 lib
中建立一個名為 DetailsPage.dart
的新檔案,並貼上下列程式碼:
import 'package:flutter/material.dart'; import 'models/Record.dart'; // 1 class DetailPage extends StatelessWidget { final Record record; // 2 DetailPage({this.record}); @override Widget build(BuildContext context) { return new Scaffold( appBar: new AppBar( title: new Text(record.name), ), body: new ListView( children: <Widget>[ Hero( tag: "avatar_" + record.name, child: new Image.network( record.photo ), ), // 3 GestureDetector( onTap: () { URLLauncher().launchURL(record.url); }, child: new Container( padding: const EdgeInsets.all(32.0), child: new Row( children: [ // First child in the Row for the name and the // Release date information. new Expanded( // Name and Address are in the same column child: new Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Code to create the view for name. new Container( padding: const EdgeInsets.only(bottom: 8.0), child: new Text( "Name: " + record.name, style: new TextStyle( fontWeight: FontWeight.bold, ), ), ), // Code to create the view for address. new Text( "Address: " + record.address, style: new TextStyle( color: Colors.grey[500], ), ), ], ), ), // Icon to indicate the phone number. new Icon( Icons.phone, color: Colors.red[500], ), new Text(' ${record.contact}'), ], ), ) ), ] ) ); } }
上面這些程式碼所做的是:
- 為
DetailsPage
建立StatelessWidget
。 - 建立建構式來接收來自
HomePage
的Record
物件(也就是已選的聯絡人)。 - 基於
Record
物件,我們建立 UI 並且加入這些欄位:- Photo
- Name
- Address
- Phone Number
- URL
你應該注意到一個新的 UI 元件:GestureDetector。一如其名,這個 Widget 類別是設計來偵測點擊的。當使用者點擊其中一個欄位時,App 會呼叫 URLLauncher().launchURL(record.url)
來在網頁瀏覽器讀取 URL。不過這個 URLLauncher
類別還沒準備好。
讓我們來在 helpers
資料夾裡建立一個名為 URLLauncher.dart
的新檔案。
為了進行 URL 載入,我們需要安裝一個新程式包 url-launcher。因此,我們需要這樣更新 pubspec.yaml
:
這裡我們要加入一行設定來讀取 url_launcher
。在編輯後,點擊 Packages Get 按鈕來執行 flutter packages get
。這樣我們就可以安裝額外的程式包來增加 App 的功能了!看,你剛剛就學會了另一個技能了!
現在回到 URLLauncher.dart
,插入以下程式碼來實作 launchURL
方法:
import 'package:url_launcher/url_launcher.dart'; class URLLauncher { launchURL(String url) async { if (await canLaunch(url)) { await launch(url); } else { throw 'Could not launch $url'; } } }
回到 DetailsPage.dart
檔案,並匯入我們剛實作的檔案:
import 'helpers/URLLauncher.dart';
最後一步,我們要啟動從 HomePage 導航至 DetailsPage。回到 HomePage.dart
,這樣編輯 _buildListItem
方法的 onTap:
事件:
Widget _buildListItem(BuildContext context, Record record) { ... onTap: () { Navigator.push( context, MaterialPageRoute(builder: (context) => new DetailPage(record: record))); }, ), ), ); }
同時,別忘了在 HomePage.dart
中匯入:
import 'DetailsPage.dart';
做得好!你已經完成了 iOS 和 Andriod App 囉!執行它,並享受一下自己的偉大傑作吧 :)
總結
你已經完成了這個使用 Flutter 進行開發的基礎教學!就我來看,Flutter 開發建基於流行的行動 App 知識上,我們可以用幾行程式碼就輕鬆完成 UI 元件。雖然它的可擴縮性 (scalability) 還是個問號,不過我們可以看到 Google 及它的社群在這個框架上投資了很多,所以我們可以預料它的未來將會非常光明,努力超越 React Native。
你可以在這裡下載完整專案。
原文:Introduction to Flutter: Building iOS and Android Apps from a Single Codebase