iT邦幫忙

2024 iThome 鐵人賽

DAY 10
0
Mobile Development

Flutter 開發實戰 - 30 天逃離新手村系列 第 10

Day 10 認識內建組件 Widgets

  • 分享至 

  • xImage
  •  

Text

Text(
	"文字",
  style: TextStyle(color: Colors.red, fontSize: 14),
  textAlign: TextAlign.center
)

注意到 Text 建構子的組成。第一個參數是顯示的文字字串,是位置參數。後續為具名參數,Flutter 傾向使用具名參數來確保 Widget 結構的可讀性。一些基礎參數或必填參數,例如這裡的「文字」則經常為第一個位置參數。

Image

Image 用於顯示圖片,截至目前為止 Widget 支援 jpeg, png, gif, webp, bmp, wbmp

Image(
  image: AssetImage('images/2.png'),
  width: 100,
  height: 100,
)

Image 須指定圖片來源,可以從網路讀取,本地裝置,或應用程式定義的資源。為了管理不同的資源,Widget 有一個 image 屬性可以指定 ImageProvider 類型。上面範例的 AssetImage 提供者由 app 資源。如果要讀取網路圖片則是使用 NetworkImage,如果是裝置檔案則使用 FileImage

此外,Image 也支援一些方便的建構子:

  • Image.asset:這會建立一個 AssetImage 提供者類型的圖片,可以讀起 app 的資源圖片。

    Image.asset('images/logo.png')
    
  • Image.networkNetworkImage 提供者類型的圖片,可從 URL 讀取圖片

    Image.network('https://picsum.photos/250?image=9')
    
  • Image.fileFileImage 提供者類型的圖片

    Image.file(File(file_path))
    

注意,若是本地資源檔案,則須先至 pubspec.yaml 設定。

flutter:
  uses-material-design: true
  # 加入資源檔案則如下:
  assets:
    - images/1.png
    - images/2.png

Material Design 和 iOS Cupertino

Flutter 中許多 Widget 某種程度都跟特定平台的設計規範有關:如 Material Design 或 iOS Cupertino。這也讓開發者容易遵循平台的設計規範。

舉例來說 Flutter 沒有 Button 而是由 Google Material Design 和 iOS Cupertino 提供按鈕。我們可以簡單的選擇其中一個實作。實務上不用根據執行平台來切換風格。

Dialog

一個對話視窗覆蓋在當前的介面也就是 Modal 效果且後面搭配半透明深色遮罩。在提供資訊、警告或提示錯誤時非常使用。

Flutter 提供了 Material Design 和 Cupertino 兩種。包含 Material Design 的 SimpleDialogAlertDialog 以及 Cupertino 的 CupertinoAlertDialog

TextButton(
  onPressed: () => showDialog<String>(
    context: context,
    builder: (BuildContext context) => Dialog(
      child: Padding(
        padding: const EdgeInsets.all(8.0),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text('基本對話框'),
            const SizedBox(height: 15),
            TextButton(
              onPressed: () {
                Navigator.pop(context);
              },
              child: const Text('關閉'),
            ),
          ],
        ),
      ),
    ),
  ),
  child: const Text('顯示對話框'),
),

互動 Widget

互動 Widget 在任何應用程式都是至關重要的環節,因為它們讓使用者可以操作你的 app 以及使 app 可以提供呈現資訊或者基於使用者的需求進行的動作。使用者有很多方式可以和 app 互動,在後續章節我們會深入介紹。這裡我們從一個最基礎的方式開始 Button

Button

按鈕是一種可以接受互動的 Widget ,點擊並呼叫在其建構函式(constructor)中所提供的相關程式碼或方法。。Material Design 實作了:

  • FloatingActionButton :如我們之前提到的,浮動動作按鈕是一個圓形,顯示圖示 ,一般漂浮在右下角,位置也可以設定調整。一般用於主要行為操作。例如一個顯示電子郵件訊息的也沒,這個按鈕可以是搭配一個 + Icon 功能是建立新郵件。
  • TextButton:文字按鈕為在 Material 元件上放置一串文字。使用者點擊時會產生 Material 波紋效果。
  • ElevatedButton:和文字按鈕很類似,凸起按鈕呈現些微懸浮的視覺效果。
  • OutlinedButton:輪廓按鈕也和文字按鈕很接近,但文字周圍有外框。
  • IconButton:圖示按鈕是在 Material 元件上顯示一個圖示。
  • DropDownButton:下拉按鈕類似於網站常見的下拉式選單。它顯示當前選取的項目,旁邊帶有箭頭符號。按下按鈕會展開一個選單,讓使用者選擇其他項目。
  • PopUpMenuButton:彈出式選單按鈕會在按下時顯示一個選單,包含多個選項讓使用者選擇。

而針對 Cupertino ,Flutter 則提供 CupertinoButton

文字欄位 Text Field

文字欄位讓使用者可以使用裝置的鍵盤輸入文字。Material Design 和 iOS Cupertino 兩個設計架構中都有實作 Text Field 對應分別是 TextFieldCupertinoTextField兩個元件都具有顯示虛擬鍵盤讓使用者輸入的功能。以下是一些它們共有的屬性:

  • autofocus : 該屬性設定 TextField 顯示在畫面上時會自動聚焦
  • enabled:設定文字框是否可以編輯
  • keyboardType :變更鍵盤類型。例如如果你只希望使用者輸入數字,或者希望使用者輸入密碼時自動修正功能關閉。
TextField(
  decoration: InputDecoration(
    hintText: '請輸入文字',
  ),
),

學習這些原廠組件最好的方式就是參考官方 API 文件,例如 TextField,你可以到文件上查詢說明和範例。我們需要花點時間掌握這些組件。

Selection Widget

選擇類 Widget 可以讓使用者選擇選項。在 Material Design 系列中有:

  • Checkbox 可以選擇列表中多個選項
  • Radio 可以選擇列表中的單一選項
  • Switch 可以開關選項
  • Slider 可以通過拖拉的方式選擇範圍中的值

Cupertino 系列中:

  • CupertinoActionSheet :這是一個 iOS 風格的 Modal 動作選單,可以先單選或多選
  • CupertinoPicker :選擇器控制可用於從一組較短的清單中選擇項目
  • CupertinoSegmentedControl :類似 Radio Button 支援單選
  • CupertinoSlider :類似 Material Design 的 Slider
  • CupertinoSwitch:類似 Swtich

值得注意的是,混合搭配 Material Design 和 Cupertino 風格的元件是「完全沒有問題」的。如果你認為某個 Cupertino 元件看起來比 Material Design 元件更合適,那麼就使用它。

日期和時間選擇器

針對 Material Design,Flutter 通過 showDatePickershowTimePicker 函式提供選擇器,其使用 Material Design 對話視窗建置。

而 Cupertino 則是提供 CupertinoDatePickerCupertinoTimerPicker Widget。

class DatePickerBox extends StatefulWidget {
  @override
  State<DatePickerBox> createState() => _DatePickerBoxState();
}

class _DatePickerBoxState extends State<DatePickerBox> {
  DateTime selectedDate = DateTime.now();

  Future<void> _selectDate(BuildContext context) async {
    final DateTime? picked = await showDatePicker(
      context: context,
      initialDate: selectedDate,
      firstDate: DateTime(2000),
      lastDate: DateTime(2025),
    );
    if (picked != null && picked != selectedDate) {
      setState(() {
        selectedDate = picked;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: <Widget>[
        ElevatedButton(
          onPressed: () => _selectDate(context),
          child: Text('Select date'),
        ),
      ],
    );
  }
}

雖然剛從 React Native 過來一定會有很多覺得不夠直覺的部分,但隨著範例的學習即可慢慢掌握。

現在你已經知道了許多內建的 Widget 而自然而然下一個問題就是如何控制 Widget 的呈現。

佈局

從目前我們看到的 Widget 關於如何設定位置的方式或許不是很明顯,又或者如何根據螢幕的尺寸呈現。

例如:要在螢幕下方角落放置一個按鈕,我們可能以為可以根據螢幕的相對位置指定。但你可能注意到了我們的按鈕沒有位置相關的屬性。

那麼 Widget 是如何排版的?答案是更多的 Widget!Flutter 提供了一些用來組合佈局的 Widget 它們可以設定位置,大小,樣式等等。

單純呈現一個 Widget 在畫面上無法組織呈現介面。通常我們會使用一系列 Widget 來組成。

Container

管理佈局最簡單的方式就是使用 Container Widget

Container(
	padding: EdgeInsets.all(14),
  decoration: BoxDesoration(
  	border: Border.all(),
  ),
  child: Text('Beautiful Teesside')
)

在這個範例中 Container 會有 14px 的內邊距並且有邊線,最後 Text Widget 包含文字。Container Widget 有一些實用的屬性例如:

  • padding :設定容器內部邊距
  • color:容器背景色
  • margin:容器外部邊距
  • decoration:設定容器是否有背景圖片或背景色,周圍是否有邊框,是否有圓角。注意:我們不行同時設定 colordecoration 如果使用 decoration 那麼就要把 color 的設定移到 decoration 物件內
  • height/width :設定容器的寬高

專用容器

Flutter 還支援特殊的 Widget 基於一般的 Container。包含支援動畫的 Container 後續在動畫章節會探討。

置中的效果可以通過 Center Widget 達成:

Center(
	child: Text('文字')
)

對齊子 Widget 和父層可以使用 Align

Align(
	alignment: Alignment.topRight,
  child: Text('文字')
)

廣泛使用的 Padding 可以指定子元素周圍的空間:

Padding(
	padding: EdgeInsets.all(16.0),
  child: Text('文字')
)

如你所見,當設定內邊距時,我們使用 EdgeInsets 類別。它包含了一些工廠建構子可以協助我們建立 EdgeInsets 物件。上面 EdgeInsets.all() 會為 4 邊設定邊距。

fromLTRB 建構子可以指定 4 週的邊距:

EdgeInsets.fromLTRB(left, top, right, bottom);

only 可以設定某一邊

EdgeInsets.only(right: 5);

symmetric 可以設定水平或垂直邊距:

EdgeInsets.symmetric(vertical: 8.0)

在實務中我們會很常看到 EdgeInsets 用在 paddingmargin 屬性。

雖然 Container 更加通用且除了佈局設定位置還可以支援樣式控制。但怎麼做可能會讓程式碼可讀性和可維護性變差。例如置中需求我們可以就使用 Center 即可會比較易懂。

Row 和 Column

Flutter 中最常見的容器是 RowColumn Widget。它們都支援 children 屬性,可以設定一個 Widget 列表。 Row 為水平排列,Column 為垂直排列。我們在前面的範例已經看過 Column 了,下面來看看 Row 的範例:

Row(
	mainAxisAlignment: MainAxisAligment.spaceBetween,
  children: [
    Text('紅'),
    Text('橙'),
    Text('黃')
  ],
)

上面的例子,3 個字串會水平排列在螢幕上。

mainAxisAlignment 參數設定子元素在父元件中沿著主軸的間距佈局。以 Row 來說所謂的主軸是水平的。而 MainAxisAlignment.spaceBetween 標註子元素之間擁有相同的空間。

請注意,Row 並不擅長處理超出空間的情況,因此在小裝置中,我們需要額外添加 Widget 或屬性來確保不會破版:

  • ExpandedFlexible 可以讓子組件根據上層組件的空間調整大小。

  • Wrap 超出空間換行顯示。

  • SingleChildScrollView 搭配 crollDirection: Axis.horizontal 可以水平滾動。

  • LayoutBuilder 根據空間通過程式動態調整。

    LayoutBuilder(
      builder: (BuildContext context, BoxConstraints constraints) {
        if (constraints.maxWidth > 600) {
          return Row(children: [...]);
        } else {
          return Column(children: [...]);
        }
      },
    )
    
  • FittedBox 可以縮放其子 Widget 以適應可用空間。

  • Overflow 對於 Text ,你可以使用 overflow 屬性來處理文字溢出的情況。

接著,我們來看看 Column 的範例。語法和屬性非常類似 Row 但子元素會垂直排列:

Column(
	crossAxisAlignment: CrossAxisAlignment.start,
  mainAxisSize: MainAxisSize.min,
  children: [
    DestinationWidget(destinationName: 'Staithes'),
    DestinationWidget(destinationName: 'Saltburn'),
    DestinationWidget(destinationName: 'Whitby'),
  ],
)

crossAxisAlignment屬性設定子元素如何沿著父元件中和主軸垂直的軸佈局間距。對 Column 而言,主軸是垂直的,也就交叉軸是水平方向。CrossAxisAlignment.start 會將所有子元件對齊到 Column 的左側,也就是軸的起點。

mainAxisSize 屬性指的是在 Column 的主軸方向上應該占用多少空間。對於 Column 而言,其主軸是垂直方向的,所以這裡的空間指的是高度。換言之,MainAxisSize.min 會使 Column 在垂直方向上的高度盡可能地小,僅足以容納其所有子元件。

你可能注意到了,我們在宣告列表或傳遞參數時,常會看到尾隨逗號。Dart 對於你的列表是否有尾隨逗號並不在意:對比 [item1, item2][item1, item2,]。然而,有幾個理由你可能會選擇加上尾隨逗號。首先,如果列表已經存在一個逗號,那麼加入項目比較省事不會忘記。接著,複製和貼上時,因為有相同的語法比較不會出錯。第三,Dart 的程式碼格式化工具會把列表中的每一項自動排列到新的一行。這樣做的好處是讓代碼看起來更整潔、更容易閱讀。

Stack

另一個廣泛使用的 Widget 就是 Stack,它可以按圖層的方式組織子元件,即一個子元件可以局部或完整重疊另一個子元件。這意味著,使用 Stack,你可以將多個組件疊加在一起,形成一個層次結構。

類似於 RowColumn 可以傳入一個 children 子元件列表,但會根據順序疊起來。也就是第一個 Widget 會在堆疊的最下層,最後一個蓋在上面。

Stack(
	children: [
    Container(
    	width: 100,
      height: 100,
      color: Colors.red,
    ),
    Container(
      width: 50,
      height: 50,
      color: Colors.green,
    ),
    Container(
      width: 25,
      height: 25,
      color: Colors.blue,
    ),
  ]
)

紅色方塊會在最下面,藍色在最上面。

Scaffold

Scaffold 實作了 Material Design 或 Cupertino 的基本佈局結構。**一般來說我們通常會使用作為頁面的根節點。**主要是它支援了標準佈局格式,讓我們可以建立一致性和標準化的頁面結構。

在 Material Design Scaffold 可以包含多種 Material Design 元件如:

  • AppBar :該 Widget 位於裝置螢幕的頂部。一般會在左邊有一個文字,右邊則是一些操作如按鈕等。
  • TabBar: 通常會在 AppBar 下面,可以水平切換幾個子頁面。
  • TabBarView:為了協助 TabBar 運作,通常需要定義幾個呈現頁面讓使用者切換。TabBarView 就是搭配頁籤切換的內容
  • body:頁面主要區塊。顯示在 AppBarTabBar 下面幾乎涵蓋整個頁面。
  • BottomNavigationBar:位於裝置頁面的最下方導航列。使用者可以通過點擊不同的標籤來快速切換應用中的主要視圖。
  • Drawer:這是一個從側邊滑出的面板(導覽列),讓使用者可以快速切換頁面。通常在下方的為主要的切換例如在 Line 底下的選單。而 Drawer 則針對該頁面提供相關導航選單例如 Gamil 切換收件夾和垃圾郵件。然而 Flutter 並沒有限制該如何設計,你可以任意使用這些 Widget。

在 iOS Cupertino ,頁面結構稍微不同,並且提供一些特定的轉換效果和行為。

關於 Cupertino 風格支援 CupertinoPageScaffoldCupertinoTabScaffold 通常搭配下面 Widget

  • CupertinoNavigationBar 置頂的導航列通常和 CupertinoPageScaffold 搭配使用
  • CupertinoTabBar 底部的頁籤切換列,一般也是和 CupertinoPageScaffold 搭配使用

如你所見,Cupertino 的 Scaffold 有比較多的限制。通常建議從 Material Design 的 Scaffold 入手,因為其涵蓋比較多功能可以滿足一個頁面大部分的需求。接著我們可以嘗試修改 Hello World 專案的 Scaffold

@override
Widget build(BuildContext) {
  return Scaffold(
  	appBar: AppBar(
    	title: Text("標題")
    ),
    body: Center(
    	child: Column(
      	crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: [
          DestinationWidget(destinationName: 'Staithes'),
          DestinationWidget(destinationName: 'Saltburn'),
          DestinationWidget(destinationName: 'Whitby'),
        ]
      )
    )
  );
}

WidgetApp 、MaterialApp、CupertinoApp

WidgetApp 是 Flutter 中的基本組件,支援了常見的 Widget 主要的核心功能就是將系統的「返回」綁定到 Navigator 操作或退出應用程式;返回、上一頁、退出這些功能。

MaterialAppCupertinoApp 都是基於 WidgetApp 的功能實作的,例如程式碼中使用到 WidgetsApp.router。無論使用 MaterialAppCupertinoApp 風格基本功能都是使用 WidgetApp 實現。除了導覽器 WidgetApp 還支援了其他基礎功能如 Localization、無障礙、系統字體等等。

用途和關係

  • MaterialApp 是開發 Android 應用或者想要 Material Design 風格的應用的起點。它提供了許多基礎設施,比如導航、主題、國際化等。
  • CupertinoApp 是為了開發符合 iOS 風格的應用而設計。它同樣提供導航、主題等基礎設施,但是風格和動畫效果都模仿 iOS。
  • ScaffoldCupertinoScaffold 則是提供了頁面的基本結構

MaterialApp

在 Flutter 中,MaterialApp 的本質是一個整合 Widget ,若你需要遵循 Material Design ,MaterialAppWidgetsApp 的基礎上增加了特定於 Material Design 的功能,比如 AnimatedThemeGridPaper。例如:

  • 主題 Theme
  • 動畫 AnimatedTheme
  • 網格 GridPaper

預設 MaterialApp 會將 WidgetApp.textStyle 設定成很醜的紅黃樣式。這是為了提醒開發者你沒有做任何設定。一般情況下若搭配使用 Scaffold 那麼就會取得 MaterialApp.theme 的主題樣式。

支援導航系統

MaterialApp 支援 Navigator 管理路由。也就是可以切換頁面,當使用者點擊按鈕或進行其他操作時,Navigator 會根據一定的規則來找到要顯示的下一畫面。

  1. 如果有設定 home 的情況下,造訪 / 時會顯示 home
  2. 若沒有 home 則查詢 routes 定義。過程中會檢查是否設定 onGenerateRoute,也就是但沒有 home 也沒有定義 routes 時會使用 onGenerateRoute
  3. 如果上述方法都不能決定路由,則會調用 onUnknownRoute,這是最後的備選方案,用於處理未知的路由。

如果 Navigator 被建立了,那麼至少要處理 / 路由,這是因為 Flutter 會嘗試使用這個無效的初始路由來找到一個匹配的頁面,如果沒有適當的配置來處理它,應用就會出現問題。

MaterialApp(
  home: MyHomePage(),
  routes: {
    '/settings': (context) => SettingsPage(),
  },
);

ListView 和 GridView

Flutter 也提供了 ListViewGridViewListView 比較接近 Column 但它可以捲動,且可以依據需求渲染。例如,我們的有個目的地列表項目超過 20 個,那麼一個畫面可能無法全部呈現,就會需要可捲動的功能。進一步但我們的 Widget 更複雜的時候,因為效能的考量我們不希望一口氣全部渲染,只有當 Widget 進入可視範圍才渲染。

ListView 支援捲動功能,通過 ListView.builder 建構子,當 Widget 需要顯示在畫面時才渲染。後續段落會有更詳細的介紹,現階段我們先嘗試使用 Column 來呈現大量的項目,然後看看 ListView 是如何運作的。

我們先在 Column 裡面放入 20 個 DestinationWidget

Padding(
	padding: const EdgeInsets.all(16),
  child: DestinationWidget("Staithes")
)

你應該會在下方看到 “黃黑線條”的警告區塊,並且提供訊息 Column 太長了超出畫面 BOTTOM OVERFLOW BY 100 PIXELS ,然後如果你嘗試要往下滑動,會注意到 Column 預設是無法捲動的。

接著,我們自己把 Column 替換成 ListView 。黃黑區塊和警告消失了,並且現在可以捲動了。這是一個簡單的 ListView 示範,希望帶來一個觀念,就是了解到需要考慮 Widget 適應不同情境需求。

GridView 也很類似,但是是建立柵格,而非列表,同時它也有和 ListView 相似的功能。

此外,還有其他不那麼常用,但也挺重要的 Widget 例如 Table 它以表格方式呈現。

其他佈局

每一個平台還有針對設計的 Widget。舉例來說 Material Design 有卡片的概念:一張卡片用於表示一些相關資訊。

另一方面,Cupertino 組件具有 iOS 特有的轉場效果。

你應該花時間探索,然後為你的應用程式決定設計標準。一致性很重要,如此才能提升使用者體驗。

進階 Widget (手勢、動畫、變形效果)

Flutter 支援任何和 UI 相關的 Widget。舉例來說,手勢如捲動或點擊都和管理手勢的 Widge 有關。動畫和變形如縮放、旋轉也都由特定的 Widget 管理,後續我們會深入探討。

SnackBar 與 Scaffold

在 Flutter 中,SnackBar 用於顯示臨時的訊息,這些訊息會在螢幕底下彈出,還可以提供一些簡單的操作。但是 SnackBar 不能單獨存在通常需要和 Scaffold 搭配使用。

Scaffold 是一個提供了一個基本的 Material Design 佈局結構的 Widget。例如 AppBar 和右下角的懸浮按鈕 FloatingActionButton ,而 SnackBar 也是其中一種效果。Scaffold 本身會管理這些搭配 Widget 的佈局包含 SnackBar 顯示的空間。

Flutter 1.22 之後加入了 ScaffoldMessenger 目的在提供一個更好的方式顯示 SnackBar 。不管目前的 Scaffold 可不可以使用,它讓 SnackBar 可以支援多個 Scaffold 解決了例如在頁面切換的時候 SnackBar 無法保持顯示的問題。

一個最基本的例子:

ElevatedButton(
	onPressed: () {
    ScaffoldMessenger.of(context).showSnackBar(
    	SnackBar(
      	content: Text('哈囉'),
      ),
    );
  },
  child: Text('問候')
);

但有時候在靜態方法或者事件 callback 中,我們沒有 context 可以使用。這個時候可以使用 GlobalKey<ScaffoldMessengerState>

首先,需要先建立一個全域 Key

final GlobalKey<ScaffoldMessengerState> snackBarKey = GlobalKey<ScaffoldMessengerState>();

然後搭配 MaterialAppScaffoldMessenger 等:

MaterialApp(
	scaffoldMessengerKey: snackBarKey,
  home: Scaffold(),
);
// 或
ScaffoldMessenger(
  key: snackBarKey,
  child: Scaffold(
    ...
  ),
);

最後即便沒有 context 我們也可以:

snackBarKey.currentState?.showSnackBar(
	SnackBar(
    content: Text('無 context 的 SnackBar'),
  ),
);

總結

我們認識了一些基本的內建 Widget 包含了基本呈現,操作的 Widget 等等,然後關於佈局的 Widget 從 ContainerListView 學習了如何使用這些 Widget 建構設計基本的 app。現在我們對 Flutter 應用是如何構建的有了更好的理解;但還有很多東西需要了解。在下一章中,我們將進一步探討用戶如何與 Flutter 應用互動。關於這個章節,建議應該花更多的時間參考官方文件進行學習和實作好進一步深入的掌握 Flutter 。


上一篇
Day 9 Widget 與使用者介面
下一篇
Day 11 使用者輸入與手勢處理
系列文
Flutter 開發實戰 - 30 天逃離新手村27
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言