从0开始写一个基于Flutter的开源中国客户端(3)——初识Flutter & 常用的Widgets

7,784 阅读17分钟

上一篇主要介绍了Dart语言的语法基础,从这一篇开始就要真正涉及到Flutter的开发了,希望自己在写作的过程中能温故知新,同时给Flutter初学者带来一些帮助。

索引 文章
1 从0开始写一个基于Flutter的开源中国客户端(1)
Flutter简介及开发环境搭建 | 掘金技术征文
2 从0开始写一个基于Flutter的开源中国客户端(2)
Dart语法基础
👉3 从0开始写一个基于Flutter的开源中国客户端(3)
初识Flutter & 常用的Widgets
4 从0开始写一个基于Flutter的开源中国客户端(4)
Flutter布局基础
5 从0开始写一个基于Flutter的开源中国客户端(5)
App整体布局框架搭建
6 从0开始写一个基于Flutter的开源中国客户端(6)
各个静态页面的实现
7 从0开始写一个基于Flutter的开源中国客户端(7)
App网络请求和数据存储
8 从0开始写一个基于Flutter的开源中国客户端(8)
插件的使用

一个最简单的Flutter App

创建项目,添加代码

还记得在上一篇中,我们使用Android Studio创建了一个Flutter项目吗?新创建的Flutter项目自动为我们生成了一些代码,代码在/lib/main.dart文件中,这里我们先清空/lib/main.dart文件中的代码,用下面的代码代替:

// main.dart文件内容
import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text('First App')
        ),
        body: new Center(
          child: new Text('Hello world'),
        ),
      ),
    );
  }
}

启动模拟器

为了在手机上跑起我们的App来,首先我们得运行一个模拟器(当然你也可以用真机调试)。如果你的电脑上Flutter开发环境配置得没有问题,该装的都装了(运行flutter doctor命令检查依赖是否安装,AndroidStudio的dart和flutter插件也必须安装),那么在Android Studio的工具栏上,应该可以看到如下图的图标:

这里由于我的电脑上还没有任何运行的Android / iOS模拟器,所以这里显示的是<no devices>,点击该按钮,选择Open iOS Simulator即可启动一个iOS模拟器(确保你的电脑上安装了Xcode)如下图所示:

如果你想创建Android模拟器,必须先确保你有可用的Android模拟器,在AndroidStudio的工具栏上找到AVD Manager图标,如下图:

点击打开Android模拟器管理对话框,如下图:

这里我已经创建了一个API 27的Android模拟器,如果你的列表为空,点击图中Create Virtual Devices...创建模拟器即可。

运行Dart代码

第一步中我们已经写好了代码,第二步中我们的模拟器也启动了,点击AndroidStudio工具栏中的Run按钮即可运行Flutter项目到我们的模拟器中了,Run按钮在下图所示位置:

稍等片刻,模拟器中就会自动安装并打开我们的第一个Flutter App了,如下图所示:

可以看到,我们仅仅用了20多行代码,就完成了一个精美的Demo App(虽然没有实现任何功能,但是对比下如果要用Android或iOS原生开发方式,可以做到这么简单实现吗),这一切都归功于Flutter为我们提供的Widgets,下面的篇幅里会针对常用的Widgets做一些讲解。

Flutter项目结构

新创建的Flutter项目的结构如下图所示:

各个目录/文件说明如下:

.
├── README.md                   ---markdown项目描述文件
├── android                     ---Android源代码目录    
├── build                       ---项目构建后输出的相关文件目录
├── flutter_app.iml             ---项目相关的配置文件
├── flutter_app_android.iml     ---Android相关的配置文件
├── ios                         ---iOS源代码目录
├── lib                         ---Dart源码目录
├── pubspec.lock                ---安装锁定文件
├── pubspec.yaml                ---flutter依赖配置文件,类似Android中的build.grale
└── test                        ---测试代码目录

我们开发的代码主要存放在lib/目录下,项目的入口文件main.dart也在lib/目录下。

Flutter App是怎样的App

关于一个Flutter App,你需要了解如下几个点:

  1. Flutter App的布局文件都是使用Dart代码来写的(业务逻辑代码和UI代码都用dart来写),没有像Android中的xml布局文件或者iOS中的xib, storyboard文件等。
  2. Flutter App中的界面都是由Widget组成的,Widget分为两种:StatefulWidget和StatelessWidget。StatefulWidget表示一个有状态的组件,这个组件的状态发生改变时,组件UI会同步发生改变;StatelessWidget表示一个无状态的组件,它没有状态的改变,UI也不会发生改变。如果你熟悉Reactjs,对Flutter中的这两种组件就很容易理解了。
  3. Dart是一门单线程语言,这意味着在Flutter开发过程中你不用去考虑线程的同步异步、锁、线程切换等问题,网络请求也好,UI更新也好,都在一个线程中执行,只不过那些比较耗时的操作(网络IO,文件IO等等)会被放入延迟运算队列中以免阻塞了其他的操作而造成卡顿。
  4. Flutter跟ReactNative或者WEEX这类移动端跨平台框架最大的区别在于:Flutter通过AOT(Ahead of Time)或者JIT(Just In Time)的方式(Debug模式下采用JIT编译,Release模式下使用AOT编译)将Dart代码直接编译成对应平台的代码用于在移动设备上执行,而ReactNative、WEEX则是有一套自己的jsRender,将js代码通过渲染引擎渲染成原生的UI,这个过程有js和native的互操作,也就是一个jsBridge,所以ReactNative或者WEEX虽然写出来的也是原生应用,但是由于有了jsBridge的存在,导致代码运行的效率没有直接编译成原生代码的Flutter App的运行效率高。

Flutter常用Widgets

在移动开发中,我们经常会跟按钮、文本输入框、图片等打交道,Flutter中也不例外,使用Flutter开发的App,界面上的每一个UI元素都是一个Widget,通过不同的Widget组合形成一整个页面。除了按钮、输入框、图片等Widget外,Flutter还给我们提供了很多功能强大,界面美观的Widget,比如在本文最开始的一段代码:

// main.dart文件内容
import 'package:flutter/material.dart';

void main() => runApp(new MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Flutter Demo',
      theme: new ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text('First App')
        ),
        body: new Center(
          child: new Text('Hello world'),
        ),
      ),
    );
  }
}

在上面的代码中,MyApp是我们自定义的一个类,它继承自StatelessWidget,代表它是一个无状态的组件,UI不会发生改变。build方法是父类的一个方法,被MyApp类重写了,继承自StatelessWidget的类必须实现build方法并返回一个Widget对象。所以在上面的代码中,MaterialApp也是一个Widget,如果你用AndroidStudio查看源码,会发现MaterialApp的参数home也是一个Widget对象,所以上面的Scaffold也是一个Widget。

StatefulWidget和StatelessWidget

StatefulWidget和StatelessWidget是Flutter中所有Widget的两个分类,StatefulWidget的内部保存有状态,当状态发生改变时,Widget的界面也会随之改变(这点跟React类似);StatelessWidget的内部没有保存状态,它的界面也不会发生改变。上面的代码中已经展示了定义一个无状态Widget的步骤:继承StatelessWidget并实现build方法即可。如果是定义一个有状态的Widget,代码会稍微多一点,如下代码所示:

import 'package:flutter/material.dart';

void main() => runApp(new MyStatefulWidget());

// 定义一个有状态的组件
class MyStatefulWidget extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return new MyStatefulWidgetState();
  }
}

// 定义一个有状态的组件时,必须为该组件创建一个状态类,这个类继承自State类
class MyStatefulWidgetState extends State<MyStatefulWidget> {

  String text = "Click Me!";

  changeText() {
    if (text == "Click Me!") {
      setState(() {
        text = "Hello World!";
      });
    } else {
      setState(() {
        text = "Click Me!";
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: "Test",
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text("Test"),
        ),
        body: new Center(
          // InkWell是Flutter内置的一个Widget,用于给其他Widget添加点击事件,并且在点击时会有水波纹扩散效果
          child: new InkWell(
            child: new Text(text),
            onTap: () {
              this.changeText();
            },
          ),
        ),
      ),
    );
  }
}

上面的代码运行后,会在页面中央显示文本,点击该文本时,文本内容会在"Click Me!"和"Hello World!"间切换,如下图所示:

定义一个有状态的组件的步骤如下:
  1. 创建类继承自StatefulWidget并实现createState方法,注意,这里跟StatelessWidget不同了,不是实现build方法。createState方法返回的是一个状态State。
  2. 为了让第一步中的createState方法有返回值,还需要创建一个状态类继承自State类,State类是个泛型类,你需要将第一步中创建的类传给State。
  3. 创建完自定义的State类后,实现build方法,并返回你所需要的Widget。
  4. 在自定义的State类中,用变量保存组件的状态,并在合适的时候改变这个状态值。比如在上面的代码中,我们需要在点击文本时切换文本,所以用一个text变量保存组件的文本值,当点击按钮时,通过调用State组件的setState()方法,重新为text变量赋值,从而达到改变文本的目的。

如果你了解Reactjs,那么对于Flutter的这种状态机制肯定也不陌生。React中也是通过一个state对象保存Component的状态,当状态需要改变时,调用setState()方法修改状态,组件就会自动刷新。

MaterialApp和Scaffold

MaterialApp和Scaffold是Flutter提供的两个Widget,其中:

  • MaterialApp是一个方便的Widget,它封装了应用程序实现Material Design所需要的一些Widget。(参考
  • Scaffold组件是Material Design布局结构的基本实现。此类提供了用于显示drawer、snackbar和底部sheet的API。(参考

基于Flutter的开源中国客户端App中,我也使用到了MaterialApp和Scaffold两个组件,下面是部分代码:

  @override
  Widget build(BuildContext context) {
    initData();
    return new MaterialApp(
      theme: new ThemeData(
        // 设置主题颜色
        primaryColor: const Color(0xFF63CA6C)
      ),
      home: new Scaffold(
        // 设置App顶部的AppBar
        appBar: new AppBar(
          // AppBar的标题
          title: new Text(appBarTitles[_tabIndex], 
          // 标题文本的颜色
          style: new TextStyle(color: Colors.white)),
          // AppBar上的图标的颜色
          iconTheme: new IconThemeData(color: Colors.white)
        ),
        body: _body,
        // 页面底部的导航栏
        bottomNavigationBar: new CupertinoTabBar(
          items: <BottomNavigationBarItem>[
            new BottomNavigationBarItem(
                icon: getTabIcon(0),
                title: getTabTitle(0)),
            new BottomNavigationBarItem(
                icon: getTabIcon(1),
                title: getTabTitle(1)),
            new BottomNavigationBarItem(
                icon: getTabIcon(2),
                title: getTabTitle(2)),
            new BottomNavigationBarItem(
                icon: getTabIcon(3),
                title: getTabTitle(3)),
          ],
          currentIndex: _tabIndex,
          // 底部Tab的点击事件处理
          onTap: (index) {
            setState((){
              _tabIndex = index;
            });
          },
        ),
        // 侧滑菜单,这里的MyDrawer是自定义的Widget
        drawer: new MyDrawer(),
      ),
    );
  }

Text组件

Text组件是非常常用的组件,任何需要显示文本的地方基本都会用到。通过查看Text类的源码,可以发现Text是一个无状态的组件,下面的代码演示了如何修改Text组件的字号、颜色,给字体加粗、设置下划线、设置斜体等:

import 'package:flutter/material.dart';

void main() => runApp(new MaterialApp(
  title: "Text Demo",
  home: new Scaffold(
    appBar: new AppBar(
      title: new Text("Text Demo"),
    ),
    body: new Center(
      child: new Text(
        "Hello Flutter",
        style: new TextStyle(
          color: Colors.red, // 或者用这种写法:const Color(0xFF6699FF) 必须使用AARRGGBB
          fontSize: 20.0, // 字号
          fontWeight: FontWeight.bold, // 字体加粗
          fontStyle: FontStyle.italic, // 斜体
          decoration: new TextDecoration.combine([TextDecoration.underline]) // 文本加下划线
        ),
      ),
    ),
  ),
));

注意

  1. MaterialApp的title参数是字符串类型,而AppBar的title参数是一个Text组件类型。
  2. 开发基于Flutter的开源中国客户端时,Flutter还是beta版本,导致在设置中文文本的某些样式时不起作用,比如字体加粗,斜体等。目前的Flutter Preview版本,该问题好像已经修复了。

TextField组件

TextFiled组件用于文本的输入,示例代码如下:

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: "Test",
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text("Test")
        ),
        body: new Padding(
          padding: const EdgeInsets.all(8.0),
          child: new TextField(
            maxLines: 8, // 设置输入框显示的最大行数(不是可输入的最大行数)
            maxLength: 30, // 设置输入框中最多可输入的字符数
            decoration: new InputDecoration( // 给输入框添加样式
              hintText: "Input something...", // 输入框中placeholder文本
              border: new OutlineInputBorder( // 输入框的边框
                borderRadius: const BorderRadius.all(Radius.circular(1.0))
              )
            ),
          )
        )
      ),
    );
  }
}

在模拟器中运行界面如下图:

InkWell和GestureDetector

这两个组件放到一起说,是因为在处理组件的点击事件时,会经常用到它们。 比如某个列表的item的点击事件,某个图标的点击事件等等。Flutter有专门设计MaterialDesign风格的按钮,但是更多时候我们希望自定义按钮样式或者为某个组件添加点击事件,所以在处理点击事件时,最常见的做法是,用InkWell或者GestureDetector将某个组件包起来。

InkWell的使用方法如下:

  new InkWell(
    child: new Text("Click me!"),
    onTap: () {
      // 单击
    },
    onDoubleTap: () {
      // 双击
    },
    onLongPress: () {
      // 长按
    }
  );

GestureDetector用法与InkWell类似,不过GestureDetector有更多处理手势的方法,这里暂时不做介绍(其实我也用得不多)。

按钮

Flutter提供了几种类型的按钮组件:RaisedButton FloatingActionButton FlatButton IconButton PopupMenuButton,下面用一段代码说明这几种按钮的用法:

import 'package:flutter/material.dart';

main() {
  runApp(new MyApp());
}

enum WhyFarther { harder, smarter, selfStarter, tradingCharter }

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Test',
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text('Test')
        ),
        body: new Column(
          children: <Widget>[
            new RaisedButton(
              child: new Text("Raised Button"),
              onPressed: (){},
            ),
            new FloatingActionButton(
              child: new Icon(Icons.add),
              onPressed: (){},
            ),
            new FlatButton(
              onPressed: (){},
              child: new Text("Flat Button")
            ),
            new IconButton(
              icon: new Icon(Icons.list),
              onPressed: (){}
            ),
            new PopupMenuButton<WhyFarther>(
              onSelected: (WhyFarther result) {},
              itemBuilder: (BuildContext context) => <PopupMenuEntry<WhyFarther>>[
                const PopupMenuItem<WhyFarther>(
                  value: WhyFarther.harder,
                  child: const Text('Working a lot harder'),
                ),
                const PopupMenuItem<WhyFarther>(
                  value: WhyFarther.smarter,
                  child: const Text('Being a lot smarter'),
                ),
                const PopupMenuItem<WhyFarther>(
                  value: WhyFarther.selfStarter,
                  child: const Text('Being a self-starter'),
                ),
                const PopupMenuItem<WhyFarther>(
                  value: WhyFarther.tradingCharter,
                  child: const Text('Placed in charge of trading charter'),
                ),
              ],
            )
          ],
        )
      )
    );
  }
}

在模拟器中上面的代码运行效果如下图所示:

Dialog组件

Flutter提供了两种类型的对话框:SimpleDialog和AlertDialog。SimpleDialog是一个可以显示附加的提示或操作的简单对话框,AlertDialog则是一个会中断用户操作的对话框,需要用户确认的对话框,下面用代码来说明其用法:

import 'package:flutter/material.dart';

main() {
  runApp(new MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      title: 'Test',
      home: new Scaffold(
        appBar: new AppBar(
          title: new Text('Test')
        ),
//        body: new MyAlertDialogView()
        body: new MySimpleDialogView(),
      ),
    );
  }
}

class MyAlertDialogView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new RaisedButton(
      child: new Text('显示AlertDialog'),
      onPressed: () {
        showDialog<Null>(
          context: context,
          barrierDismissible: false, // 不能点击对话框外关闭对话框,必须点击按钮关闭
          builder: (BuildContext context) {
            return new AlertDialog(
              title: new Text('提示'),
              content: new Text('微软重申Windows 7将在2020年1月到达支持终点,公司希望利用这个机会说服用户在最新更新发布之前升级到Windows 10。'),
              actions: <Widget>[
                new FlatButton(
                  child: new Text('明白了'),
                  onPressed: () {
                    Navigator.of(context).pop();
                  },
                ),
              ],
            );
          },
        );
      },
    );
  }
}

class MySimpleDialogView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return new RaisedButton(
      child: new Text('显示SimpleDialog'),
      onPressed: () {
        showDialog(
          context: context,
          builder: (BuildContext ctx) {
            return new SimpleDialog(
              title: new Text('这是SimpleDialog'),
              children: <Widget>[
                new SimpleDialogOption(
                  onPressed: () { Navigator.pop(context); },
                  child: const Text('确定'),
                ),
                new SimpleDialogOption(
                  onPressed: () { Navigator.pop(context); },
                  child: const Text('取消'),
                ),
              ],
            );
          }
        );
      },
    );
  }
}

上面的代码分别展示了SimpleDialog和AlertDialog的基本用法。需要注意的是,这里并没有直接将按钮和显示对话框的逻辑写到MyApp类中,而是分两个StatelessWidget来写的,如果你直接将按钮及显示对话框的逻辑写到MyApp的build方法里,是会报错的,具体报错信息为:

Navigator operation requested with a context that does not include a Navigator.

意思是导航操作需要一个不包含Navigator的上下文对象,而如果我们将showDialog的逻辑写到MyApp的build方法中时,使用的是MaterialApp的上下文对象,这个上下文对象是包含Navigator的,所以就会报错。上面的代码在模拟器中运行效果如下图:

Image组件

Image组件用于显示一张图片,可以加载本地(项目中或手机存储中)或网络图片。

加载本地图片

使用下面的方法加载一张项目中的图片:

new Image.asset(path, width: 20.0, height: 20.0, fit: BoxFit.cover)

其中path是项目中的图片目录。

加载项目中的图片一定要注意编辑pubspec.yaml文件:

假设当前我们在跟lib/同级的目录下创建了images/目录,在images/目录下存放了若干图片供项目使用,那么一定要记得在项目根目录下(也是跟images/同级的目录)编辑pubspec.yaml文件,打开pubspec.yaml文件,默认情况下assets是被注释了的,这里我们要取消注释assets并添加images/目录下的每个图片的路径,如下图所示:

在上图中我们配置了文件路径images/ic_nav_news_normal.png,所以可以用下面的代码来加载图片了:

new Image.asset('images/ic_nav_news_normal.png', width: 20.0, height: 20.0, fit: BoxFit.cover)

widthheight是图片长宽,为double类型,如果你传整型20则会报错。 如果要加载手机存储中的图片,使用下面的方法:

new Image.file(path, width: 20.0, height: 20.0, fit: BoxFit.cover)

fit属性指定了图片显示的不同方式,有如下几个值:

  • contain:尽可能大,同时仍然包含图片完全在目标容器内。
  • cover:尽可能小,同时仍然覆盖整个目标容器。
  • fill:通过拉伸图片的长宽比填充目标容器。
  • fitHeight:确保是否显示了图片的完整高度,而不管是否意图片高度溢出了目标容器。
  • fitWidth:确保是否显示了图片的完整宽度,而不管是否图片高度溢出目标容器。
  • none:对齐目标容器内的图片(默认情况下居中)并丢弃位于容器外的图片的任何部分。图片原始大小不会被调整。
  • scaleDown:对齐目标容器内的图片(默认情况下居中),如果必要的话,对图片进行缩放,以确保图片适合容器。这与contain的情况相同,否则它与没有一样。

加载网络图片

加载网络图片使用下面的方法:

new Image.network(imgUrl, width: 20.0, height: 20.0, fit: BoxFit.cover)

ListView组件

ListView组件用于显示一个列表,在基于Flutter的开源中国客户端App中,新闻列表、动弹列表等都需要用到ListView,一个最简单的ListView可以用如下代码实现:

import 'package:flutter/material.dart';

void main() {
  List<Widget> items = new List();
  for (var i = 0; i < 20; i++) {
    items.add(new Text("List Item $i"));
  }
  runApp(new MaterialApp(
    title: "Text Demo",
    home: new Scaffold(
      appBar: new AppBar(
        title: new Text("Text Demo"),
      ),
      body: new Center(
          child: new ListView(children: items)
      ),
    ),
  ));
}

运行上面的代码,结果如下图所示:

这样的ListView显示不是我们需要的,太难看,每个item没有边距而且没有分割线,所以我们用下面的代码改造一下:

import 'package:flutter/material.dart';

void main() {
  // 装有ListView中所有item的集合
  List<Widget> items = new List();
  for (var i = 0; i < 20; i++) {
    var text = new Text("List Item $i");
    // Padding也是一个Widget,是一个有内边距的容器,可以装其他Widget
    items.add(new Padding(
      // 内边距设置为15.0,上下左右四边都是15.0
      padding: const EdgeInsets.all(15.0),
      // Padding容器中装的是Text组件
      child: text
    ));
  }
  runApp(new MaterialApp(
    title: "Text Demo",
    home: new Scaffold(
      appBar: new AppBar(
        title: new Text("Text Demo"),
      ),
      body: new Center(
        // build是ListView提供的静态方法,用于创建ListView
        child: new ListView.builder(
          // itemCount是ListView的item个数,这里之所以是items.length * 2是因为将分割线也算进去了
          itemCount: items.length * 2,
          itemBuilder: (context, index) {
            // 如果index为奇数,则返回分割线
            if (index.isOdd) {
              return new Divider(height: 1.0);
            }
            // 这里index为偶数,为了根据下标取items中的元素,需要对index做取整
            index = index ~/ 2;
            return items[index];
          },
        )
      )
    ),
  ));
}

此时再次运行上面的代码,UI就好看多了:

关于ListView的用法,上面的代码中已有相关注释,更详细的用法会在后面的篇幅中介绍,比如ListView中的item实现不同的布局,下拉刷新,加载更多等等。

小结

关于Flutter常用的部分Widget,在上面已有相关示例代码和说明,你还可以在Flutter中文网上查看更多组件及其用法。下一篇中我将记录Flutter中的布局,任何移动开发,甚至Web开发和桌面端应用开发中都不可避免的需要了解布局的知识。

我的开源项目

  1. 基于Google Flutter的开源中国客户端,希望大家给个Star支持一下,源码:
  1. 基于Flutter的俄罗斯方块小游戏,希望大家给个Star支持一下,源码:
上一篇 下一篇
从0开始写一个基于Flutter的开源中国客户端(2)
——Dart语法基础
从0开始写一个基于Flutter的开源中国客户端(4)
——Flutter布局基础