简单项目实战flutter(布局篇)

4,416 阅读11分钟

这是一个在撸完两个官方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保存数据,调用原生方法,多语言等等。

下篇传送门:简单项目实战flutter(功能篇)