跳到主要内容

四、容器组件

定义

看了这么久,我div呢?布局控件相对使用比较麻烦,容器组件感觉更贴近一些。

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

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

容器(Container)

它就是我们的div吧... 它结合了绘制、定位和大小调整为一体。可以用它来设置背景颜色、形状、边框等。如果没有子组件,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,
...
})

注意:

  • 容器的大小可以通过widthheight属性来指定,也可以通过constraints来指定;如果它们同时存在时,widthheight优先。实际上Container内部会根据widthheight来生成一个constraints
  • colordecoration是互斥的,如果同时设置它们则会报错!实际上,当指定color时,Container内会自动创建一个decoration

实现一个圆角带阴影的卡片示例:

Container(
margin: const EdgeInsets.only(top: 50.0, left: 120.0),
constraints: const BoxConstraints.tightFor(width: 200.0, height: 150.0), //卡片大小
// 前景装饰
// foregroundDecoration: BoxDecoration( // 前景装饰会把背景装饰和子组件都遮住
// color: Colors.blue,
// ),
//背景装饰
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10.0), //圆角
gradient: const RadialGradient( // 背景径向渐变
colors: [Colors.red, Colors.orange],
center: Alignment.topLeft,
radius: .98, // 半径
),
boxShadow: const [ // 卡片阴影
BoxShadow(
color: Colors.black54,
offset: Offset(2.0, 2.0),
blurRadius: 4.0,
)
],
),
transform: Matrix4.rotationZ(.2), //卡片倾斜变换
alignment: Alignment.center, //卡片内文字居中
child: const Text(
//卡片文字
"5.20", style: TextStyle(color: Colors.white, fontSize: 40.0),
),
)

填充(Padding)

和css一样,内边距。Padding组件用于给其子组件添加填充空间。它只接受一个子组件,并且可以指定上下左右的填充量。

Padding(
padding: EdgeInsets.all(20.0),
child: Text('Hello, Flutter!'),
)

EdgeInsets可以设置空间的内边距:

  • fromLTRB(double left, double top, double right, double bottom):分别指定四个方向的填充。注意与css的 top right bottom left顺序不同
  • all(double value) : 所有方向均使用相同数值的填充。
  • only({left, top, right ,bottom }):可以设置具体某个方向的填充(可以同时指定多个方向)。
  • symmetric({ vertical, horizontal }):用于设置对称方向的填充,verticaltopbottomhorizontalleftright

样式(DecoratedBox)

可以在其子 widget 绘制前或绘制后绘制一个装饰(Decoration)。这个装饰可以包括背景色、边框、边距、圆角、渐变、背景图像等。DecoratedBox 对于添加简单的装饰效果非常有用,而无需创建一个完整的 Container widget,这使得布局更加轻量级。

const DecoratedBox({
Decoration decoration,
DecorationPosition position = DecorationPosition.background,
Widget? child
})

主要属性:

  • decoration:这是 DecoratedBox 的核心属性,类型为 Decoration。这个属性定义了如何绘制装饰,比如颜色、形状、阴影等。BoxDecorationDecoration 的一个常用子类,它提供了设置背景色、图片、边框、圆角等的能力。
  • position:这个属性决定装饰是绘制在子 widget 的背景之下还是前景之上,取值为 DecorationPosition.backgroundDecorationPosition.foreground
  • childDecoratedBox 的子 widget,它位于装饰之上或之下,取决于 position 的值。

BoxDecoration

BoxDecoration({
Color color, //颜色
DecorationImage image,//图片
BoxBorder border, //边框
BorderRadiusGeometry borderRadius, //圆角
List<BoxShadow> boxShadow, //阴影,可以指定多个
Gradient gradient, //渐变
BlendMode backgroundBlendMode, //背景混合模式
BoxShape shape = BoxShape.rectangle, //形状
})
属性类型描述
colorColor背景颜色。注意,如果你同时使用了imagecolor就会作为图片的背景色。
imageDecorationImage背景图像。可以设置图片的填充方式、对齐方式等。
borderBorder边框。可以单独设置每一边的样式,如颜色、宽度。
borderRadiusBorderRadius边框圆角。可以分别设置每个角的圆角大小。
boxShadowList<BoxShadow>阴影列表。可以设置多个阴影,每个阴影可以有不同的颜色、偏移、模糊程度等。
gradientGradient渐变。可以设置线性渐变(LinearGradient)、径向渐变(RadialGradient)或扫描式渐变(SweepGradient)。
shapeBoxShape形状。可以是BoxShape.rectangle(矩形,默认值)或BoxShape.circle(圆形)。
backgroundBlendModeBlendMode背景混合模式。用于与背景色混合,创建不同的视觉效果。

示例:

DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
// 线性渐变
colors: [Colors.red, Colors.orange],
),
borderRadius: BorderRadius.circular(10.0), // 圆角
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)),
),
)

变换(Transform)

类似css中的transform。 Transform控件可以对widget进行旋转、缩放、平移和倾斜等变换,而不改变widget本身的布局属性(如大小和位置)。Transform直接作用于绘制阶段,因此不会影响布局,这意味着即使变换后的widget视觉上超出了原始布局空间,也不会影响其他widget的布局。

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!'),
),
),
)

主要属性:

  • transform:这是 Transform widget 的核心属性,它接受一个 Matrix4 对象,定义了如何变换widget。Matrix4 是一个4x4矩阵,用于描述3D变换。
  • alignment:定义变换的原点,默认是中心点 Alignment.center。变换(如旋转和缩放)是相对于这个点进行的。
  • origin:一个 Offset 类型的点,也可以用来定义变换的原点。如果同时指定了 originalignment,则 origin 优先。
  • child:要变换的子widget。

基本示例:

DecoratedBox( // 旋转
decoration:BoxDecoration(color: Colors.blue),
child: Transform.rotate(
//旋转90度
angle:math.pi/2 ,
child: Text("Hello world"),
),
),
SizedBox(height: 50,),
DecoratedBox( // 缩放
decoration:BoxDecoration(color: Colors.green),
child: Transform.scale(
scale: 1.5, //放大到1.5倍
child: Text("Hello world")
)
),
SizedBox(height: 50,),
DecoratedBox( // 平移
decoration:BoxDecoration(color: Colors.red),
//默认原点为左上角,左移20像素,向上平移5像素
child: Transform.translate(
offset: Offset(-20.0, -5.0),
child: Text("Hello world"),
),
),
SizedBox(height: 80,),
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.orange,
child: const Text('Apartment for rent!'),
),
),
)

RotatedBox

RotatedBoxTransform.rotate功能相似,它们都可以对子组件进行旋转变换,但是有一点不同:RotatedBox的变换是在layout阶段,会影响在子组件的位置和大小

DecoratedBox(
decoration:BoxDecoration(color: Colors.red),
//默认原点为左上角,左移20像素,向上平移5像素
child: Transform.translate(
offset: Offset(-20.0, -5.0),
child: Text("Hello world"),
),
),
DecoratedBox(
decoration: BoxDecoration(color: Colors.red),
//将Transform.rotate换成RotatedBox
child: RotatedBox(
quarterTurns: 1, //旋转90度(1/4圈)
child: Text("Hello world"),
),
),

由于RotatedBox是作用于layout阶段,所以子组件会旋转90度(而不只是绘制的内容),decoration会作用到子组件所占用的实际空间上,所以最终就是上图的效果

思考

使用Transform对其子组件先进行平移然后再旋转和先旋转再平移,两者最终的效果一样吗?

在使用 Transform 对其子组件进行变换时,先进行平移然后再旋转与先旋转再平移的效果一般是不一样的。这是因为变换(Transformations)是按照指定的顺序应用的,且变换的效果是相对于当前坐标系来说的。在变换的过程中,每一步变换都可能改变后续变换的基准坐标系。

先平移再旋转:

当你先对一个组件进行平移操作,你是在当前的坐标系中移动了组件的位置。接着当你对组件进行旋转时,旋转是相对于旋转中心(默认是组件的中心)在已平移的新位置上进行的。

先旋转再平移:

相反,如果你先旋转组件,组件将围绕旋转中心(或指定的旋转点)旋转。此时如果再对组件进行平移,平移将在已旋转的坐标系中进行,这意味着平移的方向可能与原始坐标系中的方向不同。

body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
// 先平移再旋转
Transform.translate(
offset: Offset(50, 0), // 向右平移50个单位
child: Transform.rotate(
angle: math.pi / 2, // 旋转90度
child: Container(
color: Colors.red,
width: 100,
height: 100,
child: Text('先平移再旋转'),
),
),
),
SizedBox(height: 50), // 添加一些间隔
// 先旋转再平移
Transform.rotate(
angle: math.pi / 2, // 旋转90度
child: Transform.translate(
offset: Offset(50, 0), // 向右平移50个单位
child: Container(
color: Colors.blue,
width: 100,
height: 100,
child: Text('先旋转再平移'),
),
),
),
],
),

同时可以看到,变换规则是父组件->子组件

裁剪(Clip) - overflow

剪裁(Clipping)是一种常用的UI效果,用于对widget进行形状裁剪或尺寸限制,使其显示区域被限制在特定的形状或路径内。Flutter提供了多种剪裁widget来实现不同的剪裁效果:

默认裁剪

剪裁Widget默认行为
ClipOval子组件为正方形时剪裁成内贴圆形;为矩形时,剪裁成内贴椭圆
ClipRRect将子组件剪裁为圆角矩形
ClipRect默认剪裁掉子组件布局空间之外的绘制内容(溢出部分剪裁)
ClipPath按照自定义的路径剪裁
Widget build(BuildContext context) {
final Widget avatar = Image.asset("images/pyy.jpeg", width: 60.0, height: 60, fit: BoxFit.fill,); // 设置图像的裁剪效果可以调整fit属性
return Scaffold(
appBar: AppBar(
title: const Text(
'容器',
),
centerTitle: true, // 文本居中
backgroundColor: Colors.cyanAccent.shade700,
),
body: 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))
],
),
],
),
));
}

widthFactorAlign和其他一些布局widget的一个属性,用于确定子widget的宽度相对于其父widget的大小 最后的两个Row通过Align设置widthFactor为0.5后,图片的实际宽度等于60×0.5,即原宽度一半,但此时图片溢出部分依然会显示。 所以第一个“你好世界”会和图片的另一部分重合,为了剪裁掉溢出部分,我们在第二个Row中通过ClipRect将溢出部分剪裁掉了。

自定义裁剪(CustomClipper)

如果我们想剪裁子组件的特定区域,比如,在上面示例的图片中,如果我们只想截取图片中部40×30像素的范围应该怎么做?这时我们可以使用CustomClipper来自定义剪裁区域,实现代码如下:

class MyClipper extends CustomClipper<Rect> {

Rect getClip(Size size) => Rect.fromLTWH(100.0, 0, 300.0, 300.0); // left,top,width,height


bool shouldReclip(CustomClipper<Rect> oldClipper) => false;
}
  • getClip()是用于获取剪裁区域的接口,由于图片大小是499x285,我们返回剪裁区域为Rect.fromLTWH(100.0, 0, 300.0, 300.0),即图片中部40×30像素的范围。
  • shouldReclip() 接口决定是否重新剪裁。如果在应用中,剪裁区域始终不会发生变化时应该返回false,这样就不会触发重新剪裁,避免不必要的性能开销。如果剪裁区域会发生变化(比如在对剪裁区域执行一个动画),那么变化后应该返回true来重新执行剪裁。
final Widget avatar = Image.asset("images/pyy.jpeg", width: 375, height: 285, fit: BoxFit.cover,);

DecoratedBox(
decoration: BoxDecoration(
color: Colors.red
),
child: ClipRect(
clipper: MyClipper(), //使用自定义的clipper
child: avatar
),
),
avatar, //不剪裁

可以看到我们的剪裁成功了,但是图片所占用的空间大小仍然是375×285(红色区域),这是因为组件大小是是在layout阶段确定的,而剪裁是在之后的绘制阶段进行的,所以不会影响组件的大小,这和Transform原理是相似的。

ClipPath 可以按照自定义的路径实现剪裁,它需要自定义一个CustomClipper<Path> 类型的 Clipper,定义方式和 MyClipper 类似,只不过 getClip 需要返回一个 Path:

// 改写刚才的例子
class MyClipperPath extends CustomClipper<Path> {

Path getClip(Size size) {
// 定义一个新的路径
var path = Path();
// 从左上角(100.0, 0)开始
path.moveTo(100.0, 0);
// 定义一个矩形路径,与原来的Rect.fromLTWH(100.0, 0, 300.0, 300.0)等价
path.lineTo(100.0, 300.0); // 向下画线
path.lineTo(400.0, 300.0); // 向右画线
path.lineTo(400.0, 0); // 向上画线
path.close(); // 闭合路径
return path;
}


bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}

空间适配(FittedBox)

子组件大小超出了父组件大小时,如果不经过处理的话 Flutter 中就会显示一个溢出警告并在控制台打印错误日志: 当看到黄色和黑色条纹警告,这通常表示某个widget超出了其父widget的布局约束。在这个例子中,Row widget 包含一个文本(Text('xyz' * 50)),这个文本的内容超过了屏幕的宽度。

Row widget 和其他Flex类widget(如 ColumnFlex)默认情况下不会对其子widget进行剪裁。这意味着如果子widget超出了 Row 的边界,它会在界面上显示为溢出内容的视觉提示,即那些黄色和黑色的条纹。

在这个特定的例子中,虽然 Container 设置了无限宽度的约束(BoxConstraints(minWidth: double.infinity)),但是 Row 仍然尝试让所有的子widget在水平方向上排列,而不考虑它们的总宽度是否超过屏幕宽度。因此,当文本内容超过屏幕宽度时,Row 的子widget(在这里是文本)就会溢出,并在界面上显示溢出的警告。

抛出需求: 如果我们想让 Text 文本在超过父组件的宽度时不要换行而是字体缩小呢?还有一种情况,比如父组件的宽高固定,而 Text 文本较少,这时候我们想让文本放大以填充整个父组件空间该怎么做呢?实际上,这两个问题的本质就是:子组件如何适配父组件空间。

定义

FittedBox根据自身的大小和子 widget 的大小,对子 widget 进行缩放和定位,以确保子 widget 能够根据指定的适配方式完全填充 FittedBox 的空间。这对于解决不同屏幕尺寸和分辨率导致的布局问题特别有用,是实现空间适配的一种常见方法。

const FittedBox({
Key? key,
this.fit = BoxFit.contain, // 适配方式
this.alignment = Alignment.center, //对齐方式
this.clipBehavior = Clip.none, //是否剪裁
Widget? child,
})

BoxFit 枚举值描述
fill子widget会被拉伸来完全填充FittedBox的空间,可能会导致子widget的宽高比失衡。
contain保持子widget的宽高比,使得子widget尽可能大地填充,同时确保其完整显示在FittedBox内部。
cover保持子widget的宽高比,尽可能小地填充,确保FittedBox的每个部分都被子widget覆盖。
fitWidth保持子widget的宽高比,缩放子widget以填充FittedBox的宽度。
fitHeight保持子widget的宽高比,缩放子widget以填充FittedBox的高度。

适配原理

  1. FittedBox 在布局子组件时会忽略其父组件传递的约束,可以允许子组件无限大,即FittedBox 传递给子组件的约束为(0<=width<=double.infinity, 0<= height <=double.infinity)。
  2. FittedBox 对子组件布局结束后就可以获得子组件真实的大小。
  3. FittedBox 知道子组件的真实大小也知道他父组件的约束,那么FittedBox 就可以通过指定的适配方式(BoxFit 枚举中指定),让起子组件在 FittedBox 父组件的约束范围内按照指定的方式显示。

示例

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),
),
);
}

body: Center(
child: Column(
children: [
wContainer(BoxFit.none),
Text('上方区域有越界'),
wContainer(BoxFit.contain),
Text('正常展示'),
],
),

因为父Container要比子Container 小,所以当没有指定任何适配方式时,子组件会按照其真实大小进行绘制:

  1. 第一个蓝色区域会超出父组件的空间,因而看不到红色区域
  2. 第二个我们指定了适配方式为 BoxFit.contain,含义是按照子组件的比例缩放,尽可能多的占据父组件空间,因为子组件的长宽并不相同,所以按照比例缩放适配父组件后,父组件能显示一部分 把子组件的蓝色背景删除看下效果: 在未指定适配方式时,虽然 FittedBox 子组件的大小超过了 FittedBox 父 Container 的空间,但FittedBox 自身还是要遵守其父组件传递的约束,所以最终 FittedBox 的本身的大小是 50×50,这也是为什么蓝色会和下面文本重叠的原因,因为在布局空间内,父Container只占50×50的大小,接下来文本会紧挨着Container进行布局,而此时Container 中有子组件的大小超过了自己,所以最终的效果就是绘制范围超出了Container,但布局位置是正常的,所以就重叠了。

    如果我们不想让蓝色超出父组件布局范围,那么可以使用 ClipRect 包裹wContainer中的Container对超出的部分剪裁掉即可

页面框架(Scaffold)- 重要

Scaffold 是 Flutter 中的一个非常重要的布局组件,它提供了一个框架,用于实现基本的材料设计布局结构。Scaffold 提供了默认的导航栏、标题、包含主屏幕widget树的body属性、一个浮动操作按钮、抽屉菜单(通常用于创建导航抽屉)等等。

属性类型描述
appBarPreferredSizeWidget显示在界面顶部的AppBar。
bodyWidget当前界面所显示的主要内容Widget。
floatingActionButtonWidget浮动在body上的按钮,通常用于执行应用程序中的主要动作。
drawerWidget抽屉菜单,通常是一个Drawer widget,从屏幕边缘滑入。
bottomNavigationBarWidget底部导航条,可以有多个BottomNavigationBarItem组成。
backgroundColorColorScaffold的背景颜色。
import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {

Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: Text('Scaffold 示例'),
actions: <Widget>[
//导航栏右侧菜单
IconButton(icon: Icon(Icons.share), onPressed: () {}),
],
),
body: Center(
child: Text('Hello, Scaffold!'),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// 浮动动作按钮的点击事件
print('FloatingActionButton tapped');
},
child: Icon(Icons.add),
),
bottomNavigationBar: BottomNavigationBar(
items: const <BottomNavigationBarItem>[
BottomNavigationBarItem(
icon: Icon(Icons.home),
label: '首页',
),
BottomNavigationBarItem(
icon: Icon(Icons.school),
label: '学习',
),
BottomNavigationBarItem(
icon: Icon(Icons.school),
label: '学习',
),
BottomNavigationBarItem(
icon: Icon(Icons.school),
label: '学习',
),
],
type: BottomNavigationBarType.fixed, // tabbar超过3个,不加该属性会展示异常
currentIndex: 0,
selectedItemColor: Theme.of(context).colorScheme.primary,
// 切换页面
onTap: (index) {
print(index);
},
),
drawer: Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: <Widget>[
DrawerHeader(
child: Text('抽屉头部'),
decoration: BoxDecoration(
color: Colors.blue,
),
),
ListTile(
title: Text('项 1'),
onTap: () {
// 更新应用状态...
Navigator.pop(context); // 关闭抽屉菜单
},
),
ListTile(
title: Text('项 2'),
onTap: () {
// 更新应用状态...
Navigator.pop(context); // 关闭抽屉菜单
},
),
// ... 其他列表项
],
),
),
),
);
}
}

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,
... //其他属性见源码注释
})