3

I have a Border with a Content of TextBlock that I want to be perfectly centered both horizontally and vertically. No matter what I try it never looks centered. What am I missing?

Using the code below the top of the text is 19px below the border, the bottom of the text is 5px above the border. It's also off center left or right depending on the Text value which I assume is related to the font.

The solution should work for varying text (1-31) with any font.

Code

<Grid Width="50" Height="50">
    <Border BorderThickness="1" BorderBrush="Black">
        <TextBlock Text="13" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="50"/>
    </Border>
</Grid>

Result

enter image description hereenter image description here

helb
  • 7,609
  • 8
  • 36
  • 58
Kevin Kalitowski
  • 6,829
  • 4
  • 36
  • 52

3 Answers3

5

Well then, challenge accepted ;-) This solution is based on the following idea:

  1. Fit the TextBlock inside the border and make sure the entire text is rendered, even if not visible.
  2. Render the text into a bitmap.
  3. Detect the glyphs (i.e. characters) inside the bitmap to get the pixel-exact position.
  4. Update the UI layout so the text is centered inside the border.
  5. If possible, allow simple, generic usage.

1. TextBlock inside border / fully rendered

This is simple once you realize that the entire content of a ScrollViewer is rendered, so here is my UserControl XAML:

<UserControl x:Class="WpfApplication4.CenteredText"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             mc:Ignorable="d" 
             d:DesignHeight="300" d:DesignWidth="300">
    <Grid>
        <ScrollViewer x:Name="scroll" 
                      IsHitTestVisible="False"
                      VerticalScrollBarVisibility="Hidden"
                      HorizontalScrollBarVisibility="Hidden" />
    </Grid>
</UserControl>

With the code behind as:

public partial class CenteredText : UserControl
{
    public CenteredText()
    {
        InitializeComponent();
    }

    public static readonly DependencyProperty ElementProperty = DependencyProperty
        .Register("Element", typeof(FrameworkElement), typeof(CenteredText),
        new PropertyMetadata(OnElementChanged));

    private static void OnElementChanged(DependencyObject d, 
        DependencyPropertyChangedEventArgs e)
    {
        var elem = e.NewValue as FrameworkElement;
        var ct = d as CenteredText;
        if(elem != null)
        {
            elem.Loaded += ct.Content_Loaded;
            ct.scroll.Content = elem;
        }
    }

    public FrameworkElement Element
    {
        get { return (FrameworkElement)GetValue(ElementProperty); }
        set { SetValue(ElementProperty, value); }
    }

    void Content_Loaded(object sender, RoutedEventArgs e) /*...*/
}

This control is basically a ContentControlwhich allows to handle the Loaded event of the content generically. There may be a simpler way to do this, I'm not sure.

2. Render to Bitmap

This one is simple. In the Content_Loaded() method:

void Content_Loaded(object sender, RoutedEventArgs e)
{       
    FrameworkElement elem = sender as FrameworkElement;
    int w = (int)elem.ActualWidth;
    int h = (int)elem.ActualHeight;
    var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32);
    rtb.Render(elem);

    /* glyph detection ... */
 }

3. Detect the glyphs

This is surprisingly easy since a TextBlock is rendered with fully transparent background by default and we are only interested in bounding rectangle. This is done in a separate method:

bool TryFindGlyphs(BitmapSource src, out Rect rc)
{
    int left = int.MaxValue;
    int toRight = -1;
    int top = int.MaxValue;
    int toBottom = -1;

    int w = src.PixelWidth;
    int h = src.PixelHeight;
    uint[] buf = new uint[w * h];
    src.CopyPixels(buf, w * sizeof(uint), 0);
    for (int y = 0; y < h; y++)
    {
        for (int x = 0; x < w; x++)
        {
            // background is assumed to be fully transparent, i.e. 0x00000000 in Pbgra
            if (buf[x + y * w] != 0)
            {
                if (x < left) left = x;
                if (x > toRight) toRight = x;
                if (y < top) top = y;
                if (y > toBottom) toBottom = y;
            }
        }
    }

    rc = new Rect(left, top, toRight - left, toBottom - top);
    return (toRight > left) && (toBottom > top);
}

The above method tries to find the leftmost, rightmost, topmost and bottommost pixel which is not transparent and returns the results as a Rect in the output parameter.

4. Update Layout

This is done later in the Content_Loaded method:

void Content_Loaded(object sender, RoutedEventArgs e)
{       
    /* render to bitmap ... */

    Rect rc;
    if (TryFindGlyphs(rtb, out rc))
    {
        if (rc.Height > this.scroll.ActualHeight || rc.Width > this.scroll.ActualWidth)
        {
            return; // todo: error handling
        }
        double desiredV = rc.Top - 0.5 * (this.scroll.ActualHeight - rc.Height);
        double desiredH = rc.Left - 0.5 * (this.scroll.ActualWidth - rc.Width);

        if (desiredV > 0)
        {
            this.scroll.ScrollToVerticalOffset(desiredV);
        }
        else
        {
            elem.Margin = new Thickness(elem.Margin.Left, elem.Margin.Top - desiredV, 
                elem.Margin.Right, elem.Margin.Bottom);
        }
        if (desiredH > 0)
        {
            this.scroll.ScrollToHorizontalOffset(desiredH);
        }
        else
        {
            elem.Margin = new Thickness(elem.Margin.Left - desiredH, elem.Margin.Top, 
                elem.Margin.Right, elem.Margin.Bottom);
        }
    }
}

This UI is updated using the following strategy:

  • Compute the desired offset between the border and the glyph rectangle in both directions
  • If the desired offset is positive, it means that the text needs to move up (or left in the horizontal case) so we can scroll down (right) by the desired offset.
  • If the desired offset is negative, it means that the text needs to move down (or right in the horizontal case). This cannot be done by scrolling since the TextBlock is top-left-aligned (by default) and the ScrollViewer is still at the initial (top/left) position. There is a simple solution though: Add the desired offset to the Margin of the TextBlock.

5. Simple Usage

The CenteredText control is used as follows:

<Border BorderBrush="Black" BorderThickness="1" Width="150" Height="150">
    <local:CenteredText>
        <local:CenteredText.Element>
            <TextBlock Text="31" FontSize="150" />
        </local:CenteredText.Element>
    </local:CenteredText>
</Border>

Results

For border size 150x150 and FontSize 150:

enter image description here

For border size 150x150 and FontSize 50:

enter image description here

For border size 50x50 and FontSize 50:

enter image description here

Note: There is a 1-pixel error where the space to the left of the text is 1 pixel thicker or thinner than the space to the right. Same with the top / bottom spacing. This happens if the border has an even width and the rendered text an odd width (no sub-pixel perfectness is provided, sorry)

Conclusion

The presented solution should work up to a 1-pixel error with any Font, FontSize and Text and is simple to use.

And if you haven't noticed yet, very limited assumptions were made about the FrameworkElement which is used with the Elem property of the CenteredText control. So this should also work with any element which has transparent background and needs (near-)perfect centering.

Mat
  • 202,337
  • 40
  • 393
  • 406
helb
  • 7,609
  • 8
  • 36
  • 58
0

What you are talking about is related to the specific font (and characters within that font) that you are using. Different fonts will have different baselines, heights and other attributes. In order to combat that, just use Padding on the Border or Margin on the TextBlock to make it fit where you want it:

<Grid Width="50" Height="50">
    <Border BorderThickness="1" BorderBrush="Black">
        <TextBlock Text="13" VerticalAlignment="Center" HorizontalAlignment="Center" 
            FontSize="50" Margin="0,0,3,14" />
    </Border>
</Grid>

Note: You can also use the TextBlock.TextAlignment Property to make adjustments to the horizontal alignment of text content.

Sheridan
  • 68,826
  • 24
  • 143
  • 183
  • Prior to posting I tried margins, negative margins to be precise, but each piece of text needs a different margin. So if I set the margin to work for 13, it's not correct for 31. TextAlignment doesn't help either. It only moves the text a fraction of a pixel left/right. – Kevin Kalitowski Mar 05 '15 at 14:17
  • 1
    As I said, that is down to your selected font. Perhaps you'd get better results using a mono type font, but you'll always have some differences? – Sheridan Mar 05 '15 at 14:27
0

I'd add this as a comment but I haven't got enough reputation :P

It looks off center because the height and width you have specified for the grid (50x50) is too small to house a font size of 50. Either increase the size to 100x100 or lower the font size to something smaller.

To demonstrate that they will be perfectly aligned in the center by doing this - view this code in visual studio somewhere. You will see the numbers of these textblocks overlap perfectly.

<Grid Height="100" Width="100">
    <Border BorderThickness="1" BorderBrush="Black" >
        <TextBlock Text="13" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="50"/>
    </Border>
    <Border BorderThickness="1" BorderBrush="Black" >
        <TextBlock Text="31" VerticalAlignment="Center" HorizontalAlignment="Center" FontSize="50"/>
    </Border>
</Grid>

I hope this helps you out :)

koalarisms
  • 19
  • 3