第5章:容器类组件 —— 5.6 空间适配(FittedBox)

56 阅读9分钟

5.6 空间适配(FittedBox)

本节介绍《Flutter实战·第二版》第5章的 5.6 节内容,讲解 FittedBox 组件的使用,以及如何用它解决子组件超出父组件空间的问题。

📚 学习内容

  1. 溢出问题 - 子组件超出父组件的表现
  2. FittedBox 基础 - 自动缩放子组件以适配父容器
  3. BoxFit 模式 - contain、cover、fill 等适配模式
  4. FittedBox 原理 - 工作机制和约束传递
  5. 实例:Row 溢出 - Row 中文本过长的处理
  6. SingleLineFittedBox - 自定义组件实现完美适配

🎯 核心概念

FittedBox 是什么?

FittedBox 是一个适配组件,它会按照指定的适配模式(BoxFit)缩放并调整子组件的位置,使其适配父组件的空间。

常见使用场景

  1. 防止文本溢出 - 文本过长时自动缩小字体
  2. 图片适配 - 让图片完整显示在固定尺寸容器中
  3. 响应式布局 - 根据屏幕大小自动调整组件尺寸

📖 基础用法

溢出问题

当子组件大小超出父组件时,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 适配模式

FittedBoxfit 参数指定子组件的适配模式:

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

详细解释

  1. 获取父容器约束

    • 父容器告诉 FittedBox:"我的空间是这么大"
    • 例如:BoxConstraints(0.0<=w<=400.0, 0.0<=h<=600.0)
  2. 给子组件无限制约束

    • FittedBox 告诉子组件:"你想多大就多大"
    • 传递:BoxConstraints(unconstrained)BoxConstraints(0.0<=w<=∞, 0.0<=h<=∞)
  3. 子组件确定自己的大小

    • 子组件根据自己的内容计算出需要的尺寸
    • 例如:文本"Hello"需要 50x20 的空间
  4. FittedBox 计算缩放比例

    • 根据 BoxFit 模式,计算如何缩放才能适配
    • 例如:contain 模式会计算保持比例的最大缩放
  5. 对子组件进行缩放

    • 使用 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: [...], // 缩放后正常显示
  ),
)

问题: 短数字时,三个盒子挤在一起。

原因分析:

  1. 不使用 FittedBox 时

    • 父容器传给 Row 的约束:maxWidth = 屏幕宽度
    • Row 根据 spaceEvenly 算法,将屏幕宽度平分
    • Row 的最终宽度 = 屏幕宽度
  2. 使用 FittedBox 时

    • FittedBox 传给 Row 的约束:maxWidth = ∞(无限大)
    • Row 无法平分无限大的空间
    • 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: [...], // 自动缩放,不溢出
  ),
)

工作原理:

  1. LayoutBuilder 获取父容器约束

    • constraints.maxWidth = 屏幕宽度(例如 400)
  2. ConstrainedBox 修改约束

    • minWidth = 400(Row 宽度至少 400)
    • maxWidth = ∞(Row 可以超出 400)
  3. 短数字情况('800')

    • 子组件宽度之和 < 400
    • Row 的宽度 = 400(因为 minWidth = 400)
    • spaceEvenly 平分 400 宽度 ✅
  4. 长数字情况('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 会:

  1. 测量子组件的实际大小
  2. 计算缩放比例
  3. 使用 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:实现一个自适应的价格标签

目标: 创建一个价格标签组件,无论价格多长都能完整显示在固定宽度的容器中

要求:

  1. 固定宽度 120 像素
  2. 价格过长时自动缩小
  3. 价格较短时保持原始大小(不放大)
💡 查看答案
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:实现响应式图标按钮组

目标: 创建一个按钮组,当空间不足时自动缩小图标

要求:

  1. 3-5个图标按钮水平排列
  2. 空间不足时自动等比缩小
  3. 保持间距比例
💡 查看答案
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 行内

要求:

  1. 正常情况下使用原始字体大小
  2. 文本过长时缩小字体
  3. 最多显示 2 行
  4. 过长时显示省略号
💡 查看答案
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: '这是一段非常非常非常非常非常非常长的文本,'
          '即使缩小也无法完全显示,所以会显示省略号',
    ),
  ],
)

📖 参考资源