Flutter学习笔记

Flutter学习笔记

包和插件

https://pub.flutter-io.cn

基础组件

文本及样式

Text

Text 用于显示简单样式文本,它包含一些控制文本显示样式的一些属性。

  • textAlign:文本的对齐方式;可以选择左对齐、右对齐还是居中。注意,对齐的参考系是Text widget 本身。本例中虽然是指定了居中对齐,但因为 Text 文本内容宽度不足一行,Text 的宽度和文本内容长度相等,那么这时指定对齐方式是没有意义的,只有 Text 宽度大于文本内容长度时指定此属性才有意义。下面我们指定一个较长的字符串:
Text("Hello world "*6,  //字符串重复六次
  textAlign: TextAlign.center,
)

在这里插入图片描述
​ 字符串内容超过一行,Text 宽度等于屏幕宽度,第二行文本便会居中显示。

  • maxLines、overflow:指定文本显示的最大行数,默认情况下,文本是自动折行的,如果指定此参数,则文本最多不会超过指定的行。如果有多余的文本,可以通过overflow来指定截断方式,默认是直接截断,本例中指定的截断方式TextOverflow.ellipsis,它会将多余文本截断后以省略符“…”表示;
  • textScaleFactor:代表文本相对于当前字体大小的缩放因子,相对于去设置文本的样式style属性的fontSize,它是调整字体大小的一个快捷方式。该属性的默认值可以通过MediaQueryData.textScaleFactor获得,如果没有MediaQuery,那么会默认值将为1.0。
TextStyle
  • height:该属性用于指定行高,但它并不是一个绝对值,而是一个因子,具体的行高等于fontSize*height。
  • fontFamily :由于不同平台默认支持的字体集不同,所以在手动指定字体时一定要先在不同平台测试一下。
  • fontSize:该属性和 Text 的textScaleFactor都用于控制字体大小。但是有两个主要区别:
    (1)fontSize可以精确指定字体大小,而textScaleFactor只能通过缩放比例来控制。
    (2)textScaleFactor主要是用于系统字体大小设置改变时对 Flutter 应用字体进行全局调整,而fontSize通常用于单个文本,字体大小不会跟随系统字体大小变化。

富文本

如果我们需要对一个 Text 内容的不同部分按照不同的样式显示,这时就可以使用TextSpan,它代表文本的一个“片段”。我们看看 TextSpan 的定义:

const TextSpan({
  TextStyle style, 
  Sting text,
  List<TextSpan> children,
  GestureRecognizer recognizer,
});

其中style 和 text属性代表该文本片段的样式和内容。 children是一个TextSpan的数组,也就是说TextSpan可以包括其他TextSpan。而recognizer用于对该文本片段上用于手势进行识别处理。下面我们看一个效果(图3-8),然后用TextSpan实现它。

示例

在这里插入图片描述

import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

class ContactServiceWidget extends StatelessWidget {
  final TapGestureRecognizer _tapGestureRecognizer = TapGestureRecognizer();
  final VoidCallback? onTap;
  ContactServiceWidget({Key? key,this.onTap}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Text.rich(
      TextSpan(
        children: [
          const TextSpan(text: "遇到问题?您可以"),
          TextSpan(
            text: "联系客服",
            style: const TextStyle(color: Colors.orange),
            recognizer: _tapGestureRecognizer
              ..onTap = onTap,
          )
        ]
      )
    );
  }
}

按钮

Material 组件库中提供了多种按钮组件如ElevatedButton、TextButton、OutlineButton等,它们都是直接或间接对RawMaterialButton组件的包装定制,所以他们大多数属性都和RawMaterialButton一样。在介绍各个按钮时我们先介绍其默认外观,而按钮的外观大都可以通过属性来自定义。另外,所有 Material 库中的按钮都有如下相同点:

按下时都会有“水波动画”(又称“涟漪动画”,就是点击时按钮上会出现水波扩散的动画)。
有一个onPressed属性来设置点击回调,当按钮按下时会执行该回调,如果不提供该回调则按钮会处于禁用状态,禁用状态不响应用户点击。

TextButton

TextButton即文本按钮,默认背景透明并不带阴影。按下后,会有背景色。
在这里插入图片描述

TextButton(
      onPressed: () {},
      child: Text("TextButton按钮"),
)

修改 TextButton 的样式 需要通过 ButtonStyle 来修改,描述如下:

 //这是一个文本按钮 未设置点击事件下的样式
  Widget buildTextButton2() {
    return TextButton(
      child: Text("TextButton按钮"),
      //添加一个点击事件
      onPressed: () {},
      //设置按钮是否自动获取焦点
      autofocus: true,
      //定义一下文本样式
      style: ButtonStyle(
        //定义文本的样式 这里设置的颜色是不起作用的
        textStyle: MaterialStateProperty.all(
            TextStyle(fontSize: 18, color: Colors.red)),
        //设置按钮上字体与图标的颜色
        //foregroundColor: MaterialStateProperty.all(Colors.deepPurple),
        //更优美的方式来设置
        foregroundColor: MaterialStateProperty.resolveWith(
          (states) {
            if (states.contains(MaterialState.focused) &&
                !states.contains(MaterialState.pressed)) {
              //获取焦点时的颜色
              return Colors.blue;
            } else if (states.contains(MaterialState.pressed)) {
              //按下时的颜色
              return Colors.deepPurple;
            }
            //默认状态使用灰色
            return Colors.grey;
          },
        ),
        //背景颜色
        backgroundColor: MaterialStateProperty.resolveWith((states) {
          //设置按下时的背景颜色
          if (states.contains(MaterialState.pressed)) {
            return Colors.blue[200];
          }
          //默认不使用背景颜色
          return null;
        }),
        //设置水波纹颜色
        overlayColor: MaterialStateProperty.all(Colors.yellow),
        //设置阴影  不适用于这里的TextButton
        elevation: MaterialStateProperty.all(0),
        //设置按钮内边距
        padding: MaterialStateProperty.all(EdgeInsets.all(10)),
        //设置按钮的大小
        minimumSize: MaterialStateProperty.all(Size(200, 100)),

        //设置边框
        side:
            MaterialStateProperty.all(BorderSide(color: Colors.grey, width: 1)),
        //外边框装饰 会覆盖 side 配置的样式
        shape: MaterialStateProperty.all(StadiumBorder()),
      ),
    );
  }

MaterialStateProperty.all() 方法是设置点击事件所有状态下的样式。
MaterialStateProperty.resolveWith() 可拦截分别设置不同状态下的样式。

CupertinoButton

CupertinoButton 是 flutter 提供的一个 iOS 风格的 button,自带一个 Radius.circular(8.0) 的圆角。而material风格里面的Button都没有办法直接设置圆角。

属性解析
  • borderRadius:圆角,默认为 const BorderRadius.all(Radius.circular(8.0))
  • minSize:最小可点击大小,默认为 kMinInteractiveDimensionCupertino,也就是44。我们可以通过这个属性设置按钮的高度。
  • color:按钮背景颜色
  • disabledColor:不可交互时颜色。onPressed == null 时显示,默认为 CupertinoColors.quaternarySystemFill
  • pressedOpacity:按压下去时 button 透明度,默认为 0.4
  • padding:button 内间距
  • onPressed:@required 点击事件
  • child:@required 子控件
示例代码
 //创建登录按钮
  Widget _createLoginButton() {
    return CupertinoButton(
      color: const Color(0xFF2a65eb), //按钮背景颜色
      minSize: 50, //按钮高度
      borderRadius: const BorderRadius.all(Radius.circular(25.0)), //按钮圆角
      child: const Text(
        "登录",
      ), 
      onPressed: _loginButtonDidClicked);
  }

在这里插入图片描述

ElevatedButton

ElevatedButton 即"漂浮"按钮,它默认带有阴影和灰色背景。按下后,阴影会变大。它带有一个长按响应事件onLongPress,是上面几个按钮没有的。
样式的修改与上面的TextButton类似。

InkWell 和 Ink

  • InkWell组件在用户点击时出现“水波纹”效果,InkWell简单用法:
InkWell(
    onTap: (){},
    child: Text('这是InkWell点击效果'),
)

onTap是点击事件回调,如果不设置无法出现“水波纹”效果。

  • 设置水波纹颜色:
InkWell(
	onTap: () {},
	splashColor: Colors.red,
	...
)
  • 设置高亮颜色颜色:
InkWell(
	onTap: () {},
	highlightColor: Colors.blue,
	...
)
  • 给字体添加边距和圆角边框,扩大“水波纹”效果:
InkWell(
        onTap: (){},
        child: Container(
          padding: EdgeInsets.symmetric(horizontal: 20,vertical: 8),
          decoration: BoxDecoration(
            border:Border.all(color: Colors.blue),
            borderRadius: BorderRadius.all(Radius.circular(30))
                
          ),
          child: Text('这是InkWell点击效果'),
        ),
      )

发现“水波纹”超出的了圆角边框,如何解决这个问题呢?Ink隆重登场。

Ink

Ink控件用于在[Material]控件上绘制图像和其他装饰,以便[InkWell]、[InkResponse]控件的“水波纹”效果在其上面显示。

Ink(
        decoration: BoxDecoration(
            gradient: LinearGradient(
                begin: Alignment.topLeft,
                end: Alignment.bottomRight,
                colors: [Color(0xFFDE2F21), Color(0xFFEC592F)]),
            borderRadius: BorderRadius.all(Radius.circular(20))),
        child: InkWell(
          borderRadius: BorderRadius.all(Radius.circular(20)),
          child: Container(
            padding: EdgeInsets.symmetric(vertical: 8, horizontal: 20),
            child: Text(
              '这是InkWell的点击效果',
              style: TextStyle(color: Colors.white),
            ),
          ),
          onTap: () {},
        ),
      )

TextField

  • controller:编辑框的控制器,通过它可以设置/获取编辑框的内容、选择编辑内容、监听编辑文本改变事件。大多数情况下我们都需要显式提供一个controller来与文本框交互。如果没有提供controller,则TextField内部会自动创建一个。
  • focusNode:用于控制TextField是否占有当前键盘的输入焦点。它是我们和键盘交互的一个句柄(handle)。
  • InputDecoration:用于控制TextField的外观显示,如提示文本、背景颜色、边框等。
  • keyboardType:用于设置该输入框默认的键盘输入类型,取值如下:
TextInputType枚举值含义
text文本输入键盘
multiline多行文本,需和maxLines配合使用(设为null或大于1)
number数字;会弹出数字键盘
phone优化后的电话号码输入键盘;会弹出数字键盘并显示“* #”
datetime优化后的日期输入键盘;Android上会显示“: -”
emailAddress优化后的电子邮件地址;会显示“@ .”
url优化后的url输入键盘; 会显示“/ .”
  • textInputAction:键盘动作按钮图标(即回车键位图标),它是一个枚举值,有多个可选值,全部的取值列表读者可以查看API文档,下面是当值为TextInputAction.search时,原生Android系统下键盘样式如图3-24所示:
    在这里插入图片描述
  • style:正在编辑的文本样式。
  • textAlign: 输入框内编辑文本在水平方向的对齐方式。
  • autofocus: 是否自动获取焦点。
  • obscureText:是否隐藏正在编辑的文本,如用于输入密码的场景等,文本内容会用“•”替换。
  • maxLines:输入框的最大行数,默认为1;如果为null,则无行数限制。
  • maxLength和maxLengthEnforcement :maxLength代表输入框文本的最大长度,设置后输入框右下角会显示输入的文本计数。maxLengthEnforcement决定当输入文本长度超过maxLength时如何处理,如截断、超出等。
  • toolbarOptions:长按或鼠标右击时出现的菜单,包括 copy、cut、paste 以及 selectAll。
  • onChange:输入框内容改变时的回调函数;注:内容改变事件也可以通过controller来监听。
  • onEditingComplete和onSubmitted:这两个回调都是在输入框输入完成时触发,比如按了键盘的完成键(对号图标)或搜索键(🔍图标)。不同的是两个回调签名不同,onSubmitted回调是ValueChanged类型,它接收当前输入内容做为参数,而onEditingComplete不接收参数。
  • inputFormatters:用于指定输入格式;当用户输入内容改变时,会根据指定的格式来校验。
  • enable:如果为false,则输入框会被禁用,禁用状态不接收输入和事件,同时显示禁用态样式(在其decoration中定义)。
  • cursorWidth、cursorRadius和cursorColor:这三个属性是用于自定义输入框光标宽度、圆角和颜色的。

登录输入框

布局
Column(
  children: <Widget>[
    TextField(
      autofocus: true,
      decoration: InputDecoration(
        labelText: "用户名",
        hintText: "用户名或邮箱",
        prefixIcon: Icon(Icons.person)
      ),
    ),
    TextField(
      decoration: InputDecoration(
        labelText: "密码",
        hintText: "您的登录密码",
        prefixIcon: Icon(Icons.lock)
      ),
      obscureText: true,
    ),
  ],
);

运行后,效果如图3-25所示:
在这里插入图片描述
获取输入内容有两种方式:
1、定义两个变量,用于保存用户名和密码,然后在onChange触发时,各自保存一下输入内容。
2、通过controller直接获取。
第一种方式比较简单,不在举例,我们来重点看一下第二种方式,我们以用户名输入框举例:
定义一个controller:

//定义一个controller
TextEditingController _unameController = TextEditingController();

然后设置输入框controller:

TextField(
    autofocus: true,
    controller: _unameController, //设置controller
    ...
)

通过controller获取输入框内容

print(_unameController.text)
监听文本变化

监听文本变化也有两种方式:
1、设置onChange回调,如:

TextField(
    autofocus: true,
    onChanged: (v) {
      print("onChange: $v");
    }
)

2、通过controller监听,如:

@override
void initState() {
  //监听输入改变  
  _unameController.addListener((){
    print(_unameController.text);
  });
}

两种方式相比,onChanged是专门用于监听文本变化,而controller的功能却多一些,除了能监听文本变化外,它还可以设置默认值、选择文本,下面我们看一个例子:
创建一个controller:

TextEditingController _selectionController =  TextEditingController();

设置默认值,并从第三个字符开始选中后面的字符

_selectionController.text="hello world!";
_selectionController.selection=TextSelection(
    baseOffset: 2,
    extentOffset: _selectionController.text.length
);

设置controller:

TextField(
  controller: _selectionController,
)

运行效果如图3-26所示:
在这里插入图片描述

控制焦点

焦点可以通过FocusNode和FocusScopeNode来控制,默认情况下,焦点由FocusScope来管理,它代表焦点控制范围,可以在这个范围内可以通过FocusScopeNode在输入框之间移动焦点、设置默认焦点等。我们可以通过FocusScope.of(context) 来获取Widget树中默认的FocusScopeNode。下面看一个示例,在此示例中创建两个TextField,第一个自动获取焦点,然后创建两个按钮:

  • 点击第一个按钮可以将焦点从第一个TextField挪到第二个TextField。
  • 点击第二个按钮可以关闭键盘。
    在这里插入图片描述
    代码如下:
class FocusTestRoute extends StatefulWidget {
  @override
  _FocusTestRouteState createState() => _FocusTestRouteState();
}

class _FocusTestRouteState extends State<FocusTestRoute> {
  FocusNode focusNode1 = FocusNode();
  FocusNode focusNode2 = FocusNode();
  FocusScopeNode focusScopeNode;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.all(16.0),
      child: Column(
        children: <Widget>[
          TextField(
            autofocus: true, 
            focusNode: focusNode1,//关联focusNode1
            decoration: InputDecoration(
                labelText: "input1"
            ),
          ),
          TextField(
            focusNode: focusNode2,//关联focusNode2
            decoration: InputDecoration(
                labelText: "input2"
            ),
          ),
          Builder(builder: (ctx) {
            return Column(
              children: <Widget>[
                RaisedButton(
                  child: Text("移动焦点"),
                  onPressed: () {
                    //将焦点从第一个TextField移到第二个TextField
                    // 这是一种写法 FocusScope.of(context).requestFocus(focusNode2);
                    // 这是第二种写法
                    if(null == focusScopeNode){
                      focusScopeNode = FocusScope.of(context);
                    }
                    focusScopeNode.requestFocus(focusNode2);
                  },
                ),
                RaisedButton(
                  child: Text("隐藏键盘"),
                  onPressed: () {
                    // 当所有编辑框都失去焦点时键盘就会收起  
                    focusNode1.unfocus();
                    focusNode2.unfocus();
                  },
                ),
              ],
            );
          },
          ),
        ],
      ),
    );
  }

}
监听焦点状态改变事件

FocusNode继承自ChangeNotifier,通过FocusNode可以监听焦点的改变事件,如:

...
// 创建 focusNode   
FocusNode focusNode = FocusNode();
...
// focusNode绑定输入框   
TextField(focusNode: focusNode);
...
// 监听焦点变化    
focusNode.addListener((){
   print(focusNode.hasFocus);
});

获得焦点时focusNode.hasFocus值为true,失去焦点时为false。

自定义样式

虽然我们可以通过decoration属性来定义输入框样式,下面以自定义输入框下划线颜色为例来介绍一下:

TextField(
  decoration: InputDecoration(
    labelText: "请输入用户名",
    prefixIcon: Icon(Icons.person),
    // 未获得焦点下划线设为灰色
    enabledBorder: UnderlineInputBorder(
      borderSide: BorderSide(color: Colors.grey),
    ),
    //获得焦点下划线设为蓝色
    focusedBorder: UnderlineInputBorder(
      borderSide: BorderSide(color: Colors.blue),
    ),
  ),
),

上面代码我们直接通过InputDecoration的enabledBorder和focusedBorder来分别设置了输入框在未获取焦点和获得焦点后的下划线颜色。另外,我们也可以通过主题来自定义输入框的样式,下面我们探索一下如何在不使用enabledBorder和focusedBorder的情况下来自定义下滑线颜色。

由于TextField在绘制下划线时使用的颜色是主题色里面的hintColor,但提示文本颜色也是用的hintColor, 如果我们直接修改hintColor,那么下划线和提示文本的颜色都会变。值得高兴的是decoration中可以设置hintStyle,它可以覆盖hintColor,并且主题中可以通过inputDecorationTheme来设置输入框默认的decoration。所以我们可以通过主题来自定义,代码如下:

Theme(
  data: Theme.of(context).copyWith(
      hintColor: Colors.grey[200], //定义下划线颜色
      inputDecorationTheme: InputDecorationTheme(
          labelStyle: TextStyle(color: Colors.grey),//定义label字体样式
          hintStyle: TextStyle(color: Colors.grey, fontSize: 14.0)//定义提示文本样式
      )
  ),
  child: Column(
    children: <Widget>[
      TextField(
        decoration: InputDecoration(
            labelText: "用户名",
            hintText: "用户名或邮箱",
            prefixIcon: Icon(Icons.person)
        ),
      ),
      TextField(
        decoration: InputDecoration(
            prefixIcon: Icon(Icons.lock),
            labelText: "密码",
            hintText: "您的登录密码",
            hintStyle: TextStyle(color: Colors.grey, fontSize: 13.0)
        ),
        obscureText: true,
      )
    ],
  )
)

运行效果如图3-28所示:
在这里插入图片描述
我们成功的自定义了下划线颜色和提问文字样式,细心的读者可能已经发现,通过这种方式自定义后,输入框在获取焦点时,labelText不会高亮显示了,正如上图中的"用户名"本应该显示蓝色,但现在却显示为灰色,并且我们还是无法定义下划线宽度。另一种灵活的方式是直接隐藏掉TextField本身的下划线,然后通过Container去嵌套定义样式,如:

Container(
  child: TextField(
    keyboardType: TextInputType.emailAddress,
    decoration: InputDecoration(
        labelText: "Email",
        hintText: "电子邮件地址",
        prefixIcon: Icon(Icons.email),
        border: InputBorder.none //隐藏下划线
    )
  ),
  decoration: BoxDecoration(
      // 下滑线浅灰色,宽度1像素
      border: Border(bottom: BorderSide(color: Colors.grey[200], width: 1.0))
  ),
)

运行效果:
在这里插入图片描述
通过这种组件组合的方式,也可以定义背景圆角等。一般来说,优先通过decoration来自定义样式,如果decoration实现不了,再用widget组合的方式。

图片及Icon

Flutter 中,我们可以通过Image组件来加载并显示图片,Image的数据源可以是asset、文件、内存以及网络。

ImageProvider

ImageProvider 是一个抽象类,主要定义了图片数据获取的接口load(),从不同的数据源获取图片需要实现不同的ImageProvider ,如AssetImage是实现了从Asset中加载图片的 ImageProvider,而NetworkImage 实现了从网络加载图片的 ImageProvider。

Image

Image widget 有一个必选的image参数,它对应一个 ImageProvider。下面我们分别演示一下如何从 asset 和网络加载图片。

从asset中加载图片

1、在工程根目录下创建一个images目录,并将图片 avatar.png 拷贝到该目录。
2、在pubspec.yaml中的flutter部分添加如下内容:

 assets:
    - images/avatar.png

注意: 由于 yaml 文件对缩进严格,所以必须严格按照每一层两个空格的方式进行缩进,此处 assets 前面应有两个空格。
3、加载该图片

Image(
  image: AssetImage("images/avatar.png"),
  width: 100.0
);

Image也提供了一个快捷的构造函数Image.asset用于从asset中加载、显示图片:

Image.asset("images/avatar.png",
  width: 100.0,
)
从网络加载图片
Image(
  image: NetworkImage(
      "https://avatars2.githubusercontent.com/u/20411648?s=460&v=4"),
  width: 100.0,
)

Image也提供了一个快捷的构造函数Image.network用于从网络加载、显示图片:

Image.network(
  "https://avatars2.githubusercontent.com/u/20411648?s=460&v=4",
  width: 100.0,
)
参数

Image在显示图片时定义了一系列参数,通过这些参数我们可以控制图片的显示外观、大小、混合效果等。我们看一下 Image 的主要参数:

const Image({
  ...
  this.width, //图片的宽
  this.height, //图片高度
  this.color, //图片的混合色值
  this.colorBlendMode, //混合模式
  this.fit,//缩放模式
  this.alignment = Alignment.center, //对齐方式
  this.repeat = ImageRepeat.noRepeat, //重复方式
  ...
})
  • width、height:用于设置图片的宽、高,当不指定宽高时,图片会根据当前父容器的限制,尽可能的显示其原始大小,如果只设置width、height的其中一个,那么另一个属性默认会按比例缩放,但可以通过下面介绍的fit属性来指定适应规则。
  • fit:该属性用于在图片的显示空间和图片本身大小不同时指定图片的适应模式。适应模式是在BoxFit中定义,它是一个枚举类型,有如下值:
项目Value
fill会拉伸填充满显示空间,图片本身长宽比会发生变化,图片会变形。
cover会按图片的长宽比放大后居中填满显示空间,图片不会变形,超出显示空间部分会被剪裁。
contain这是图片的默认适应规则,图片会在保证图片本身长宽比不变的情况下缩放以适应当前显示空间,图片不会变形。
fitWidth图片的宽度会缩放到显示空间的宽度,高度会按比例缩放,然后居中显示,图片不会变形,超出显示空间部分会被剪裁。
fitHeight图片的高度会缩放到显示空间的高度,宽度会按比例缩放,然后居中显示,图片不会变形,超出显示空间部分会被剪裁。
none图片没有适应策略,会在显示空间内显示图片,如果图片比显示空间大,则显示空间只会显示图片中间部分。

在这里插入图片描述

  • color和 colorBlendMode:在图片绘制时可以对每一个像素进行颜色混合处理,color指定混合色,而colorBlendMode指定混合模式,下面是一个简单的示例:
Image(
  image: AssetImage("images/avatar.png"),
  width: 100.0,
  color: Colors.blue,
  colorBlendMode: BlendMode.difference,
);

运行效果如图3-19所示(彩色):
在这里插入图片描述

  • repeat:当图片本身大小小于显示空间时,指定图片的重复规则。简单示例如下:
Image(
  image: AssetImage("images/avatar.png"),
  width: 100.0,
  height: 200.0,
  repeat: ImageRepeat.repeatY ,
)

运行后效果如图3-20所示:
在这里插入图片描述
完整的示例代码如下:

import 'package:flutter/material.dart';

class ImageAndIconRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var img=AssetImage("imgs/avatar.png");
    return SingleChildScrollView(
      child: Column(
        children: <Image>[
          Image(
            image: img,
            height: 50.0,
            width: 100.0,
            fit: BoxFit.fill,
          ),
          Image(
            image: img,
            height: 50,
            width: 50.0,
            fit: BoxFit.contain,
          ),
          Image(
            image: img,
            width: 100.0,
            height: 50.0,
            fit: BoxFit.cover,
          ),
          Image(
            image: img,
            width: 100.0,
            height: 50.0,
            fit: BoxFit.fitWidth,
          ),
          Image(
            image: img,
            width: 100.0,
            height: 50.0,
            fit: BoxFit.fitHeight,
          ),
          Image(
            image: img,
            width: 100.0,
            height: 50.0,
            fit: BoxFit.scaleDown,
          ),
          Image(
            image: img,
            height: 50.0,
            width: 100.0,
            fit: BoxFit.none,
          ),
          Image(
            image: img,
            width: 100.0,
            color: Colors.blue,
            colorBlendMode: BlendMode.difference,
            fit: BoxFit.fill,
          ),
          Image(
            image: img,
            width: 100.0,
            height: 200.0,
            repeat: ImageRepeat.repeatY ,
          )
        ].map((e){
          return Row(
            children: <Widget>[
              Padding(
                padding: EdgeInsets.all(16.0),
                child: SizedBox(
                  width: 100,
                  child: e,
                ),
              ),
              Text(e.fit.toString())
            ],
          );
        }).toList()
      ),
    );
  }
}
Image缓存

Flutter框架对加载过的图片是有缓存的(内存)

Icon

Flutter 中,可以像Web开发一样使用 iconfont,iconfont 即“字体图标”,它是将图标做成字体文件,然后通过指定不同的字符而显示不同的图片。
在字体文件中,每一个字符都对应一个位码,而每一个位码对应一个显示字形,不同的字体就是指字形不同,即字符对应的字形是不同的。而在iconfont中,只是将位码对应的字形做成了图标,所以不同的字符最终就会渲染成不同的图标。
在Flutter开发中,iconfont和图片相比有如下优势:
1、体积小:可以减小安装包大小。
2、矢量的:iconfont都是矢量图标,放大不会影响其清晰度。
3、可以应用文本样式:可以像文本一样改变字体图标的颜色、大小对齐等。
4、可以通过TextSpan和文本混用。

使用Material Design字体图标

Flutter默认包含了一套Material Design的字体图标,在pubspec.yaml文件中的配置如下

flutter:
  uses-material-design: true

Material Design所有图标可以在其官网查看:https://material.io/tools/icons/

String icons = "";
// accessible: 0xe03e
icons += "\uE03e";
// error:  0xe237
icons += " \uE237";
// fingerprint: 0xe287
icons += " \uE287";

Text(
  icons,
  style: TextStyle(
    fontFamily: "MaterialIcons",
    fontSize: 24.0,
    color: Colors.green,
  ),
);

运行效果如图3-21所示:
在这里插入图片描述
通过这个示例可以看到,使用图标就像使用文本一样,但是这种方式需要我们提供每个图标的码点,这并对开发者不友好,所以,Flutter封装了IconData和Icon来专门显示字体图标,上面的例子也可以用如下方式实现:

Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: <Widget>[
    Icon(Icons.accessible,color: Colors.green),
    Icon(Icons.error,color: Colors.green),
    Icon(Icons.fingerprint,color: Colors.green),
  ],
)

Icons类中包含了所有Material Design图标的IconData静态变量定义。

使用自定义字体图标

我们也可以使用自定义字体图标。iconfont.cn上有很多字体图标素材,我们可以选择自己需要的图标打包下载后,会生成一些不同格式的字体文件,在Flutter中,我们使用ttf格式即可。

假设我们项目中需要使用一个书籍图标和微信图标,我们打包下载后导入:

1、导入字体图标文件;这一步和导入字体文件相同,假设我们的字体图标文件保存在项目根目录下,路径为"fonts/iconfont.ttf":

fonts:
  - family: myIcon  #指定一个字体名
    fonts:
      - asset: fonts/iconfont.ttf

2、为了使用方便,我们定义一个MyIcons类,功能和Icons类一样:将字体文件中的所有图标都定义成静态变量:

class MyIcons{
  // book 图标
  static const IconData book = const IconData(
      0xe614, 
      fontFamily: 'myIcon', 
      matchTextDirection: true
  );
  // 微信图标
  static const IconData wechat = const IconData(
      0xec7d,  
      fontFamily: 'myIcon', 
      matchTextDirection: true
  );
}

3、使用

Row(
  mainAxisAlignment: MainAxisAlignment.center,
  children: <Widget>[
    Icon(MyIcons.book,color: Colors.purple),
    Icon(MyIcons.wechat,color: Colors.green),
  ],
)

运行后效果如图3-22所示:
在这里插入图片描述

布局类组件

单个Widget布局主要就是对子Widget进行样式包装,比如限制大小、添加背景色、内边距、外边距、边框、变换等,主要使用Container、Padding和Center。
多个子Widget布局方式有:线性布局(Column与row)、弹性布局(flex与expanded)、流式布局(Wrap)、层叠布局(Stack和Positioned)

线性布局

Row

在Flutter中非常常见的一个多子节点控件,将children排列成一行。估计是借鉴了Web中Flex布局,所以很多属性和表现,都跟其相似。但是注意一点,自身不带滚动属性并且不能换行,如果超出了一行,在debug下面则会显示溢出的提示。

布局行为
  • 首先按照不受限制的主轴(main axis)约束条件,对flex为null或者为0的child进行布局,然后按照交叉轴( cross axis)的约束,对child进行调整;
  • 然后对flex不为空或者0的child,将主轴方向上剩余的空间分成相应的几等分;
  • 对上述步骤flex值不为空的child,在交叉轴方向进行调整,在主轴方向使用最大约束条件,让其占满步骤2所分得的空间;
  • Flex交叉轴的范围取自子节点的最大交叉轴;
  • 主轴Flex的值是由mainAxisSize属性决定的,其中MainAxisSize可以取max、min以及具体的value值;
  • 每一个child的位置是由mainAxisAlignment以及crossAxisAlignment所决定。
    在这里插入图片描述
属性解析
  • MainAxisAlignment:主轴方向上的对齐方式,会对child的位置起作用,默认是start。
    其中MainAxisAlignment枚举值:
    (1)start:将children放置在主轴的起点;
    (2)center:将children放置在主轴的中心;
    (3)将children放置在主轴的末尾;
    (4)spaceAround:将主轴方向上的空白区域均分,使得children之间的空白区域相等,但是首尾child的空白区域为1/2;
    在这里插入图片描述
    (5) spaceBetween:将主轴方向上的空白区域均分,使得children之间的空白区域相等,首尾child都靠近首尾,没有间隙;
    在这里插入图片描述
    (6)spaceEvenly:将主轴方向上的空白区域均分,使得children之间的空白区域相等,包括首尾child;
    在这里插入图片描述
    其中spaceAround、spaceBetween以及spaceEvenly的区别,就是对待首尾child的方式。其距离首尾的距离分别是空白区域的1/2、0、1。
  • MainAxisSize:在主轴方向占有空间的值,默认是max。
    MainAxisSize的取值有两种:
    (1)max:根据传入的布局约束条件,最大化主轴方向的可用空间;
    (2)min:与max相反,是最小化主轴方向的可用空间;
  • CrossAxisAlignment:children在交叉轴方向的对齐方式,与MainAxisAlignment略有不同。
    CrossAxisAlignment枚举值有如下几种:
    (1)baseline:在交叉轴方向,使得children的baseline对齐;
    (2)center:children在交叉轴上居中展示;
    (3)end:children在交叉轴上末尾展示;
    (4)start:children在交叉轴上起点处展示;
    (5)stretch:让children填满交叉轴方向;
  • TextDirection:阿拉伯语系的兼容设置,一般无需处理。
  • VerticalDirection:定义了children摆放顺序,默认是down。
    VerticalDirection枚举值有两种:
    (1)down:从top到bottom进行布局;
    (2)up:从bottom到top进行布局。
  • top对应Row以及Column的话,就是左边和顶部,bottom的话,则是右边和底部。
  • TextBaseline:使用的TextBaseline的方式,有两种,前面已经介绍过。
示例代码
Row(
  children: <Widget>[
    Expanded(
      child: Container(
        color: Colors.red,
        padding: EdgeInsets.all(5.0),
      ),
      flex: 1,
    ),
    Expanded(
      child: Container(
        color: Colors.yellow,
        padding: EdgeInsets.all(5.0),
      ),
      flex: 2,
    ),
    Expanded(
      child: Container(
        color: Colors.blue,
        padding: EdgeInsets.all(5.0),
      ),
      flex: 1,
    ),
  ],
)

使用Expanded控件,将一行的宽度分成四个等分,第一、三个child占1/4的区域,第二个child占1/2区域,由flex属性控制。

使用场景

Row和Column都是非常常用的布局控件。一般情况下,比方说需要将控件在一行或者一列显示的时候,都可以使用。但并不是说只能使用Row或者Column去布局,也可以使用Stack,看具体的场景选择。

Column

Row和Column都是Flex的子类,只是direction参数不同。

Expand

Expanded 只能作为 Flex 的孩子(否则会报错),它可以按比例分配Flex子组件所占用的空间。因为 Row和Column 继都承自Flex,所以 Expanded 也可以作为它们的孩子。

const Expanded({
  int flex = 1, 
  required Widget child,
})
布局行为
  • flex参数为弹性系数,如果为 0 或null,则child是没有弹性的,不会强制填充剩余留白空间。
  • 如果大于0,所有的Expanded按照其 flex 的比例来分割主轴的全部空闲空间。
  • 如果colum、row、flex的两个子child中,一个是Expanded,一个是其他,则Expanded默认flex是1,会尽可能的填充剩余空间。

Flex

Flex组件可以沿着水平或垂直方向排列子组件,如果你知道主轴方向,使用Row或Column会方便一些,**因为Row和Column都继承自Flex,**参数基本相同,所以能使用Flex的地方基本上都可以使用Row或Column。

层叠布局

Stack

Stack可以类比web中的absolute,绝对布局。

布局行为

Stack的布局行为,根据child是positioned还是non-positioned来区分。

  • 对于positioned的子节点,它们的位置会根据所设置的top、bottom、right以及left属性来确定,这几个值都是相对于Stack的左上角;
  • 对于non-positioned的子节点,它们会根据Stack的aligment来设置位置。
    对于绘制child的顺序,则是第一个child被绘制在最底端,后面的依次在前一个child的上面,类似于web中的z-index。如果想调整显示的顺序,则可以通过摆放child的顺序来进行。
属性解析
  • alignment:对齐方式,默认是左上角(topStart)。
  • textDirection:文本的方向,绝大部分不需要处理。
  • fit:定义如何设置non-positioned节点尺寸,默认为loose。
    其中StackFit有如下几种:
    • loose:子节点宽松的取值,可以从min到max的尺寸;
    • expand:子节点尽可能的占用空间,取max尺寸;
    • passthrough:不改变子节点的约束条件。
  • overflow:超过的部分是否裁剪掉(clipped)。
示例代码
Stack(
  alignment: const Alignment(0.6, 0.6),
  children: [
    CircleAvatar(
      backgroundImage: AssetImage('images/pic.jpg'),
      radius: 100.0,
    ),
    Container(
      decoration: BoxDecoration(
        color: Colors.black45,
      ),
      child: Text(
        'Mia B',
        style: TextStyle(
          fontSize: 20.0,
          fontWeight: FontWeight.bold,
          color: Colors.white,
        ),
      ),
    ),
  ],
);

在这里插入图片描述

使用场景

Stack的场景还是比较多的,对于需要叠加显示的布局,一般都可以使用Stack。有些场景下,也可以被其他控件替代,我们应该选择开销较小的控件去实现。

IndexStackView

IndexedStack继承自Stack,它的作用是显示第index个child,其他child都是不可见的。所以IndexedStack的尺寸永远是跟最大的子节点尺寸一致。

示例
Container(
  color: Colors.yellow,
  child: IndexedStack(
    index: 1,
    alignment: const Alignment(0.6, 0.6),
    children: [
      CircleAvatar(
        backgroundImage: AssetImage('images/pic.jpg'),
        radius: 100.0,
      ),
      Container(
        decoration: BoxDecoration(
          color: Colors.black45,
        ),
        child: Text(
          'Mia B',
          style: TextStyle(
            fontSize: 20.0,
            fontWeight: FontWeight.bold,
            color: Colors.white,
          ),
        ),
      ),
    ],
  ),
)

在这里插入图片描述

使用场景

如果需要展示一堆控件中的一个,可以使用IndexedStack。有一定的使用场景,但是也有控件可以实现其功能,只不过操作起来可能会复杂一些。

流式布局

在介绍 Row 和 Colum 时,如果子 widget 超出屏幕范围,则会报溢出错误,如:

Row(
  children: <Widget>[
    Text("xxx"*100)
  ],
);

运行效果所示:
在这里插入图片描述
可以看到,右边溢出部分报错。这是因为Row默认只有一行,如果超出屏幕不会折行。我们把超出屏幕显示范围会自动折行的布局称为流式布局。Flutter中通过Wrap和Flow来支持流式布局。

Flow

Flow按照解释的那样,是一个实现流式布局算法的控件。流式布局在大前端是很常见的布局方式,但是一般使用Flow很少,因为其过于复杂,很多场景下都会去使用Wrap。

布局行为

Flow官方介绍是一个对child尺寸以及位置调整非常高效的控件,主要是得益于其FlowDelegate。另外Flow在用转换矩阵(transformation matrices)对child进行位置调整的时候进行了优化。

Flow以及其child的一些约束都会受到FlowDelegate的控制,例如重写FlowDelegate中的geiSize,可以设置Flow的尺寸,重写其getConstraintsForChild方法,可以设置每个child的布局约束条件。

Flow之所以高效,是因为其在定位过后,如果使用FlowDelegate中的paintChildren改变child的尺寸或者位置,只是重绘,并没有实际调整其位置。

属性解析
  • delegate:影响Flow具体布局的FlowDelegate。
    其中FlowDelegate包含如下几个方法:
    • getConstraintsForChild: 设置每个child的布局约束条件,会覆盖已有的;
    • getSize:设置Flow的尺寸;
    • paintChildren:child的绘制控制代码,可以调整尺寸位置,写起来比较的繁琐;
    • shouldRepaint:是否需要重绘;
    • shouldRelayout:是否需要重新布局。
      其中,我们平时使用的时候,一般会使用到paintChildren以及shouldRepaint两个方法。
代码示例
const width = 80.0;
const height = 60.0;

Flow(
  delegate: TestFlowDelegate(margin: EdgeInsets.fromLTRB(10.0, 10.0, 10.0, 10.0)),
  children: <Widget>[
    new Container(width: width, height: height, color: Colors.yellow,),
    new Container(width: width, height: height, color: Colors.green,),
    new Container(width: width, height: height, color: Colors.red,),
    new Container(width: width, height: height, color: Colors.black,),
    new Container(width: width, height: height, color: Colors.blue,),
    new Container(width: width, height: height, color: Colors.lightGreenAccent,),
  ],
)

class TestFlowDelegate extends FlowDelegate {
  EdgeInsets margin = EdgeInsets.zero;

  TestFlowDelegate({this.margin});
  @override
  void paintChildren(FlowPaintingContext context) {
    var x = margin.left;
    var y = margin.top;
    for (int i = 0; i < context.childCount; i++) {
      var w = context.getChildSize(i).width + x + margin.right;
      if (w < context.size.width) {
        context.paintChild(i,
            transform: new Matrix4.translationValues(
                x, y, 0.0));
        x = w + margin.left;
      } else {
        x = margin.left;
        y += context.getChildSize(i).height + margin.top + margin.bottom;
        context.paintChild(i,
            transform: new Matrix4.translationValues(
                x, y, 0.0));
        x += context.getChildSize(i).width + margin.left + margin.right;
      }
    }
  }

  @override
  bool shouldRepaint(FlowDelegate oldDelegate) {
    return oldDelegate != this;
  }
}

FlowDelegate需要自己实现child的绘制,其实大多数时候就是位置的摆放。上面例子中,对每个child按照给定的margin值,进行排列,如果超出一行,则在下一行进行布局。

使用场景

Flow在一些定制化的流式布局中,有可用场景,但是一般写起来比较复杂,但胜在灵活性以及其高效。
在这里插入图片描述
对于上述child宽度的变化,这个例子是没问题的,如果每个child的高度不同,则需要对代码进行调整,具体的调整是换行的时候,需要根据上一行的最大高度来确定下一行的起始y坐标。

Wrap

布局行为

Flow可以很轻易的实现Wrap的效果,但是Wrap更多的是在使用了Flex中的一些概念,某种意义上说是跟Row、Column更加相似的。

单行的Wrap跟Row表现几乎一致,单列的Wrap则跟Row表现几乎一致。但Row与Column都是单行单列的,Wrap则突破了这个限制,mainAxis上空间不足时,则向crossAxis上去扩展显示。

从效率上讲,Flow肯定会比Wrap高,但是Wrap使用起来会方便一些。

属性解析
  • direction:主轴(mainAxis)的方向,默认为水平。
  • alignment:主轴方向上的对齐方式,默认为start。
  • spacing:主轴方向上的间距。
  • runAlignment:run的对齐方式。run可以理解为新的行或者列,如果是水平方向布局的话,run可以理解为新的一行。
  • runSpacing:run的间距。
  • crossAxisAlignment:交叉轴(crossAxis)方向上的对齐方式。
  • textDirection:文本方向。
  • verticalDirection:定义了children摆放顺序,默认是down,见Flex相关属性介绍。
示例代码
Wrap(
  spacing: 8.0, // gap between adjacent chips
  runSpacing: 4.0, // gap between lines
  children: <Widget>[
    Chip(
      avatar: CircleAvatar(
          backgroundColor: Colors.blue.shade900, child: new Text('AH', style: TextStyle(fontSize: 10.0),)),
      label: Text('Hamilton'),
    ),
    Chip(
      avatar: CircleAvatar(
          backgroundColor: Colors.blue.shade900, child: new Text('ML', style: TextStyle(fontSize: 10.0),)),
      label: Text('Lafayette'),
    ),
    Chip(
      avatar: CircleAvatar(
          backgroundColor: Colors.blue.shade900, child: new Text('HM', style: TextStyle(fontSize: 10.0),)),
      label: Text('Mulligan'),
    ),
    Chip(
      avatar: CircleAvatar(
          backgroundColor: Colors.blue.shade900, child: new Text('JL', style: TextStyle(fontSize: 10.0),)),
      label: Text('Laurens'),
    ),
  ],
)

在这里插入图片描述

使用场景

对于一些需要按宽度或者高度,让child自动换行布局的场景,可以使用,但是Wrap可以满足的场景,Flow一定可以实现,只不过会复杂很多,但是相对的会灵活以及高效很多。

容器组件

容器类Widget和布局类Widget都作用于其子Widget,不同的是:

  • 布局类Widget一般都需要接受widget数组(children),他们直接或间接继承自(或包含)MultiChildRenderObjectWidget ;而容器类Widget一般只需要接收一个子Widget(child),他们直接或间接继承自(或包含)SingleChildRenderObjectWidget。
  • 布局类Widget是按照一定的排列方式来对其子Widget进行排列;而容器类Widget一般只是包装其子Widget,对其添加一些修饰(补白或背景色等)、变换(旋转或剪裁等)、或限制(大小等)。

Container容器

Container容器可以把它理解成为CSS里面的盒模型,可以设置大小、内边距、边框、外边距、背景色等。

组成

Container的组成如下:

  • 最里层的是child元素;
  • child元素首先会被padding包着;
  • 然后添加额外的constraints限制;
  • 最后添加margin。

Container的绘制的过程如下:

  • 首先会绘制transform效果;
  • 接着绘制decoration;
  • 然后绘制child;
  • 最后绘制foregroundDecoration。

Container自身尺寸的调节分两种情况:

  • Container在没有子节点(children)的时候,会试图去变得足够大。除非constraints是unbounded限制,在这种情况下,Container会试图去变得足够小。
  • 带子节点的Container,会根据子节点尺寸调节自身尺寸,但是Container构造器中如果包含了width、height以及constraints,则会按照构造器中的参数来进行尺寸的调节。

布局行为

  • 如果没有子节点、没有设置width、height以及constraints,并且父节点没有设置unbounded的限制,Container会将自身调整到足够小。
  • 如果没有子节点、对齐方式(alignment),但是提供了width、height或者constraints,那么Container会根据自身以及父节点的限制,将自身调节到足够小。
  • 如果没有子节点、width、height、constraints以及alignment,但是父节点提供了bounded限制,那么Container会按照父节点的限制,将自身调整到足够大。
  • 如果有alignment,父节点提供了unbounded限制,那么Container将会调节自身尺寸来包住child;
  • 如果有alignment,并且父节点提供了bounded限制,那么Container会将自身调整的足够大(在父节点的范围内),然后将child根据alignment调整位置;
  • 含有child,但是没有width、height、constraints以及alignment,Container会将父节点的constraints传递给child,并且根据child调整自身。
  • 另外,margin以及padding属性也会影响到布局。

属性解析

  • key:Container唯一标识符,用于查找更新。
  • alignment:控制child的对齐方式,如果container或者container父节点尺寸大于child的尺寸,这个属性设置会起作用,有很多种对齐方式。
  • padding:decoration内部的空白区域,如果有child的话,child位于padding内部。padding与margin的不同之处在于,padding是包含在content内,而margin则是外部边界,设置点击事件的话,padding区域会响应,而margin区域不会响应。
  • color:用来设置container背景色,如果foregroundDecoration设置的话,可能会遮盖color效果。
  • decoration:绘制在child后面的装饰,设置了decoration的话,就不能设置color属性,否则会报错,此时应该在decoration中进行颜色的设置。
  • foregroundDecoration:绘制在child前面的装饰。
  • width:container的宽度,设置为double.infinity可以强制在宽度上撑满,不设置,则根据child和父节点两者一起布局。
  • height:container的高度,设置为double.infinity可以强制在高度上撑满。
  • constraints:添加到child上额外的约束条件。
  • margin:围绕在decoration和child之外的空白区域,不属于内容区域。
  • transform:设置container的变换矩阵,类型为Matrix4。
  • child:container中的内容widget。

使用场景

  • 需要设置间隔(这种情况下,如果只是单纯的间隔,也可以通过Padding来实现);
  • 需要设置背景色;
  • 需要设置圆角或者边框的时候(ClipRRect也可以实现圆角效果);
  • 需要对齐(Align也可以实现);
  • 需要设置背景图片的时候(也可以使用Stack实现)。

填充 Padding

Padding主要用来设置内边距属性,内边距的空白区域,也是widget的一部分。

布局行为

Padding的布局分为两种情况:

  • 当child为空的时候,会产生一个宽为left+right,高为top+bottom的区域;
  • 当child不为空的时候,Padding会将布局约束传递给child,根据设置的padding属性,缩小child的布局尺寸。然后Padding将自己调整到child设置了padding属性的尺寸,在child周围创建空白区域。

属性解析

  • padding:padding的类型为EdgeInsetsGeometry,EdgeInsetsGeometry是EdgeInsets以及EdgeInsetsDirectional的基类。在实际使用中不涉及到国际化,例如适配阿拉伯地区等,一般都是使用EdgeInsets。EdgeInsetsDirectional光看命名就知道跟方向相关,因此它的四个边距不限定上下左右,而是根据方向来定的。
EdgeInsets

EdgeInsets提供的便捷方法:

  • fromLTRB(double left, double top, double right, double bottom):分别指定四个方向的填充。
  • all(double value) : 所有方向均使用相同数值的填充。
  • only({left, top, right ,bottom }):可以设置具体某个方向的填充(可以同时指定多个方向)。
  • symmetric({ vertical, horizontal }):用于设置对称方向的填充,vertical指topbottomhorizontalleftright
 Widget build(BuildContext context) {
    return Padding(
      padding: EdgeInsets.fromLTRB(50, 30, 10, 10),
      child: Text("Hello World!"),
    );
  }

在这里插入图片描述

Align

Align主要用来设置child的对齐方式,例如居中、居左等,并根据child尺寸调节自身尺寸。

布局行为

Align的布局行为分为两种情况:

  • 当widthFactor和heightFactor为null的时候,当其有限制条件的时候,Align会根据限制条件尽量的扩展自己的尺寸,当没有限制条件的时候,会调整到child的尺寸;
  • 当widthFactor或者heightFactor不为null的时候,Aligin会根据factor属性,扩展自己的尺寸,例如设置widthFactor为2.0的时候,那么,Align的宽度将会是child的两倍。

属性解析

  • alignment:对齐方式
  • widthFactor:宽度因子,如果设置的话,Align的宽度就是child的宽度乘以这个值,不能为负数。
  • heightFactor:高度因子,如果设置的话,Align的高度就是child的高度乘以这个值,不能为负数。

Center

Center继承自Align,只不过是将alignment设置为Alignment.center,其他属性例如widthFactor、heightFactor,布局行为,都与Align完全一样。

Scaffold

Scaffold

一个完整的路由页可能会包含导航栏、抽屉菜单(Drawer)以及底部 Tab 导航菜单等。Scaffold 是一个路由页的骨架,我们使用它可以很容易地拼装出一个完整的页面。

我们实现一个页面,它包含:
1、一个导航栏
2、导航栏右边有一个分享按钮
3、有一个抽屉菜单
4、有一个底部导航
5、右下角有一个悬浮的动作按钮
在这里插入图片描述
实现代码如下:

class ScaffoldRoute extends StatefulWidget {
  @override
  _ScaffoldRouteState createState() => _ScaffoldRouteState();
}

class _ScaffoldRouteState extends State<ScaffoldRoute> {
  int _selectedIndex = 1;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar( //导航栏
        title: Text("App Name"), 
        actions: <Widget>[ //导航栏右侧菜单
          IconButton(icon: Icon(Icons.share), onPressed: () {}),
        ],
      ),
      drawer: MyDrawer(), //抽屉
      bottomNavigationBar: BottomNavigationBar( // 底部导航
        items: <BottomNavigationBarItem>[
          BottomNavigationBarItem(icon: Icon(Icons.home), title: Text('Home')),
          BottomNavigationBarItem(icon: Icon(Icons.business), title: Text('Business')),
          BottomNavigationBarItem(icon: Icon(Icons.school), title: Text('School')),
        ],
        currentIndex: _selectedIndex,
        fixedColor: Colors.blue,
        onTap: _onItemTapped,
      ),
      floatingActionButton: FloatingActionButton( //悬浮按钮
          child: Icon(Icons.add),
          onPressed:_onAdd
      ),
    );
  }
  void _onItemTapped(int index) {
    setState(() {
      _selectedIndex = index;
    });
  }
  void _onAdd(){
  }
}

AppBar

AppBar是一个Material风格的导航栏,通过它可以设置导航栏标题、导航栏菜单、导航栏底部的Tab标题等。下面我们看看AppBar的定义:

AppBar({
  Key? key,
  this.leading, //导航栏最左侧Widget,常见为抽屉菜单按钮或返回按钮。
  this.automaticallyImplyLeading = true, //如果leading为null,是否自动实现默认的leading按钮
  this.title,// 页面标题
  this.actions, // 导航栏右侧菜单
  this.bottom, // 导航栏底部菜单,通常为Tab按钮组
  this.elevation = 4.0, // 导航栏阴影
  this.centerTitle, //标题是否居中 
  this.backgroundColor,
  ...   //其它属性见源码注释
})

如果给Scaffold添加了抽屉菜单,默认情况下Scaffold会自动将AppBar的leading设置为菜单按钮,点击它便可打开抽屉菜单。如果我们想自定义菜单图标,可以手动来设置leading。

Scaffold(
  appBar: AppBar(
    title: Text("App Name"),
    leading: Builder(builder: (context) {
      return IconButton(
        icon: Icon(Icons.dashboard, color: Colors.white), //自定义图标
        onPressed: () {
          // 打开抽屉菜单  
          Scaffold.of(context).openDrawer(); 
        },
      );
    }),
    ...  
  )  

在这里插入图片描述
可以看到左侧菜单已经替换成功。
代码中打开抽屉菜单的方法在ScaffoldState中,通过Scaffold.of(context)可以获取父级最近的Scaffold 组件的State对象。

抽屉菜单Drawer

Scaffold的drawer和endDrawer属性可以分别接受一个Widget来作为页面的左、右抽屉菜单。如果开发者提供了抽屉菜单,那么当用户手指从屏幕左(或右)侧向里滑动时便可打开抽屉菜单。本节开始部分的示例中实现了一个左抽屉菜单MyDrawer,它的源码如下:

class MyDrawer extends StatelessWidget {
  const MyDrawer({
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Drawer(
      child: MediaQuery.removePadding(
        context: context,
        //移除抽屉菜单顶部默认留白
        removeTop: true,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Padding(
              padding: const EdgeInsets.only(top: 38.0),
              child: Row(
                children: <Widget>[
                  Padding(
                    padding: const EdgeInsets.symmetric(horizontal: 16.0),
                    child: ClipOval(
                      child: Image.asset(
                        "imgs/avatar.png",
                        width: 80,
                      ),
                    ),
                  ),
                  Text(
                    "Wendux",
                    style: TextStyle(fontWeight: FontWeight.bold),
                  )
                ],
              ),
            ),
            Expanded(
              child: ListView(
                children: <Widget>[
                  ListTile(
                    leading: const Icon(Icons.add),
                    title: const Text('Add account'),
                  ),
                  ListTile(
                    leading: const Icon(Icons.settings),
                    title: const Text('Manage accounts'),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

抽屉菜单通常将Drawer组件作为根节点,它实现了Material风格的菜单面板,MediaQuery.removePadding可以移除Drawer默认的一些留白(比如Drawer默认顶部会留和手机状态栏等高的留白),读者可以尝试传递不同的参数来看看实际效果。抽屉菜单页由顶部和底部组成,顶部由用户头像和昵称组成,底部是一个菜单列表,用ListView实现。

FloatingActionButton

FloatingActionButton是Material设计规范中的一种特殊Button,通常悬浮在页面的某一个位置作为某种常用动作的快捷入口。
本节示例中页面右下角的"➕"号按钮。我们可以通过Scaffold的floatingActionButton属性来设置一个FloatingActionButton,同时通过floatingActionButtonLocation属性来指定其在页面中悬浮的位置。

BottomNavigationBar

BottomNavigationBar({
    Key key,
     this.items,
    this.onTap,
    this.currentIndex = 0,
    this.elevation = 8.0,
    BottomNavigationBarType type,
    Color fixedColor,
    this.backgroundColor,
    this.iconSize = 24.0,
    Color selectedItemColor,
    this.unselectedItemColor,
    this.selectedIconTheme = const IconThemeData(),
    this.unselectedIconTheme = const IconThemeData(),
    this.selectedFontSize = 14.0,
    this.unselectedFontSize = 12.0,
    this.selectedLabelStyle,
    this.unselectedLabelStyle,
    this.showSelectedLabels = true,
    bool showUnselectedLabels,
  })
  • items:这是一个 BottomNavigationBarItem 数组,@required 修饰符表明此参数是默认一定要传的,而且至少2个。官方有建议底栏数目最好是3~5个。
  • onTap:导航栏的点击事件,通过它来切换页面
  • currentIndex:用来表明当前选中的是哪一个底部item
  • elevation:阴影高度
  • type:底部item类型,有两种可选:fixed 自适应,shifting 选择放大。如果未指定,则它会自动设置为 BottomNavigationBarType.fixed(当项目少于四个时),否则默认为 BottomNavigationBarType.shifting。
  • fixedColor:被选中 item 的 icon 和 label 颜色。这个属性已经淘汰了,只是为了向后兼容留着的。更建议使用 selectedItemColor 属性。
  • backgroundColor:底部导航栏颜色
  • iconSize:item图标大小
  • selectedItemColor:功能同 fixedColor,两者只能设定一个,建议选 selectedItemColor。
  • unselectedItemColor:未选中 item 的颜色
  • selectedIconTheme:选中 item 的 icon 样式
  • unselectedIconTheme:未选中 item 的 icon 样式
  • selectedFontSize:选中 item 的 label 字号
  • unselectedFontSize:未选中 item 的 label 字号。可以看到,默认选中和未选中两者字号是不一样的,切换的时候就会有动态变化。如果有需要保持一致,两个属性设置成一样大就可以了
  • selectedLabelStyle:选中 item 的 label 样式
  • unselectedLabelStyle:未选中 item 的 label 样式
  • showSelectedLabels:是否要展示选中 item 的 label,默认是
  • showUnselectedLabels:是否要展示未选中 item 的 label

BottomNavigationBar 有一个必传的参数是 items,它是一个 BottomNavigationBarItem 数组。我们也来看下BottomNavigationBarItem 的构造函数:

const BottomNavigationBarItem({
     this.icon,
    this.title,
    Widget activeIcon,
    this.backgroundColor,
  })
  • icon:导航栏item的图标,一定要设置的
  • title:item的label,虽然没有加@required 修饰符,但是也是一定要设置的,否则会报:Every item must have a non-null title
  • activeIcon:选中 item 的图标
  • backgroundColor:底部导航栏的背景动画的颜色。当导航栏的类型是 BottomNavigationBarType.shifting时,给 item 设置这个属性会生效。

BottomAppBar

BottomAppBar({
    Key key,
    this.color,
    this.elevation,
    this.shape,
    this.clipBehavior = Clip.none,
    this.notchMargin = 4.0,
    this.child,
  })
  • color:底部导航栏颜色
  • elevation:阴影高度
  • shape:底部导航栏缺口的类型:NotchedShape,一般设置这个属性都是为了和 FloatingActionButton 融合,所以使用的值都是 CircularNotchedRectangle(),就是有缺口的圆形效果。
  • clipBehavior:裁剪组件的效果。默认是 Clip.none,也可以传 Clip.antiAlias,Clip.antiAliasWithSaveLayer,Clip.hardEdge。关于 Clip ,推荐看一下这篇文章,图文并茂,介绍的很清楚
  • notchMargin:FloatingActionButton 和 BottomAppBar 缺口的间距。如果上面的 shape 属性是 null,那么这里设置的就自动无效了
  • child:即底部导航栏包含的内容,可以随需求自定义。通常是放一个 Row,在其 children 属性中传入多个 IconButton 。

在这里插入图片描述
Material组件库中提供了一个BottomAppBar 组件,它可以和FloatingActionButton配合实现这种“打洞”效果,源码如下:

bottomNavigationBar: BottomAppBar(
  color: Colors.white,
  shape: CircularNotchedRectangle(), // 底部导航栏打一个圆形的洞
  child: Row(
    children: [
      IconButton(icon: Icon(Icons.home)),
      SizedBox(), //中间位置空出
      IconButton(icon: Icon(Icons.business)),
    ],
    mainAxisAlignment: MainAxisAlignment.spaceAround, //均分底部导航栏横向空间
  ),
)

可以看到,上面代码中没有控制打洞位置的属性,实际上,打洞的位置取决于FloatingActionButton的位置,上面FloatingActionButton的位置为:

floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,

所以打洞位置在底部导航栏的正中间。
BottomAppBar的shape属性决定洞的外形,CircularNotchedRectangle实现了一个圆形的外形,我们也可以自定义外形。

CupertinoTabBar

它是iOS 风格的底部导航栏组件。通常与 CupertinoTabScaffold 一起使用。
来看下 CupertinoTabBar 组件的构造函数:

CupertinoTabBar({
    Key key, 
     List<BottomNavigationBarItem> items, 
    ValueChanged<int> onTap, 
    int currentIndex: 0, 
    Color backgroundColor, 
    Color activeColor, 
    Color inactiveColor: CupertinoColors.inactiveGray, 
    double iconSize: 30.0, 
    Border border: const Border(
      top: BorderSide(color: _kDefaultTabBarBorderColor, width: 0.0, style: BorderStyle.solid)) 
  })

页面 body

Scaffold 有一个 body 属性,接收一个 Widget,我们可以传任意的 Widget。

可滚动组件

PageView与页面缓存

PageView

如果要实现页面切换和 Tab 布局,我们可以使用 PageView 组件。需要注意,PageView 是一个非常重要的组件,因为在移动端开发中很常用,比如大多数 App 都包含 Tab 换页效果、图片轮动以及抖音上下滑页切换视频功能等等,这些都可以通过 PageView 轻松实现。

项目Value
scrollDirection设置滚动的方向即horizontal(水平)和vertical(垂直)两个,默认是horizontal的
reverse规定了children(子节点)的排序是否是倒序,默认false。这个参数在ListView也有,一般在做IM工具聊天内容用ListView展示时需要倒序展示的。
controller可以传入一个PageController的实例进去,可以更好的控制PageView的各种动作,可以设置:(1)初始页面(initialPage)(2)是否保存PageView状态(keepPage)(3)每一个PageView子节点的内容占改视图的比例(viewportFraction)(4)直接调转到指定的PageView的子节点的方法(jumpToPage)(5)动画(平滑移动)到指定的PageView的子节点的方法(animateToPage)(6)到下一个PageView的子节点的方法(nextPage)(7)到上一个PageView的子节点的方法(previousPage)
physics设置滑动效果,NeverScrollableScrollPhysics表示不可滑动,RangeMaintainingScrollPhysics当内容突然改变尺寸时,试图将滚动位置保持在范围内的滚动物理,BouncingScrollPhysics表示滚动到底了会有弹回的效果,就是iOS的默认交互,会响应滚动事件。ClampingScrollPhysics表示滚动到底了就给一个效果,就是Android的默认交互会响应滚动事件,AlwaysScrollableScrollPhysics始终响应用户的滚动。
pageSnapping每次滑动是否强制切换整个页面,如果为false,则会根据实际的滑动距离显示页面dragStartBehavior
allowImplicitScrolling主要是配合辅助功能用的,设置为true后会缓存前后两页。

其他属性查看官方文档

用法

定义一个PageController,用来操作PageView或者监听PageView ,初始化方法如下:

class _ExampleState extends State<Example508> {
  /// 初始化控制器
  PageController pageController;

  //PageView当前显示页面索引
  int currentPage = 0;

  @override
  void initState() {
    super.initState();

    //创建控制器的实例
    pageController = new PageController(
      //用来配置PageView中默认显示的页面 从0开始
      initialPage: 0,
      //为true是保持加载的每个页面的状态
      keepPage: true,
    );

    ///PageView设置滑动监听
    pageController.addListener(() {
      //PageView滑动的距离
      double offset = pageController.offset;
      //当前显示的页面的索引
      double page = pageController.page;
      print("pageView 滑动的距离 $offset  索引 $page");
    });
  }

然后在页面的主体我们就是构建了一个PageView,其详细概述如下:

 @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("PageView "),
      ),
      body: Container(
        height: 200,
        child: PageView.builder(
          //当页面选中后回调此方法
          //参数[index]是当前滑动到的页面角标索引 从0开始
          onPageChanged: (int index) {
            print("当前的页面是 $index");
            currentPage = index;
          },
          //值为flase时 显示第一个页面 然后从左向右开始滑动
          //值为true时 显示最后一个页面 然后从右向左开始滑动
          reverse: false,
          //滑动到页面底部无回弹效果
          physics: BouncingScrollPhysics(),
          //纵向滑动切换
          scrollDirection: Axis.vertical,
          //页面控制器
          controller: pageController,
          //所有的子Widget
          itemBuilder: (BuildContext context, int index) {
            return Container(
              //缩放的图片
              width: MediaQuery.of(context).size.width,
              child: Image.asset(
                "assets/images/2.0x/banner3.webp",
              ),
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        child: Icon(Icons.arrow_upward),
        onPressed: () {
          //
          if (currentPage > 0) {
            //滚动到上一屏
            pageController.animateToPage(
              currentPage - 1,
              curve: Curves.ease,
              duration: Duration(milliseconds: 200),
            );
          }
        },
      ),
    );
  }

在这里的 floatingActionButton 悬浮按钮小编只是写了一个控制PageView上滑一个页面的功能,是PageController来操作的,详细方法描述如下:

 void pageViewController() {
    //动画的方式滚动到指定的页面
    pageController.animateToPage(
      //子Widget的索引
      0,
      //动画曲线
      curve: Curves.ease,
      //滚动时间
      duration: Duration(milliseconds: 200),
    );

    //动画的方式滚动到指定的位置
    pageController.animateTo(
      100,
      //动画曲线
      curve: Curves.ease,
      //滚动时间
      duration: Duration(milliseconds: 200),
    );

    //无动画切换到指定的页面
    pageController.jumpToPage(0);
    //无动画 切换到指定的位置
    pageController.jumpTo(100);
  }

页面缓存

PageView每当页面切换时都会触发新Page页面的build,因此PageView默认并没有缓存功能,一旦页面滑出它就会被销毁ListView/GridView 不一样,在创建 ListView/GridView 时我们可以手动指定 ViewPort 之外多大范围内的组件需要预渲染和缓存(通过 cacheExtent 指定),只有当组件滑出屏幕后又滑出预渲染区域,组件才会被销毁,但是不幸的是 PageView 并没有 cacheExtent 参数!

按道理 cacheExtent 是 Viewport 的一个配置属性,且 PageView 也是要构建 Viewport 的,在 PageView 创建Viewport 的代码中是这样的:

child: Scrollable(
  ...
  viewportBuilder: (BuildContext context, ViewportOffset position) {
    return Viewport(
      // TODO(dnfield): we should provide a way to set cacheExtent
      // independent of implicit scrolling:
      // https://github.com/flutter/flutter/issues/45632
      cacheExtent: widget.allowImplicitScrolling ? 1.0 : 0.0,
      cacheExtentStyle: CacheExtentStyle.viewport,
      ...
    );
  },
)

虽然 PageView 没有透传 cacheExtent,但是却在allowImplicitScrolling 为 true 时设置了预渲染区域,注意,此时的缓存类型为 CacheExtentStyle.viewport,则 cacheExtent 则表示缓存的长度是几个 Viewport 的宽度,cacheExtent 为 1.0,则代表前后各缓存一个页面宽度,即前后各一页。既然如此,那我们将 PageView 的 allowImplicitScrolling 置为 true就可以缓存两页了。

事件处理与通知

Flutter中的手势系统有两个独立的层。第一层为原始指针(pointer)事件,它描述了屏幕上指针(例如,触摸、鼠标和触控笔)的位置和移动。 第二层为手势,描述由一个或多个指针移动组成的语义动作,如拖动、缩放、双击等。

原始指针事件处理

Pointer 代表的是人机界面交互的原始数据。一共有四种指针事件:

Flutter中可以使用Listener来监听原始触摸事件。

Listener({
  Key key,
  this.onPointerDown, //手指按下回调
  this.onPointerMove, //手指移动回调
  this.onPointerUp,//手指抬起回调
  this.onPointerCancel,//触摸事件取消回调
  this.behavior = HitTestBehavior.deferToChild, 
  Widget child
})

在指针下落事件中,框架做了一个 hit test 的操作确定与屏幕发生接触的位置上有哪些组件以及分发给最内部的组件去响应。事件会沿着组件树从这个最内部的组件向组件树的根部冒泡分发。并且不存在用于取消或停止指针事件进行进一步分发的机制。

使用 Listener 可以在组件层直接监听指针事件。然而,一般情况下,请考虑使用下面的 gestures 替代。

我们先看一个示例,下面代码功能是: 手指在一个容器上移动时查看手指相对于容器的位置。

class _PointerMoveIndicatorState extends State<PointerMoveIndicator> {
  PointerEvent? _event;

  @override
  Widget build(BuildContext context) {
    return Listener(
      child: Container(
        alignment: Alignment.center,
        color: Colors.blue,
        width: 300.0,
        height: 150.0,
        child: Text(
          '${_event?.localPosition ?? ''}',
          style: TextStyle(color: Colors.white),
        ),
      ),
      onPointerDown: (PointerDownEvent event) => setState(() => _event = event),
      onPointerMove: (PointerMoveEvent event) => setState(() => _event = event),
      onPointerUp: (PointerUpEvent event) => setState(() => _event = event),
    );
  }
}

在这里插入图片描述
手指在蓝色矩形区域内移动即可看到当前指针偏移,当触发指针事件时,参数 PointerDownEvent、 PointerMoveEvent、 PointerUpEvent 都是PointerEvent的子类,PointerEvent类中包括当前指针的一些信息,注意 Pointer,即“指针”, 指事件的触发者,可以是鼠标、触摸板、手指。
如:

  • position:它是指针相对于当对于全局坐标的偏移。
  • localPosition: 它是指针相对于当对于全局坐标的偏移
  • delta:两次指针移动事件(PointerMoveEvent)的距离。
  • pressure:按压力度,如果手机屏幕支持压力传感器(如iPhone的3D Touch),此属性会更有意义,如果手机不支持,则始终为1。
  • orientation:指针移动方向,是一个角度值。

忽略指针事件

假如我们不想让某个子树响应PointerEvent的话,我们可以使用IgnorePointer和AbsorbPointer,这两个组件都能阻止子树接收指针事件,不同之处在于AbsorbPointer本身会参与命中测试,而IgnorePointer本身不会参与,这就意味着AbsorbPointer本身是可以接收指针事件的(但其子树不行),而IgnorePointer不可以。一个简单的例子如下:

Listener(
  child: AbsorbPointer(
    child: Listener(
      child: Container(
        color: Colors.red,
        width: 200.0,
        height: 100.0,
      ),
      onPointerDown: (event)=>print("in"),
    ),
  ),
  onPointerDown: (event)=>print("up"),
)

点击Container时,由于它在AbsorbPointer的子树上,所以不会响应指针事件,所以日志不会输出"in",但AbsorbPointer本身是可以接收指针事件的,所以会输出"up"。如果将AbsorbPointer换成IgnorePointer,那么两个都不会输出。

手势识别

GestureDetector

GestureDetector是一个用于手势识别的功能性组件,我们通过它可以来识别各种手势。GestureDetector 内部封装了 Listener,用以识别语义化的手势。

点击、双击、长按

我们通过GestureDetector对Container进行手势识别,触发相应事件后,在Container上显示事件名,为了增大点击区域,将Container设置为200×100,代码如下:

class _GestureTestState extends State<GestureTest> {
  String _operation = "No Gesture detected!"; //保存事件名
  @override
  Widget build(BuildContext context) {
    return Center(
      child: GestureDetector(
        child: Container(
          alignment: Alignment.center,
          color: Colors.blue,
          width: 200.0,
          height: 100.0,
          child: Text(
            _operation,
            style: TextStyle(color: Colors.white),
          ),
        ),
        onTap: () => updateText("Tap"), //点击
        onDoubleTap: () => updateText("DoubleTap"), //双击
        onLongPress: () => updateText("LongPress"), //长按
      ),
    );
  }

  void updateText(String text) {
    //更新显示的事件名
    setState(() {
      _operation = text;
    });
  }
}

运行效果如图8-2所示:
在这里插入图片描述
注意: 当同时监听onTap和onDoubleTap事件时,当用户触发tap事件时,会有200毫秒左右的延时,这是因为当用户点击完之后很可能会再次点击以触发双击事件,所以GestureDetector会等一段时间来确定是否为双击事件。如果用户只监听了onTap(没有监听onDoubleTap)事件时,则没有延时。

拖动、滑动

一次完整的手势过程是指用户手指按下到抬起的整个过程,期间,用户按下手指后可能会移动,也可能不会移动。GestureDetector对于拖动和滑动事件是没有区分的,他们本质上是一样的。GestureDetector会将要监听的组件的原点(左上角)作为本次手势的原点,当用户在监听的组件上按下手指时,手势识别就会开始。下面我们看一个拖动圆形字母A的示例:

class _Drag extends StatefulWidget {
  @override
  _DragState createState() => _DragState();
}

class _DragState extends State<_Drag> with SingleTickerProviderStateMixin {
  double _top = 0.0; //距顶部的偏移
  double _left = 0.0;//距左边的偏移

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        Positioned(
          top: _top,
          left: _left,
          child: GestureDetector(
            child: CircleAvatar(child: Text("A")),
            //手指按下时会触发此回调
            onPanDown: (DragDownDetails e) {
              //打印手指按下的位置(相对于屏幕)
              print("用户手指按下:${e.globalPosition}");
            },
            //手指滑动时会触发此回调
            onPanUpdate: (DragUpdateDetails e) {
              //用户手指滑动时,更新偏移,重新构建
              setState(() {
                _left += e.delta.dx;
                _top += e.delta.dy;
              });
            },
            onPanEnd: (DragEndDetails e){
              //打印滑动结束时在x、y轴上的速度
              print(e.velocity);
            },
          ),
        )
      ],
    );
  }
}

运行后,就可以在任意方向拖动了,运行效果如图8-3所示:
在这里插入图片描述

  • agDownDetails.globalPosition:当用户按下时,此属性为用户按下的位置相对于屏幕(而非父组件)原点(左上角)的偏移。
  • DragUpdateDetails.delta:当用户在屏幕上滑动时,会触发多次Update事件,delta指一次Update事件的滑动的偏移量。
  • DragEndDetails.velocity:该属性代表用户抬起手指时的滑动速度(包含x、y两个轴的),示例中并没有处理手指抬起时的速度,常见的效果是根据用户抬起手指时的速度做一个减速动画。
单一方向拖动

在本示例中,是可以朝任意方向拖动的,但是在很多场景,我们只需要沿一个方向来拖动,如一个垂直方向的列表,GestureDetector可以只识别特定方向的手势事件,我们将上面的例子改为只能沿垂直方向拖动:

class _DragVertical extends StatefulWidget {
  @override
  _DragVerticalState createState() => _DragVerticalState();
}

class _DragVerticalState extends State<_DragVertical> {
  double _top = 0.0;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        Positioned(
          top: _top,
          child: GestureDetector(
            child: CircleAvatar(child: Text("A")),
            //垂直方向拖动事件
            onVerticalDragUpdate: (DragUpdateDetails details) {
              setState(() {
                _top += details.delta.dy;
              });
            },
          ),
        )
      ],
    );
  }
}
缩放

GestureDetector可以监听缩放事件,下面示例演示了一个简单的图片缩放效果:

class _Scale extends StatefulWidget {
  const _Scale({Key? key}) : super(key: key);

  @override
  _ScaleState createState() => _ScaleState();
}

class _ScaleState extends State<_Scale> {
  double _width = 200.0; //通过修改图片宽度来达到缩放效果

  @override
  Widget build(BuildContext context) {
    return Center(
      child: GestureDetector(
        //指定宽度,高度自适应
        child: Image.asset("./images/sea.png", width: _width),
        onScaleUpdate: (ScaleUpdateDetails details) {
          setState(() {
            //缩放倍数在0.8到10倍之间
            _width=200*details.scale.clamp(.8, 10.0);
          });
        },
      ),
    );
  }
}

在这里插入图片描述
现在在图片上双指张开、收缩就可以放大、缩小图片。本示例比较简单,实际中我们通常还需要一些其它功能,如双击放大或缩小一定倍数、双指张开离开屏幕时执行一个减速放大动画等,读者可以在学习完后面“动画”一章中的内容后自己来尝试实现一下。

GestureRecognizer

GestureDetector内部是使用一个或多个GestureRecognizer来识别各种手势的,而GestureRecognizer的作用就是通过Listener来将原始指针事件转换为语义手势,GestureDetector直接可以接收一个子widget。GestureRecognizer是一个抽象类,一种手势的识别器对应一个GestureRecognizer的子类,Flutter实现了丰富的手势识别器,我们可以直接使用。

示例

假设我们要给一段富文本(RichText)的不同部分分别添加点击事件处理器,但是TextSpan并不是一个widget,这时我们不能用GestureDetector,但TextSpan有一个recognizer属性,它可以接收一个GestureRecognizer。
假设我们需要在点击时给文本变色:

import 'package:flutter/gestures.dart';

class _GestureRecognizer extends StatefulWidget {
  const _GestureRecognizer({Key? key}) : super(key: key);

  @override
  _GestureRecognizerState createState() => _GestureRecognizerState();
}

class _GestureRecognizerState extends State<_GestureRecognizer> {
  TapGestureRecognizer _tapGestureRecognizer = TapGestureRecognizer();
  bool _toggle = false; //变色开关

  @override
  void dispose() {
    //用到GestureRecognizer的话一定要调用其dispose方法释放资源
    _tapGestureRecognizer.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text.rich(
        TextSpan(
          children: [
            TextSpan(text: "你好世界"),
            TextSpan(
              text: "点我变色",
              style: TextStyle(
                fontSize: 30.0,
                color: _toggle ? Colors.blue : Colors.red,
              ),
              recognizer: _tapGestureRecognizer
                ..onTap = () {
                  setState(() {
                    _toggle = !_toggle;
                  });
                },
            ),
            TextSpan(text: "你好世界"),
          ],
        ),
      ),
    );
  }
}

运行效果:
在这里插入图片描述
注意:使用GestureRecognizer后一定要调用其dispose()方法来释放资源(主要是取消内部的计时器)。

Flutter事件机制

Provider状态管理

https://juejin.cn/post/7015887922117214238#heading-39

数据持久化

Shared_preferences介绍

shared_preferences主要的作用是用于将数据异步持久化到磁盘,因为持久化数据只是存储到临时目录,当app删除时该存储的数据就是消失,web开发时清除浏览器存储的数据也将消失。
支持存储类型:

  • bool
  • int
  • double
  • string
  • stringList

shared_preferences应用场景

持久化用户信息

因为用户信息基本是不改变的,而在一个应用程序中常常会有多个页面需要展示用户信息,我们不可能每次都去获取接口,那么本地持久化就会变得很方便。

持久化列表数据

为了给用户更好的体验,在获取列表数据时我们常常会先展示旧数据,带给用户更好的体验,不至于一打开页面就是空白的,当我们采用持久化列表数据后,可以直接先展示本地数据,当网络数据请求回来后在进行数据更新。

shared_preferences使用的对应类库

  • iOS: NSUserDefaults
  • Android: SharedPreferences
  • Web: localStorage
  • Linux: FileSystem(保存数据到本地系统文件库中)
  • Mac OS: FileSystem(保存数据到本地系统文件库中)
  • Windows: FileSystem(保存数据到本地系统文件库中)

shared_preferences基本使用

  • 安装
shared_preferences: ^2.0.8
  • 导入头文件
import 'package:shared_preferences/shared_preferences.dart';

获取实例对象

SharedPreferences? sharedPreferences = await SharedPreferences.getInstance();

组件之间的通信

子组件向父组件传值

回调函数

  • 使用系统回调函数ValueChanged
typedef ValueChanged<T> = void Function(T value);

当子组件值发生变化时调用回调函数,父组件在使用子组件时通过回调函数接收传过来的值。

  • 自定义回调函数

父组件向子组件传值

直接通过属性

定义父组件变量 data,在子组件Child的构造方法中把data值传进去,子组件接收data。

回调函数

State与widget的通信

在State中监听widget变化

使用didUpdateWidget这个生命周期函数就可以了,在state所依附的widget更新时,就会触发这个回调。但是如果widget中没有属性发生变化,这种方式就无法生效,需要使用自定义Controller的方式

自定义Controller

// 1、定义CountController类,继承自ValueNotifier

class CountController extends ValueNotifier<int> {
  CountController(int value) : super(value);

  // 逐个增加到目标数字
  Future<void> countTo(int target) async {
    int delta = target - value;
    for (var i = 0; i < delta.abs(); i++) {
      await Future.delayed(Duration(milliseconds: 1000 ~/ delta.abs()));
      this.value += delta ~/ delta.abs();
    }
  }

  // 如果没有属性发生改变,通过回调函数传递值。
  void customFunction() {
    _onCustomFunctionCall?.call();
  }

  // 目标state注册这个方法
  Function _onCustomFunctionCall;
}

//2、定义_Row,包含一个controller的实例。
class _Row extends StatefulWidget {
  final CountController controller;
  const _Row({
    Key key,
    @required this.controller,
  }) : super(key: key);

  @override
  __RowState createState() => __RowState();
}

//3、在__RowState中给controller实例添加监听。
class __RowState extends State<_Row> with TickerProviderStateMixin {
  @override
  void initState() {
  	//(1)当value值发生改变时调用
    widget.controller.addListener(() {
      //全局更新
      setState(() {});
    });
    //(2)当value值没有发生改变时,可以通过回调函数传值
    widget.controller._onCustomFunctionCall = () {
      print('响应方法调用');
    };
    super.initState();
  }

  // 这里controller应该是在外面dispose
  // @override
  // void dispose() {
  //   widget.controller.dispose();
  //   super.dispose();
  // }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 60,
      width: double.infinity,
      alignment: Alignment.centerLeft,
      padding: EdgeInsets.only(
        left: 24,
      ),
      child: Text('${widget.controller.value}'),
    );
  }
}

使用controller可以完全控制下一层state的数据和方法调用,比较灵活。但是代码量大,业务中应当避免写这种模式,只在复杂的地方构建controller来控制数据。如果你写了很多自定义controller,那应该反思你的项目结构是不是出了问题。无论如何实现,这种传递方式都不应当是项目中的通用做法。

页面之间通信

Navigator 的then 函数

在实际项目开发中,有一种业务需求就是 页面A 进入页面B ,在页面B中数据发生改变后需要更新页面A 中的内容。
使用 then函数回调,在页面A中以动态路由的方式打开页面TestBPage,并实现 Navigator 的then 函数,then 函数会在 TestBPage 页面关闭时回调。

 void openPageFunction(BuildContext context) {
   ///以动态路由的方式打开
   Navigator.of(context).push(
     MaterialPageRoute(
       builder: (BuildContext context) {
         return TestBPage();
       },
     ),
     ///页面 TestBPage 关闭后会回调 then 函数
     ///其中参数 value 为回传的参数
   ).then((value) {
     if (value != null) {
       setState(() {
         _message = value;
       });
     }
   });
 }
///代码 清单 1-2
 OutlineButton buildOutlineButton(BuildContext context) {
   return OutlineButton(
     child: Text("返回页面 A "),
     onPressed: () {
       String result = "345";
       Navigator.of(context).pop(result);
     },
   );
 }

这一种方法的一个实际应用场所如一个订单的详情页面,打开下一个页面进行操作后,再返回当前页面后需要刷新页面的数据,此种场景就可使用这种方法。
使用 then 函数达成的数据传递或者说页面刷新,对于用户来讲是可见的,就是有时数据刷新的慢点,用户是可以有感觉的,使用ValueNotifier可以达到无感刷新。

ValueNotifier

可以实现页面和组件之间的传智,实现页面的局部刷新。

简单使用

通过点击右下角的按钮,更改 ValueNotifier的value ,会自动触发监听,然后更新UI

在这里插入图片描述

  • 1、定义ValueNotifierData类,继承自ValueNotifier
class ValueNotifierData extends ValueNotifier<String> {
  ValueNotifierData(value) : super(value);
}
  • 2、 定义_WidgetOne,包含一个ValueNotifierData的实例。
class _WidgetOne extends StatefulWidget {
  _WidgetOne({this.data});

  final ValueNotifierData data;

  @override
  _WidgetOneState createState() => _WidgetOneState();
}
  • 3、在_WidgetOneState中给ValueNotifierData实例添加监听。
class _WidgetOneState extends State<_WidgetOne> {
  String info;

  @override
  initState() {
    super.initState();
    // 监听 value 的变化。当value的值发生变化是会触发,实现局部刷新。如果想实现整页刷新,可以调用setState方法。
   	widget.data.addListener(_handleValueChanged);
    info = 'Initial mesage: ' + widget.data.value;
  }

  void _handleValueChanged() {
    setState(() {
      info = 'Message changed to: ' + widget.data.value;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Text(info),
    );
  }

  @override
  dispose() {
    widget.data.removeListener(_handleValueChanged);
    super.dispose();
  }
}
  • 4、在ValueNotifierCommunication组件中实例化_WidgetOne,可以通过改变ValueNotifierData实例的value来触发_WidgetOneState的更新。
class ValueNotifierCommunication extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    ValueNotifierData vd = ValueNotifierData('Hello World');
    return Scaffold(
      appBar: AppBar(
        title: Text('ValueNotifier通信'),
      ),
      body: _WidgetOne(data: vd),
      floatingActionButton: FloatingActionButton(
          child: Icon(Icons.refresh),
          onPressed: () {
            vd.value = 'Yes';
          }),
    );
  }
}

自定义局部更新

如有一个用户数据类型的Model定义如下 :

///实际中变量可能足够的多
class UserInfo {
  String name;
  int age ;
}

我们期望只修改其中的如 age 一个属性的值,此时就需要自定 ValueNotifier

/// UserInfo 为数据类型
class UserNotifier extends ValueNotifier<UserInfo> {

  UserNotifier(UserInfo userInfo): super(userInfo);

  void setName(String name) {
    ///赋值 这里需要注意的是 如果没有给 ValueNotifier 赋值 UserInfo 对象时
    /// value 会出现空指针异常
    value.name =name;
    ///通知更新
    notifyListeners();
  }
}

然后在使用的时候,创建 UserNotifier 如下 :

///第一步
UserNotifier _testUserNotifier = UserNotifier(UserInfo(name: "", age: 0));

构建 ValueListenableBuilder

///第二步 定义
 Widget buildUserListenableBuilder() {
   return ValueListenableBuilder(
     ///数据发生变化时回调
     builder: (context, value, child) {
       return Text("姓名是:${(value as UserInfo ).name}  年龄是: ${(value as UserInfo).age}");
     },
     ///监听的数据
     valueListenable: _testUserNotifier,
   );
 }

当数据变化时进行更新操作

void testUserFunction() {
   _testUserNotifier.setName("李四");
 }

这种应用场景如实际项目开发中的修改用户数据,只修改了其中的一个属性数据,可以考虑使用这种方法,当然有很多情况大家是不考虑这种细节的,直接全部更新的,但是所有的细节综合起来,就解决了 你的应用为什么体验总是差点的问题。

Flutter异步编程

Future

什么是Future

Future表示在接下来的某个时间的值或者错误,借助Future可以实现Flutter异步操作。它类似于ES 6中的Promise。
Future是dart:async包中的一个类,使用它时需要导入dart:async包,Future有两种状态:

  • pending-执行中
  • compelted:执行结束,成功或者失败。

Future的常见用法

  • 使用Future.then获取future的值与捕获future的异常。
  • 结合async、await
  • future.whenComplete
  • future.timeout
future.then获取future的值与捕获future的异常
import 'dart:async'

Future<String> testFuture(){
	//throw new Error();
	return Future.value('success');
	//return Future.error('error');
}
main(){
testFuture().then((s){
	print(s);
},onError:(e){
	print('onError:');
	print(e);
}).catchError((e){
	print('catchError');
	print(e);
})
}

如果catchError与onError同时存在,则只调用onError。

  • Future的then原型:
Future<R> then<R>(FutureOr<R> onValue(T value),{Function onError});

第一个参数会成功的结果回调,第二个参数onError可选表示执行出现异常。

结合async await

Future是异步的,如果我们要将异步转为同步,可以借助async await来完成。

import 'dart:async'

test() async {
	int result = await Future.delayed(Duration(millseconds:2000),(){
	return Future.value(123);
});
print('t3:'+DateTime.now().toString());
}

main() {
	print('t1:' + DateTime.now().toString());
	test();
	print('t2:' + DateTime.now().toString());
}
Future.whenComplete

有时候我们需要在Future结束的时候做些事情,我们知道then().catchError()的模式类似于try-catch,try-catch有个finally代码块,而future.whenComplete就是Future的finally。

import 'dart:async';
import 'dart:math';

void main() {
	var random = Random();
	Future.delayed(Duration(seconds:3),(){
		if(random.nextBool()){
			return 100;
		}else{
			throw 'boom!';	
		}
	}).then(print).catchError(print).whenComplete() {
	print('done!');
	});
}
future.timeout

完成一个异步操作可能需要很长的时间,我们需要为一个异步操作设置一个超时时间

import 'dart:async'
void main() {
	new Future.delayed(new Duration(seconds:3),(){
		return 1;
	}).timeout(new Duration(seconds:2)).then(print).catch(print);
}

Future Builder

什么是FutureBuilder

FutureBuilder是一个将异步操作和异步UI更新结合在一起的类,通过它我们可以将网络请求、数据库读取等的结果更新在页面上。

FutureBuilder构造函数

FutureBuilder({Key key, Future<T> future, T initialData, @requred AsyncWidgetBuilder<T> builder})

  • future: Future对象表示此构建起当前连接的异步操作
  • initialData:表示一个非空的Future完成前的初始化数据
  • builder:AsyncWidgetBuilder类型的回调函数,是一个基于异步交互构建widget的函数。

这个builder函数接受两个参数BuildContext context与AsyncSnapshot snapshot,它返回一个widget。AsyncSnapshot包含异步计算的信息,它具有以下属性:

  • connectionState-枚举ConnectionState的值,表示与异步计算的连接状态,ConnectionState有四个值:none、waiting、active与done;data-异步计算接受的最新的数据。error-异步计算接收的最新错误对象。

Flutter与Navite混合开发

创建Flutter module

在进行混合开发之前需要创建一个Flutter module。
假如你的Native项目是这样的:

 'xxx/flutter_hybrid/Native项目'
cd xxx/flutter_hybrid/
flutter create -t module flutter_module

上面代码切换到你的Anroid/iOS项目的上一级目录,并创建一个flutter模块。
在这里插入图片描述

上面flutter_module的文件结构,里面包含了.android和.ios连个隐藏文件,也是这个flutter_module的宿主工程:

  • .android-flutter_module的Android宿主工程。
  • .iOS - flutter_module的iOS宿主工程
  • lib - flutter_module的Dart部分的代码
  • pubspec.yaml-flutter_module的项目依赖配置文件。

因为宿主工程的存在,这个flutter_module在不佳额外的配置的情况下,通过安装了Flutter与Dart插件的AndroidStudio打开这个flutter_module项目,通过运行按钮可以直接运行它的。

Flutter与Android进行混合开发

为已存在的Android应用添加Flutter module依赖

打开Android项目的setting.gradle添加如下代码:

//FlutterHybridAndroid/settings.gradle
include ':app'
setBinding(new Binding([gradle:this]))
evaluate(new File(
	settingsDir.parentFile,
	'my_flutter/.android/include_flutter.groovy'
))

setBinding与evaluate允许Flutter模块包括它自己在内的任何Flutter插件,在settings.gradle中类似: :flutter、package_info、:video_player的方式存在。

添加Flutter依赖

//FlutterHybridAndroid/app/build.gradle
...
dependencies {
	implementation project(':flutter')
	...
}

在Java中调用FlutterModule

在Java中调用Flutter模块主要有两种方式:

  • 使用Flutter.createWebView API的方式
  • 使用FlutterFragment的方式
使用Flutter.createView API的方式
// FlutterHybridAndroid/app/src/main/java/some/package/MainActivity.java

fab.setOnClickListener(new View.OnClickListener(){
	@Override
	public void onClick(View view) {
		View flutterView = Flutter.createView(
			MainActivity.this,
			getLifecycle(),
			"route1"
		);
		FrameLayout.LayoutParams layout = new FrameLayout.LayoutParams(600,800);
		layout.leftMargin = 100;
		layout.topMargin = 200;
		addContentView(flutterView,layout);	
	}
})
使用FlutterFragment

创建一个FlutterFragment来自己处理生命周期:

fab.setOnClickListener(new View.OnClickListener(){
	@Override
	public void onClick(View view) {
		FragmentTransaction tx = getSupportFragmentManaget().beginTransaction();
		tx.replace(R.id.someContainer,Flutter.createFragment('route1'));
		tx.commit();
	}
})

上面使用字符串"route1"来告诉dart代码在Flutter视图中显示哪个小部件。Flutter项目的lib/main.dart文件需要通过window.defaultRouteName来获取Native指定要显示的路由名,以确定要创建哪个窗口小部件并且传递给runApp:

import 'package:flutter/material.dart';
import 'dart:ui';
void main() => runApp(_widgetForRoute(window.defaultRouteName));

Widget _widgetForRoute(String route) {
  switch (route) {
    case 'route1': {
      return SomeWidget(...);
    }
    case 'route2': {
      return SomeWidget(...);
    }
  }
}
调用Flutter module时传递数据

无论是通过Flutter.createView的方式还是通过Flutter.createFragment的方式,都允许我们加载Flutter module时传递一个String类型的initialRoute参数,从参数名字它是用作路由名的,但是我们可以通过它传递一些参数。

FragmentTransaction tx = getSupportFragmentManager().beginTransaction();
tx.replace(R.id.someContainer,Flutter.createFragment("{name:'hello'}"))
tx.commit

然后在Flutter module中通过如下方式获取:

import 'dart:ui'; //要使用windwo对象必须导入

String initParams = window.defaultRouteName;
序列化成Dart obj

Flutter项目架构搭建

项目初始化

  • 创建项目
flutter create myproject
  • 创建lib目录下增加目录: pages、widgets、providers、routes、assets、utils、api、contants。

屏幕适配

适配原理

  • 设计尺寸(初始化指定 - 一般是设计师出图的尺寸,是可以预先知道的)
    designWidth: 750 px
    designHeight: 1334 px

  • 终端尺寸
    deviceWidth: 1080 px
    deviceHeight: 1920 px

  • 缩放比例
    scaleWidth = deviceWidth / designWidth
    scaleHeight = deviceHeight / designHeight
    明确了缩放比例后,我们就可以适配终端了。

例如:终端宽度是 1080 px,此时,如果声明 50% 的宽度,可以写成 375.w(因为设计尺寸的宽度是
750 px,所以 50% 的宽度就是 375),375.w 会根据缩放比例,计算出实际终端的宽度。计算公式为:
实际宽度的 50% = 375 X scaleWidth = 375 X (1080 / 750) = 540。

flutter_screenutil

flutter_screenutil 是用来解决屏幕适配的包。
flutter_screenutil 的工作原理是:在具体设备上,把原型图的尺寸,等比例放大或缩小。

具体用法:

https://github.com/OpenFlutter/flutter_screenutil/blob/master/README_CN.md

搭建基本UI骨架

在这里插入图片描述

  • 在pages目录下面建立一个index_page.dart。
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:xxxxxxx/pages/enablingTool/enabling_tool_page.dart';
import 'package:xxxxxxx/pages/helpAsk/help_ask_page.dart';
import 'package:xxxxxxx/pages/medicalService/medical_service_page.dart';
import 'package:xxxxxxx/pages/mine/mine_page.dart';
import 'package:xxxxxxx/pages/learn/learn_page.dart';


class IndexPage extends StatefulWidget {
  const IndexPage({Key? key}) : super(key: key);
  @override
  State<StatefulWidget> createState() {
    return _IndexPageState();
  }
}

class _IndexPageState extends State<IndexPage> {
  int _currentIndex = 2;
  PageController? _pageController;
  final List<BottomNavigationBarItem> bottomNaviItems = [
    const BottomNavigationBarItem(
      label: "帮问",
      icon: Icon(Icons.home),
      backgroundColor: Colors.black,
    ),
    const BottomNavigationBarItem(
      label: "帮诊",
      icon: Icon(Icons.home),
      backgroundColor: Colors.black,
    ),
    const BottomNavigationBarItem(
      label: "人设",
      icon: Icon(Icons.home),
      backgroundColor: Colors.black,
    ),
    const BottomNavigationBarItem(
      label: "学习",
      icon: Icon(Icons.home),
      backgroundColor: Colors.black,
    ),
    const BottomNavigationBarItem(
      label: "管理",
      icon: Icon(Icons.home),
      backgroundColor: Colors.black,
    ),
  ];
  final List pages = [
    {
      "appBar" : AppBar(
        title: const Text("帮问"),
        centerTitle: true,
        elevation: 0,
      ),
      "page":const HelpAskPage()
    },
    {
      "appBar" : AppBar(
        title: const Text("帮诊"),
        centerTitle: true,
        elevation: 0,
      ),
      "page":const MedicalServicePage()
    },
    {
      "appBar" : AppBar(
        title: const Text("人设"),
        centerTitle: true,
        elevation: 0,
      ),
      "page":const EnablingToolPage()
    },
    {
      "appBar" : AppBar(
        title: const Text("学习"),
        centerTitle: true,
        elevation: 0,
      ),
      "page":const LearnPage()
    },
    {
      "appBar" : AppBar(
        title: const Text("管理"),
        centerTitle: true,
        elevation: 0,
      ),
      "page":const MinePage()
    },
  ];

  //创建底部TabBar
  BottomNavigationBar _createBottomNavigationBar() {
    return BottomNavigationBar(
        items: bottomNaviItems,
        selectedItemColor: Colors.orange,
        currentIndex: _currentIndex,
        type: BottomNavigationBarType.fixed,
        onTap: _onTap,
      );
  }
  // 点击事件
  void _onTap(int index){
    setState(() {
      _currentIndex = index;
      _pageController?.jumpToPage(_currentIndex);
    });
  }

  @override
  void initState() {
    _pageController = PageController(
      initialPage: _currentIndex,
    );
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: pages[_currentIndex]["appBar"],
      body: PageView(
        controller: _pageController,
        allowImplicitScrolling:true, //开启缓存,会缓存前后两页
        children: pages.map<Widget>((e) => e["page"]).toList(),
      ),
      bottomNavigationBar: _createBottomNavigationBar(),
    );
  }

企业级路由管理工具Fluro

  • 安装fluro:
    https://pub.dev/packages/fluro/install

二次封装思路一

1、声明路由处理器

在routes目录下面创建routes_handler.dart。

import 'package:flutter/material.dart';
import 'package:fluro/fluro.dart';
import 'package:xxxxxxx/pages/enablingTool/enabling_tool_page.dart';
import 'package:xxxxxxx/pages/helpAsk/help_ask_page.dart';
import 'package:xxxxxxx/pages/index_page.dart';
import 'package:xxxxxxx/pages/learn/learn_page.dart';
import 'package:xxxxxxx/pages/medicalService/medical_service_page.dart';
import 'package:xxxxxxx/pages/mine/mine_page.dart';
import 'package:xxxxxxx/pages/not_found_page.dart';
import 'package:xxxxxxx/pages/sign/login_page.dart';


var notFoundHandler = Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
  return const NotFoundPage();
});


var indexHandler = Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
  return const IndexPage();
});

//帮问
var helpAskHandler = Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
  return const HelpAskPage();
});

//帮诊
var medicalServiceHandler = Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
  return const MedicalServicePage();
});

//人设
var enablingToolsHandler = Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
  return const EnablingToolPage();
});

//学习
var learnHandler = Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
  return const LearnPage();
});

//管理
var mineHanlder = Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
  return const MinePage();
});

//登录
var loginHandler = Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
  return const LoginPage();
});
2、声明路由

在routes目录下面,创建routes.dart声明路由。

import 'package:flutter/material.dart';
import 'package:fluro/fluro.dart';
import 'package:xxxxxxx/utils/global.dart';
import 'package:xxxxxxx/routes/routes_handler.dart';

class Routes {
  static const String root = "/";
  static const String helpAsk = "/helpAsk";
  static const String medicalService = "/medicalService";
  static const String enablingTool = "/enablingTool";
  static const String learn = "/learn";
  static const String mine = "/mine";
  static const String login = "/login";

  static void configureRoutes(FluroRouter router) {
    router.define(root, handler: indexHandler);
    router.define(helpAsk, handler: helpAskHandler);
    router.define(medicalService, handler: medicalServiceHandler);
    router.define(enablingTool, handler: enablingToolsHandler);
    router.define(learn, handler: learnHandler);
    router.define(mine, handler: mineHanlder);
    router.define(login, handler: loginHandler);
    router.notFoundHandler = notFoundHandler;
  }
 }
3、封装fluro路由跳转工具类

在routes目录下面创建navigator_utls.dart。

import 'package:flutter/material.dart';
import 'package:fluro/fluro.dart';
import 'package:xxxxxxx/routes/routes.dart';

//fluro路由跳转工具类
class NavigatorUtls {
  static void push(BuildContext context, String path,
      {bool replace = false, bool clearStack = false, Object? arguments}) {
    unfocus();
    Routes.router.navigateTo(context, path,
      replace: replace,
      clearStack: clearStack,
      transition: TransitionType.native,
      routeSettings: RouteSettings(
        arguments: arguments,
      ),
    );
  }

  static void pushResult(BuildContext context, String path, Function(Object) function,
      {bool replace = false, bool clearStack = false, Object? arguments}) {
    unfocus();
    Routes.router.navigateTo(context, path,
      replace: replace,
      clearStack: clearStack,
      transition: TransitionType.native,
      routeSettings: RouteSettings(
        arguments: arguments,
      ),
    ).then((Object? result) {
      // 页面返回result为null
      if (result == null) {
        return;
      }
      function(result);
    }).catchError((dynamic error) {
      print('$error');
    });
  }

  /// 返回
  static void goBack(BuildContext context) {
    unfocus();
    Navigator.pop(context);
  }

  /// 带参数返回
  static void goBackWithParams(BuildContext context, Object result) {
    unfocus();
    Navigator.pop<Object>(context, result);
  }
  
  /// 跳到WebView页
  static void goWebViewPage(BuildContext context, String title, String url) {
    //fluro 不支持传中文,需转换
    push(context, '${Routes.webViewPage}?title=${Uri.encodeComponent(title)}&url=${Uri.encodeComponent(url)}');
  }

  static void unfocus() {
    // 使用下面的方式,会触发不必要的build。
    // FocusScope.of(context).unfocus();
    // https://github.com/flutter/flutter/issues/47128#issuecomment-627551073
    FocusManager.instance.primaryFocus?.unfocus();
  }
}

二次封装思路二

每个功能模块管理自己的路由文件。

1、在routes目录下面创建抽象类i_router.dart。
import 'package:fluro/fluro.dart';

abstract class IRouterProvider {
  void initRouter(FluroRouter router);
}
2、在每个功能模块下实现该抽象类

比如Sign模块

import 'package:flutter/material.dart';
import 'package:fluro/fluro.dart';
import 'package:xxxxxxx/routes/i_router.dart';
import 'package:xxxxxxx/pages/sign/login_page.dart';
import 'package:xxxxxxx/pages/sign/register_page.dart';

class SignRouter implements IRouterProvider {
  static const String login = "/login";
  static const String regster = "/register";
  @override
  void initRouter(FluroRouter router) {
      router.define(
        login, 
        handler: Handler(
          handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
            return const LoginPage();
          }
        )
      );

       router.define(
        regster, 
        handler: Handler(
          handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
            return const RegisterPage();
          }
        )
      );
  }
  
}
3、集中处理各个模块的路由

在routes目录下创建routes.dart

import 'package:flutter/material.dart';
import 'package:fluro/fluro.dart';
import 'package:xxxxxxx/pages/index_page.dart';
import 'package:xxxxxxx/pages/not_found_page.dart';
import 'package:xxxxxxx/pages/sign/sign_router.dart';
import 'package:xxxxxxx/pages/webview_page.dart';
import 'package:xxxxxxx/routes/i_router.dart';


class Routes {

  static final FluroRouter router = FluroRouter();
  static final List<IRouterProvider> _listRouter = [];

  static const String webViewPage = "/webview";
  static const String root = "/";

  static void configureRoutes() {
    //指定路由跳转错误返回页面
    router.notFoundHandler = Handler(handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
      return const NotFoundPage();
    });
    //
    router.define(
      root, 
      handler: Handler(
        handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
          return const IndexPage();
        }
      )
    );
    //指定webview
    router.define(
      webViewPage, 
      handler: Handler(
        handlerFunc: (BuildContext? context, Map<String, List<String>> parameters){
          final String? title = parameters['title']?.first;
          final String? url = parameters['url']?.first;
          return WebViewPage(title: title,url: url ?? "");
        }
      )
    );

    _listRouter.clear();
    //添加各个功能模块的路由,统一在这里进行初始化操作
    _listRouter.add(SignRouter()); //登录注册相关

    //初始化路由
    for (var routerProvider in _listRouter) { 
      routerProvider.initRouter(router);
    }
  }
}
4、封装fluro路由跳转工具类

和思路一种是一样的。

状态管理Provider

安装Provider

https://pub.dev/packages/provider/install

创建数据模型

可以在每个模块中创建数据模型,也可以放在一个文件夹下集中管理。

import 'package:flutter/material.dart';
import 'dart:convert';

class SignProvider with ChangeNotifier {
  bool _isLogin = false;
  Map _user = {};
  Map _userInfo = {};

  bool get isLogin => _isLogin;
  Map get user => _user;
  Map get userInfo => _userInfo;

  doLogin(data) {
    if (data != false) {
      _isLogin = true;
      _user = json.decode(data);

      notifyListeners();
    }
  }

  doLogout() {
    _isLogin = false;
    _user = {};
    _userInfo = {};

    notifyListeners();
  }

  // 获取用户信息后,给状态赋值
  setUserInfo(data) {
    _userInfo = data;
  }
}

注册事件模型

void main() {
  Routes.configureRoutes();
  
  runApp( 
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => SignProvider()),
      ],
      child: const MyApp(),
    ),);
}

在具体组件中使用Provider中的数据。

访问 Provider 时,有两种方式:监听和取消监听

监听

监听方法只能用来 [StatelessWidget.build] 和 [State.build] 中使用。监听值发生变化时,会重建组件。

Provider.of<T>(context) // 语法糖是: context.watch<T>(context)
取消监听

取消监听,不能在 [StatelessWidget.build] 或 [State.build] 中使用;换句话说,它可以在上
述两个方法之外的所有方法中使用。监听值发生变化时,不会重建组件。

Provider.of<T>(context, listen: false) // 语法糖是: context.read<T> (context)
访问数据
Provider.of<CurrentIndexProvider>(context).currentIndex;
访问方法
Provider.of<CurrentIndexProvider>(context, listen: false).changeIndex(index)

网络请求

Http

flutter社区的一款网络请求的框架。

Dio

安装 Dio:https://pub.dev/packages/dio/install
报错: Insecure HTTP is not allowed by platform
原因:平台不支持不安全的 HTTP 协议,即不允许访问 HTTP 域名的地址。

  • Android解决
    打开 android/app/src/main/AndroidManifest.xml
   <uses-permission android:name="android.permission.INTERNET" /> <!-- 添加这 一行 -->

   <application
        android:label="flutter_project"
        android:usesCleartextTraffic="true"  <!-- 添加这 一行 -->


  • iOS解决
    打开 ios/Runner/Info.plist。添加如下代码:
<key>NSAppTransportSecurity</key> 
	<dict>
		<key>NSAllowsArbitraryLoads</key> 
		<true/> 
	</dict>

网络层封装

JSON处理

在dart中有个内置的json解析器: dart:convert,对于较小项目可以借助它来进行手动JSON序列化。

  • json转map
import 'dart:convert'
....
const jsonString = "{\"name\":\"fluttter\",\"\url":\"https://coding.imooc.com/class/487.html\"}";
Map<String,dynamic> jsonMap = jsonDecode(jsonString);

  • map转json
String json = jsonEncode(jsonMap);

方法一: 手写实体类

class Owner {
	String name;
	String face;
	int fans;
	
	Category({this.name,this.face,this.fans});
	
	//将map转成实体类
	Owner.fromJson(Map<String,dynamic> json) {
		name = json['name'];
		face = json['face'];
		fans = json['fans'];
	}
	
	//将实体类转换成Map
	Map<String,dynamic> toJson() {
		final Map<String,dynamic>data = Map();
		data['name'] = this.name;
		data['face'] = this.face;
		data['fans'] = this.fans;
		
		return data;
	}
	
}

方法二 网页生成

在线转换:
https://www.devio.org/io/tools/json-to-dart/

第三方库 json_serializable

1、安装相关插件
dependencies:
...
	dio: ^4.0.1
	json_annotation: ^4.3.0

dev_dependencies:
...
	json_serializable: ^6.0.1
	build_runner: ^2.1.4
2、配置实体类:
  • 创建Result实体类
import 'package:json_annotation/json_annotation.dart';

//user.g.dart将在我们运行生成命令后自动生成。
//固定格式,报错不要怕
part 'result.g.dart';

///这个标注告诉生成器,这类是需要Model类的。
@JsonSerializable()
class Result {
	//定义构造方法
	Result(this.cdoe,this.method,this.requestPrams);	
	
	//定义字段
	int code;
	String method;
	String requestPrams;
	
	//固定格式,不同的类使用不同的mixin即可。
	factory Result.fromJson(Map<String,dynamic> json)=>_$ResultFromJson(json);
	
	//固定格式
	Map<String,dynamic> toJson()=>_$ResultToJson(this);
}
3、执行build生成实体类
flutter packages pub run build_runner build;

执行完之后result.g.dart会生成两个解析方法

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'result.g.dart';

// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************

Result _$ResultFromJson(Map<String, dynamic> json) =>
    PartnerConfigModel(
      json['code'] as int,
      json['method'] as String,
      json['requestPrams'] as String,
    );

Map<String, dynamic> _$ResultToJson(Result instance) =>
    <String, dynamic>{
      'code': instance.code,
      'method': instance.method,
      'requestPrams': instance.requestPrams,
    };
处理enum类型的数据

如果下发的字段时性别,可以定义成枚举类型,通过@JsonValue命令标记枚举的值。

class UserModel {
	...,
	Sex? sex,
}
enum Sex {
  @JsonValue(1)
  unknow,
  @JsonValue(2)
  man,
  @JsonValue(3)
  female,
}

json_serializable工具会自动处理枚举值enum。

本地缓存 shared_preferences

安装: shared_preferences: ^2.0.8

封装

import 'dart:async';
import 'dart:convert';

import 'package:shared_preferences/shared_preferences.dart';
import 'package:synchronized/synchronized.dart';

/// https://github.com/Sky24n/sp_util
class CacheUtils {
  static CacheUtils? _singleton;
  static SharedPreferences? _prefs;
  static Lock _lock = Lock();

  static Future<CacheUtils?> getInstance() async {
    if (_singleton == null) {
      await _lock.synchronized(() async {
        if (_singleton == null) {
          // keep local instance till it is fully initialized.
          // 保持本地实例直到完全初始化。
          var singleton = CacheUtils._();
          await singleton._init();
          _singleton = singleton;
        }
      });
    }
    return _singleton;
  }

  CacheUtils._();

  Future _init() async {
    _prefs = await SharedPreferences.getInstance();
  }

  /// put object.
  static Future<bool>? putObject(String key, Object value) {
    return _prefs?.setString(key, json.encode(value));
  }

  /// get obj.
  static T? getObj<T>(String key, T f(Map v), {T? defValue}) {
    Map? map = getObject(key);
    return map == null ? defValue : f(map);
  }

  /// get object.
  static Map? getObject(String key) {
    String? _data = _prefs?.getString(key);
    return (_data == null || _data.isEmpty) ? null : json.decode(_data);
  }

  /// put object list.
  static Future<bool>? putObjectList(String key, List<Object> list) {
    List<String>? _dataList = list.map((value) {
      return json.encode(value);
    }).toList();
    return _prefs?.setStringList(key, _dataList);
  }

  /// get obj list.
  static List<T>? getObjList<T>(String key, T f(Map v),
      {List<T>? defValue = const []}) {
    List<Map>? dataList = getObjectList(key);
    List<T>? list = dataList?.map((value) {
      return f(value);
    }).toList();
    return list ?? defValue;
  }

  /// get object list.
  static List<Map>? getObjectList(String key) {
    List<String>? dataLis = _prefs?.getStringList(key);
    return dataLis?.map((value) {
      Map _dataMap = json.decode(value);
      return _dataMap;
    }).toList();
  }

  /// get string.
  static String? getString(String key, {String? defValue = ''}) {
    return _prefs?.getString(key) ?? defValue;
  }

  /// put string.
  static Future<bool>? putString(String key, String value) {
    return _prefs?.setString(key, value);
  }

  /// get bool.
  static bool? getBool(String key, {bool? defValue = false}) {
    return _prefs?.getBool(key) ?? defValue;
  }

  /// put bool.
  static Future<bool>? putBool(String key, bool value) {
    return _prefs?.setBool(key, value);
  }

  /// get int.
  static int? getInt(String key, {int? defValue = 0}) {
    return _prefs?.getInt(key) ?? defValue;
  }

  /// put int.
  static Future<bool>? putInt(String key, int value) {
    return _prefs?.setInt(key, value);
  }

  /// get double.
  static double? getDouble(String key, {double? defValue = 0.0}) {
    return _prefs?.getDouble(key) ?? defValue;
  }

  /// put double.
  static Future<bool>? putDouble(String key, double value) {
    return _prefs?.setDouble(key, value);
  }

  /// get string list.
  static List<String>? getStringList(String key,
      {List<String>? defValue = const []}) {
    return _prefs?.getStringList(key) ?? defValue;
  }

  /// put string list.
  static Future<bool>? putStringList(String key, List<String> value) {
    return _prefs?.setStringList(key, value);
  }

  /// get dynamic.
  static dynamic getDynamic(String key, {Object? defValue}) {
    return _prefs?.get(key) ?? defValue;
  }

  /// have key.
  static bool? haveKey(String key) {
    return _prefs?.getKeys().contains(key);
  }

  /// contains Key.
  static bool? containsKey(String key) {
    return _prefs?.containsKey(key);
  }

  /// get keys.
  static Set<String>? getKeys() {
    return _prefs?.getKeys();
  }

  /// remove.
  static Future<bool>? remove(String key) {
    return _prefs?.remove(key);
  }

  /// clear.
  static Future<bool>? clear() {
    return _prefs?.clear();
  }

  /// Fetches the latest values from the host platform.
  static Future<void>? reload() {
    return _prefs?.reload();
  }

  ///Sp is initialized.
  static bool isInitialized() {
    return _prefs != null;
  }

  /// get Sp.
  static SharedPreferences? getSp() {
    return _prefs;
  }
}

登录处理

获取验证码

在这里插入图片描述
在这里插入图片描述

import 'dart:async';
import 'package:flutter/material.dart';

class CountdownButtonWidget extends StatefulWidget {
  /// 倒计时的秒数,默认60秒。
  final int countdown;

  /// 用户点击时的回调函数。
  final Function? onTapCallback;
  final double width;
  final double height;
  final Color normalBackgroundColor;
  final Color disabledBackgroundColor;
  final Color textColor;
  final double borderRadius;

  const CountdownButtonWidget(
      {Key? key,
      this.countdown = 60,
      this.width = 120,
      this.height = 40,
      this.normalBackgroundColor = const Color(0xFF2a65eb),
      this.disabledBackgroundColor = Colors.grey,
      this.textColor = Colors.white,
      this.borderRadius = 5,
      this.onTapCallback})
      : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return _CountdownButtonWidgetState();
  }
}

class _CountdownButtonWidgetState extends State<CountdownButtonWidget> {
  int _seconds = 60;
  Timer? _timer;
  String _countdownMessage = "";
  int _index = 0;

  @override
  void initState() {
    _seconds = widget.countdown;
    super.initState();
  }

  void _startTimer() {
    _index = 1;
    _countdownMessage = "重新获取${_seconds}s";
    setState(() {});

    const duration = Duration(seconds: 1);
    _timer = Timer.periodic(duration, (timer) {
      if (_seconds == 0) {
        _cancelTimer();
        _seconds = widget.countdown;
        _index = 0;
        setState(() {}); //刷新页面
        return;
      }
      _seconds--;
      _countdownMessage = "重新获取${_seconds}s";
      setState(() {}); //刷新页面
    });
  }

  void _cancelTimer() {
    _timer?.cancel();
    _timer = null;
  }

  Widget _createNormalWidget() {
    return InkWell(
      child: Container(
          alignment: Alignment.center,
          decoration: BoxDecoration(
              borderRadius: BorderRadius.circular(widget.borderRadius),
              color: widget.normalBackgroundColor),
          width: widget.width,
          height: widget.height,
          child: Text(
            "获取验证码",
            style: TextStyle(color: widget.textColor),
          )),
      onTap: () {
        _startTimer();
        if (widget.onTapCallback != null) {
          widget.onTapCallback!();
        }
      },
    );
  }

  Widget _createSelectedWidget() {
    return InkWell(
      child: Container(
        width: widget.width,
        height: widget.height,
        alignment: Alignment.center,
        child: Text(
          _countdownMessage,
          style: TextStyle(color: widget.textColor),
        ),
        decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(widget.borderRadius),
            color: widget.disabledBackgroundColor),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return IndexedStack(
      index: _index,
      children: [_createNormalWidget(), _createSelectedWidget()],
    );
  }
}

打开第三方App

在开发的过程中经常会有打开浏览器、地图等第三方软件的需求。pub.dev 提供了加载网页的插件url_launcher;所谓的插件也是用安卓和苹果原生代码实现的,对插件的代码进行解压可以看到。

安装:

dependencies:
  url_launcher: ^6.0.12

拍照及从图库选择图片处理

安装:

dependencies:
  image_picker: ^0.8.4+4

1、获取图片需要对安卓和iOS做一些图片相关的权限配置。参考官网配置。

2、插件需要兼容Androidx,具体兼容Androidx的步骤:

1.搜索
在这里插入图片描述
在这里插入图片描述
具体操作步骤:https://flutter.dev/docs/development/androidx-migration

轮播图处理

dependencies:
  flutter_swiper: ^1.1.6

处理HTML

HTML 不能直接在 Flutter 中展示,因此,我们需要将
HTML 代码,转成 Flutter 支持的 Dart 代码。

flutter_html

https://pub.dev/packages/flutter_html

 flutter_html: ^2.1.5

遇到的一些问题

flutter int类型取值范围注意

在java语言中,整型的数据类型有int和long类型;
int数据范围:-2^31 ~ 2^31-1,
long数据范围:-2^63 ~ 2^63-1;
而flutter dart语言没有long类型,并且dart int类型数据范围也不是和java int取值范围(-2^31 ~ 2^31-1)一样;

flutter dart语言的数据范围是-2^53 ~ 2^53,也就是-9007199254740992~9007199254740992,比java int取值范围大多了,一般flutter想要用到long类型可以直接用int类型来替代,但需要注意这里的int取值范围并不完全等于java中的long类型,注意边界问题;

flutter dart int类型取值范围容易发生的坑是在开发和原生通讯的插件时,比如在flutter 层定义int类型传给原生没越界,原生用int类型接收就可能越界了,可以参考插件化开发之flutter和原生颜色传输遇到的坑。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值