Flutter 仿微信输入框最佳实践:自适应高度 + 超行数智能切换全屏

145 阅读4分钟

在移动端文本输入场景中,微信的输入体验被广泛认可:输入框高度随内容自动扩展,当内容超出一定行数时,自动显示全屏入口按钮,点击后可进入全屏编辑模式。本文介绍一种基于 TextPainter + GlobalKey 的精确行数计算方案,实现零延迟、无抖动、自然流畅的切换体验。

一、背景与痛点

常见的输入框实现问题

方案一:固定高度 TextField

TextField(maxLines: 3)
  • 只能限制最大行

方案二:minLines + maxLines(Flutter 内置)

TextField(minLines: 1, maxLines: 6)
  • 看似解决了自适应,但无法知道内部实际渲染了多少行
  • 无法在这个临界点插入"全屏入口"按钮

实际需求场景

在聊天与工具类应用中,用户经常需要输入长数据:

单行不够用,6 行 TextField 放不下,此时用户最自然的诉求就是:点击一个按钮,切到全屏输入

核心挑战

  1. Flutter TextField 不暴露内部渲染行数 — 需要自行计算
  2. TextField 实际渲染宽度受内边距、边框影响 — 需要在渲染后通过 GlobalKey 获取

二、核心实现

1. 状态变量

// 右侧Column内放大图标的显隐状态
bool _rightSideFullscreenIcon = false;

// 输入框实际宽度(用于 TextPainter 计算)
double _inputFieldWidth = 0.0;

// 输入框行数计算用的 GlobalKey(用于获取 TextField 实际渲染宽度)
final GlobalKey _inputFieldKey = GlobalKey();

三个变量各司其职:

  • _rightSideFullscreenIcon:控制全屏图标显示与否的开关
  • _inputFieldWidth:TextPainter 计算时的实际可用宽度
  • _inputFieldKey:获取 TextField 渲染后精确尺寸的桥梁

2. 精确计算自动换行的行数 — TextPainter

Flutter 的 TextField 在渲染完成后,可以通过 TextPainter 模拟相同的渲染过程,从而精确计算出给定宽度下的文本行数。

/// 使用 TextPainter 精确计算给定文本在指定宽度下的行数
int _calculateLineCount(String text, double maxWidth, TextStyle style) {
  if (text.isEmpty) return 0;
  final painter = TextPainter(
    text: TextSpan(text: text, style: style),
    maxLines: null,
    textDirection: TextDirection.ltr,
  );
  painter.layout(maxWidth: maxWidth);
  return painter.computeLineMetrics().length;
}

关键点解读:

参数作用
maxLines: null不限制最大行数,让 TextPainter 完整渲染,计算真实换行次数
maxWidth必须是 TextField 实际可用宽度,而非屏幕宽度
computeLineMetrics().length返回渲染后的真实行数

3. onChanged — 实时判断行数并更新状态

onChanged: (value) {
  // 第一步:通过 GlobalKey 获取 TextField 渲染后的实际宽度
  final RenderBox? renderBox = _inputFieldKey.currentContext
      ?.findRenderObject() as RenderBox?;
  if (renderBox != null) {
    _inputFieldWidth = renderBox.size.width;
  }

  // 第二步:计算实际可用宽度(减去输入框内边距与输入框自带边框 36px)
  final double availableWidth = _inputFieldWidth - 36;

  // 第三步:用 TextPainter 计算当前文本行数
  final lineCount =
      _calculateLineCount(value, availableWidth, inputStyle);

  // 第四步:超过 3 行则显示全屏图标
  final newState = lineCount > 3;
  if (newState != _rightSideFullscreenIcon) {
    setState(() {
      _rightSideFullscreenIcon = newState;
    });
  }
},

为什么要减 36px?

TextField 的 maxWidth 应该是其内部文本的实际渲染宽度,要减去输入框的内边距与边框宽度

4. 布局结构

Column (mainAxisSize: min)
└── IntrinsicHeight
    └── Row (crossAxisAlignment: end, mainAxisSize: min)
        ├── Expanded
        │   └── TextField (minLines: 1, maxLines: 6)
        │       └── key: _inputFieldKey
        └── SizedBox (width: 60)
            └── Column
                ├── 全屏图标 (条件渲染,_rightSideFullscreenIcon)
                └── 发送按钮 (固定显示)
使用 IntrinsicHeight 包住 Row,让Row的子组件高度相同

右侧 Column 的动态对齐策略:

mainAxisAlignment: _rightSideFullscreenIcon
    ? MainAxisAlignment.spaceBetween  // 有图标时:上下分布
    : MainAxisAlignment.end            // 无图标时:底部对齐

5. 全屏输入页面

class FullScreenInputPage extends StatefulWidget {
  final String initialText;              // 传入当前 TextField 内容
  final ValueChanged<String> onTextChanged; // 实时同步回原输入框
  final ValueChanged<String> onSend;     // 发送回调
  // ...
}

class _FullScreenInputPageState extends State<FullScreenInputPage> {
  late TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController(text: widget.initialText);
    // 自动聚焦并定位到末尾
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _controller.selection = TextSelection.fromPosition(
        TextPosition(offset: _controller.text.length),
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
        child: Column(
          children: [
            // 顶部导航栏(标题居中,左侧关闭按钮)
            // 错误提示(条件渲染)
            // Expanded 主体输入区(maxLines: null, expands: true)
            // 底部操作栏(格式选择 + 发送按钮)
          ],
        ),
      ),
    );
  }
}

全屏页面的两个核心交互:

  1. 输入实时同步:TextField 每一次 onChanged 都通过 onTextChanged 回调回传内容,即使在全屏页面返回后,原 TextField 内容也保持一致。
  2. 发送后关闭:点击发送按钮时,调用 onSend 后主动 Navigator.pop(context),全屏页关闭,焦点回到原页面。

三、总结

本文的核心在于解决一个看似简单但实际有坑的问题:如何知道 TextField 当前渲染了多少行?

通过 GlobalKey + RenderBox 获取实际渲染宽度,再用 TextPainter 模拟渲染计算行数,最后用状态变量驱动 UI 切换 — 这套组合拳构成了完整的解决方案。

关键要点:

  • TextPainter.computeLineMetrics().length 是获取精确行数的唯一可靠方式
  • TextField 的 maxWidth 应该是其内部文本的实际渲染宽度,要减去输入框的内边距与边框宽度
  • 使用 IntrinsicHeight 包住 Row,让Row的子组件高度相同
  • 全屏页面通过回调实时同步内容,返回后数据不丢失