iT邦幫忙

2022 iThome 鐵人賽

DAY 12
0
Mobile Development

Flutter 30: from start to store系列 第 12

Flutter介紹:頁面的排版 - layout

  • 分享至 

  • xImage
  •  

今天和大家一起來看佈局原理以及佈局相關元件,分為幾個部分說明:

  • 父組件和子組件(Parent and Child Widget)
  • 佈局約束策略(Layout Constraint)
  • 接受單一子組件的佈局組件(Single Child Layout Widget)
  • 接受多個子組件的佈局組件(Multi Child Layout Widget)
  • 專案改動

好的,那我們就開始吧!


父組件和子組件

  • flutter的畫面是由各層級的Widget所組成,上層(外層)的Widget稱為Parent,裡面包著其他的Widget
  • 下層(內層)的組件稱為Child,一個Child Widget也有可能還包含其他組件,成為其他組件的Parent
Container( // parent widget
    color: Colors.blue,
    child: Container(color:Colors.red), // child widget
)

佈局約束策略

在flutter裡面,佈局須依循以下原則,

Constraints go down.
Sizes go up.
Parent sets position.

也就是

1.約束條件是由上層傳遞至下層

  • Child Widget並不是長寬設為多少就會長成多少,必須接受來自上層的Parent Widget最大長寬度的限制。
  • 同樣,這個Child Widget本身若也有Child Widget,也會向自己的Chile Widget傳遞長寬的約束。

2.組件大小由下層提供給上層

  • 接著,各個Child Widget皆會將自身期望的長寬包含約束條件向上傳遞給Parent Widget。

3.最後由上層決定下層的位置

  • Parent統整了所有Child Widget的尺寸資訊後,便開始在畫面上依照大小決定各自的位置

layout

如圖,黃色組件會

  1. 詢問上層灰色Parent Widget的佈局限制
  2. 和自身限制統整後,告訴內部的First Child 和Second Child他們的限制是多少
  3. 綜合First child限制和它期望的尺寸後,決定First child的尺寸
  4. 依照「First Child佔用後剩下的空間」和「Second Child的期望尺寸」綜合評估、決定Second Child實際尺寸
  5. 在畫面上依照First Child、 Second Child的尺寸決定位置
  6. 繪製完所有的child的尺寸時,同時也決定自身會佔用的尺寸了。此時黃色Widget會再把自身的尺寸回報給Parent Widget

接受單一子組件的佈局組件

接下來我們來介紹一些接受單一child widget的佈局組件:

  • Align:將組件對齊,由Alignment class指定對齊位置

    • 寫法:
      Center(
        child: Container(
          height: 120.0,
          width: 120.0,
          color: Colors.blue[50],
          child: const Align(
            alignment: Alignment.topRight, // 對齊右上角
            child: FlutterLogo(
              size: 60,
            ),
          ),
        )
      )
      
  • AspectRatio:試圖讓組件呈現一定的比例,如16:9

    • 寫法
      AspectRatio(
          aspectRatio: 16 / 9,
          child: Container(
            color: Colors.green,
          ),
      ),
      
  • Center:讓組件置中

    • 寫法:
      Center(
          child: Container(color: Colors.green)
      )
      
  • ConstrainedBox: 對組件施加額外的限制,例如:最小高度50px

    • 寫法:

      ConstrainedBox(
        constraints: const BoxConstraints(minHeight:50.0),
        child: const Card(child: Text('Hello World!')),
      )
      
  • Container:簡單的容器組件,可以在內部放任何東西並排版,類似HTML的<div>

    • 寫法:
      Container(
          margin: const EdgeInsets.all(10.0),
          color: Colors.amber[600],
          width: 48.0,
          height: 48.0,
      ),
      
  • Expanded:在與其他組件共享固定空間的同時,試圖擴展並填滿剩餘的空間

    • 寫法:
      Column(
        children: <Widget>[
          Container(
            color: Colors.blue,
            height: 100,
            width: 100,
          ),
          Expanded(
          // 視剩下的空間有多少,盡可能填滿
            child: Container(
              color: Colors.amber,
              width: 100,
            ),
          ),
          Container(
            color: Colors.blue,
            height: 100,
            width: 100,
          ),
        ],
      ),
      
  • Padding: 在組件加上padding(組件和外框的間隙)

    • 寫法:
      const Card(
        child: Padding(
          padding: EdgeInsets.all(16.0),
          child: Text('Hello World!'),
        ),
      )
      
    • 設定padding數值時,可以使用EdgeInsets來決定要設置哪邊的間隙
      • EdgeInsets.all(8.0):上下左右皆空出8.0

      • EdgeInsets.symmetric(vertical:8.0):垂直方向空出8.0

      • EdgeInset.only(top:8.0):只在上方空出8.0

  • SingleChildScrollView: 讓單一組件可以滑動。scrollview相關組件可以透過滑動讓頁面延伸,並得以放進更內容;通常搭配其他佈局元件使用。

    • 寫法:
      SingleChildScrollView(
          child: ConstrainedBox(
            constraints: BoxConstraints(
              minHeight: viewportConstraints.maxHeight,
            ),
            child: Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: <Widget>[
                Container(
                  // A fixed-height child.
                  color: const Color(0xffeeee00), // Yellow
                  height: 120.0,
                  alignment: Alignment.center,
                  child: const Text('Fixed Height Content'),
                ),
                Container(
                  // Another fixed-height child.
                  color: const Color(0xff008000), // Green
                  height: 120.0,
                  alignment: Alignment.center,
                  child: const Text('Fixed Height Content'),
                ),
              ],
            ),
          ),
      );
      

    如上例,SingleChildScrollView內部先包了一層ConstrainedBox限制最小高度,ConstraintBox內層又包了Column用於垂直排列其他兩個Container children。就算在SingelChildScrollView內部塞入的組件總高度已經「超出螢幕高度」,仍然可以透過滾動捲軸查看完整畫面,不會有破版問題。


接受多個子組件的佈局組件

此外還有接受多個child widget的佈局組件

  • Column:表示垂直方向的多個child widget佈局

    • mainAxisAlignment: 主軸(y軸)的對齊方式

      • center: 所有child垂直置中
      • start:所有child靠上方(主軸起始處)放置
      • end:所有child靠下方(主軸末端處)放置
      • spaceAround:所有child之間留相等的間距,第一個和最後一個child與邊界的距離是1/2的間距
      • spaceBetween: 所有child之間留相等的間距,但第一個和最後一個child與邊界不留間距
      • spaceEvenly: child和child之間、child和邊界之間的間距相等
    • crossAxisAlignment: 副軸(x軸)的對齊方式

      • center:所有child水平置中
      • start:所有child靠左方(副軸起始處)放置
      • end:所有child靠右方(副軸末端處)放置
      • stretch:延伸child使其填滿寬度
      • baseline: 所有child對齊baseline
    • 寫法:

      Column(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: const <Widget>[
          Text('Deliver features faster'),
          Text('Craft beautiful UIs'),
          Expanded(
            child: FittedBox(
              child: FlutterLogo(),
            ),
          ),
        ],
      )
      
  • GridView:網格佈局,呈現2D維度的格子且包含捲軸

    • 寫法:
      GridView.count(
        primary: false,
        padding: const EdgeInsets.all(20),
        crossAxisSpacing: 10,
        mainAxisSpacing: 10,
        crossAxisCount: 2,
        children: <Widget>[
          Container(
            padding: const EdgeInsets.all(8),
            color: Colors.teal[100],
            child: const Text("He'd have you all unravel at the"),
          ),
          Container(
            padding: const EdgeInsets.all(8),
            color: Colors.teal[200],
            child: const Text('Heed not the rabble'),
          ),
          Container(
            padding: const EdgeInsets.all(8),
            color: Colors.teal[300],
            child: const Text('Sound of screams but the'),
          ),
          Container(
            padding: const EdgeInsets.all(8),
            color: Colors.teal[400],
            child: const Text('Who scream'),
          ),
        ],
      )
      
  • ListView:列表佈局,呈現垂直/水平的列表且包含捲軸

    • 寫法:
      ListView(
        padding: const EdgeInsets.all(8),
        children: <Widget>[
          Container(
            height: 50,
            color: Colors.amber[600],
            child: const Center(child: Text('Entry A')),
          ),
          Container(
            height: 50,
            color: Colors.amber[500],
            child: const Center(child: Text('Entry B')),
          ),
          Container(
            height: 50,
            color: Colors.amber[100],
            child: const Center(child: Text('Entry C')),
          ),
        ],
      )
      
  • Row:表示水平方向的多個child widget的佈局,和Column相反

    • mainAxisAlignment: 主軸(x軸),properties同Column mainAxisAlignment
    • crossAxisAlignment: 副軸(y軸),properties同Column crossAxisAlignment
      Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: const <Widget>[
          Expanded(
            child: Text('Deliver features faster', textAlign: TextAlign.center),
          ),
          Expanded(
            child: Text('Craft beautiful UIs', textAlign: TextAlign.center),
          ),
          Expanded(
            child: FittedBox(
              child: FlutterLogo(),
            ),
          ),
        ],
      )
      
  • Stack:堆疊佈局,設定組件的child相對於組件邊界的位置

    • 排序較後面的元件會在畫面上顯示在較為上層的位置
    • 寫法:
    Stack(
      children: <Widget>[
        Container(
          width: 100,
          height: 100,
          color: Colors.red,
        ),
        Container(
          width: 90,
          height: 90,
          color: Colors.green,
        ),
        Container(
          width: 80,
          height: 80,
          color: Colors.blue,
        ),
      ],
    )
    

更多關於佈局組件的用法,詳見官方文件


專案實作

我們昨天已經預先跟負責垂直排列元件的Column打過招呼了,今天試著透過佈局組件來排除破版的問題吧:

  1. 在頁面主體加入SingleChildScrollView,讓頁面得以滑動並解決說明文字超出版面的問題。

    • 我們可以自己改程式碼加入SingleChildScrollView,或者透過IDE flutter extension提供的方法
      1. 對目標組件Column按下右鍵,選取:Refactor
      2. 在之後出現的視窗選取 Wrap with widget...
      3. 再將外層新出現的Widget改成SingleChildScrollView即可,用這個方法會比手打方便許多
    • 設置完成後,按下debug按鈕列上的hot reload按鈕之後應該會看到畫面的破版消失了
    • 試著上下拖曳畫面,會發現畫面可滑動了
  2. 在Image外層包上Container,並設置對Image的constraint: 寬度為螢幕寬、最小高度為螢幕寬。當下螢幕寬度可以透過 MediaQuery.of(context).size.width取得

    Container(
      constraints: BoxConstraints(minHeight: deviceWidth),
      width: deviceWidth,
      child: Image.network(
          'https://apod.nasa.gov/apod/image/2209/WaterlessEarth2_woodshole_2520.jpg',
          loadingBuilder: (BuildContext context, Widget child,
              ImageChunkEvent? loadingProgress) {
        if (loadingProgress == null) {
          return child;
        }
        return Center(
          child: CircularProgressIndicator(
            value: loadingProgress.expectedTotalBytes != null
                ? loadingProgress.cumulativeBytesLoaded /
                    loadingProgress.expectedTotalBytes!
                : null,
          ),
        );
      }),
    ),
    
  3. 將「favorite」Button和主要的Image 以 Stack的形式重疊,Button要在Image的右上角

    Stack(
      children: [
        Container(
          constraints: BoxConstraints(minHeight: deviceWidth),
          width: deviceWidth,
          child: Image.network(
              'https://apod.nasa.gov/apod/image/2209/WaterlessEarth2_woodshole_2520.jpg',
              loadingBuilder: (BuildContext context, Widget child,
                  ImageChunkEvent? loadingProgress) {
            if (loadingProgress == null) {
              return child;
            }
            return Center(
              child: CircularProgressIndicator(
                value: loadingProgress.expectedTotalBytes != null
                    ? loadingProgress.cumulativeBytesLoaded /
                        loadingProgress.expectedTotalBytes!
                    : null,
              ),
            );
          }),
        ),
        ElevatedButton(
            onPressed: () {
              print('add to favorite');
            },
            child: const Text('favorite')),
      ],
    ),
    
  4. ElevatedButton 外層加上Position,將其定位在Image的右上角距離邊框10.0的位置。Position在這邊是搭配Stack layout定位使用的Widget

    Positioned(
      top: 10.0, // 相對上方距離10.0
      right: 10.0, // 相對右方距離10.0
      child: ElevatedButton(
          onPressed: () {
            print('add to favorite');
          },
          child: const Text('favorite')),
    ),
    
  5. 畫面呈現如下:

  • 本次改動的相關程式碼放在我的github,見Day12相關commit

Recap

今天看到了佈局相關的規則以及許多佈局組件,總結如下:

  • 組件有外層和內層的關係,通常以Parent/Child相稱
  • 繪製組件時並不是指定長寬多少就是多少,必須考量其Parent Widget的限制(layout constraint)
  • Single Child Layout Widget
    • Align:對齊
    • AspectRatio:指定比例
    • Center:置中
    • ConstratinedBox:長寬限制
    • Container:外框容器
    • Expanded:盡可能佔據剩餘空間
    • Padding:與外框的間隙
    • SingleChildScrollView:加上頁面捲軸
  • Multi Child Layout Widget
    • Column:垂直排列
    • GridView:網格式排列
    • ListView:列表式排列
    • Row:水平排列
    • Stack:堆疊排列

內容蠻多的呢!明天我們要來創建新的頁面,
並看看如何在APP內導頁~


上一篇
Flutter介紹:頁面的建構 - Image, Text, Button
下一篇
Flutter介紹:在App內導頁 - navigation
系列文
Flutter 30: from start to store30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言