1. 现象
最近做一个消息气泡控件,气泡里放只读 RichTextBox 渲染富文本(不同字体、表情、混排)。内容有三行,运行时气泡却只露出一行的高度,剩下的被裁掉,滚动条也没出现。手动把气泡拉大,文字才肯显示全。
仔细检查才发现,气泡自己算出来的高度是错的——不是没测,是测的时机不对。
2. 第一版为什么会错
最常见的写法是在句柄创建后测量,用末尾字符的坐标推算总高:
rtb.HandleCreated += (s, e) =>
{
// 看着很合理:句柄都有了,问它排版排到哪了
var pos = rtb.GetPositionFromCharIndex(rtb.TextLength - 1);
rtb.Height = pos.Y + rtb.Font.Height + rtb.Padding.Vertical + 6;
};
问题在于 GetPositionFromCharIndex 返回的是已经按当前宽度回流之后的坐标。而刚建好句柄这一刻,回流往往还没发生,于是末尾字符的 Y 还是第一行的位置——你怎么加都只有一行。
3. 根因分两层
- 测量手段依赖回流:
GetPositionFromCharIndex是"问排版结果",不是"问内容需要多高"。排版没做,它就只能给你错的。 - 句柄创建不等于布局已定:控件常常是在隐藏状态下建的句柄,或者父容器(AutoSize 的面板)还没把最终宽度定下来。这时 RichEdit 引擎拿到的宽度不是最终宽度,按这个宽度排出来的行数自然也不对。
第二层才是关键:HandleCreated 触发时,你不能假定 Width 已经是最终值。很多人(包括当时的我)以为句柄都有了,尺寸自然也就有了。
4. 正确做法之一:直接问 RichEdit 引擎要像素高
不要去反推坐标,而是发 EM_GETTEXTLENGTHEX 消息,让引擎按当前宽度排完之后把总像素高度告诉你,天然包含自动换行和混行里最高的一行:
private const int EM_GETTEXTLENGTHEX = 0x0400 + 95; // WM_USER + 95
private const uint TextLenFlags = 0x02 | 0x08; // 见第 6 节:这个组合务必自己实测
[StructLayout(LayoutKind.Sequential)]
private struct TEXTLENGTHEX
{
public uint flags;
public uint codepage;
}
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int SendMessage(IntPtr hWnd, int Msg, ref TEXTLENGTHEX wParam, int lParam);
封装成一个测高方法,并保留一条降级路径——引擎问不到时退回坐标估算,至少不至于显示成空白:
private void AutoSizeRichTextBox(RichTextBox rtb)
{
if (rtb == null || !rtb.IsHandleCreated) return;
rtb.Width = BubbleTextWidth; // 先锁定气泡内文本宽度
int needed = 0;
try
{
var tle = new TEXTLENGTHEX { flags = TextLenFlags, codepage = 1200 };
int rawPx = SendMessage(rtb.Handle, EM_GETTEXTLENGTHEX, ref tle, 0);
if (rawPx > 0)
needed = rawPx + rtb.Padding.Vertical + 2;
}
catch { /* 有意静默:测不到就走下面的降级估算 */ }
if (needed <= 0) // 降级:按末尾字符坐标估
{
int len = rtb.TextLength;
var endPos = rtb.GetPositionFromCharIndex(len > 0 ? len - 1 : 0);
needed = endPos.Y + rtb.Font.Height + rtb.Padding.Vertical + 6;
}
if (needed < 20) needed = 20;
if (Math.Abs(rtb.Height - needed) > 1) // 差异超过 1px 才赋值,避免抖动
rtb.Height = needed;
}
5. 光换 API 还不够:可见之后必须重测
第 3 节第二层原因还在。所以除了 HandleCreated,一定要再订阅 VisibleChanged,等控件真正可见、宽度定下来之后再量一次,并让父容器把新高度吸收掉:
rtb.HandleCreated += (s, e) =>
{
AutoSizeRichTextBox(rtb);
AdjustHeight(); // 重算气泡自身高度
this.Parent?.PerformLayout(); // 让外层面板吸收新高度
};
rtb.VisibleChanged += (s, e) =>
{
if (rtb.Visible && rtb.IsHandleCreated) // 隐藏时建句柄的兜底
{
AutoSizeRichTextBox(rtb);
AdjustHeight();
this.Parent?.PerformLayout();
}
};
最后那三行同样重要。只改 RichTextBox 的高度,外层气泡和滚动面板不会自动跟上——测准了却没生效,表现和测不准一模一样,很迷惑人。
6. 一个务必实测的点:flags 取值
我项目里实际跑通的 flags 组合,与 richedit.h 中同名宏的取值并不完全对得上,但在这套 RichEdit 版本上拿到的确实就是"按宽度排版后的总像素高度"。RichEdit 有多个版本(RICHEDIT50W 与更早的),控件内部装载哪个取决于运行时。
所以这里不给"照抄即可"的结论:请在你自己的目标环境里打印一次返回值核对,别直接抄网上的常量组合。我上面代码里把 flags 提成常量,就是为方便你替换验证。
一句话小结:凡是依赖"已经排版"的测量(换行高度、自适应宽高),都不能在句柄创建那一刻下结论;要等可见、等宽度定稿后重测,并记得让父容器 PerformLayout 吸收结果。
参考与出处
- Microsoft Learn · RichTextBox 类
- Win32 公开文档 · Rich Edit Controls(含 EM_GETTEXTLENGTHEX 与 TEXTLENGTHEX)
- Microsoft Learn · Control.PerformLayout 方法
本文代码取自本人参与的 WinForms 项目并做脱敏与简化,类名、字段名与工程信息已替换为通用命名;数值结论以文中描述为准,未列出的内部实现细节不予公开。