只读 RichTextBox 自适应高度:为什么你测出来永远只有一行

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 吸收结果。

参考与出处

本文代码取自本人参与的 WinForms 项目并做脱敏与简化,类名、字段名与工程信息已替换为通用命名;数值结论以文中描述为准,未列出的内部实现细节不予公开。

免责声明:本站为个人学习性质的技术站点,文章内容仅供学习参考,不构成任何业务或技术采购建议。如涉及版权等问题,请通过“意见反馈”页邮箱联系,核实后将删除或修正相关内容。