5.4 容器组件(Container)+ 5.5 剪裁(Clip)
本节合并了《Flutter实战·第二版》第5章的 5.4 和 5.5 两节内容,分别介绍 Container 容器组件和各种 Clip 剪裁组件的使用。
📚 学习内容
Container 部分
- Container 基础 - 多功能组合容器
- Container 属性 - alignment、padding、margin、decoration 等
- Container 大小 - width、height、constraints 的关系
- 实例:卡片效果 - 渐变、阴影、旋转的综合应用
- Padding vs Margin - 容器内外补白的本质区别
Clip 部分
- ClipOval - 圆形/椭圆形裁剪
- ClipRRect - 圆角矩形裁剪
- ClipRect - 矩形裁剪 + 自定义 Clipper
- ClipPath - 自定义路径裁剪(三角形、五角星、对话框)
- CustomClipper - 自定义裁剪器的实现
🎯 核心概念
Container 是什么?
Container 是一个组合类容器,它本身不对应具体的 RenderObject,而是以下组件的组合:
DecoratedBox- 装饰功能ConstrainedBox- 约束功能Transform- 变换功能Padding- 填充功能Align- 对齐功能
这是 组合优先于继承 原则的典型案例。
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 重要规则
1. 大小优先级
// 规则:width/height 优先于 constraints
// 方式1:使用 width/height
Container(
width: 100,
height: 100,
child: Text('100x100'),
)
// 方式2:使用 constraints
Container(
constraints: BoxConstraints.tightFor(width: 100, height: 100),
child: Text('100x100'),
)
// 方式3:同时存在,width/height 优先
Container(
width: 120, // ✅ 这个生效
height: 120, // ✅ 这个生效
constraints: BoxConstraints.tightFor(width: 100, height: 100), // ❌ 被忽略
child: Text('实际是 120x120'),
)
2. color 和 decoration 互斥
// ❌ 错误:不能同时设置
Container(
color: Colors.blue,
decoration: BoxDecoration(
gradient: LinearGradient(...),
),
// 会报错:Cannot provide both a color and a decoration
)
// ✅ 正确:只使用 decoration
Container(
decoration: BoxDecoration(
color: Colors.blue, // 在 decoration 内设置颜色
),
)
原因: 当设置 color 时,Container 内部会自动创建一个 BoxDecoration,与手动设置的 decoration 冲突。
Padding vs Margin 的本质区别
// Margin - 容器外补白
Container(
margin: EdgeInsets.all(20), // 橙色背景不包含这20px
color: Colors.orange,
child: Text('Hello'),
)
// 等价于
Padding(
padding: EdgeInsets.all(20),
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.orange),
child: Text('Hello'),
),
)
// Padding - 容器内补白
Container(
padding: EdgeInsets.all(20), // 橙色背景包含这20px
color: Colors.orange,
child: Text('Hello'),
)
// 等价于
DecoratedBox(
decoration: BoxDecoration(color: Colors.orange),
child: Padding(
padding: EdgeInsets.all(20),
child: Text('Hello'),
),
)
关键区别:
margin在decoration装饰范围外padding在decoration装饰范围内
✂️ Clip 剪裁组件
剪裁的本质
剪裁(Clipping)发生在 绘制阶段(Paint),不会影响 布局阶段(Layout)的组件大小。
// 示例:裁剪后的组件
Container(
width: 100,
height: 100,
color: Colors.red,
child: ClipRect(
clipper: MyClipper(), // 裁剪为 20x20
child: Image.asset('avatar.png'),
),
)
// 结果:
// - 红色容器仍然是 100x100(布局大小)
// - 图片只显示 20x20 区域(绘制范围)
Clip 组件对比
| 组件 | 功能 | 使用场景 |
|---|---|---|
ClipOval | 裁剪成圆形/椭圆形 | 圆形头像、圆形按钮 |
ClipRRect | 裁剪成圆角矩形 | 圆角卡片、圆角图片 |
ClipRect | 按矩形裁剪 | 配合自定义 Clipper 使用 |
ClipPath | 按自定义路径裁剪 | 不规则形状、特殊效果 |
ClipOval 示例
// 圆形头像
ClipOval(
child: Image.network(
'https://example.com/avatar.jpg',
width: 100,
height: 100,
fit: BoxFit.cover,
),
)
// 椭圆形
ClipOval(
child: Container(
width: 120,
height: 80,
color: Colors.blue,
),
)
ClipRRect 示例
// 统一圆角
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset('image.jpg'),
)
// 不同圆角
ClipRRect(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(30),
bottomRight: Radius.circular(30),
),
child: Image.asset('image.jpg'),
)
ClipRect + CustomClipper 示例
// 自定义矩形裁剪器
class MyRectClipper extends CustomClipper<Rect> {
@override
Rect getClip(Size size) {
// 裁剪为左上角 20x20 的区域
return Rect.fromLTWH(0, 0, 20, 20);
}
@override
bool shouldReclip(CustomClipper<Rect> oldClipper) => false;
}
// 使用
ClipRect(
clipper: MyRectClipper(),
child: Image.asset('avatar.png', width: 60, height: 60),
)
ClipPath + CustomClipper 示例
// 三角形裁剪器
class TriangleClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final path = Path();
path.moveTo(size.width / 2, 0); // 顶点
path.lineTo(0, size.height); // 左下角
path.lineTo(size.width, size.height); // 右下角
path.close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
// 使用
ClipPath(
clipper: TriangleClipper(),
child: Container(
width: 100,
height: 100,
color: Colors.blue,
),
)
CustomClipper 详解
CustomClipper - 矩形裁剪
class MyRectClipper extends CustomClipper<Rect> {
@override
Rect getClip(Size size) {
// 返回一个矩形区域
return Rect.fromLTWH(x, y, width, height);
}
@override
bool shouldReclip(CustomClipper<Rect> oldClipper) {
// 是否需要重新裁剪
// 返回 true 表示需要重新裁剪
return false;
}
}
CustomClipper - 路径裁剪
class MyPathClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final path = Path();
// 使用 Path API 绘制任意形状
path.moveTo(x, y);
path.lineTo(x, y);
path.quadraticBezierTo(x1, y1, x2, y2);
path.cubicTo(x1, y1, x2, y2, x3, y3);
path.addOval(rect);
path.addRRect(rrect);
// ...
path.close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
📊 实例:卡片效果
Container(
margin: EdgeInsets.only(top: 50, left: 120),
constraints: BoxConstraints.tightFor(width: 200, height: 150),
decoration: BoxDecoration(
// 径向渐变背景
gradient: RadialGradient(
colors: [Colors.red, Colors.orange],
center: Alignment.topLeft,
radius: 0.98,
),
// 卡片阴影
boxShadow: [
BoxShadow(
color: Colors.black54,
offset: Offset(2, 2),
blurRadius: 4,
),
],
),
// 卡片倾斜 0.2 弧度(约11.5度)
transform: Matrix4.rotationZ(0.2),
alignment: Alignment.center,
child: Text(
'5.20',
style: TextStyle(color: Colors.white, fontSize: 40),
),
)
效果:
- 红橙色径向渐变背景
- 右下方阴影
- 顺时针倾斜约11.5度
- 文字居中显示
🎨 实例:五角星裁剪
class StarClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final path = Path();
final centerX = size.width / 2;
final centerY = size.height / 2;
final outerRadius = size.width / 2;
final innerRadius = outerRadius * 0.4;
for (int i = 0; i < 5; i++) {
// 外顶点
final outerAngle = (i * 2 * pi / 5) - pi / 2;
final outerX = centerX + outerRadius * cos(outerAngle);
final outerY = centerY + outerRadius * sin(outerAngle);
if (i == 0) {
path.moveTo(outerX, outerY);
} else {
path.lineTo(outerX, outerY);
}
// 内顶点
final innerAngle = ((i * 2 + 1) * pi / 5) - pi / 2;
final innerX = centerX + innerRadius * cos(innerAngle);
final innerY = centerY + innerRadius * sin(innerAngle);
path.lineTo(innerX, innerY);
}
path.close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
⚠️ 注意事项
Container
-
color 和 decoration 互斥
- 不能同时设置
color和decoration - 需要颜色时,在
decoration内设置
- 不能同时设置
-
大小优先级
width/height优先于constraints- 同时存在时,
width/height生效
-
性能考虑
- Container 是组合组件,会创建多个子组件
- 如果只需要单一功能,使用具体组件更高效
Clip
-
不影响布局
- 裁剪只影响绘制,不改变组件的布局大小
- 裁剪区域外的内容不可见,但仍占用空间
-
性能开销
- 裁剪会带来一定的性能开销
- 避免在列表等高频渲染场景过度使用
-
抗锯齿
- 默认开启抗锯齿(antiAlias)
- 曲线裁剪(圆形、圆角)效果更平滑
💡 最佳实践
Container 使用
// ✅ 推荐:需要多种功能时使用 Container
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8),
),
child: Text('多功能组合'),
)
// ✅ 推荐:只需要单一功能时使用具体组件
Padding(
padding: EdgeInsets.all(16),
child: Text('仅需填充'),
)
DecoratedBox(
decoration: BoxDecoration(color: Colors.blue),
child: Text('仅需装饰'),
)
Clip 使用
// ✅ 推荐:为图片添加圆角
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network('url'),
)
// ✅ 推荐:圆形头像
ClipOval(
child: Image.network('avatar_url', width: 60, height: 60),
)
// ❌ 避免:在 ListView 中对每个 item 使用复杂裁剪
ListView.builder(
itemBuilder: (context, index) {
return ClipPath(
clipper: ComplexClipper(), // ❌ 性能开销大
child: ListTile(...),
);
},
)
// ✅ 改进:使用更简单的裁剪或装饰
ListView.builder(
itemBuilder: (context, index) {
return ClipRRect(
borderRadius: BorderRadius.circular(8), // ✅ 性能更好
child: ListTile(...),
);
},
)
🎓 练习题
练习1:实现一个带标签的卡片
目标: 使用 Container 和 ClipRRect 创建一个带圆角和标签的卡片
要求:
- 卡片有圆角和阴影
- 左上角有一个彩色标签
- 内容区域有适当的 padding
💡 查看答案
class TaggedCard extends StatelessWidget {
final String tag;
final Color tagColor;
final Widget child;
const TaggedCard({
super.key,
required this.tag,
required this.tagColor,
required this.child,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
// 主卡片
Container(
margin: const EdgeInsets.only(top: 12, left: 8, right: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.3),
spreadRadius: 2,
blurRadius: 5,
offset: const Offset(0, 3),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: child,
),
),
// 标签
Positioned(
top: 0,
left: 20,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: tagColor,
borderRadius: BorderRadius.circular(12),
),
child: Text(
tag,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
}
}
// 使用
TaggedCard(
tag: '新品',
tagColor: Colors.red,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('商品标题', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 8),
Text('商品描述信息'),
],
),
)
练习2:实现圆形头像裁剪(带边框)
目标: 使用 ClipOval 创建一个带边框的圆形头像
要求:
- 头像是圆形
- 外层有彩色边框
- 支持点击放大预览
💡 查看答案
class CircleAvatarWithBorder extends StatelessWidget {
final String imageUrl;
final double size;
final Color borderColor;
final double borderWidth;
const CircleAvatarWithBorder({
super.key,
required this.imageUrl,
this.size = 80,
this.borderColor = Colors.blue,
this.borderWidth = 3,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
// 点击放大预览
showDialog(
context: context,
builder: (context) => Dialog(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(imageUrl, fit: BoxFit.cover),
),
),
);
},
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: borderColor, width: borderWidth),
),
child: ClipOval(
child: Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[300],
child: const Icon(Icons.person, size: 40),
);
},
),
),
),
);
}
}
// 使用
CircleAvatarWithBorder(
imageUrl: 'https://example.com/avatar.jpg',
size: 100,
borderColor: Colors.purple,
borderWidth: 4,
)
练习3:实现对话气泡(自定义裁剪)
目标: 使用 ClipPath 和 CustomClipper 创建聊天对话气泡
要求:
- 左侧气泡(他人消息):灰色,左边有小三角
- 右侧气泡(我的消息):蓝色,右边有小三角
💡 查看答案
// 左侧气泡裁剪器
class LeftBubbleClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final path = Path();
// 左侧小三角
path.moveTo(10, 10);
path.lineTo(0, 15);
path.lineTo(10, 20);
// 圆角矩形
path.lineTo(10, size.height - 10);
path.quadraticBezierTo(10, size.height, 20, size.height);
path.lineTo(size.width - 10, size.height);
path.quadraticBezierTo(size.width, size.height, size.width, size.height - 10);
path.lineTo(size.width, 10);
path.quadraticBezierTo(size.width, 0, size.width - 10, 0);
path.lineTo(20, 0);
path.quadraticBezierTo(10, 0, 10, 10);
path.close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
// 右侧气泡裁剪器
class RightBubbleClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
final path = Path();
// 圆角矩形
path.moveTo(10, 0);
path.lineTo(size.width - 20, 0);
path.quadraticBezierTo(size.width - 10, 0, size.width - 10, 10);
// 右侧小三角
path.lineTo(size.width - 10, 10);
path.lineTo(size.width, 15);
path.lineTo(size.width - 10, 20);
path.lineTo(size.width - 10, size.height - 10);
path.quadraticBezierTo(size.width - 10, size.height, size.width - 20, size.height);
path.lineTo(10, size.height);
path.quadraticBezierTo(0, size.height, 0, size.height - 10);
path.lineTo(0, 10);
path.quadraticBezierTo(0, 0, 10, 0);
path.close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
// 聊天气泡组件
class ChatBubble extends StatelessWidget {
final String message;
final bool isMe;
const ChatBubble({
super.key,
required this.message,
required this.isMe,
});
@override
Widget build(BuildContext context) {
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: EdgeInsets.only(
left: isMe ? 60 : 10,
right: isMe ? 10 : 60,
top: 5,
bottom: 5,
),
child: ClipPath(
clipper: isMe ? RightBubbleClipper() : LeftBubbleClipper(),
child: Container(
padding: const EdgeInsets.all(12),
color: isMe ? Colors.blue[300] : Colors.grey[300],
child: Text(
message,
style: TextStyle(
color: isMe ? Colors.white : Colors.black87,
fontSize: 14,
),
),
),
),
),
);
}
}
// 使用
Column(
children: [
ChatBubble(message: '你好!', isMe: false),
ChatBubble(message: '你好,很高兴认识你!', isMe: true),
ChatBubble(message: '我也是!', isMe: false),
],
)