【Flutter 1.5】製作一個簡單的名片列表APP

主要這一篇文章是要做一個比較完整的案例,個人一直覺得『挫折感才是成功的捷徑』,一直做『對的』事情很難成長的,唯有『挫折感』才能讓人記憶深刻,像我的好人卡已經打破櫻木花道50+的記錄了,真是めでたしめでたし啊,等我要推出好人撲克牌的時候,再麻煩大家再來抖內,集資一下吧。話不多說,大家一起來從做中學吧,以下只會將部分的Code貼上解說。

架構

畫面架構

  • 在圖上可以看到一共有三個畫面,一個是登入的畫面,一個是清單列表,一個是介紹的內容,很符合一般APP的長相。

檔案架構

  • 如圖上所示,畫面放在『libs/widgets』內,有關資料的長相就放在『libs/models』中,而『libs/helpers』則是放一些公用程式,資源檔 (圖片、影音、文件…)都放在『assets』之中。

常數

// Constants.dart
import 'package:flutter/material.dart';

// 文字
const appTitle = "MyTableViewApp";
const pinCodeHintText = "Pin Code";
const loginButtonText = "Login";

// 常數
const bigRadius = 66.0;
const buttonHeight = 24.0;

// 顏色
Color appDarkGreyColor = Color.fromRGBO(58, 66, 86, 1.0);
Color appGreyColor = Color.fromRGBO(64, 75, 96, .9);

// 圖片
Image appLogo = Image.asset('assets/images/flutter-logo-round.jpg');

// 頁面Tag
const loginPageTag = 'Login Page';
const homePageTag = 'Home Page';

主程式

void main() => runApp(MyTableViewApp());

class MyTableViewApp extends StatelessWidget {
  final routes = <String, WidgetBuilder>{
    loginPageTag: (context) => LoginPage(),
    homePageTag: (context) => HomePage(),
  };

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: appTitle,
      theme: ThemeData(primaryColor: appDarkGreyColor,),
      home: LoginPage(),
      routes: routes,
    );
  }
}

登入畫面

新增檔案

  • 首先,我們先開啟一個LoginPage.dart檔案,當做登入頁面寫Code的地方,

Logo圖示

/// 產生logo圖示
CircleAvatar _appLogoMaker() {
  var circleAvatar = CircleAvatar(
    backgroundColor: Colors.transparent,
    radius: bigRadius,
    child: appLogo,
  );
  return circleAvatar;
}
取得圖片
  • 不得不說flutter在取得網路圖片上很簡單,iOSAndroid要取得網路上的圖片一般都需要安裝套件,或者是寫一堆Code,但是flutter內建的Image Class一行就OK了。
// 從本機內取得
Image localImage = Image.asset('assets/images/flutter-logo-round.jpg');

// 從網路上取得 (緩存)
Image networkImage = Image.network('https://cdn-images-1.medium.com/max/1600/1*TFZQzyVAHLVXI_wNreokGA.jpg');
製作圖片圓角
  • 在這裡可以使用CircleAvatar來製作圓角,圖的大小就由radius屬性來決定。
CircleAvatar(radius: 25.0,);

產生輸入框

/// 產生輸入框
TextFormField _appPinCodeMaker() {
  var textFormField = TextFormField(
    controller: _pinCodeController, 
    keyboardType: TextInputType.phone,
    maxLength: 4,
    minLines: 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),
  );

    return textFormField;
}
控制器
  • controller這個屬性是用來看看是誰來控制它 (取得文字)
鍵盤的樣式
字數及行數

產生按鈕

/// 產生按鈕
RaisedButton _raisedButtonMaker(BuildContext context) {
  var raisedButton = RaisedButton(
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
    padding: EdgeInsets.all(12),
    color: appDarkGreyColor,
    onPressed: () { Navigator.of(context).pushNamed(homePageTag); },
    child: _buttonTextMaker(),
  );

  return raisedButton;
}

Widget合體

/// 整體架構
Scaffold appScaffold(BuildContext context) {
  final logo = _appLogoMaker();
  final pinCode = _appPinCodeMaker();
  final loginButton = _appLoginButtonMaker(context);

  Scaffold scaffold = 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
        ],
      ),
    ),
  );

  return scaffold;
}

人員清單

Card的組成

  • 這裡就不再細部作介紹了,相信大家能看到這裡,就已經是Layout界的高高手了
/// 取得大頭照
CircleAvatar _getRecordPhoto(Record record) {
  CircleAvatar circleAvatar = CircleAvatar(
    radius: 32,
    backgroundImage: NetworkImage(record.photo),
  );

  return circleAvatar;
}

/// 取得內容文字 (介紹)
RichText _getRecordRichText(Record record) {
  RichText richText = RichText(
    text: TextSpan(
    text: record.address,
    style: TextStyle(color: Colors.white),
  ),
    maxLines: 3,
    softWrap: true,
  );

  return richText;
}

/// 取得內容文字 (姓名)
Text _getRecordText(Record record) {
  Text text = Text(
    record.name, 
    style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
  );
    
  return text;
}

/// 取得箭頭圖示
Icon _getArrowIcon() {
  Icon icon = Icon(Icons.keyboard_arrow_right, color: Colors.white, size: 30.0);
  return icon;
}

資料的取得

  • 相信大家可以看到上面的程式都有一個變數叫『Record record』,其實它就是去讀取『records.json』的資料來使用 (雖然一般都是去讀取網路上的資料),主要的處理都是在『models』資料夾之內
資料模型
  • Record.dart跟RecordList.dart就是存資料的樣子,裡面比較重要的就是factory關鍵字,聽名字就知道它是一個工廠方法,利用fromJson()來將JSON字串轉成Class,這個動作就叫做反序列化
// 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) {
    var record = Record(
        name: json['name'],
        address: json['address'],
        contact: json['contact'],
        photo: json['photo'],
        url: json['url']);
    return record;
  }
}
// RecordList.dart
import 'Record.dart';

class RecordList {
  List<Record> records = List();

  RecordList({this.records});

  factory RecordList.fromJson(List<dynamic> parsedJson) {
    List<Record> records = List<Record>();
    records = parsedJson.map((i) => Record.fromJson(i)).toList();

    return RecordList(
      records: records,
    );
  }
}
取得資料
  • 利用async / await來取得網路上的資料(非同步),再使用setState()來改變畫面上的樣子(重畫),這個動作是StatefulWidget才會有的,故名思義,它就是會改變的Widget,當然也有不會改變的StatelessWidget,我想應該是為了效能才有這兩者的分別才是。
/// 取得資料
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);
    }
  });
}
產生搜尋列
  • 其實它就是在AppBar上加上一個TextField輸入文字,來進行搜尋
/// 搜尋功能
void _searchPressed() {
  setState(() {
    if (this._searchIcon.icon != Icons.search) { _closeSerachBar(); return; }
    _openSerachBar();
  });
}

/// 把SearchBar打開
void _openSerachBar() {
  this._searchIcon = Icon(Icons.close);
  this._appBarTitle = TextField(
    controller: _filterTextEditingController,
    style: TextStyle(color: Colors.white),
    decoration: InputDecoration(
      prefixIcon: Icon(Icons.search, color: Colors.white),
      fillColor: Colors.white,
      hintText: 'Search by name',
      hintStyle: TextStyle(color: Colors.white),
    ),
  );
}

/// 把SearchBar關掉
void _closeSerachBar() {
  this._searchIcon = Icon(Icons.search);
  this._appBarTitle = Text(appTitle);
  _filterTextEditingController.clear();
}
過濾資料
  • 就是先把文字轉成小寫再去做文字的比對
/// 過濾資料 (轉小寫比較)
void _filterRecords() {
  if (_searchText.isNotEmpty) {
    _filteredRecords.records = 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]);
      }
    }
  }
}

內文頁

# pubspec.yaml
name: flutter_uitableview
description: A new Flutter project.

version: 1.0.0+1

environment:
  sdk: ">=2.1.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^0.1.2
  url_launcher: ^5.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true

  assets:
    - assets/images/flutter-logo-round.jpg
    - assets/data/records.json
import 'package:url_launcher/url_launcher.dart';

class URLLauncher {

  /// 開啟網頁
  void launchURL(String url) async {
    if (await canLaunch(url)) {
      await launch(url);
    } else {
      throw 'Could not launch $url';
    }
  }
}

結果

範例程式碼下載

後記

  • 因為個人也是個初學者,Code雖然Key得出來,也能夠Run,但是對內容其實不是很瞭解,還是要多多學習才是。