5.6 空间适配(FittedBox)
本节介绍《Flutter实战·第二版》第5章的 5.6 节内容,讲解 FittedBox 组件的使用,以及如何用它解决子组件超出父组件空间的问题。
📚 学习内容
- 溢出问题 - 子组件超出父组件的表现
- FittedBox 基础 - 自动缩放子组件以适配父容器
- BoxFit 模式 - contain、cover、fill 等适配模式
- FittedBox 原理 - 工作机制和约束传递
- 实例:Row 溢出 - Row 中文本过长的处理
- SingleLineFittedBox - 自定义组件实现完美适配
🎯 核心概念
FittedBox 是什么?
FittedBox 是一个适配组件,它会按照指定的适配模式(BoxFit)缩放并调整子组件的位置,使其适配父组件的空间。
常见使用场景
- 防止文本溢出 - 文本过长时自动缩小字体
- 图片适配 - 让图片完整显示在固定尺寸容器中
- 响应式布局 - 根据屏幕大小自动调整组件尺寸
📖 基础用法
溢出问题
当子组件大小超出父组件时,Flutter 会显示溢出警告:
// ❌ 会溢出
Padding(
padding: EdgeInsets.symmetric(vertical: 30),
child: Row(
children: [
Text('xx' * 30), // 文本长度超出 Row 的最大宽度
],
),
)
效果: 右边会出现黄黑条纹的溢出警告标志,控制台会打印错误日志。
使用 FittedBox 解决
// ✅ 不会溢出
Padding(
padding: EdgeInsets.symmetric(vertical: 30),
child: FittedBox(
child: Row(
children: [
Text('xx' * 30), // 自动缩放以适配
],
),
),
)
效果: 文本会自动缩小,完整显示在父容器内,不会溢出。
🎨 BoxFit 适配模式
FittedBox 的 fit 参数指定子组件的适配模式:
FittedBox(
fit: BoxFit.contain, // 适配模式
child: Image.asset('image.png'),
)
BoxFit 枚举值
| 模式 | 说明 | 特点 |
|---|---|---|
contain | 包含模式(默认) | 完整显示子组件,可能留白 |
cover | 覆盖模式 | 填满父容器,可能裁剪子组件 |
fill | 填充模式 | 拉伸子组件填充父容器,可能变形 |
fitWidth | 适配宽度 | 子组件宽度填满父容器,高度按比例缩放 |
fitHeight | 适配高度 | 子组件高度填满父容器,宽度按比例缩放 |
none | 不缩放 | 保持子组件原始大小,居中显示 |
scaleDown | 缩小模式 | 类似 contain,但只缩小不放大 |
可视化对比
假设父容器是 100x80,子组件是 60x100(高度超出):
// contain - 完整显示,高度适配(留有左右空白)
FittedBox(
fit: BoxFit.contain,
child: Container(width: 60, height: 100, color: Colors.blue),
)
// cover - 填满容器,宽度适配(上下会被裁剪)
FittedBox(
fit: BoxFit.cover,
child: Container(width: 60, height: 100, color: Colors.blue),
)
// fill - 拉伸填充(可能变形)
FittedBox(
fit: BoxFit.fill,
child: Container(width: 60, height: 100, color: Colors.blue),
)
🔍 FittedBox 工作原理
五个步骤
%%{init: {'theme':'dark'}}%%
graph TD
A[1. 获取父容器约束] --> B[2. 给子组件无限制约束]
B --> C[3. 子组件确定大小]
C --> D[4. 计算缩放比例]
D --> E[5. 缩放子组件]
style A fill:#5a2d8f,stroke:#3d1e5f,color:#fff
style B fill:#2d7a4f,stroke:#1e5f3d,color:#fff
style C fill:#8f5a2d,stroke:#5f3d1e,color:#fff
style D fill:#2d5a8f,stroke:#1e3d5f,color:#fff
style E fill:#8f2d5a,stroke:#5f1e3d,color:#fff
详细解释
-
获取父容器约束
- 父容器告诉 FittedBox:"我的空间是这么大"
- 例如:
BoxConstraints(0.0<=w<=400.0, 0.0<=h<=600.0)
-
给子组件无限制约束
- FittedBox 告诉子组件:"你想多大就多大"
- 传递:
BoxConstraints(unconstrained)或BoxConstraints(0.0<=w<=∞, 0.0<=h<=∞)
-
子组件确定自己的大小
- 子组件根据自己的内容计算出需要的尺寸
- 例如:文本"Hello"需要 50x20 的空间
-
FittedBox 计算缩放比例
- 根据
BoxFit模式,计算如何缩放才能适配 - 例如:
contain模式会计算保持比例的最大缩放
- 根据
-
对子组件进行缩放
- 使用
Transform.scale对子组件进行缩放 - 缩放后的子组件正好适配父容器
- 使用
代码模拟
// FittedBox 内部的简化逻辑
class FittedBox {
Widget build() {
return LayoutBuilder(
builder: (context, constraints) {
// 1. 获取父容器约束
final parentWidth = constraints.maxWidth;
final parentHeight = constraints.maxHeight;
// 2. 给子组件无限制约束,让它确定自己的大小
final childSize = _getChildSize(child);
// 3. 计算缩放比例
final scale = _calculateScale(
parentWidth, parentHeight,
childSize.width, childSize.height,
fit, // BoxFit.contain, cover 等
);
// 4. 缩放子组件
return Transform.scale(
scale: scale,
child: child,
);
},
);
}
}
💡 实战案例:单行缩放布局
问题场景
在一个 Row 中显示三个数字,使用 MainAxisAlignment.spaceEvenly 平分空间:
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Container(width: 50, height: 50, color: Colors.red, child: Text('800')),
Container(width: 50, height: 50, color: Colors.green, child: Text('800')),
Container(width: 50, height: 50, color: Colors.blue, child: Text('800')),
],
)
期望:
- 短数字(如 '800')时:三个盒子平均分布,间距相等
- 长数字(如 '90000000000000000')时:自动缩放,不溢出
方案演进
❌ 方案1:直接使用 Row
// 短数字 '800' - ✅ 正常
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [...], // 平均分布
)
// 长数字 '90000000000000000' - ❌ 溢出
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [...], // 右侧溢出
)
问题: 长数字会溢出。
⚠️ 方案2:FittedBox + Row
// 短数字 '800' - ❌ 挤在一起
FittedBox(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [...], // 挤在一起,没有平分空间
),
)
// 长数字 '90000000000000000' - ✅ 自动缩放
FittedBox(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [...], // 缩放后正常显示
),
)
问题: 短数字时,三个盒子挤在一起。
原因分析:
-
不使用 FittedBox 时:
- 父容器传给 Row 的约束:
maxWidth = 屏幕宽度 - Row 根据
spaceEvenly算法,将屏幕宽度平分 - Row 的最终宽度 = 屏幕宽度
- 父容器传给 Row 的约束:
-
使用 FittedBox 时:
- FittedBox 传给 Row 的约束:
maxWidth = ∞(无限大) - Row 无法平分无限大的空间
- Row 的最终宽度 = 子组件宽度之和(最小宽度)
- 所以三个盒子挤在一起
- FittedBox 传给 Row 的约束:
✅ 方案3:SingleLineFittedBox(最终方案)
核心思路:让 Row 的约束满足:
- minWidth = 屏幕宽度:保证 Row 至少占满屏幕宽度
- maxWidth = ∞:允许 Row 超出屏幕(超出时 FittedBox 会缩放)
class SingleLineFittedBox extends StatelessWidget {
const SingleLineFittedBox({super.key, this.child});
final Widget? child;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return FittedBox(
child: ConstrainedBox(
constraints: constraints.copyWith(
minWidth: constraints.maxWidth, // 最小宽度 = 屏幕宽度
maxWidth: double.infinity, // 最大宽度 = 无限大
),
child: child,
),
);
},
);
}
}
使用:
// 短数字 '800' - ✅ 平均分布
SingleLineFittedBox(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [...], // 平均分布,间距相等
),
)
// 长数字 '90000000000000000' - ✅ 自动缩放
SingleLineFittedBox(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [...], // 自动缩放,不溢出
),
)
工作原理:
-
LayoutBuilder 获取父容器约束
constraints.maxWidth= 屏幕宽度(例如 400)
-
ConstrainedBox 修改约束
minWidth = 400(Row 宽度至少 400)maxWidth = ∞(Row 可以超出 400)
-
短数字情况('800')
- 子组件宽度之和 < 400
- Row 的宽度 = 400(因为 minWidth = 400)
spaceEvenly平分 400 宽度 ✅
-
长数字情况('90000000000000000')
- 子组件宽度之和 > 400(例如 600)
- Row 的宽度 = 600(因为 maxWidth = ∞)
- FittedBox 检测到 Row(600) > 父容器(400)
- 缩放比例 = 400 / 600 = 0.67
- 缩放后 Row 显示为 400 宽度 ✅
🎯 对比总结
| 方案 | 短数字 | 长数字 | 优点 | 缺点 |
|---|---|---|---|---|
| Row | ✅ 平均分布 | ❌ 溢出 | 简单 | 长数字溢出 |
| FittedBox + Row | ❌ 挤在一起 | ✅ 自动缩放 | 不溢出 | 短数字挤在一起 |
| SingleLineFittedBox | ✅ 平均分布 | ✅ 自动缩放 | 完美解决 | 需要自定义组件 |
📊 约束传递流程图
%%{init: {'theme':'dark'}}%%
graph TD
A[父容器: 屏幕宽度 400] --> B{使用哪种方案?}
B -->|直接 Row| C[Row 约束: maxWidth=400]
C --> D[Row 宽度 = 400]
D --> E{内容宽度?}
E -->|<400| F[✅ 平分空间]
E -->|>400| G[❌ 溢出]
B -->|FittedBox + Row| H[Row 约束: maxWidth=∞]
H --> I[Row 宽度 = 内容宽度]
I --> J{内容宽度?}
J -->|150| K[❌ 挤在一起]
J -->|600| L[✅ FittedBox 缩放]
B -->|SingleLineFittedBox| M[Row 约束: minWidth=400, maxWidth=∞]
M --> N{内容宽度?}
N -->|<400| O[Row 宽度 = 400 ✅ 平分]
N -->|>400| P[Row 宽度 = 600 ✅ FittedBox 缩放]
style A fill:#5a2d8f,stroke:#3d1e5f,color:#fff
style F fill:#2d7a4f,stroke:#1e5f3d,color:#fff
style G fill:#8f2d2d,stroke:#5f1e1e,color:#fff
style K fill:#8f2d2d,stroke:#5f1e1e,color:#fff
style L fill:#2d7a4f,stroke:#1e5f3d,color:#fff
style O fill:#2d7a4f,stroke:#1e5f3d,color:#fff
style P fill:#2d7a4f,stroke:#1e5f3d,color:#fff
⚠️ 注意事项
1. FittedBox 不是万能的
// ❌ 错误:在无限约束环境中使用
ListView(
children: [
FittedBox(child: Text('Hello')), // 没有意义,ListView 给的是无限约束
],
)
// ✅ 正确:给 FittedBox 指定明确的父容器大小
ListView(
children: [
SizedBox(
height: 100,
child: FittedBox(child: Text('Hello')),
),
],
)
2. 性能考虑
FittedBox 会:
- 测量子组件的实际大小
- 计算缩放比例
- 使用 Transform.scale 进行缩放
在列表等高频渲染场景中要谨慎使用。
3. 文本缩放的替代方案
如果只是文本需要适配,可以考虑:
// 方式1:使用 FittedBox(通用但性能开销大)
FittedBox(child: Text('Hello'))
// 方式2:动态计算字体大小(性能更好)
LayoutBuilder(
builder: (context, constraints) {
final fontSize = constraints.maxWidth / 10;
return Text('Hello', style: TextStyle(fontSize: fontSize));
},
)
// 方式3:使用 AutoSizeText 包(推荐)
AutoSizeText(
'Hello',
style: TextStyle(fontSize: 20),
maxLines: 1,
)
💡 最佳实践
适合使用 FittedBox 的场景
// ✅ 1. 固定尺寸容器中显示不确定大小的内容
Container(
width: 100,
height: 100,
child: FittedBox(
child: Icon(Icons.home, size: 200), // Icon 太大,自动缩小
),
)
// ✅ 2. 防止单行文本溢出
SizedBox(
width: 100,
child: FittedBox(
fit: BoxFit.scaleDown, // 只缩小不放大
child: Text('This is a very long text'),
),
)
// ✅ 3. 响应式图片显示
AspectRatio(
aspectRatio: 16 / 9,
child: FittedBox(
fit: BoxFit.cover,
child: Image.asset('image.png'),
),
)
避免过度使用
// ❌ 避免:嵌套多层 FittedBox
FittedBox(
child: FittedBox( // 没有意义
child: Text('Hello'),
),
)
// ❌ 避免:在列表中大量使用
ListView.builder(
itemBuilder: (context, index) {
return FittedBox( // ❌ 性能开销大
child: ListTile(title: Text('Item $index')),
);
},
)
🎓 练习题
练习1:实现一个自适应的价格标签
目标: 创建一个价格标签组件,无论价格多长都能完整显示在固定宽度的容器中
要求:
- 固定宽度 120 像素
- 价格过长时自动缩小
- 价格较短时保持原始大小(不放大)
💡 查看答案
class PriceTag extends StatelessWidget {
final double price;
final Color backgroundColor;
const PriceTag({
super.key,
required this.price,
this.backgroundColor = Colors.red,
});
@override
Widget build(BuildContext context) {
return Container(
width: 120,
height: 40,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.symmetric(horizontal: 12),
child: FittedBox(
fit: BoxFit.scaleDown, // 只缩小不放大
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'¥',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
Text(
price.toStringAsFixed(2),
style: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
}
// 使用示例
Column(
children: [
PriceTag(price: 9.99), // 正常显示
SizedBox(height: 10),
PriceTag(price: 12999.99), // 自动缩小
SizedBox(height: 10),
PriceTag(price: 5.0), // 正常显示(不放大)
],
)
练习2:实现响应式图标按钮组
目标: 创建一个按钮组,当空间不足时自动缩小图标
要求:
- 3-5个图标按钮水平排列
- 空间不足时自动等比缩小
- 保持间距比例
💡 查看答案
class ResponsiveIconBar extends StatelessWidget {
final List<IconData> icons;
final List<VoidCallback> onTaps;
const ResponsiveIconBar({
super.key,
required this.icons,
required this.onTaps,
}) : assert(icons.length == onTaps.length);
@override
Widget build(BuildContext context) {
return Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(
icons.length,
(index) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: IconButton(
iconSize: 40,
icon: Icon(icons[index]),
onPressed: onTaps[index],
color: Colors.blue,
),
),
),
),
),
);
}
}
// 使用示例
ResponsiveIconBar(
icons: [
Icons.home,
Icons.search,
Icons.favorite,
Icons.shopping_cart,
Icons.person,
],
onTaps: [
() => print('Home'),
() => print('Search'),
() => print('Favorite'),
() => print('Cart'),
() => print('Profile'),
],
)
练习3:实现智能文本框
目标: 创建一个文本框,当文本过长时自动缩小,但保证至少显示在 2 行内
要求:
- 正常情况下使用原始字体大小
- 文本过长时缩小字体
- 最多显示 2 行
- 过长时显示省略号
💡 查看答案
class SmartTextBox extends StatelessWidget {
final String text;
final double maxWidth;
final TextStyle? style;
const SmartTextBox({
super.key,
required this.text,
this.maxWidth = 200,
this.style,
});
@override
Widget build(BuildContext context) {
final defaultStyle = style ?? const TextStyle(fontSize: 16);
return Container(
width: maxWidth,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: LayoutBuilder(
builder: (context, constraints) {
// 先尝试正常显示
final textPainter = TextPainter(
text: TextSpan(text: text, style: defaultStyle),
maxLines: 2,
textDirection: TextDirection.ltr,
)..layout(maxWidth: constraints.maxWidth);
// 如果超出2行,使用 FittedBox
if (textPainter.didExceedMaxLines) {
return FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth,
maxHeight: defaultStyle.fontSize! * 2.5,
),
child: Text(
text,
style: defaultStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
);
}
// 正常显示
return Text(
text,
style: defaultStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
},
),
);
}
}
// 使用示例
Column(
children: [
SmartTextBox(text: '短文本'),
SizedBox(height: 10),
SmartTextBox(text: '这是一段比较长的文本,需要自动调整大小'),
SizedBox(height: 10),
SmartTextBox(
text: '这是一段非常非常非常非常非常非常长的文本,'
'即使缩小也无法完全显示,所以会显示省略号',
),
],
)