簡單專案實戰flutter(佈局篇)

朱子宥發表於2019-04-10

簡單專案實戰flutter(佈局篇)

這是一個在擼完兩個官方Demo之後,為了實踐操作重寫了原來app的專案。 雖然這個app基本上只有一個頁面,算不上覆雜但可以說內容豐富,涉及到的常用功能也不少,在花了三天假期的兩天擼完大部分內容之後,感覺還是學到了不少知識點,在此做一些總結歸納,避免過幾天忘記了。

專案地址:friday_today, 因為整體還沒完全完成,主體程式碼就沒有分幾個檔案,都放在main.dart ,一共800多行

這裡是原來的原生程式碼,全部用kotlin寫的,也是基本上都在一個Activity裡面:FridayActivity和佈局檔案activity_friday.xml

再放兩張截圖對比,上面是flutter版本,下面是原生Android版本:

flutter版本
Android版本

總體來說Flutter的佈局方式是和ReactNative類似的,State來控制狀態,以及Flex佈局之類。 flutter裡面的描述控制元件的是widget,描述佈局的也是widget,於是不得不面臨重重巢狀,寫佈局時十分懷念方便的ConstraintLayout,不過寫多了之後發現這種佈局方式有一個好處:就是非常容易寫一個方法返回一個通用widget,然後只需要呼叫方法就可以重複構建類似的佈局,雖然在a ndroid可以用include標籤(只能重用佈局),也可以用自定義佈局(需要新建類比較麻煩),但從使用上都不如flutter這樣直接用一個方法封裝來的方便快捷。

接下來從根佈局捋一捋用到的widget和一些踩到的坑:

根佈局:Stack,Position和AspectRatio

app的介面主要分為兩部分,用於展示的介面(包括背景和文字),和用於控制的介面(即圖上的黑色半透明有一堆按鈕的部分),其中展示部分有佔全螢幕和縮減為正方形兩種模式,因此不論如何這兩個部分肯定是重疊的,我用兩個方法分別構建這兩部分的元件,然後使用Stack來放置這兩個部分(最外層這個bodyScaffold的內容):

body: Stack(
 alignment: Alignment.bottomCenter,
  children: <Widget>[
  // 展示部分需要整體居中
    Center(
    // 套的這層RepaintBoundary是用來截圖的,後面再說
      child: RepaintBoundary(
        key: screenKey,
        child: screenType == 1
	        // 寬高一比一的情況
            ? AspectRatio(
                aspectRatio: 1 / 1,
                child: Container(
                  color: bgColor,
                  child: _buildShowContent(),
                ),
              )
             // 佔全螢幕的情況
            : Container(
                color: bgColor,
                child: _buildShowContent(),
              ),
      ),
    ),
    _buildControlPanel(),
  ],
));
複製程式碼

最初我以為在子佈局中設定alignment為botton就可以把controlPanel部分固定在底部,但實際並沒有效果,這塊內容飛到了頂上,半透明背景也沒有出現,推測是controlPanel的內容佔滿了螢幕高度,就算是固定在底部也看不出來。所以解決方式是在controlPanel中給Column新增縱軸上的最大值mainAxisSize: MainAxisSize.min(參見_buildControlPanel()方法的程式碼)。在最初瞎幾把亂試的時候誤打誤撞發現了一個可以達成相同效果的方式,就是使用Pisitioned來固定。

// 使用Positioned把這部分固定在底部,然後left和right為0使佈局撐開達到寬度match_parent的效果
Positioned(
  bottom: 0,
  left: 0,
  right: 0,
  child: _buildControlPanel(),
)
複製程式碼

AspectRatio的作用是使子佈局寬高限制為固定寬高比,屬性的作用都很顯而易見,就不贅述了。

文字展示部分,Column,BoxDecoration

展示的部分佈局很簡單,就是從上到下襬上幾個文字,用Column就可以實現,給Column設定在主軸上居中。flutter不像android所有View都可以設定marginpadding,而是要在外面套上Container,然後給Container設定設定各種屬性來修飾。 BoxDecoration則可以很方便的給Container的子元素設定背景色,圓角,邊框,陰影等效果,讓我覺得比較實用的就是對於Button類有一個現成的半圓圓角方法,在android裡實現一般需要確定高度,不然就只能等渲染完了再按照高度的一半設定圓角。

/// 繪製中間顯示的部分
_buildShowContent() {
  return Column(
        mainAxisAlignment: MainAxisAlignment.center, // 子佈局在橫軸上居中
        children: <Widget>[
          Container(
            margin: EdgeInsets.only(bottom: 20.0),
//              padding: EdgeInsets.symmetric(vertical: 15, horizontal: 20),
            height: 60.0,
            // 不是按鈕沒有現成的半圓方法,設定固定高度再加圓角
            decoration: BoxDecoration(
              color: bubbleColor, // 設定氣泡(文字的背景)顏色
              borderRadius: BorderRadius.circular(30.0),
            ),
            child: Center(// 使文字整體居中
              widthFactor: 1.3, // 寬度是文字寬度的1.3倍
              child: Text(
                langType == 0 ? "今天是週五嗎?" : "Is today Friday?",
                style: TextStyle(
                    fontSize: 25, color: textColor, fontFamily: fontName),
              ),
            ),
          ),
          Text(
            today.weekday == 5
                ? langType == 1 ? "YES!" : "是"
                : langType == 1 ? "NO" : "不是",
            style:
                TextStyle(fontSize: 90, color: textColor, fontFamily: fontName),
          ),
          Container(
            margin: EdgeInsets.only(top: 20.0),
            child: Text(
              "${weekdayToString(today.weekday)} ${today.year}.${today.month}.${today.day}",
              textAlign: TextAlign.center,
              style: TextStyle(color: textColor, fontFamily: fontName),
            ),
          ),
        ],
      );
}
複製程式碼

第一行文字有一個白色圓角背景作為氣泡,文字要在氣泡中居中,在android裡我使用gravity=center,然後設定好padding,最後根據渲染完之後文字的整個高度來設定圓角,但在這裡我固定了背景高度,由於無法知道文字的確切高度就不能用設定padding 的方法使文字居中了,解決方案是使用了一個Center包裹Text,寬度則由widthFactor設定為文字寬度的倍數,flutter裡面用倍數設定寬度的操作讓我覺得很奇怪。

控制皮膚

控制皮膚由各種按鈕組成,整體是一個Column從上到下一共7行,各行用Row來排布按鈕,是比較規律的排列方式,因為按鈕有很多相似性,此處用了好幾個方法來封裝不同的按鈕:

/// 繪製整個控制皮膚
_buildControlPanel() {
  return Container(
    padding: EdgeInsets.all(12.0),
    color: Color.fromARGB(30, 0, 0, 0), // 半透明的黑色背景
    child: Column(
      crossAxisAlignment: CrossAxisAlignment.start, // 子佈局在橫軸上左對齊
      mainAxisSize: MainAxisSize.min, // 高度保持最小高度以固定在底部
      children: <Widget>[
        // 前三行分別是背景,氣泡和文字的顏色控制,用一個方法封裝
        _buildColorController(0),
        _buildColorController(1),
        _buildColorController(2),
        // 展示可選擇的字型
        Container(
          height: 30.0,
          margin: EdgeInsets.only(bottom: 8.0),
          // 中文字型只有4個但是英文有11個,需要滾動,所以使用ListView而不是Row
          child: new ListView(
            padding: EdgeInsets.all(0.0),
            scrollDirection: Axis.horizontal, // 設定橫向滾動
            children: _buildFontRow(langType), // 根據字型種類生成切換字型的按鈕組
          ),
        ),
        Container(
          margin: EdgeInsets.only(bottom: 8.0),
          child: Row(
            // 一行四個按鈕,用通用的方法生成,點選事件也從外部傳入
            mainAxisAlignment: MainAxisAlignment.start,
            children: <Widget>[
              _buildCommonButton(
                  Text(
                    FridayLocalizations.of(context).square,
                    style: TextStyle(color: FridayColors.jikeWhite),
                  ),
                  25.0,
                  () => setState(() {
                        screenType = 1;
                      })),
              _buildCommonButton(
                  Text(
                    FridayLocalizations.of(context).full,
                    style: TextStyle(color: FridayColors.jikeWhite),
                  ),
                  25.0,
                  () => setState(() {
                        screenType = 0;
                      })),
              _buildCommonButton(
                  Text(
                    FridayLocalizations.of(context).titleCn,
                    style: TextStyle(color: FridayColors.jikeWhite),
                  ),
                  25.0,
                  () => {_changeLangType(0)}),
              _buildCommonButton(
                  Text(
                    FridayLocalizations.of(context).titleEn,
                    style: TextStyle(color: FridayColors.jikeWhite),
                  ),
                  25.0,
                  () => {_changeLangType(1)}),
            ],
          ),
        ),
        // 四個功能按鈕是2X2,用兩個row
        Container(
          margin: EdgeInsets.only(bottom: 8.0),
          child: Row(
            children: <Widget>[
              _buildCommonButton(
                  Text(
                    FridayLocalizations.of(context).wallpaper,
                    style: TextStyle(
                        color: FridayColors.jikeWhite, fontSize: 14.0),
                  ),
                  40.0,
                  () => {_capturePng(1)}),
              _buildCommonButton(
                  Text(
                    FridayLocalizations.of(context).titleShare,
                    style: TextStyle(
                      color: FridayColors.jikeWhite,
                      fontSize: 14.0,
                    ),
                  ),
                  40.0,
                  () => {_capturePng(2)}),
            ],
          ),
        ),
        Container(
          margin: EdgeInsets.only(bottom: 8.0),
          child: Row(
            children: <Widget>[
              _buildCommonButton(
                  Text(
                    FridayLocalizations.of(context).group,
                    style: TextStyle(
                        color: FridayColors.jikeWhite, fontSize: 14.0),
                  ),
                  40.0,
                  _toJike),
              _buildCommonButton(
                  Text(
                    FridayLocalizations.of(context).titleSave,
                    style: TextStyle(
                      color: FridayColors.jikeWhite,
                      fontSize: 14.0,
                    ),
                  ),
                  40.0,
                  () => {_capturePng(0)}),
            ],
          ),
        )
      ],
    ),
  );
}
複製程式碼

行:Expanded,Button

接下來繪製頭三行,因為是一樣的格式,封裝在下面這個方法裡:

/// 繪製切換顏色的三行 [type]背景/氣泡/字型
_buildColorController(int type) {
  return Container(
    margin: EdgeInsets.only(bottom: 8.0),
    child: Row(
      mainAxisAlignment: MainAxisAlignment.start,  // 主軸上從左到右
      mainAxisSize: MainAxisSize.max, // 寬度佔滿
      children: <Widget>[
        Text(
          getTitleByType(type, context), // 標題由type獲取
          style: TextStyle(
            fontSize: 12.0,
          ),
        ),
        // 生成顏色按鈕
        _buildColorClickDot(type, FridayColors.jikeWhite),
        _buildColorClickDot(type, FridayColors.jikeYellow),
        _buildColorClickDot(type, FridayColors.jikeBlue),
        _buildColorClickDot(type, FridayColors.jikeBlack),
        // Expanded佔滿剩餘寬度
        Expanded(
          child: Container(
            height: 30.0,
            padding: EdgeInsets.all(2.0), // 這個padding用於擠壓縮小按鈕本身
            margin: EdgeInsets.only(left: 8.0),
            child: RaisedButton(// 有凸起陰影效果的按鈕
              child: Text(
                FridayLocalizations.of(context).moreColor,
                style: TextStyle(fontSize: 12.0, color: Colors.white),
              ),
              onPressed: () => {_showPickColorDialog(type)},
              color: Colors.black26,
              shape: StadiumBorder(), // 半圓形背景
            ),
          ),
        ),
        Expanded(
          child: Container(
            height: 30.0,
            padding: EdgeInsets.all(2.0),
            margin: EdgeInsets.only(left: 8.0),
            child: RaisedButton(
              padding: EdgeInsets.all(0.0),
              child: Text(
                FridayLocalizations.of(context).customColor,
                style: TextStyle(fontSize: 12.0, color: Colors.white),
              ),
              onPressed: () => {_showCustomColorDialog(type)},
              color: Colors.black26,
              shape: StadiumBorder(),
            ),
          ),
        ),
      ],
    ),
  );
}
複製程式碼

最主要用到的空間就是RaisedButton,是有凸起陰影效果的按鈕,如果要沒有立體效果的可以使用FlatButtonshape: StadiumBorder()可以方便地使按鈕有半圓形效果,這裡順便放出三個顏色按鈕的生成方法:

/// 繪製點選切換顏色的小圓點
_buildColorClickDot(int type, Color color) {
  return Container(
    margin: EdgeInsets.only(left: 8.0),
    child: Container(
      // 限制按鈕的寬度
      width: 20.0,
      height: 20.0,
      child: RaisedButton(
//          onPressed: _changeColor(type, color), // 這樣寫顏色出不來
        onPressed: () => {_changeColor(type, color)},
        color: color,
        // 設定為圓形按鈕,如果不新增這個shape就是20*20的正方形
        // 因為寬高一樣,半圓和圓的結果是一樣的,所以用shape: StadiumBorder()也ok
        shape: CircleBorder(
            side: BorderSide(
          color: Colors.transparent,
          width: 0,
        )),
      ),
    ),
  );
}
複製程式碼

一開始我想像原生那樣用一個有顏色的View設定一個點選事件就完事了,但是Flutter裡面不是啥都可以點選的,如果不是Button類的widget,需要套一個GestureDetectorwidget來新增點選事件(後面會用到),所以還是直接用了Button,立體效果看起來感覺比原來好一點。使用Button的時候起初感覺很麻煩的就是它有一些自帶的padding等等,導致尺寸很不好控制,最後發現只要在在外面套上Container設定寬高就可以了,智障如我。

一行最後兩個文字按鈕,本來也是總有padding導致文字放不下,甚至按鈕本身超出螢幕,最後設定了Expanded來使它們的寬度自適應,但是這樣在小屏上文字可能真的放不下,於是把文字減了字數→_→

其他行也都是類似的結構,就不重複提了。

彈出部分:AlertDialog

選更多顏色和自定義顏色時都會彈出dialog,flutter有現成的Dialog型別控制元件,一般使用SimpleDialog顯示一個多行選項列表,AlertDialog顯示自定義的內容,並在最下面有幾個按鈕。 flutter有一個自帶的showDialog方法,接收一些引數,並使用一個builder來生成Dialog

/// 顯示自定義顏色的dialog
Future<void> _showCustomColorDialog(int type) async {
  return showDialog<void>(
    context: context,
    barrierDismissible: false, // user must tap button!
    builder: (BuildContext context) {
      return AlertDialog(
        contentPadding: EdgeInsets.all(16.0),
        title: Text(getCustomColorTitleByType(type, context)),
        content: ..., // 內容widget
        actions: <Widget>[
          FlatButton(
            child: Text('OK'),
            onPressed: () {
              _handleSubmitted(type, _inputController.text);
              Navigator.of(context).pop();
            },
          ),
        ],
      );
    },
  );
}
複製程式碼

以下是dialogcontent部分內容:

可滑動Widget:GridView,SingleChildScrollView,Wrap

其中一個Dialog需要展示500多個顏色列表,最初我使用了一個可滑動的SingleChildScrollViewcontent,其中放了500多個按鈕,用的是從左往右一行一行的排列,用Wrap比ListView更合適

content: SingleChildScrollView(// 可滑動
  child: Center( // 設定居中避免兩側空白不對稱
    child: Wrap(
      spacing: 5.0,
      runSpacing: 5.0,
      children: getColorRows(type),
    ),
  ),
),
複製程式碼

但是在我看了一下SingleChildScrollView的原始碼之後,裡面推薦了一堆別的控制元件,Flutter提供的控制元件太多了令人困惑,經過幾次嘗試,最終發現使用GridView.count可以完美實現,用法和Wrap很相似:

content: GridView.count(
  crossAxisCount: 8, // 一行的按鈕個數
  crossAxisSpacing: 5.0, //列間距
  mainAxisSpacing: 5.0, // 行間距
  children: getColorRows(type),
),
複製程式碼

輸入與提示:TextField,Snackbar

另一個Dialog則彈出文字輸入框供輸入六位或者八位顏色值,這裡涉及到文字輸入與控制,在官方Demo中有現成的例子,所以就直接拿來用了:

final TextEditingController _inputController = new TextEditingController();

...
content: TextField(
  controller: _inputController,
  decoration: InputDecoration(
      hintText: FridayLocalizations.of(context).hintInputColor,
      hintStyle: TextStyle(fontSize: 12.0)),
),
actions: <Widget>[
  FlatButton(
    child: Text('OK'),
    onPressed: () {
      _handleSubmitted(type, _inputController.text); // 點選ok時提交
      Navigator.of(context).pop(); // dialog隱藏
    },
  ),
],
...

void _handleSubmitted(int type, String text) {
 _inputController.clear(); // 清除輸入的文字
  if (text.length != 8 && text.length != 6) {
    // 輸入的格式不正確,彈出提示
    (scaffoldKey.currentState as ScaffoldState).showSnackBar(new SnackBar(
      content: new Text(FridayLocalizations.of(context).noticeWrongInput),
    ));
  } else {
    // 設定顏色
    _changeColor(
        type, Color(int.parse(text.length == 8 ? "0x$text" : "0xFF$text")));
  }
}
複製程式碼

這裡用到了一個showSnackBar來顯示SnackBar,有一個小坑,我先開始用網上搜尋到的Scaffold.of(context).showSnackBar(snackBar);來顯示,結果一直報Scaffold.of() called with a context that does not contain a Scaffold.這個錯,由於對flutter的context不夠了解,在查閱了一些資料後大致知道要把什麼widget拆出來,這樣就能通過context找到了(參考這篇文章),但是我並不想為了顯示一個snackBar這麼做,最後找到了另一個解決辦法,Scaffold.of(context)是為了獲取一個ScaffoldState,所以可以在Scaffold上新增一個key,再用這個key拿到state來呼叫方法:

GlobalKey<ScaffoldState> scaffoldKey = GlobalKey();

...
@override
  Widget build(BuildContext context) {
    return Scaffold(
        key: scaffoldKey,
        body: ...
        );
}
...

...
scaffoldKey.currentState.showSnackBar(new SnackBar(
      content: new Text(FridayLocalizations.of(context).noticeWrongInput),
    ));
...
複製程式碼

整個頁面的大致內容就是如此,原本我打算頁面整出來就完事了又不是不能用.jpg, 但在寫這篇總結的時候也為了探索是不是有別的實現方式做了一些嘗試,最終也進行了一些優化。 其實作為一個習慣了原生的Android開發者,很多時候都會困惑於原生很容易實現的效果放到Flutter中使用widget要怎麼寫,比如如何實現WRAP_COTENTMATCH_PARENT(可以參考這篇文章)等等,正是因為如此,我認為需要寫更多的佈局才能逐漸習慣Flutter的程式碼風格。 下一篇文章(如果有的話)會描述這個app的功能部分,包括截圖,儲存圖片,分享,跳轉其他app,SharedPreference儲存資料,呼叫原生方法,多語言等等。

相關文章