Flutter

初探 Flutter :使用單一程式碼輕鬆建立 iOS 及 Android App!

Flutter 讓開發者可以利用單一程式碼建立 Android 和 iOS apps。Flutter 開發建基於流行的行動 App 知識上,我們可以用幾行程式碼就輕鬆完成 UI 元件!一起試試建立一個非常簡單的通訊簿 App,讓我們看看 Flutter 的能力吧!
初探 Flutter :使用單一程式碼輕鬆建立 iOS 及 Android App!
初探 Flutter :使用單一程式碼輕鬆建立 iOS 及 Android App!
In: Flutter, iOS App 程式開發, Swift 程式語言

歡迎閱讀我第一篇 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-1

Flutter 渲染 Widget 樹,並將其繪製在平台的畫布上,過程非常好而簡單(還有很快)。它的 Hot-Reload 功能可以提供實時開發體驗。

你可以看看這篇文章,來了解更多 Flutter 的資訊及優點。

開始

今天,我們會建立一個非常簡單的 Flutter App,可以同時發佈到 iOS 及 Android 上,名為 Contactly。這是一個簡單的通訊簿 App,可以展示 Flutter 的能力,包含:

  1. TextField 與驗證
  2. 按鈕點擊
  3. 導航列
  4. 圖形渲染(本地端與線上)
  5. 錯誤警告提示
  6. 可滾動的列表視圖 (List View)
  7. 列表視圖搜尋功能
  8. JSON 檔案解析
  9. JSON 轉換物件
  10. 開啟外部網頁瀏覽器

這個 App 完成後應該像這樣:

flutter-demo

它包括了這些功能:

  1. 輸入 Pin 碼來登入
  2. JSON 檔案讀取聯絡資訊
  3. 搜尋特定的聯絡人
  4. 點擊查看聯絡人資料
  5. 點擊開啟外部瀏覽器來瀏覽聯絡資訊

Flutter 專案架構

雖然你沒有用過 Flutter 來建立任何 App,不過讓我們快速導覽一下這個專案的架構。稍後當你建立一個 Flutter 專案後,你應該看到這樣的專案架構:

  1. android:用來建立 Android App。而且,如果你需要添加 Android 特定的功能時,就會在這裡進行實作。
  2. assets:用來儲存圖形、資料檔案等。
  3. ios:用來建立 iOS App。而且,如果你需要添加 iOS 特定的功能時,就會在這裡進行實作。
  4. lib:包含了 App 的主程式碼。稍後,你會看到我們在這裡建立所有程式碼檔案。
  5. test:用來做 Unit Testing,不過我們不會在這篇教學詳述這部分。

看到這裡,你一定急不及待想要試用 Flutter。讓我們來深入了解 Flutter,並設定機器上所有必須的工具吧!

安裝 Flutter

在撰寫本篇文章時,我正在使用以下的機器設定及軟體版本:

  1. 執行 macOS High Sierra 的 Macbook Pro
  2. Android Studio 3.2.1
  3. Xcode 10.1
  4. 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-installation-failure

建立一個新的 Flutter 專案

安裝完 Flutter,讓我們來開始建立你的第一個 Flutter 專案吧。

首先,開啟 Android Studio,然後點擊 Start a new Flutter Project

step-1-flutter-project

接下來,選擇 Flutter Application 並點擊 Next

step-2-flutter-application

然後,在 Project name 裡填入 Contactly,你也可以為 App 改一個想要的名字。一般情況下,它應該會顯示 Flutter 的預設路徑。如果不喜歡的話,你可以指定自己的 Flutter SDK 路徑。此外,你也可以改變專案位置,並寫一個簡單的敘述。然後,點擊 Next

step-3-project-name

最後,填上一個 Company domain,這會複製到你的 Bundle Identifier (iOS) & Package Name (Android)。在我的情況而言,我同時勾選了 KotlinSwift 支援。然後,點擊 Finish

step-4-company-domain

在 iOS 模擬器上測試 App

當啟動了你的 Flutter App 後,版面就會自動產生一個示例 App 的模版程式碼,你可以點擊當中的按鈕並更新文字。在我們進行任何程式碼更動前,我們應該先在 iOS 模擬器上試著執行它。

為了執行 App,點擊右上角的顯示著 <no devices> 的下拉式選單 ,然後選擇 Open iOS Simulator

step-5

你上一次選擇的模擬器硬體會被挑選,以我的例子就是 iPhone XR

step-6

點擊綠色三角形的 Run,然後 App 應該會在你的模擬器上開啟。你應該能夠與 Demo App 互動、並按按幾個按鈕!

step-7

製作主頁 (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

step-8

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 這篇文章中了解更多資訊。

在我們輸入程式碼前,先來看看登入頁面的製成品吧:

step-9

如你所見,這個畫面有以下元件:

  1. 一個圖片 Logo
  2. 一個有替代字符的 TextField
  3. 一個登入按鈕

為了實作畫面元件,插入下面的程式碼。先複製貼上,我們等下會一個一個來看!


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 的運作。

我將程式碼拆解成三個部分,讓我們可以更簡單地消化它們:

  1. 就如早前提到的,我們會為 LoginPage 建立一個 Stateless Widget。這就是我們要從 StatelessWidget 擴充類別的原因。
  2. 因為 App 已經有一個文字檔案供使用者輸入,所以我們會初始化一個 TextEditingController 實例,主要負責處理所有文字編輯的邏輯。
  3. 在我們擴充 StatelessWidget 來繪製頁面的 UI 畫布時,必須要實作 build 方法。如果我們沒有實作這個方法,就會發生錯誤。此外,我們還會在這裡創建四個變數:
    • 一開始,我們會有個 logo,Circular Frame 利用 CircularAvatar 類別把 logo 嵌入其中。而 logo 也有一個 appLogo 圖像 (image)。

      如果你現在執行這個 App,可能會出現一個錯誤說 Image Asset 無法被讀取。我們知道圖像的讀取路徑,不過還缺了兩個部分:圖像本身、和我們需要在 pubspec.yaml 裡包含的路徑。

      首先,你可以從這裡取得我所用的 Logo 圖像。然後在根目錄裡建立名為 assets 的新資料夾 ,接著建立 images 子資料夾。

      step-10

      你應該要把圖像放置在 root/assets/images 裡。

      step-11

      然後,前往 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,因為大多都很清晰,像是 keyboardTypephonemaxLength4 個字元、maxLines1autoFocus 設定為 true,好在頁面顯示時會馬上觸發鍵盤。我們也使用 decorationstyle 來給 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 吧!你應該會看到像這樣的登入畫面:

step-12

很酷對吧?那麼繼續完成剩下的畫面吧。

建立聯絡人列表頁面 (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 也會被呼叫。這也是我們在呼叫頁面時會維護的可變動狀態物件。

在我們再次深入程式碼前,先來看看我們的聯絡人列表頁面的製成品:

step-13

在這裡我們有很多事情要做:

  1. 允許從 LoginPage 導航到 HomePage(路由)
  2. 填入 JSON 資料及映射 (Map) 到 ListView
  3. 顯示聯絡人列表
  4. 加入搜尋功能

設定路由

讓我們把 LoginPageHomePage 導航路徑掛勾起來。前往 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: () {},
        ),
      ),
    );
  }

這是一段很長的程式碼,讓我們逐步拆解並消化:

  1. 我們使用了 Material Design 類別中的 Card 來建立 Card-Like UI。
  2. 在每個 Card 裡,我們擁有一個 ListTile,而在每個 ListTile 中我們會有:
    • leading:包在 Hero 裡頭的 CircleAvatar 圖像,讓我們之後在導航到資料頁面時實作一些動畫。(Record 的照片)
    • title:它包含了聯絡人的名字。
    • subtitle:它被包在 Flexible 裡,允許輸入增長文字。
    • trailing:它是表示互動性的右箭頭圖示。

實作完這些後,差不多可以執行 App 及測試它了!在讓它順利執行前,最後一件事就是處理登入按鈕的 onPressed 事件,我們先前還沒指定任何實作。現在前往 LoginPage.dart,然後更改 loginButton 變數的 onPressed 事件為:

onPressed: () {
          Navigator.of(context).pushNamed(homePageTag);
        },

這樣就完成了!點擊 Run 按鈕,試著從登入頁面導航到首頁吧!

step-14

加入搜尋功能

為了讓 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 改變:

  1. 搜尋圖示會變成 close 圖示。
  2. appTitle 會變成搜尋輸入框。
  3. 當我們輸入搜尋文字時,列表會重新載入,並重新渲染出過濾後的結果。

所以,以下就是你需要的程式碼。繼續添加以下的程式碼來處理搜尋功能:

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”:

step-15-flutter-search

建立聯絡人資料頁面

為了完成 Contactly App,讓我們來建立最後的 Details 頁面,讓 App 顯示更多關於聯絡人的資訊吧。首先來看看完成後的頁面:

step-16-flutter-details

頁面顯示了聯絡人的大頭貼、名字、地址、及電話號碼,另外還是一個隱藏功能,就是允許使用者導航至外部網頁瀏覽器來瀏覽技術網站。讓我們開始吧!

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

上面這些程式碼所做的是:

  1. DetailsPage 建立 StatelessWidget
  2. 建立建構式來接收來自 HomePageRecord 物件(也就是已選的聯絡人)。
  3. 基於 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

flutter-url-launcher-package

這裡我們要加入一行設定來讀取 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-demo

總結

你已經完成了這個使用 Flutter 進行開發的基礎教學!就我來看,Flutter 開發建基於流行的行動 App 知識上,我們可以用幾行程式碼就輕鬆完成 UI 元件。雖然它的可擴縮性 (scalability) 還是個問號,不過我們可以看到 Google 及它的社群在這個框架上投資了很多,所以我們可以預料它的未來將會非常光明,努力超越 React Native。

你可以在這裡下載完整專案。

譯者簡介:楊敦凱-目前於科技公司擔任 iOS Developer,工作之餘開發自有 iOS App同時關注網路上有趣的新玩意、話題及科技資訊。平時的興趣則是與自身專業無關的歷史、地理、棒球。來信請寄到:[email protected]

原文Introduction to Flutter: Building iOS and Android Apps from a Single Codebase

作者
Lawrence Tan
現於 2359Media 擔任 iOS 程式設計師。他喜歡開發很棒的apps,希望自己開發的程式能改善大家的生活,令人活得更好。
評論
更多來自 AppCoda 中文版
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。