一、Widget
Flutter设计思想,Everything is Widget。
Widget 是一个比较宽泛的概念,无论基本部件、布局、还是手势等都是 Widget。
它是对视图的一种包含配置及状态信息的“描述数据”,用于约束具体的视图元素
Widget树
StatelessWidget
StatelessWidget一旦创建就无法进行修改,这意味着它不会因为外部条件变化而重新绘制。
其生命周期:
- 1、初始化
- 2、通过build()方法进行渲染。
StatefulWidget
它可以在其生命周期中操作内部持有数据的变化,这些数据被称为State,这样的Widget也叫做StatefulWidget。
-
createState()
:StatefulWidget类中提供了新的接口,用于创建State;
例:
class RoutePageA extends StatefulWidget {
@override
RoutePageAState createState() => RoutePageAState();
}
class RoutePageAState extends State {
@override
Widget build(BuildContext context) {
// TODO: implement build
return Text("abc" );
}
}
State
一个StatefulWidget类会对应一个State类,State表示与其对应的StatefulWidget要维护的状态,State中的保存的状态信息可以:
1、在widget 构建时可以被同步读取。
2、在widget生命周期中可以被改变,当State被改变时,可以手动调用其
setState()
方法通知Flutter framework状态发生改变,Flutter framework在收到消息后,会重新调用其build方法重新构建widget树,从而达到更新UI的目的。
State中有两个常用属性:
1、widget,它表示与该State实例关联的widget实例,由Flutter framework动态设置。注意,这种关联并非永久的,因为在应用生命周期中,UI树上的某一个节点的widget实例在重新构建时可能会变化,但State实例只会在第一次插入到树中时被创建,当在重新构建时,如果widget被修改了,Flutter framework会动态设置State.widget为新的widget实例。
2、context, StatefulWidget对应的BuildContext
StatefulWidget生命周期
State作为StatefulWidget的主体,它可以在多个节点对State进行调整。
其生命周期:
initState()
:当Widget第一次插入到Widget树时会被调用,在生命周期中只被调用一次。重写该方法主要完成一些初始化工作。注意:在该方法中context对象可以访问但并不能拿来使用,因为此时state与context没有建立关联。didChangeDependencies()
:当State对象的依赖发生变化时会被调用;例如:在之前build() 中包含了一个InheritedWidget,然后在之后的build() 中InheritedWidget发生了变化,那么此时InheritedWidget的子widget的didChangeDependencies()回调都会被调用build()
:在didChangeDependencies()
、didUpdateWidget()
和setState()
之后执行,主要用于构建Widget子树(类似RN中render()
方法)。reassemble()
:此回调专门为开发调试而提供,在热重载时会被调用,此回调在Release模式下永远不会被调用。didUpdateWidget()
:在widget重新构建时,Flutter framework会调用Widget.canUpdate来检测Widget树中同一位置的新旧节点,然后决定是否需要更新,如果Widget.canUpdate返回true则会调用此回调。正如之前所述,Widget.canUpdate会在新旧widget的key和runtimeType同时相等时会返回true,也就是说在在新旧widget的key和runtimeType同时相等时didUpdateWidget()就会被调用。deactivate()
:当State对象从树中被移除时,会调用此回调,在一些场景下,Flutter framework会将State对象重新插到树中,如包含此State对象的子树在树的一个位置移动到另一个位置时(可以通过GlobalKey来实现)。如果移除后没有重新插入到树中则紧接着会调用dispose()方法。dispose()
:当State对象从树中被永久移除时调用;通常在此回调中释放资源
[图片上传失败...(image-64ecb1-1629883919829)]
Widget构造函数
例:
const WidgetPage({
Key? key,
required this.text,
this.backgroundColor: Colors.grey,
this.fontSize: 40,
this.child,
}) : super(key: key);
final String text;
final Color backgroundColor;
final double fontSize;
final Widget? child;
调用:
return WidgetPage(
text: "Widget Demo",
fontSize: 100,//非必要参数
);
widget的构造函数应使用命名参数
命名参数中必要的参数要加required
在继承Widget时,第一个参数通常为Key,主要要的作用是决定是否在下一次build时复用旧的widget,决定的条件在canUpdate()方法中。
如果需要接收子widget,child或children参数通常放在最后
widget的属性尽可能的被声明为final,防止被意外改变
BuildContext
Context 仅仅是已创建的所有 Widget 树结构中某个 Widget 的位置引用。
BuildContext是Widget树结构中每个Widget的上下文环境,每个BuildContext都只属于一个Widget。
如果Widget A和它的子Widget B,则Widget A的BuildContext是Widget B的BuildContext的父context。
context 提供了一些方法,比如冲当前widget开始向上遍历widget树以及按照类型查找widget的方法。
class ContextRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Context测试"),
),
body: Container(
child: Builder(builder: (context) {
// 在Widget树中向上查找最近的父级`Scaffold` widget
Scaffold scaffold = context.findAncestorWidgetOfExactType();
// 直接返回 AppBar的title, 此处实际上是Text("Context测试")
return (scaffold.appBar as AppBar).title;
}),
),
);
}
}
在flutter中我们经常会使用到这样的代码
//打开一个新的页面
Navigator.of(context).push
//打开Scaffold的Drawer
Scaffold.of(context).openDrawer
//获取display1样式文字主题
Theme.of(context).textTheme.display1
of(context)实际上对context跨组件获取数据的一个分装。
需要注意的是,在 State 中 initState阶段是无法跨组件拿数据的,只有在didChangeDependencies之后才可以使用这些方法。
二、基础组件
flutter官方提供了一些组件,如下官网截图。
文本
Text
Text
用于显示简单样式文本
Text(
"文本",
textAlign: TextAlign.left,
overflow: TextOverflow.ellipsis,
maxLines: 1,
textScaleFactor: 1.5,
style: TextStyle(
color: Colors.cyan,
fontSize: 20,
),
);
- textAlign:文本的对齐方式
- maxLines:文本显示最大行数
- overflow:指定截断方式
- textScaleFactor:代表文本相对于当前字体大小的缩放因子
- style:通过TextStyle设置文本样式
TextSpan
如果我们需要对一个Text内容的不同部分按照不同样式显示,这时可以使用TextSpan,它代表文本的一个"片断"
其定义如下:
const TextSpan({
TextStyle style,
Sting text,
List children,
GestureRecognizer recognizer,
});
例:
Text.rich(TextSpan(children: [
TextSpan(
text: "Hello",
style: TextStyle(
color: Colors.blue,
)),
TextSpan(
text: " World",
style: TextStyle(
color: Colors.red,
),
recognizer: _tapSpanText
..onTap = () {
print("SpanTextAction");
}),
]));
通过Text.rich
方法将TextSpan
添加到Text中,之所以可以这样做,是因为Text其实就是RichText
的一个包装,而RichText
是可以显示多种样式(富文本)的widget
- text:文本
- style:文本样式
- recognizer:手势
DefaultTextStyle
在Widget树中,文本的样式默认是可以被继承的,因此,如果在Widget树的某一个节点处设置一个默认的文本样式,那么该节点的子树中所有文本都会默认使用这个样式。
例:
DefaultTextStyle(
//1.设置文本默认样式
style: TextStyle(
color: Colors.red,
fontSize: 20.0,
),
child: Column(
children: [
Text("hello world"),
Text("I am Jack"),
Text(
"I am Jack",
style: TextStyle(
inherit: false, //2.不继承默认样式
color: Colors.grey),
),
],
),
);
-
DefaultTextStyle
通过style
设置了默认样式,其子Text都继承其样式 - 例中最后一个Text使用
inherit: false
设置不继承默认样式
按钮
Material组件库中的按钮
TextButton
简单的扁平按钮,按下后,会有背景色
TextButton(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 20),
),
child: Text("TextButton"),
onPressed: () => print("TextButton"),
)
-
onPressed: null
会自动显示不可点击样式
IconButton
可点击的Icon,不包括文字,默认没有背景,点击后会出现背景
IconButton(
onPressed: () => print("IconButton"),
icon: Icon(Icons.thumb_up),
);
FloatingActionButton
悬浮按钮,圆形、有阴影
FloatingActionButton(
child: Text("漂"),
tooltip: "点我",
onPressed: () => print("FloatingActionButton"));
}
ElevatedButton
漂浮按钮,它默认带有阴影和灰色背景
Column(
children: [
ElevatedButton(
child: Text("漂"), onPressed: () => print("ElevatedButton")),
ElevatedButton(child: Text("漂"), onPressed: null),
],
);
-
onPressed: null
会自动显示不可点击样式
OutlineButton
默认有一个边框,不带阴影且背景透明。按下后,边框颜色会变亮、同时出现背景和阴影(较弱)
OutlinedButton(
onPressed: () => print("OutlinedButton"),
child: Text("OutlinedButton")
);
图片
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
- 3、加载图片
Image(
image: AssetImage("images/avatar.png"),
);
在网络中加载图片
Image(
image: NetworkImage(
"https://avatars2.githubusercontent.com/u/20411648?s=460&v=4"),
)
Image也提供了一个快捷的构造函数Image.network用于从网络加载、显示图片:
Image.network(
"https://avatars2.githubusercontent.com/u/20411648?s=460&v=4",
)
参数:
-
image
: 它对应一个ImageProvider
-
width、height
:设置图片宽高 -
fit
:图片显示适应模式,在BoxFit
中定义
fill:会拉伸填充满显示空间,图片本身长宽比会发生变化,图片会变形。
cover:会按图片的长宽比放大后居中填满显示空间,图片不会变形,超出显示空间部分会被剪裁。
contain:这是图片的默认适应规则,图片会在保证图片本身长宽比不变的情况下缩放以适应当前显示空间,图片不会变形。
fitWidth:图片的宽度会缩放到显示空间的宽度,高度会按比例缩放,然后居中显示,图片不会变形,超出显示空间部分会被剪裁。
fitHeight:图片的高度会缩放到显示空间的高度,宽度会按比例缩放,然后居中显示,图片不会变形,超出显示空间部分会被剪裁。
none:图片没有适应策略,会在显示空间内显示图片,如果图片比显示空间大,则显示空间只会显示图片中间部分。
-
color
和colorBlendMode
:在图片绘制时可以对每一个像素进行颜色混合处理,color指定混合色,而colorBlendMode指定混合模式 -
repeat
:重复规则
输入框
Material组件库中提供了输入框组件TextField和表单组件Form。
TextField
TextField用于文本输入,它提供了很多属性,如下:
const TextField({
...
TextEditingController controller,
FocusNode focusNode,
InputDecoration decoration = const InputDecoration(),
TextInputType keyboardType,
TextInputAction textInputAction,
TextStyle style,
TextAlign textAlign = TextAlign.start,
bool autofocus = false,
bool obscureText = false,
int maxLines = 1,
int maxLength,
bool maxLengthEnforced = true,
ValueChanged onChanged,
VoidCallback onEditingComplete,
ValueChanged onSubmitted,
List inputFormatters,
bool enabled,
this.cursorWidth = 2.0,
this.cursorRadius,
this.cursorColor,
...
})
controller
:编辑框的控制器,通过它可以设置/获取编辑框的内容、选择编辑内容、监听编辑文本改变事件。大多数情况下我们都需要显式提供一个controller来与文本框交互。如果没有提供controller,则TextField内部会自动创建一个。focusNode
:用于控制TextField是否占有当前键盘的输入焦点。它是我们和键盘交互的一个句柄(handle)。InputDecoration
:用于控制TextField的外观显示,如提示文本、背景颜色、边框等。keyboardType
:用于设置该输入框默认的键盘输入类型
First Header | Second Header |
---|---|
TextInputType枚举值 | 含义 |
text | 文本输入键盘 |
multiline | 多行文本,需和maxLines配合使用(设为null或大于1) |
number | 数字;会弹出数字键盘 |
phone | 优化后的电话号码输入键盘;会弹出数字键盘并显示“* #” |
datetime | 优化后的日期输入键盘;Android上会显示“: -” |
emailAddress | 优化后的电子邮件地址;会显示“@ .” |
url | 优化后的url输入键盘; 会显示“/ .” |
textInputAction
:键盘动作按钮图标(即回车键位图标),它是一个枚举值,有多个可选值,全部的取值列表读者可以查看API文档,如:TextInputAction.search。style
:正在编辑的文本样式。textAlign
: 输入框内编辑文本在水平方向的对齐方式。autofocus
: 是否自动获取焦点。obscureText
:是否隐藏正在编辑的文本,如用于输入密码的场景等,文本内容会用“•”替换。maxLines
:输入框的最大行数,默认为1;如果为null,则无行数限制。maxLength和maxLengthEnforced
:maxLength代表输入框文本的最大长度,设置后输入框右下角会显示输入的文本计数。maxLengthEnforced决定当输入文本长度超过maxLength时是否阻止输入,为true时会阻止输入,为false时不会阻止输入但输入框会变红。onChange
:输入框内容改变时的回调函数;注:内容改变事件也可以通过controller来监听。onEditingComplete和onSubmitted
:这两个回调都是在输入框输入完成时触发,比如按了键盘的完成键(对号图标)或搜索键(图标)。不同的是两个回调签名不同,onSubmitted回调是ValueChanged类型,它接收当前输入内容做为参数,而onEditingComplete不接收参数。 inputFormatters
:用于指定输入格式;当用户输入内容改变时,会根据指定的格式来校验。enable
:如果为false,则输入框会被禁用,禁用状态不接收输入和事件,同时显示禁用态样式(在其decoration中定义)。cursorWidth、cursorRadius和cursorColor
:这三个属性是用于自定义输入框光标宽度、圆角和颜色的。
自定义样式
可以通过decoration属性来定义输入框样式
decoration: InputDecoration(
hintText: "请输入用户名",//提示文字
prefixIcon: Icon(Icons.lock),//左侧图标
//边框样式
border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderSide: BorderSide(
color: Colors.grey,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderSide: BorderSide(
color: Colors.red,
),
),
),
获取&监听文本变化
获取输入内容有两种方式:
- 1、通过
onChange
获取
onChanged: (value) => print(value),
- 2、通过
controller
获取
定义一个controller:
TextEditingController _unameController = TextEditingController();
然后设置输入框controller:
TextField(
autofocus: true,
controller: _unameController, //设置controller
...
)
通过controller获取输入框内容
@override
void initState() {
//监听输入改变
_unameController.addListener((){
print(_unameController.text);
});
}
两种方式相比,onChanged是专门用于监听文本变化,而controller的功能却多一些,除了能监听文本变化外,它还可以设置默认值、选择文本等
监听焦点状态改变事件:
...
// 创建 focusNode
FocusNode focusNode = new FocusNode();
...
// focusNode绑定输入框
TextField(focusNode: focusNode);
...
// 监听焦点变化
focusNode.addListener((){
print(focusNode.hasFocus);
});
此章:Icon、表单Form、进度指示器ProgressIndicator 自己看一下吧
三、布局方式
布局类组件简介
布局类组件都会包含一个或多个子组件,不同的布局类组件对子组件排版(layout)方式不同。
根据Widget是否包含子节点将其分为3类
1、
LeafRenderObjectWidget
:Widget树的叶子节点,用于没有子节点的widget,通常基础组件都属于这一类,如Image。2、
SingleChildRenderObjectWidget
:包含一个子Widget,如:ConstrainedBox、DecoratedBox等3、
MultiChildRenderObjectWidget
:包含多个子Widget,一般都有一个children参数,接受一个Widget数组。如Row、Column、Stack等
布局类组件就是指直接或间接继承(包含)MultiChildRenderObjectWidget
的Widget
线性布局(Row和Column)
所谓线性布局,即指沿水平或垂直方向排布子组件。Flutter中通过Row和Column来实现线性布局,类似于Android中的LinearLayout控件。Row和Column都继承自Flex,我们将在弹性布局一节中详细介绍Flex。
主轴和纵轴
对于线性布局,有主轴和纵轴之分,如果布局是沿水平方向,那么主轴就是指水平方向,而纵轴即垂直方向;如果布局沿垂直方向,那么主轴就是指垂直方向,而纵轴就是水平方向
Row
Row可以在水平方向排列其子widget
Row({
...
TextDirection textDirection,
MainAxisSize mainAxisSize = MainAxisSize.max,
MainAxisAlignment mainAxisAlignment = MainAxisAlignment.start,
VerticalDirection verticalDirection = VerticalDirection.down,
CrossAxisAlignment crossAxisAlignment = CrossAxisAlignment.center,
List children = const [],
})
children
:子组件数组。textDirection
:表示水平方向子组件的布局顺序(是从左往右还是从右往左),默认为从左往右,而阿拉伯语是从右往左。mainAxisSize
:表示Row在主轴(水平)方向占用的空间,默认是MainAxisSize.max,表示尽可能多的占用水平方向的空间;而MainAxisSize.min表示尽可能少的占用水平空间;mainAxisAlignment
:表示子组件在Row所占用的水平空间内对齐方式(包含start、end、 center三个值),如果mainAxisSize值为MainAxisSize.min,则此属性无意义。默认start。注意:textDirection是mainAxisAlignment的参考系。verticalDirection
:表示Row纵轴(垂直)的对齐方向,默认是VerticalDirection.down,表示从上到下。crossAxisAlignment
:表示子组件在纵轴方向的对齐方式,Row的高度等于子组件中最高的子元素高度,它的取值和MainAxisAlignment一样(包含start、end、 center三个值),不同的是crossAxisAlignment的参考系是verticalDirection;
Column
Column可以在垂直方向排列其子widget
参数和Row一样,不同的是布局方向为垂直,不同的是布局方向为垂直,主轴纵轴正好相反。
特殊情况
如果Row里面嵌套Row,或者Column里面再嵌套Column,那么只有最外面的Row或Column会占用尽可能大的空间,里面Row或Column所占用的空间为实际大小,若想内部占用尽可能空间可以使用Expanded
组件。
弹性布局(Flex)
弹性布局允许子组件按照一定比例来分配父容器空间。弹性布局的概念在其它UI系统中也都存在,如H5中的弹性盒子布局,Android中的FlexboxLayout等。Flutter中的弹性布局主要通过Flex
和Expanded
来配合实现。
Flex
Flex组件可以沿着水平或垂直方向排列子组件,如果你知道主轴方向,使用Row或Column会方便一些,因为Row和Column都继承自Flex,参数基本相同,所以能使用Flex的地方基本上都可以使用Row或Column。
Flex({
...
@required this.direction, //弹性布局的方向, Row默认为水平方向,Column默认为垂直方向
List children = const [],
})
-
direction
:Axis.vertical、Axis.horizontal
Expanded
可以按比例“扩伸” Row、Column和Flex子组件所占用的空间。
const Expanded({
int flex = 1,
@required Widget child,
})
flex
参数为弹性系数,如果为0或null,则child是没有弹性的,即不会被扩伸占用的空间。如果大于0,所有的Expanded
按照其flex
的比例来分割主轴的全部空闲空间。
Spacer
Spacer
的功能是占用指定比例的空间,实际上它只是Expanded的一个包装类
流式布局
在介绍Row和Colum时,如果子widget超出屏幕范围,则会报溢出错误
我们把超出屏幕显示范围会自动折行的布局称为流式布局。Flutter中通过Wrap和Flow来支持流式布局。
Wrap
我们可以看到Wrap的很多属性在Row(包括Flex和Column)中也有,如direction、crossAxisAlignment、textDirection、verticalDirection等,这些参数意义是相同的。Wrap和Flex(包括Row和Column)除了超出显示范围后Wrap会折行外,其它行为基本相同。下面我们看一下Wrap特有的几个属性:
spacing
:主轴方向子widget的间距runSpacing
:纵轴方向的间距alignment
: 主轴方向的对齐方式runAlignment
:纵轴方向的对齐方式
Wrap({
...
this.direction = Axis.horizontal,
this.alignment = WrapAlignment.start,
this.spacing = 0.0,
this.runAlignment = WrapAlignment.start,
this.runSpacing = 0.0,
this.crossAxisAlignment = WrapCrossAlignment.start,
this.textDirection,
this.verticalDirection = VerticalDirection.down,
List children = const [],
})
Flow
我们一般很少会使用Flow,因为其过于复杂,需要自己实现子widget的位置转换,在很多场景下首先要考虑的是Wrap是否满足需求。
性能好
灵活
使用复杂
不能自适应子组件大小,必须通过指定父容器大小或实现TestFlowDelegate的getSize返回固定大小
总结:复杂,先知道有这么个布局方式
层叠布局Stack、Positioned
层叠布局和Web中的绝对定位、Android中的Frame布局是相似的,子组件可以根据距父容器四个角的位置来确定自身的位置。绝对定位允许子组件堆叠起来(按照代码中声明的顺序)
Flutter中使用Stack
和Positioned
这两个组件来配合实现绝对定位。Stack允许子组件堆叠,而Positioned用于根据Stack的四个角来确定子组件的位置。
Stack
Stack({
this.alignment = AlignmentDirectional.topStart,
this.textDirection,
this.fit = StackFit.loose,
this.overflow = Overflow.clip,
List children = const [],
})
textDirection
:和Row、Wrap的textDirection功能一样,都用于确定alignment对齐的参考系。alignment
:此参数决定如何去对齐没有定位(没有使用Positioned)或部分定位的子组件。所谓部分定位,在这里特指没有在某一个轴上定位:left、right为横轴,top、bottom为纵轴,只要包含某个轴上的一个定位属性就算在该轴上有定位。fit
:此参数用于确定没有定位的子组件如何去适应Stack的大小。StackFit.loose表示使用子组件的大小,StackFit.expand表示扩伸到Stack的大小。
Positioned
const Positioned({
Key key,
this.left,
this.top,
this.right,
this.bottom,
this.width,
this.height,
@required Widget child,
})
left、top 、right、 bottom分别代表离Stack左、上、右、底四边的距离
width、height 表示宽高
这里和约束布局一样,如确定left、top、height、width或确定left、top 、right、 bottom及可确定一个组件的位置和大小
Stack子元素是堆叠的,所以后添加的widget在最上层
对齐与相对定位
Align
Align 组件可以调整子组件的位置,并且可以根据子组件的宽高来确定自身的的宽高,定义如下:
Align({
Key key,
this.alignment = Alignment.center,
this.widthFactor,
this.heightFactor,
Widget child,
})
alignment : 需要一个
AlignmentGeometry
类型的值,表示子组件在父组件中的起始位置,AlignmentGeometry
是一个抽象类,它有两个常用的子类:Alignment
和FractionalOffset
。widthFactor
和heightFactor
是用于确定Align 组件本身宽高的属性;它们是两个缩放因子,会分别乘以子元素的宽、高,最终的结果就是Align 组件的宽高。如果值为null,则组件的宽高将会占用尽可能多的空间。
Alignment
Alignment继承自AlignmentGeometry,表示矩形内的一个点,他有两个属性x、y,分别表示在水平和垂直方向的偏移,Alignment定义如下:
Alignment(this.x, this.y)
Alignment Widget会以矩形的中心点作为坐标原点,即Alignment(0.0, 0.0)
x、y的值从-1到1分别代表矩形左边到右边的距离和顶部到底边的距离
Alignment(-1.0, -1.0) 代表矩形的左侧顶点,而Alignment(1.0, 1.0)代表右侧底部终点,即Alignment.topRight
Alignment可以通过其坐标转换公式将其坐标转为子元素的具体偏移坐标。
(Alignment.x*childWidth/2+childWidth/2, Alignment.y*childHeight/2+childHeight/2)
其中childWidth为子元素的宽度,childHeight为子元素高度。
FractionalOffset
FractionalOffset 继承自 Alignment,它和 Alignment唯一的区别就是坐标原点不同!FractionalOffset 的坐标原点为矩形的左侧顶点,这和布局系统的一致,所以理解起来会比较容易。FractionalOffset的坐标转换公式为:
实际偏移 = (FractionalOffse.x * childWidth, FractionalOffse.y * childHeight)
Align和Stack对比
Align和Stack/Positioned都可以用于指定子元素相对于父元素的偏移,但它们还是有两个主要区别:
1、定位参考系统不同;Stack/Positioned定位的的参考系可以是父容器矩形的四个顶点;而Align则需要先通过alignment 参数来确定坐标原点,不同的alignment会对应不同原点,最终的偏移是需要通过alignment的转换公式来计算出。
2、Stack可以有多个子元素,并且子元素可以堆叠,而Align只能有一个子元素,不存在堆叠。
Center
Center
组件其实是对齐方式确定(Alignment.center)了的Align
四、容器类组件
容器类Widget和布局类Widget都作用于其子Widget,不同的是:
布局类Widget一般都需要接收一个widget数组(children),他们直接或间接继承自(或包含)MultiChildRenderObjectWidget ;而容器类Widget一般只需要接收一个子Widget(child),他们直接或间接继承自(或包含)SingleChildRenderObjectWidget。
布局类Widget是按照一定的排列方式来对其子Widget进行排列;而容器类Widget一般只是包装其子Widget,对其添加一些修饰(补白或背景色等)、变换(旋转或剪裁等)、或限制(大小等)。
填充(Padding)
Padding可以给其子节点添加填充(留白),和边距效果类似。我们在前面很多示例中都已经使用过它了,现在来看看它的定义:
Padding({
...
EdgeInsetsGeometry padding,
Widget child,
})
EdgeInsetsGeometry
是一个抽象类,开发中,我们一般都使用EdgeInsets
类,它是EdgeInsetsGeometry的一个子类,定义了一些设置填充的便捷方法。
EdgeInsets
我们看看EdgeInsets提供的便捷方法:
fromLTRB(double left, double top, double right, double bottom):分别指定四个方向的填充。
all(double value) : 所有方向均使用相同数值的填充。
only({left, top, right ,bottom }):可以设置具体某个方向的填充(可以同时指定多个方向)。
symmetric({ vertical, horizontal }):用于设置对称方向的填充,vertical指top和bottom,horizontal指left和right。
尺寸限制类容器
尺寸限制类容器用于限制容器大小,Flutter中提供了多种这样的容器,如ConstrainedBox
、SizedBox
、UnconstrainedBox
、AspectRatio
等,本节将介绍一些常用的。
ConstrainedBox
ConstrainedBox用于对子组件添加额外的约束。例如,如果你想让子组件的最小高度是80像素,你可以使用const BoxConstraints(minHeight: 80.0)作为子组件的约束。
实现一个最小高度为50,宽度尽可能大的容器
ConstrainedBox(
constraints: BoxConstraints(
minWidth: double.infinity, //宽度尽可能大
minHeight: 50.0 //最小高度为50像素
),
child: Container(
height: 5.0,
color: Colors.red,
),
)
BoxConstraints用于设置限制条件,它的定义如下:
const BoxConstraints({
this.minWidth = 0.0, //最小宽度
this.maxWidth = double.infinity, //最大宽度
this.minHeight = 0.0, //最小高度
this.maxHeight = double.infinity //最大高度
})
BoxConstraints还定义了一些便捷的构造函数,用于快速生成特定限制规则的BoxConstraints,如BoxConstraints.tight(Size size)
,它可以生成给定大小的限制;const BoxConstraints.expand()
可以生成一个尽可能大的用以填充另一个容器的BoxConstraints。除此之外还有一些其它的便捷函数,读者可以查看API文档 (opens new window)。
SizedBox
SizedBox用于给子元素指定固定的宽高,如:
SizedBox(
width: 80.0,
height: 80.0,
child: redBox
)
实际上SizedBox只是ConstrainedBox的一个定制,上面代码等价于:
ConstrainedBox(
constraints: BoxConstraints.tightFor(width: 80.0,height: 80.0),
child: redBox,
)
而BoxConstraints.tightFor(width: 80.0,height: 80.0)等价于:
BoxConstraints(minHeight: 80.0,maxHeight: 80.0,minWidth: 80.0,maxWidth: 80.0)
多重限制
如果某一个组件有多个父级ConstrainedBox限制,那么最终会是哪个生效?我们看一个例子:
ConstrainedBox(
constraints: BoxConstraints(minWidth: 60.0, minHeight: 60.0), //父
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0),//子
child: redBox,
)
)
最终显示效果是宽90,高60,也就是说是子ConstrainedBox的minWidth生效,而minHeight是父ConstrainedBox生效。单凭这个例子,我们还总结不出什么规律,我们将上例中父子限制条件换一下:
ConstrainedBox(
constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0),
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: 60.0, minHeight: 60.0),
child: redBox,
)
)
最终的显示效果仍然是90,高60
结论:有多重限制时,对于minWidth和minHeight来说,是取父子中相应数值较大的。
思考题:对于maxWidth和maxHeight,多重限制的策略是什么样的呢?
结论:有多重限制时,对于maxWidth和maxHeight来说,是取父子中相应数值较小的。
UnconstrainedBox
UnconstrainedBox不会对子组件产生任何限制,它允许其子组件按照其本身大小绘制。一般情况下,我们会很少直接使用此组件,但在"去除"多重限制的时候也许会有帮助,我们看下下面的代码:
ConstrainedBox(
constraints: BoxConstraints(minWidth: 60.0, minHeight: 100.0), //父
child: UnconstrainedBox( //“去除”父级限制
child: ConstrainedBox(
constraints: BoxConstraints(minWidth: 90.0, minHeight: 20.0),//子
child: redBox,
),
)
)
上面代码中,如果没有中间的UnconstrainedBox,那么根据上面所述的多重限制规则,那么最终将显示一个90×100的红色框。但是由于UnconstrainedBox “去除”了父ConstrainedBox的限制,则最终会按照子ConstrainedBox的限制来绘制redBox,即90×20
装饰容器DecoratedBox
DecoratedBox可以在其子组件绘制前(或后)绘制一些装饰(Decoration),如背景、边框、渐变等。DecoratedBox定义如下:
const DecoratedBox({
Decoration decoration,
DecorationPosition position = DecorationPosition.background,
Widget child
})
decoration:代表将要绘制的装饰,它的类型为Decoration。Decoration是一个抽象类,它定义了一个接口 createBoxPainter(),子类的主要职责是需要通过实现它来创建一个画笔,该画笔用于绘制装饰。
-
position:此属性决定在哪里绘制Decoration,它接收DecorationPosition的枚举类型,该枚举类有两个值:
1、 background:在子组件之后绘制,即背景装饰
2、foreground:在子组件之上绘制,即前景。
BoxDecoration
我们通常会直接使用BoxDecoration类,它是一个Decoration的子类,实现了常用的装饰元素的绘制。
BoxDecoration({
Color color, //颜色
DecorationImage image,//图片
BoxBorder border, //边框
BorderRadiusGeometry borderRadius, //圆角
List boxShadow, //阴影,可以指定多个
Gradient gradient, //渐变
BlendMode backgroundBlendMode, //背景混合模式
BoxShape shape = BoxShape.rectangle, //形状
})
变换(Transform)
Transform可以在其子组件绘制时对其应用一些矩阵变换来实现一些特效。
平移
Transform.translate接收一个offset参数,可以在绘制时沿x、y轴对子组件平移指定的距离。
Widget _createTransform_translate() {
return Center(
child: Transform.translate(
offset: Offset(10, 100),
child: Text(
"哈哈哈哈哈哈",
style: TextStyle(
backgroundColor: Colors.grey,
),
),
),
);
}
旋转
Transform.rotate可以对子组件进行旋转变换,如:
Widget _createTransform_rotate() {
return Center(
child: Transform.rotate(
angle: pi / 2,
child: Text(
"哈哈哈哈哈哈",
style: TextStyle(
backgroundColor: Colors.grey,
),
),
),
);
}
缩放
Transform.scale可以对子组件进行缩小或放大,如:
Widget _createTransform_scale() {
return Row(
children: [
Text(
"哈哈哈哈哈哈",
style: TextStyle(
backgroundColor: Colors.grey,
),
),
Transform.scale(
scale: 2,
child: Text(
"嘿嘿嘿嘿嘿",
style: TextStyle(
backgroundColor: Colors.red,
),
),
),
],
);
}
由于使用transform只会在绘制时放大,其占用空间依然为原来部分,所以会出现重合现象。
由于矩阵变化只会作用在绘制阶段,所以在某些场景下,在UI需要变化时,可以直接通过矩阵变化来达到视觉上的UI改变,而不需要去重新触发build流程,这样会节省layout的开销,所以性能会比较好。如之前介绍的Flow组件,它内部就是用矩阵变换来更新UI,除此之外,Flutter的动画组件中也大量使用了Transform以提高性能。
RotatedBox
RotatedBox和Transform.rotate功能相似,它们都可以对子组件进行旋转变换,但是有一点不同:RotatedBox的变换是在layout阶段,会影响在子组件的位置和大小。
Widget _createRotatedBox() {
return Padding(
padding: EdgeInsets.symmetric(vertical: 100),
child: Row(
children: [
Text(
"哈哈哈哈哈哈",
style: TextStyle(
backgroundColor: Colors.grey,
),
),
RotatedBox(
quarterTurns: 1,
child: Text(
"嘿嘿嘿嘿嘿",
style: TextStyle(
backgroundColor: Colors.red,
),
),
),
],
),
);
}
Container
Container是一个组合类容器,它本身不对应具体的RenderObject,它是DecoratedBox、ConstrainedBox、Transform、Padding、Align等组件组合的一个多功能容器,所以我们只需通过一个Container组件可以实现同时需要装饰、变换、限制的场景。下面是Container的定义:
Container({
this.alignment,
this.padding, //容器内补白,属于decoration的装饰范围
Color color, // 背景色
Decoration decoration, // 背景装饰
Decoration foregroundDecoration, //前景装饰
double width,//容器的宽度
double height, //容器的高度
BoxConstraints constraints, //容器大小的限制条件
this.margin,//容器外补白,不属于decoration的装饰范围
this.transform, //变换
this.child,
})
容器的大小可以通过width、height属性来指定,也可以通过constraints来指定;如果它们同时存在时,width、height优先。实际上Container内部会根据width、height来生成一个constraints。
color和decoration是互斥的,如果同时设置它们则会报错!实际上,当指定color时,Container内会自动创建一个decoration。
Padding和Margin
接下来我们来研究一下Container组件margin和padding属性的区别:
Container(
margin: EdgeInsets.all(20.0), //容器外补白
color: Colors.orange,
child: Text("Hello world!"),
),
Container(
padding: EdgeInsets.all(20.0), //容器内补白
color: Colors.orange,
child: Text("Hello world!"),
),
总结:margin在容器外留白,padding是容器内留白
事实上,Container内margin和padding都是通过Padding 组件来实现的,上面的示例代码实际上等价于:
...
Padding(
padding: EdgeInsets.all(20.0),
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.orange),
child: Text("Hello world!"),
),
),
DecoratedBox(
decoration: BoxDecoration(color: Colors.orange),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text("Hello world!"),
),
),
Scaffold
一个完整的路由页可能会包含导航栏、抽屉菜单(Drawer)以及底部Tab导航菜单等。如果每个路由页面都需要开发者自己手动去实现这些,这会是一件非常麻烦且无聊的事。幸运的是,Flutter Material组件库提供了一些现成的组件来减少我们的开发任务。Scaffold是一个路由页的骨架,我们使用它可以很容易地拼装出一个完整的页面。
它主要包括:
- appBar:导航栏
- drawer:抽屉
- bottomNavigationBar:底部导航
- floatingActionButton:悬浮按钮
- body
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,
... //其它属性见源码注释
})
抽屉菜单Drawer
Scaffold的drawe
r和endDrawer
属性可以分别接受一个Widget来作为页面的左、右抽屉菜单。如果开发者提供了抽屉菜单,那么当用户手指从屏幕左(或右)侧向里滑动时便可打开抽屉菜单。
return Scaffold(
appBar: createAppBar(),
endDrawer: Drawer(
child: Container(
color: Colors.red,
),
),
);
底部Tab导航栏
我们可以通过Scaffold的bottomNavigationBar属性来设置底部导航,如本节开始示例所示,我们通过Material组件库提供的BottomNavigationBar和BottomNavigationBarItem两种组件来实现Material风格的底部导航栏。
bottomNavigationBar: BottomNavigationBar(
items: [
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: "首页",
),
BottomNavigationBarItem(
icon: Icon(Icons.history),
label: "历史",
),
BottomNavigationBarItem(
icon: Icon(Icons.search),
label: "搜索",
),
],
currentIndex: selectedIndex,
onTap: _itemTapAction,
),
实现中间按钮浮动效果
floatingActionButton: FloatingActionButton(onPressed: null),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar: BottomAppBar(
color: Colors.white,
shape: CircularNotchedRectangle(), // 底部导航栏打一个圆形的洞
child: Row(
children: [
IconButton(
onPressed: null,
icon: Icon(Icons.home),
),
SizedBox(), //中间位置空出
IconButton(
onPressed: null,
icon: Icon(Icons.business),
),
],
mainAxisAlignment: MainAxisAlignment.spaceAround, //均分底部导航栏横向空间
),
),
剪裁(Clip)
Flutter中提供了一些剪裁函数,用于对组件进行剪裁。
ClipOval
:子组件为正方形时剪裁为内贴圆形,为矩形时,剪裁为内贴椭圆ClipRRect
:将子组件剪裁为圆角矩形ClipRect
:剪裁子组件到实际占用的矩形大小(溢出部分剪裁)
Widget _createClip() {
const netWorkImage = NetworkImage(
"https://book.flutterchina.club/assets/img/3-17.a063365a.png");
return Column(
children: [
Padding(
padding: EdgeInsets.all(10.0),
//裁剪为圆形
child: ClipOval(
child: SizedBox(
height: 100,
width: 100,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.blue),
),
),
),
),
Padding(
padding: EdgeInsets.all(10.0),
//裁剪为圆角
child: ClipRRect(
borderRadius: BorderRadius.circular(10.0),
child: SizedBox(
height: 100,
width: 100,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.blue),
),
),
),
),
Padding(
padding: EdgeInsets.all(10.0),
//裁剪为圆角
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ClipRect(
//将溢出部分剪裁
child: Align(
alignment: Alignment.topLeft,
widthFactor: .5, //宽度设为原来宽度一半
child: Image(
image: netWorkImage,
),
),
),
Text(
"你好世界",
style: TextStyle(color: Colors.green),
)
],
),
),
],
);
}
CustomClipper
如果我们想剪裁子组件的特定区域,如果我们只想截取图片中部40×30像素的范围应该怎么做?这时我们可以使用CustomClipper来自定义剪裁区域,实现代码如下:
首先,自定义一个CustomClipper:
class MyClipper extends CustomClipper {
@override
Rect getClip(Size size) => Rect.fromLTWH(10.0, 15.0, 40.0, 30.0);
@override
bool shouldReclip(CustomClipper oldClipper) => false;
}
getClip()
是用于获取剪裁区域的接口,由于图片大小是60×60,我们返回剪裁区域为Rect.fromLTWH(10.0, 15.0, 40.0, 30.0),即图片中部40×30像素的范围。shouldReclip()
接口决定是否重新剪裁。如果在应用中,剪裁区域始终不会发生变化时应该返回false,这样就不会触发重新剪裁,避免不必要的性能开销。如果剪裁区域会发生变化(比如在对剪裁区域执行一个动画),那么变化后应该返回true来重新执行剪裁。
然后,我们通过ClipRect
来执行剪裁,为了看清图片实际所占用的位置,我们设置一个红色背景:
DecoratedBox(
decoration: BoxDecoration(
color: Colors.red
),
child: ClipRect(
clipper: MyClipper(), //使用自定义的clipper
child: avatar
),
)
可以看到我们的剪裁成功了,但是图片所占用的空间大小仍然是60×60(红色区域),这是因为剪裁是在layout完成后的绘制阶段进行的,所以不会影响组件的大小,这和Transform原理是相似的。
五、路由管理
路由(Route)在移动开发中通常指页面(Page),这跟web开发中单页应用的Route概念意义是相同的,Route在Android中通常指一个Activity,在iOS中指一个ViewController。所谓路由管理,就是管理页面之间如何跳转,通常也可被称为导航管理。Flutter中的路由管理和原生开发类似,无论是Android还是iOS,导航管理都会维护一个路由栈,路由入栈(push)操作对应打开一个新页面,路由出栈(pop)操作对应页面关闭操作,而路由管理主要是指如何来管理路由栈。
MaterialPageRoute
MaterialPageRoute
继承自PageRoute类,PageRoute类是一个抽象类,表示占有整个屏幕空间的一个模态路由页面,它还定义了路由构建及切换时过渡动画的相关接口及属性。MaterialPageRoute 是Material组件库提供的组件,它可以针对不同平台,实现与平台页面切换动画风格一致的路由切换动画:
对于Android,当打开新页面时,新的页面会从屏幕底部滑动到屏幕顶部;当关闭页面时,当前页面会从屏幕顶部滑动到屏幕底部后消失,同时上一个页面会显示到屏幕上。
对于iOS,当打开页面时,新的页面会从屏幕右侧边缘一致滑动到屏幕左边,直到新页面全部显示到屏幕上,而上一个页面则会从当前屏幕滑动到屏幕左侧而消失;当关闭页面时,正好相反,当前页面会从屏幕右侧滑出,同时上一个页面会从屏幕左侧滑入。
MaterialPageRoute
构造函数的各个参数:
MaterialPageRoute({
WidgetBuilder builder,
RouteSettings settings,
bool maintainState = true,
bool fullscreenDialog = false,
})
builder 是一个WidgetBuilder类型的回调函数,它的作用是构建路由页面的具体内容,返回值是一个widget。我们通常要实现此回调,返回新路由的实例。
settings 包含路由的配置信息,如路由名称、是否初始路由(首页)。
maintainState:默认情况下,当入栈一个新路由时,原来的路由仍然会被保存在内存中,如果想在路由没用的时候释放其所占用的所有资源,可以设置maintainState为false。
fullscreenDialog表示新的路由页面是否是一个全屏的模态对话框,在iOS中,如果fullscreenDialog为true,新页面将会从屏幕底部滑入(而不是水平方向)。
Navigator
Navigator是一个路由管理的组件,它提供了打开和退出路由页方法。Navigator通过一个栈来管理活动路由集合。通常当前屏幕显示的页面就是栈顶的路由。Navigator提供了一系列方法来管理路由栈,在此我们只介绍其最常用的两个方法:
Future push(BuildContext context, Route route)
将给定的路由入栈(即打开新的页面),返回值是一个Future对象,用以接收新路由出栈(即关闭)时的返回数据。
bool pop(BuildContext context, [ result ])
将栈顶路由出栈,result为页面关闭时返回给上一个页面的数据。
Navigator 还有很多其它方法,如Navigator.replace、Navigator.popUntil等,详情请参考API文档或SDK源码注释。
实例方法
Navigator类中第一个参数为context的静态方法都对应一个Navigator的实例方法, 比如Navigator.push(BuildContext context, Route route)
等价于Navigator.of(context).push(Route route)
路由传值
很多时候,在路由跳转时我们需要带一些参数,并且返回上一个路由页面时同时会带上一个返回参数。
例:
_jumpToNextPage() {
//跳转返回的是一个Future
var result =
Navigator.of(this.context).push(MaterialPageRoute(builder: (context) {
return RoutePageA(title: "PageA");
}));
//使用Future取得回调值value
result.then((value) {
this.setState(() {
nextActionName = value;
});
print(value);
});
}
//pop时传回调参数
leading: IconButton(
icon: BackButtonIcon(),
onPressed: () {
Navigator.pop(context, "我是返回值");
}),
),
命名路由
所谓“命名路由”(Named Route)即有名字的路由,我们可以先给路由起一个名字,然后就可以通过路由名字直接打开新的路由了,这为路由管理带来了一种直观、简单的方式。
路由表
要想使用命名路由,我们必须先提供并注册一个路由表(routing table),这样应用程序才知道哪个名字与哪个路由组件相对应。其实注册路由表就是给路由起名字,路由表的定义如下:
Map routes;
它是一个Map,key为路由的名字,是个字符串;value是个builder回调函数,用于生成相应的路由widget。我们在通过路由名字打开新路由时,应用会根据路由名字在路由表中查找到对应的WidgetBuilder回调函数,然后调用该回调函数生成路由widget并返回。
注册路由表
路由表的注册方式很简单,我们回到之前“计数器”的示例,然后在MyApp类的build方法中找到MaterialApp
,添加routes属性,代码如下:
MaterialApp(
title: 'Flutter Demo',
initialRoute:"/", //名为"/"的路由作为应用的home(首页)
theme: ThemeData(
primarySwatch: Colors.blue,
),
//注册路由表
routes:{
"new_page":(context) => NewRoute(),
"/":(context) => MyHomePage(title: 'Flutter Demo Home Page'), //注册首页路由
}
);
通过路由名打开新路由页
要通过路由名称来打开新路由,可以使用Navigator 的pushNamed方法:
Future pushNamed(BuildContext context, String routeName,{Object arguments})
命名路由参数传递
Navigator.of(context).pushNamed("new_page", arguments: "hi");
在路由页通过RouteSetting对象获取路由参数:
class EchoRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
//获取路由参数
var args=ModalRoute.of(context).settings.arguments;
//...省略无关代码
}
}
路由钩子(onGenerateRoute)
MaterialApp有一个onGenerateRoute属性,它在打开命名路由时可能会被调用,之所以说可能,是因为当调用Navigator.pushNamed(...)打开命名路由时,如果指定的路由名在路由表中已注册,则会调用路由表中的builder函数来生成路由组件;如果路由表中没有注册,才会调用onGenerateRoute来生成路由。
可以对某些指定的路由进行拦截,有时候不想改变页面结构,但是又想要求跳转到这个页面的时候可以用到
return MaterialApp(
title: "Welcome to Flutter1",
initialRoute: '/',
// home: HomePage(),
routes: RoutesMap.instance.initRoutes(context),
onGenerateRoute: (RouteSettings settings) {
String? routeName = settings.name;
return MaterialPageRoute(
fullscreenDialog: true,
builder: (context) {
return LoginPage();
});
},
);
Navigator.pushNamed(context, "没注册的page");
会直接跳转到LoginPage
其他
initialRoute
:是项目的根路由,初始化的时候最先展示的页面
onUnknownRoute
(RouteFactory类型函数):在路由匹配不到的时候用到,一般都返回一个统一的错误页面或404页面