Wednesday, December 5, 2007

Multi-line Graphics.MeasureString implementation on .Net CF

If you have ever tried to build a dynamic UI for a .Net Compact Framework application, probably you've had to build adjustable multi-line labels or text-boxes. It's hard to solve because the only supported overload for Graphics.MeasureString on .Net CF is:

public SizeF MeasureString ( string text, Font font )

When you need to resize or position the controls dynamically in runtime, it's very important to know what should be the size, particularly the height of the multi-line label or multi-line text-box. It's the same problem if you're building a new custom control with a complex layout and you need to measure a potential multi-line string.


Having only this overload on .Net CF, we cannot get a multi-line string size because it calculates just the size of a single-line string. If the string is longer than the string, it gets a big SizeF result but as a single-line text.


Solving the problem


The only solution here is to implement our own multi-line MeasureString method.


To solve the problem, we'll use the native API DrawText. It will calculate the size of the text according with the uFormat parameter and using the graphics (device context) selected font.

[DllImport("coredll.dll")]
static extern int DrawText(IntPtr hdc, string lpStr, int nCount, ref Rect lpRect, int wFormat);

Additionally, if the control if a text-box, we should use the DT_EDITCONTROL flag and add extra 6 pixels (3 pixels at top and 3 pixels at bottom) to the calculated size.


Remember, if you have an empty string, you'll probably need to force one-line size for your controls.


Here's the CFMeasureString sample class source code:

using System;
using System.Text;
using System.Runtime.InteropServices;
using System.Drawing;

namespace MeasureStringSample
{
public class CFMeasureString
{
private struct Rect
{
public int Left, Top, Right, Bottom;
public Rect(Rectangle r)
{
this.Left = r.Left;
this.Top = r.Top;
this.Bottom = r.Bottom;
this.Right = r.Right;
}
}

[DllImport("coredll.dll")]
static extern int DrawText(IntPtr hdc, string lpStr, int nCount, ref Rect lpRect, int wFormat);
private const int DT_CALCRECT = 0x00000400;
private const int DT_WORDBREAK = 0x00000010;
private const int DT_EDITCONTROL = 0x00002000;

static public Size MeasureString(Graphics gr, string text, Rectangle rect, bool textboxControl)
{
Rect bounds = new Rect(rect);
IntPtr hdc = gr.GetHdc();
int flags = DT_CALCRECT|DT_WORDBREAK;
if (textboxControl) flags |= DT_EDITCONTROL;
DrawText(hdc, text, text.Length, ref bounds, flags);
gr.ReleaseHdc(hdc);
return new Size(bounds.Right - bounds.Left, bounds.Bottom - bounds.Top + (textboxControl? 6 : 0));
}
}
}

And you can use it to resize a label in the following way:

label1.Height = CFMeasureString.MeasureString(CreateGraphics(), label1.Text, label1.ClientRectangle, false).Height;

Now we can start using it and build a dynamic UI on .Net CF.


Update: To support different font sizes, please take a look at this post which shows a good improvement (a.k.a. fix ;)).

15 comments:

Anonymous said...

Extremely useful. Very nice bit of code. Thanks very much for posting.

Anonymous said...

Perfect! Just what I was looking for. Thanks for posting!!!

Anonymous said...

Realy appreciate. With love from Russia.

Anonymous said...

I am in head each with this problem.
Thank you so much.

Anonymous said...

There are two typos in the code, probably due to erroneous copy & paste:

int flags = DT_CALCRECT|DT_WORDBREAK;
if (textboxControl) flags |= DT_EDITCONTROL;

The OR calculation "|" was missing. The first typos result in a compilation error whereas the second typo result in wrong calculation.

Jose Gallardo said...

You're right, it was a copy/paste issue with the blogger editor. I fix it with WLW and it looks fine now. Thanks for the catch!

archimed7592 said...

Thanks, Jose!

I've slightly modified it to take into account different fonts.

Mainly the difference is concentrated in these four lines:

var hFont = label.Font.ToHfont();
var oldHFont = Api.SelectObject(hDc, hFont);
// ...
Api.SelectObject(hDc, oldHFont);
Api.DeleteObject(hFont);



Full code:

public static class LabelHelper
{
public static int TextHeightForWidth(this Label label, Graphics g, string text)
{
var bounds = new Api.Rect(label.ClientRectangle);

var hDc = g.GetHdc();

var hFont = label.Font.ToHfont();

var oldHFont = Api.SelectObject(hDc, hFont);

var flags = Api.DT_CALCRECT | Api.DT_WORDBREAK;

Api.DrawText(hDc, text, text.Length, ref bounds, flags);

Api.SelectObject(hDc, oldHFont);

Api.DeleteObject(hFont);

g.ReleaseHdc(hDc);

return bounds.Bottom - bounds.Top;
}

public static int TextHeightForWidth(this Label label, Graphics g)
{
return label.TextHeightForWidth(g, label.Text);
}

public static int TextHeightForWidth(this Label label, string text)
{
Control ctrl = label;
while (!(ctrl is Form) && ctrl != null)
ctrl = label.Parent;

if (ctrl == null)
throw new NotSupportedException("Can't create graphics for label outside a form");

var form = (Form) ctrl;
using (var g = form.CreateGraphics())
return label.TextHeightForWidth(g, text);
}

public static int TextHeightForWidth(this Label label)
{
return label.TextHeightForWidth(label.Text);
}

internal static class Api
{

[StructLayout(LayoutKind.Sequential)]
public struct Rect
{
public int Left;
public int Top;
public int Right;
public int Bottom;

public Rect(Rectangle r)
{
Left = r.Left;
Top = r.Top;
Bottom = r.Bottom;
Right = r.Right;
}
}

public const int DT_CALCRECT = 0x00000400;
public const int DT_WORDBREAK = 0x00000010;

[DllImport("coredll.dll")]
public static extern int DrawText(IntPtr hdc, string lpStr, int nCount, ref Rect lpRect, int wFormat);

[DllImport("coredll.dll")]
public static extern IntPtr SelectObject(IntPtr hdc, IntPtr obj);

[DllImport("coredll.dll")]
public static extern int DeleteObject(IntPtr obj);
}
}




It can be used in several ways:

label.Height = label.TextWidthForHeight();

label.Height = label.TextWidthForHeight("some text to measure");

using(var g = this.CreateGraphics())
label.Height = label.TextWidthForHeight(g, "some text to measure");

// ... and so on


Hope that will help somebody.

Nabs said...

A rather crude implementation (that I have not extensively tested in all scenarios) would be to use MeasureString and divide the returned width with the holding container's width, e.g:

string thetext; //Text string
Font font; //Font to be used
Graphics g = this.CreateGraphics();
SizeF s = g.MeasureString(thetext, font);
int numlines = ((int)s.Width / labelDesc.Width) + 1;
labelDesc.Height = (int)s.Height * numlines;

This gives a rather tight fit so its probably worth experimenting with adding an extra 6pts as suggested in the original article.

archimed7592 said...

> Nabs said...
> A rather crude implementation

Nabs, your implementation doesn't take into account word wrapping. This implementation could produce label with clipped lines or with empty ones.

Original implementation in such cases works just great, you only have to consider label's internal padding.

Anonymous said...

Thanks so much for this post! I've been banging my head off my desk for 4 days trying to figure out how to do this! EXACTLY what I needed!

Anonymous said...

Got a little problem, VS2008 can´t find the coredll.dll.

Where can I get this file for Compact Framework 3.5?

Hamid Mirzaei said...

It's perfect and very helpful. Thank you.

Girish the great said...

Thanks José, really helped me get past the block.

archimed7592's suggestion for different font handling was very helpful

Victor Espina said...

Amigo, excelente post. Me salvaste la vida en cuestion de minutos!! Un abrazo desde Venezuela.

Anonymous said...

Thank you! Thank you! Thank you!!!