5.1 填充(Padding)
#5.1.1 Padding
Padding可以给其子节点添加填充(留白),和边距效果类似。我们在前面很多示例中都已经使用过它了,现在来看看它的定义:
Padding({
...
EdgeInsetsGeometry padding,
Widget child,
})
EdgeInsetsGeometry是一个抽象类,开发中,我们一般都使用EdgeInsets类,它是EdgeInsetsGeometry的一个子类,定义了一些设置填充的便捷方法。
#5.1.2 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。
#5.1.3 示例
下面的示例主要展示了EdgeInsets的不同用法,比较简单,源码如下:
class PaddingTestRoute extends StatelessWidget {
const PaddingTestRoute({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
//上下左右各添加16像素补白
padding: const EdgeInsets.all(16),
child: Column(
//显式指定对齐方式为左对齐,排除对齐干扰
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: const <Widget>[
Padding(
//左边添加8像素补白
padding: EdgeInsets.only(left: 8),
child: Text("Hello world"),
),
Padding(
//上下各添加8像素补白
padding: EdgeInsets.symmetric(vertical: 8),
child: Text("I am Jack"),
),
Padding(
// 分别指定四个方向的补白
padding: EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Text("Your friend"),
)
],
),
);
}
}
运行效果如图5-1所示:
5.2 装饰容器(DecoratedBox)
#5.2.1 DecoratedBox
DecoratedBox可以在其子组件绘制前(或后)绘制一些装饰(Decoration),如背景、边框、渐变等。DecoratedBox定义如下:
const DecoratedBox({
Decoration decoration,
DecorationPosition position = DecorationPosition.background,
Widget? child
})
-
decoration:代表将要绘制的装饰,它的类型为Decoration。Decoration是一个抽象类,它定义了一个接口createBoxPainter(),子类的主要职责是需要通过实现它来创建一个画笔,该画笔用于绘制装饰。 -
position:此属性决定在哪里绘制Decoration,它接收DecorationPosition的枚举类型,该枚举类有两个值:background:在子组件之后绘制,即背景装饰。foreground:在子组件之上绘制,即前景。
#5.2.2 BoxDecoration
我们通常会直接使用BoxDecoration类,它是一个Decoration的子类,实现了常用的装饰元素的绘制。
BoxDecoration({
Color color, //颜色
DecorationImage image,//图片
BoxBorder border, //边框
BorderRadiusGeometry borderRadius, //圆角
List<BoxShadow> boxShadow, //阴影,可以指定多个
Gradient gradient, //渐变
BlendMode backgroundBlendMode, //背景混合模式
BoxShape shape = BoxShape.rectangle, //形状
})
各个属性名都是自解释的,详情读者可以查看API文档。
#5.2.3 实例
下面我们实现一个带阴影的背景色渐变的按钮:
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(colors:[Colors.red,Colors.orange.shade700]), //背景渐变
borderRadius: BorderRadius.circular(3.0), //3像素圆角
boxShadow: [ //阴影
BoxShadow(
color:Colors.black54,
offset: Offset(2.0,2.0),
blurRadius: 4.0
)
]
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 80.0, vertical: 18.0),
child: Text("Login", style: TextStyle(color: Colors.white),),
)
)
运行后效果如图5-2所示:
通过BoxDecoration我们实现了一个渐变按钮的外观,但此示例还不是一个标准的按钮,因为它还不能响应点击事件,我们将在10.2节中实现一个完整功能的GradientButton。另外,上面的例子中使用了LinearGradient类,它用于定义线性渐变的类,Flutter中还提供了其他渐变配置类,如RadialGradient、SweepGradient,读者若有需要可以自行查看API文档。
5.3 变换(Transform)
Transform可以在其子组件绘制时对其应用一些矩阵变换来实现一些特效。Matrix4是一个4D矩阵,通过它我们可以实现各种矩阵操作,下面是一个例子:
Container(
color: Colors.black,
child: Transform(
alignment: Alignment.topRight, //相对于坐标系原点的对齐方式
transform: Matrix4.skewY(0.3), //沿Y轴倾斜0.3弧度
child: Container(
padding: const EdgeInsets.all(8.0),
color: Colors.deepOrange,
child: const Text('Apartment for rent!'),
),
),
)
运行效果如图5-3所示:
关于矩阵变换的相关内容属于线性代数范畴,本书不做讨论,读者有兴趣可以自行了解。本书中,我们把焦点放在Flutter中一些常见的变换效果上。另外,由于矩阵变化时发生在绘制时,而无需重新布局和构建等过程,所以性能很好。
#5.3.1 平移
Transform.translate接收一个offset参数,可以在绘制时沿x、y轴对子组件平移指定的距离。
DecoratedBox(
decoration:BoxDecoration(color: Colors.red),
//默认原点为左上角,左移20像素,向上平移5像素
child: Transform.translate(
offset: Offset(-20.0, -5.0),
child: Text("Hello world"),
),
)
效果如图5-4所示:
#5.3.2 旋转
Transform.rotate可以对子组件进行旋转变换,如:
DecoratedBox(
decoration:BoxDecoration(color: Colors.red),
child: Transform.rotate(
//旋转90度
angle:math.pi/2 ,
child: Text("Hello world"),
),
)
注意:要使用math.pi需先进行如下导包。
import 'dart:math' as math;
效果如图5-5所示:
#5.3.3 缩放
Transform.scale可以对子组件进行缩小或放大,如:
DecoratedBox(
decoration:BoxDecoration(color: Colors.red),
child: Transform.scale(
scale: 1.5, //放大到1.5倍
child: Text("Hello world")
)
);
效果如图5-6所示:
#5.3.4 Transform 注意事项
-
Transform的变换是应用在绘制阶段,而并不是应用在布局(layout)阶段,所以无论对子组件应用何种变化,其占用空间的大小和在屏幕上的位置都是固定不变的,因为这些是在布局阶段就确定的。下面我们具体说明:Row( mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ DecoratedBox( decoration:BoxDecoration(color: Colors.red), child: Transform.scale(scale: 1.5, child: Text("Hello world") ) ), Text("你好", style: TextStyle(color: Colors.green, fontSize: 18.0),) ], )运行效果如图5-14所示:
由于第一个
Text应用变换(放大)后,其在绘制时会放大,但其占用的空间依然为红色部分,所以第二个Text会紧挨着红色部分,最终就会出现文字重合。 -
由于矩阵变化只会作用在绘制阶段,所以在某些场景下,在UI需要变化时,可以直接通过矩阵变化来达到视觉上的UI改变,而不需要去重新触发build流程,这样会节省layout的开销,所以性能会比较好。如之前介绍的
Flow组件,它内部就是用矩阵变换来更新UI,除此之外,Flutter的动画组件中也大量使用了Transform以提高性能。
思考题:使用
Transform对其子组件先进行平移然后再旋转和先旋转再平移,两者最终的效果一样吗?为什么?
#5.3.5 RotatedBox
RotatedBox和Transform.rotate功能相似,它们都可以对子组件进行旋转变换,但是有一点不同:RotatedBox的变换是在layout阶段,会影响在子组件的位置和大小。我们将上面介绍Transform.rotate时的示例改一下:
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
DecoratedBox(
decoration: BoxDecoration(color: Colors.red),
//将Transform.rotate换成RotatedBox
child: RotatedBox(
quarterTurns: 1, //旋转90度(1/4圈)
child: Text("Hello world"),
),
),
Text("你好", style: TextStyle(color: Colors.green, fontSize: 18.0),)
],
),
效果如图5-8所示:
由于RotatedBox是作用于layout阶段,所以子组件会旋转90度(而不只是绘制的内容),decoration会作用到子组件所占用的实际空间上,所以最终就是上图的效果,读者可以和前面Transform.rotate示例对比理解。
5.4 容器组件(Container)
#5.4.1 Container
我们在前面的章节示例中多次用到过Container组件,本节我们就详细介绍一下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,
...
})
Container的大多数属性在介绍其他容器时都已经介绍过了,不再赘述,但有两点需要说明:
- 容器的大小可以通过
width、height属性来指定,也可以通过constraints来指定;如果它们同时存在时,width、height优先。实际上Container内部会根据width、height来生成一个constraints。 color和decoration是互斥的,如果同时设置它们则会报错!实际上,当指定color时,Container内会自动创建一个decoration。
#5.4.2 实例
我们通过Container来实现如图5-9所示的卡片:
实现代码如下:
Container(
margin: EdgeInsets.only(top: 50.0, left: 120.0),
constraints: BoxConstraints.tightFor(width: 200.0, height: 150.0),//卡片大小
decoration: BoxDecoration( //背景装饰
gradient: RadialGradient( //背景径向渐变
colors: [Colors.red, Colors.orange],
center: Alignment.topLeft,
radius: .98,
),
boxShadow: [
//卡片阴影
BoxShadow(
color: Colors.black54,
offset: Offset(2.0, 2.0),
blurRadius: 4.0,
)
],
),
transform: Matrix4.rotationZ(.2),//卡片倾斜变换
alignment: Alignment.center, //卡片内文字居中
child: Text(
//卡片文字
"5.20", style: TextStyle(color: Colors.white, fontSize: 40.0),
),
)
可以看到Container具备多种组件的功能,通过查看Container源码,我们会很容易发现它正是前面我们介绍过的多种组件组合而成。在Flutter中,Container组件也正是组合优先于继承的实例。
#5.4.3 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!"),
),
...
效果如图 5-10 所示:
可以发现,直观的感觉就是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!"),
),
),
...
5.5 剪裁(Clip)
#5.5.1 剪裁类组件
Flutter中提供了一些剪裁组件,用于对组件进行剪裁。
| 剪裁Widget | 默认行为 |
|---|---|
| ClipOval | 子组件为正方形时剪裁成内贴圆形;为矩形时,剪裁成内贴椭圆 |
| ClipRRect | 将子组件剪裁为圆角矩形 |
| ClipRect | 默认剪裁掉子组件布局空间之外的绘制内容(溢出部分剪裁) |
| ClipPath | 按照自定义的路径剪裁 |
下面看一个例子:
import 'package:flutter/material.dart';
class ClipTestRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 头像
Widget avatar = Image.asset("imgs/avatar.png", width: 60.0);
return Center(
child: Column(
children: <Widget>[
avatar, //不剪裁
ClipOval(child: avatar), //剪裁为圆形
ClipRRect( //剪裁为圆角矩形
borderRadius: BorderRadius.circular(5.0),
child: avatar,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Align(
alignment: Alignment.topLeft,
widthFactor: .5,//宽度设为原来宽度一半,另一半会溢出
child: avatar,
),
Text("你好世界", style: TextStyle(color: Colors.green),)
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ClipRect(//将溢出部分剪裁
child: Align(
alignment: Alignment.topLeft,
widthFactor: .5,//宽度设为原来宽度一半
child: avatar,
),
),
Text("你好世界",style: TextStyle(color: Colors.green))
],
),
],
),
);
}
}
运行效果如图5-1所示:
上面示例代码注释比较详细,在此不再赘述。但值得一提的是最后的两个Row!它们通过Align设置widthFactor为0.5后,图片的实际宽度等于60×0.5,即原宽度一半,但此时图片溢出部分依然会显示,所以第一个“你好世界”会和图片的另一部分重合,为了剪裁掉溢出部分,我们在第二个Row中通过ClipRect将溢出部分剪裁掉了。
#5.5.2 自定义裁剪(CustomClipper)
如果我们想剪裁子组件的特定区域,比如,在上面示例的图片中,如果我们只想截取图片中部40×30像素的范围应该怎么做?这时我们可以使用CustomClipper来自定义剪裁区域,实现代码如下:
首先,自定义一个CustomClipper:
class MyClipper extends CustomClipper<Rect> {
@override
Rect getClip(Size size) => Rect.fromLTWH(10.0, 15.0, 40.0, 30.0);
@override
bool shouldReclip(CustomClipper<Rect> 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
),
)
运行效果如图5-12所示:
可以看到我们的剪裁成功了,但是图片所占用的空间大小仍然是60×60(红色区域),这是因为组件大小是是在layout阶段确定的,而剪裁是在之后的绘制阶段进行的,所以不会影响组件的大小,这和Transform原理是相似的。
ClipPath 可以按照自定义的路径实现剪裁,它需要自定义一个CustomClipper<Path> 类型的 Clipper,定义方式和 MyClipper 类似,只不过 getClip 需要返回一个 Path,不再赘述。
5.6 空间适配(FittedBox)
#5.6.1 FittedBox
子组件大小超出了父组件大小时,如果不经过处理的话 Flutter 中就会显示一个溢出警告并在控制台打印错误日志,比如下面代码会导致溢出:
Padding(
padding: const EdgeInsets.symmetric(vertical: 30.0),
child: Row(children: [Text('xx'*30)]), //文本长度超出 Row 的最大宽度会溢出
)
运行效果如图5-13所示:
可以看到右边溢出了 45 像素。
上面只是一个例子,理论上我们经常会遇到子元素的大小超过他父容器的大小的情况,比如一张很大图片要在一个较小的空间显示,根据Flutter 的布局协议,父组件会将自身的最大显示空间作为约束传递给子组件,子组件应该遵守父组件的约束,如果子组件原始大小超过了父组件的约束区域,则需要进行一些缩小、裁剪或其他处理,而不同的组件的处理方式是特定的,比如 Text 组件,如果它的父组件宽度固定,高度不限的话,则默认情况下 Text 会在文本到达父组件宽度的时候换行。那如果我们想让 Text 文本在超过父组件的宽度时不要换行而是字体缩小呢?还有一种情况,比如父组件的宽高固定,而 Text 文本较少,这时候我们想让文本放大以填充整个父组件空间该怎么做呢?
实际上,上面这两个问题的本质就是:子组件如何适配父组件空间。而根据 Flutter 布局协议适配算法应该在容器或布局组件的 layout 中实现,为了方便开发者自定义适配规则,Flutter 提供了一个 FittedBox 组件,定义如下:
const FittedBox({
Key? key,
this.fit = BoxFit.contain, // 适配方式
this.alignment = Alignment.center, //对齐方式
this.clipBehavior = Clip.none, //是否剪裁
Widget? child,
})
#适配原理
- FittedBox 在布局子组件时会忽略其父组件传递的约束,可以允许子组件无限大,即FittedBox 传递给子组件的约束为(0<=width<=double.infinity, 0<= height <=double.infinity)。
- FittedBox 对子组件布局结束后就可以获得子组件真实的大小。
- FittedBox 知道子组件的真实大小也知道他父组件的约束,那么FittedBox 就可以通过指定的适配方式(BoxFit 枚举中指定),让起子组件在 FittedBox 父组件的约束范围内按照指定的方式显示。
我们通过一个简单的例子说明:
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
wContainer(BoxFit.none),
Text('Wendux'),
wContainer(BoxFit.contain),
Text('Flutter中国'),
],
),
);
}
Widget wContainer(BoxFit boxFit) {
return Container(
width: 50,
height: 50,
color: Colors.red,
child: FittedBox(
fit: boxFit,
// 子容器超过父容器大小
child: Container(width: 60, height: 70, color: Colors.blue),
),
);
}
运行后效果如图5-14所示:
因为父Container要比子Container 小,所以当没有指定任何适配方式时,子组件会按照其真实大小进行绘制,所以第一个蓝色区域会超出父组件的空间,因而看不到红色区域。第二个我们指定了适配方式为 BoxFit.contain,含义是按照子组件的比例缩放,尽可能多的占据父组件空间,因为子组件的长宽并不相同,所以按照比例缩放适配父组件后,父组件能显示一部分。
要注意一点,在未指定适配方式时,虽然 FittedBox 子组件的大小超过了 FittedBox 父 Container 的空间,但FittedBox 自身还是要遵守其父组件传递的约束,所以最终 FittedBox 的本身的大小是 50×50,这也是为什么蓝色会和下面文本重叠的原因,因为在布局空间内,父Container只占50×50的大小,接下来文本会紧挨着Container进行布局,而此时Container 中有子组件的大小超过了自己,所以最终的效果就是绘制范围超出了Container,但布局位置是正常的,所以就重叠了。如果我们不想让蓝色超出父组件布局范围,那么可以可以使用 ClipRect 对超出的部分剪裁掉即可:
ClipRect( // 将超出子组件布局范围的绘制内容剪裁掉
child: Container(
width: 50,
height: 50,
color: Colors.red,
child: FittedBox(
fit: boxFit,
child: Container(width: 60, height: 70, color: Colors.blue),
),
),
);
关于 BoxFit 的各种适配规则和 Image 的 fix 属性指定是一样的,读者可以查看我们在介绍 Image 组件时关于各种适配规则对应的效果。
#5.6.2 实例:单行缩放布局
比如我们有三个数据指标,需要在一行显示,因为换行的话就会将我们的页面布局打乱,所以换行是不能接受的。因为不同设备的屏幕宽度不同,且不同人的数据也不同,所以就会出现数据太长或屏幕太窄时三个数据无法在一行显示,因此,我们希望当无法在一行显示时能够对组件进行适当的缩放以确保一行能够显示的下,为此我们写了一个测试 demo :
@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
wRow(' 90000000000000000 '),
FittedBox(child: wRow(' 90000000000000000 ')),
wRow(' 800 '),
FittedBox(child: wRow(' 800 ')),
]
.map((e) => Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: e,
))
.toList();,
),
);
}
// 直接使用Row
Widget wRow(String text) {
Widget child = Text(text);
child = Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [child, child, child],
);
return child;
}
运行后效果如图5-15所示:
首先,因为我们给Row在主轴的对齐方式指定为MainAxisAlignment.spaceEvenly,这会将水平方向的剩余显示空间均分成多份穿插在每一个 child之间。
可以看到,当数字为' 90000000000000000 '时,三个数字的长度加起来已经超出了测试设备的屏幕宽度,所以直接使用 Row 会溢出,当给 Row 添加上如果加上 FittedBox时,就可以按比例缩放至一行显示,实现了我们预期的效果。但是当数字没有那么大时,比如下面的 ' 800 ',直接使用 Row 是可以的,但加上 FittedBox 后三个数字虽然也能正常显示,但是它们却挤在了一起,这不符合我们的期望。之所以会这样,原因其实很简单:在指定主轴对齐方式为 spaceEvenly 的情况下,Row 在进行布局时会拿到父组件的约束,如果约束的 maxWidth 不是无限大,则 Row 会根据子组件的数量和它们的大小在主轴方向来根据 spaceEvenly 填充算法来分割水平方向的长度,最终Row 的宽度为 maxWidth;但如果 maxWidth 为无限大时,就无法在进行分割了,所以此时 Row 就会将子组件的宽度之和作为自己的宽度。
回到示例中,当 Row 没有被 FittedBox 包裹时,此时父组件传给 Row 的约束的 maxWidth 为屏幕宽度,此时,Row 的宽度也就是屏幕宽度,而当被FittedBox 包裹时,FittedBox 传给 Row 的约束的 maxWidth 为无限大(double.infinity),因此Row 的最终宽度就是子组件的宽度之和。
父组件传递给子组件的约束可以用我们上一章中封装的 LayoutLogPrint 来打印出来:
LayoutLogPrint(tag: 1, child: wRow(' 800 ')),
FittedBox(child: LayoutLogPrint(tag: 2, child: wRow(' 800 '))),
运行后控制台日志如下:
flutter: 1: BoxConstraints(0.0<=w<=396.0, 0.0<=h<=Infinity)
flutter: 2: BoxConstraints(unconstrained)
问题原因找到了,那解决的思路就很简单了,我们只需要让FittedBox 子元素接收到的约束的 maxWidth 为屏幕宽度即可,为此我们封装了一个 SingleLineFittedBox 来替换 FittedBox 以达到我们预期的效果,实现如下:
class SingleLineFittedBox extends StatelessWidget {
const SingleLineFittedBox({Key? key,this.child}) : super(key: key);
final Widget? child;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (_, constraints) {
return FittedBox(
child: ConstrainedBox(
constraints: constraints.copyWith(
//让 maxWidth 使用屏幕宽度
maxWidth: constraints.maxWidth
),
child: child,
),
);
},
);
}
}
测试代码改为:
wRow(' 90000000000000000 '),
SingleLineFittedBox(child: wRow(' 90000000000000000 ')),
wRow(' 800 '),
SingleLineFittedBox(child: wRow(' 800 ')),
运行后效果如图5-16所示:
返现 800 正常显示了,但用SingleLineFittedBox包裹的 ' 90000000000000000 ' 的那个 Row 却溢出了!溢出的原因其实也很简单,因为我们在 SingleLineFittedBox 中将传给 Row 的 maxWidth 置为屏幕宽度后,效果和不加SingleLineFittedBox 的效果是一样的,Row 收到父组件约束的 maxWidth 都是屏幕的宽度,所以搞了半天实现了个寂寞。但是,不要放弃,其实离胜利只有一步,只要我们稍加修改,就能实现我们的预期,话不多说,直接上代码:
class SingleLineFittedBox extends StatelessWidget {
const SingleLineFittedBox({Key? key,this.child}) : super(key: key);
final Widget? child;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (_, constraints) {
return FittedBox(
child: ConstrainedBox(
constraints: constraints.copyWith(
minWidth: constraints.maxWidth,
maxWidth: double.infinity,
//maxWidth: constraints.maxWidth
),
child: child,
),
);
},
);
}
}
代码很简单,我们将最小宽度(minWidth)约束指定为屏幕宽度,因为Row必须得遵守父组件的约束,所以 Row 的宽度至少等于屏幕宽度,所以就不会出现缩在一起的情况;同时我们将 maxWidth 指定为无限大,则就可以处理数字总长度超出屏幕宽度的情况。
重新运行后如图5-17所示:
发现无论长数字还是短数字,我们的SingleLineFittedBox 都可以正常工作,大功告成!我们的组件库里面又多了一个组件 。
5.7 页面骨架(Scaffold)
Material 组件库提供了丰富多样的组件,本节介绍一下最常用的 Scaffold 组件,其余的读者可以自行查看文档或 Flutter Gallery 中 Material 组件部分的示例。
注意:Flutter Gallery是Flutter官方提供的Flutter Demo,源码位于flutter源码中的examples目录下,笔者强烈建议用户将Flutter Gallery示例跑起来,它是一个很全面的Flutter示例应用,是非常好的参考Demo,也是笔者学习Flutter的第一手资料。
#5.7.1 Scaffold
一个完整的路由页可能会包含导航栏、抽屉菜单(Drawer)以及底部 Tab 导航菜单等。如果每个路由页面都需要开发者自己手动去实现这些,这会是一件非常麻烦且无聊的事。幸运的是,Flutter Material 组件库提供了一些现成的组件来减少我们的开发任务。Scaffold 是一个路由页的骨架,我们使用它可以很容易地拼装出一个完整的页面。
#实例
我们实现一个页面,它包含:
- 一个导航栏
- 导航栏右边有一个分享按钮
- 有一个抽屉菜单
- 有一个底部导航
- 右下角有一个悬浮的动作按钮
最终效果如图5-18、图5-19所示:
实现代码如下:
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 | 一个导航栏骨架 |
| MyDrawer | 抽屉菜单 |
| BottomNavigationBar | 底部导航栏 |
| FloatingActionButton | 漂浮按钮 |
下面我们来分别介绍一下它们。
#5.7.2 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设置为菜单按钮(如图5-8所示),点击它便可打开抽屉菜单。如果我们想自定义菜单图标,可以手动来设置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();
},
);
}),
...
)
代码运行效果如图5-20所示:
可以看到左侧菜单已经替换成功。
代码中打开抽屉菜单的方法在ScaffoldState中,通过Scaffold.of(context)可以获取父级最近的Scaffold 组件的State对象。
#5.7.3 抽屉菜单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 a***ount'),
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Manage a***ounts'),
),
],
),
),
],
),
),
);
}
}
抽屉菜单通常将Drawer组件作为根节点,它实现了Material风格的菜单面板,MediaQuery.removePadding可以移除Drawer默认的一些留白(比如Drawer默认顶部会留和手机状态栏等高的留白),读者可以尝试传递不同的参数来看看实际效果。抽屉菜单页由顶部和底部组成,顶部由用户头像和昵称组成,底部是一个菜单列表,用ListView实现,关于ListView我们将在6.3节中详细介绍。
#5.7.4 FloatingActionButton
FloatingActionButton是Material设计规范中的一种特殊Button,通常悬浮在页面的某一个位置作为某种常用动作的快捷入口,如本节示例中页面右下角的"➕"号按钮。我们可以通过Scaffold的floatingActionButton属性来设置一个FloatingActionButton,同时通过floatingActionButtonLocation属性来指定其在页面中悬浮的位置,这个比较简单,不再赘述。
#5.7.5 底部Tab导航栏
我们可以通过Scaffold的bottomNavigationBar属性来设置底部导航,如本节开始示例所示,我们通过Material组件库提供的BottomNavigationBar和BottomNavigationBarItem两种组件来实现Material风格的底部导航栏。可以看到上面的实现代码非常简单,所以不再赘述,但是如果我们想实现如图5-21所示效果的底部导航栏应该怎么做呢?
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实现了一个圆形的外形,我们也可以自定义外形,比如,Flutter Gallery示例中就有一个“钻石”形状的示例,读者感兴趣可以自行查看。
#5.7.6 页面 body
最后就是页面的 Body 部分了,Scaffold 有一个 body 属性,接收一个 Widget,我们可以传任意的 Widget ,在下一章中,我们会介绍 TabBarView,它是一个可以进行页面切换的组件,在多 Tab 的 App 中,一般都会将 TabBarView 作为 Scaffold 的 Body。