佈局建構課程

這是一份如何在 Flutter 中建構佈局的指南。你將為如下 app 建立佈局:

The finished app
The finished app

這份指南之前溯源一步解釋了 Flutter 中的佈局方式,以及展示瞭如何在螢幕中放置單個 widget。經過了如何水平以及豎直放置 widgets 的討論之後,一些最常使用的 widgets 都涉及到了。

如果你想對佈局機制有個”全域”的理解,可以先從 Flutter 中的佈局 開始.

第一步: 建立 app 基礎程式碼

確保你已經 安裝和配置 好了你的環境,然後做如下步驟:

  1. 建立一個新的 Flutter 應用

  2. 用下面的程式碼來替換你的 lib/main.dart 檔案:

    lib/main.dart (all)
    import 'package:flutter/material.dart';
    
    void main() => runApp(const MyApp());
    
    class MyApp extends StatelessWidget {
      const MyApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Flutter layout demo',
          home: Scaffold(
            appBar: AppBar(
              title: const Text('Flutter layout demo'),
            ),
            body: const Center(
              child: Text('Hello World'),
            ),
          ),
        );
      }
    }

第一步: 對佈局進行圖形分解

第一步需要將佈局分解成它的各個基礎元素:

  • 識別出它的行和列。

  • 這個佈局是否包含網格佈局?

  • 是否有重疊的元素?

  • 介面是否需要選項卡?

  • 留意需要對齊、內間距、或者邊界的區域。

首先,識別出稍大的元素。在這個例子中,四個元素排成一列:一個影象,兩個行區域,和一個文字區域。

Column elements (circled in red)
Column elements (circled in red)

接著,對每一行進行圖解。第一行,也就是標題區域,有三個子元素:一個文字列,一個星形圖示,和一個數字。它的第一個子元素,文字列,包含兩行文字。第一列佔據大量空間,因此它應當被封裝在一個 Expanded widget 當中。

Title section

第二行,也就是按鈕區域,同樣有三個子元素:每個子元素是一個包含圖示和文字的列。

Button section

一旦圖解好佈局,採取自下而上的方法來實現它就變得尤為輕鬆了。為了最大程度減少,深層巢狀(Nesting)的佈局程式碼帶來的視覺混亂,需要用一些變數和函式來替代某些實現。

第二步: 實現標題行

首先,你可以建構標題部分左側列。新增如下程式碼到 MyApp 類別的 build() 方法內頂部。

lib/main.dart (titleSection)
Widget titleSection = Container(
  padding: const EdgeInsets.all(32),
  child: Row(
    children: [
      Expanded(
        /*1*/
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            /*2*/
            Container(
              padding: const EdgeInsets.only(bottom: 8),
              child: const Text(
                'Oeschinen Lake Campground',
                style: TextStyle(
                  fontWeight: FontWeight.bold,
                ),
              ),
            ),
            Text(
              'Kandersteg, Switzerland',
              style: TextStyle(
                color: Colors.grey[500],
              ),
            ),
          ],
        ),
      ),
      /*3*/
      Icon(
        Icons.star,
        color: Colors.red[500],
      ),
      const Text('41'),
    ],
  ),
);
  1. 將 Column 元素放到 Expanded widget 中可以拉伸該列,以利用該行中所有剩餘的閒置空間。設定 crossAxisAlignment 屬性值為 CrossAxisAlignment.start,這會將該列放置在行的起始位置。

  2. 將第一行文字放入 Container 容器中使得你可以增加內間距。列中的第二個子元素,同樣為文字,顯示為灰色。

  3. 標題行中的最後兩項是一個紅色星形圖示,和文字”41”。整行都在一個 Container 容器佈局中,而且每條邊都有 32 畫素的內間距。

如下新增標題部分到 app body 中:

{../base → step2}/lib/main.dart
@@ -14,11 +48,13 @@
14
48
  return MaterialApp(
15
49
  title: 'Flutter layout demo',
16
50
  home: Scaffold(
17
51
  appBar: AppBar(
18
52
  title: const Text('Flutter layout demo'),
19
53
  ),
20
- body: const Center(
21
- child: Text('Hello World'),
54
+ body: Column(
55
+ children: [
56
+ titleSection,
57
+ ],
22
58
  ),
23
59
  ),
24
60
  );

第三步: 實現按鈕行

按鈕區域包含三列使用相同佈局-一行文字上面一個圖示。此行的各列被等間隙放置,文字和圖示被著以初始色。

由於建構每列的程式碼基本相同,因此可以建立一個名為 buildButtonColumn() 的私有輔助函式,以顏色、圖示和文字為入參,返回一個以指定顏色繪製自身 widgets 的一個 column 列物件。

lib/main.dart (_buildButtonColumn)
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // ···
  }

  Column _buildButtonColumn(Color color, IconData icon, String label) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      mainAxisAlignment: MainAxisAlignment.center,
      children: [
        Icon(icon, color: color),
        Container(
          margin: const EdgeInsets.only(top: 8),
          child: Text(
            label,
            style: TextStyle(
              fontSize: 12,
              fontWeight: FontWeight.w400,
              color: color,
            ),
          ),
        ),
      ],
    );
  }
}

這個函式直接將圖示新增到這列裡。文字在以一個僅有上間距的 Container 容器中,使得文字與圖示分隔開。

透過呼叫函式並傳遞針對某列的顏色,Icon 圖示和文字,來建構包含這些列的行。然後在行的主軸方向透過使用 MainAxisAlignment.spaceEvenly,將剩餘的空間均分到每列各自的前後及中間。只需在 build() 方法中的 titleSection 宣告下新增如下程式碼:

lib/main.dart (buttonSection)
Color color = Theme.of(context).primaryColor;

Widget buttonSection = Row(
  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
  children: [
    _buildButtonColumn(color, Icons.call, 'CALL'),
    _buildButtonColumn(color, Icons.near_me, 'ROUTE'),
    _buildButtonColumn(color, Icons.share, 'SHARE'),
  ],
);

新增按鈕部分到 body 屬性中去:

{step2 → step3}/lib/main.dart
@@ -48,3 +59,3 @@
48
59
  return MaterialApp(
49
60
  title: 'Flutter layout demo',
50
61
  home: Scaffold(
@@ -54,8 +65,9 @@
54
65
  body: Column(
55
66
  children: [
56
67
  titleSection,
68
+ buttonSection,
57
69
  ],
58
70
  ),
59
71
  ),
60
72
  );
61
73
  }

第四步: 實現文字區域

將文字區域定義為一個變數,將文字放置到一個 Container 容器中,然後為每條邊新增內邊距。只需在 buttonSection 宣告下新增如下程式碼:

lib/main.dart (textSection)
Widget textSection = const Padding(
  padding: EdgeInsets.all(32),
  child: Text(
    'Lake Oeschinen lies at the foot of the Blüemlisalp in the Bernese '
    'Alps. Situated 1,578 meters above sea level, it is one of the '
    'larger Alpine Lakes. A gondola ride from Kandersteg, followed by a '
    'half-hour walk through pastures and pine forest, leads you to the '
    'lake, which warms to 20 degrees Celsius in the summer. Activities '
    'enjoyed here include rowing, and riding the summer toboggan run.',
    softWrap: true,
  ),
);

透過設定 softwrap 為 true,文字將在填充滿列寬後在單詞邊界處自動換行。

新增文字部分到 body 屬性:

{step3 → step4}/lib/main.dart
@@ -59,3 +72,3 @@
59
72
  return MaterialApp(
60
73
  title: 'Flutter layout demo',
61
74
  home: Scaffold(
@@ -66,6 +79,7 @@
66
79
  children: [
67
80
  titleSection,
68
81
  buttonSection,
82
+ textSection,
69
83
  ],
70
84
  ),
71
85
  ),

第五步: 實現圖片區域

四個列元素中的三個已經完成了,只剩下圖片部分了。如下新增圖片檔案到範例工程中:

  • Create an images directory at the top of the project.
  • 新增 lake.jpg

  • 更新 pubspec.yaml 檔案,新增一個 assets 標籤。這使得在你的程式碼中可以存取到該圖片。

    {step4 → step5}/pubspec.yaml
    @@ -18,3 +18,5 @@
    18
    18
      flutter:
    19
    19
      uses-material-design: true
    20
    + assets:
    21
    + - images/lake.jpg

現在你可以在你的程式碼中參考該圖片了:

{step4 → step5}/lib/main.dart
@@ -77,6 +77,12 @@
77
77
  ),
78
78
  body: Column(
79
79
  children: [
80
+ Image.asset(
81
+ 'images/lake.jpg',
82
+ width: 600,
83
+ height: 240,
84
+ fit: BoxFit.cover,
85
+ ),
80
86
  titleSection,
81
87
  buttonSection,
82
88
  textSection,

BoxFit.cover 告訴系統圖片應當儘可能等比縮小到剛好能夠覆蓋住整個渲染 box。

第六步: 最終的收尾

在最後的步驟中,需要在一個 ListView 中排列好所有的元素,而不是在一個 Column 中,因為當 app 執行在某個小裝置上時,ListView 支援 app body 的滾動。

{step5 → step6}/lib/main.dart
@@ -72,13 +77,13 @@
72
77
  return MaterialApp(
73
78
  title: 'Flutter layout demo',
74
79
  home: Scaffold(
75
80
  appBar: AppBar(
76
81
  title: const Text('Flutter layout demo'),
77
82
  ),
78
- body: Column(
83
+ body: ListView(
79
84
  children: [
80
85
  Image.asset(
81
86
  'images/lake.jpg',
82
87
  width: 600,
83
88
  height: 240,
84
89
  fit: BoxFit.cover,

Dart code: main.dart
Image: images
Pubspec: pubspec.yaml

大功告成!當你熱載入 app 時,你應當可以看到和本頁開頭截圖一樣的 app 佈局了。

你可以參考文件 為你的 Flutter 應用加入互動體驗 來給這個佈局增加互動。