Welcome to OStack Knowledge Sharing Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
248 views
in Technique[技术] by (71.8m points)

c# - ComboBox OwnerDrawVariable Font format size problem

I'm trying to implement a auto-complete/search box similar to Visual Studio's Go To member search:

enter image description here

However, my formatting of the bold text and its spacing isn't calculating right. I'll omit the auto complete functionality of this and only include code that is formatting the result by hard coding a search term.
The spacing determined by e.Graphics.MeasureString doesn't seem to return correct value. I tried to use StringFormat.GenericTypographic from this question and I got closer but still not correct.

Here is a display of my dropdown where the matched term (in bold) is easily showing that my format position calculation is off (the f is clearly encroaching on the i).

enter image description here

In addition to that, if I hover over an item, it redraws my text without bold. I'd like to stop that as well.

Update: I changed my code to use TextRenderer but now it appears even worse.
There now seems to be extra space before and after each match I concatenate.

Updated code below:

enter image description here

private void Form1_Load( object sender, EventArgs e )
{
    var docGenFields = new[] {
        new DocGenFieldItem { Display = $"Profile.date-birth.value", Value = "5/9/1973", FieldCode = $"Profile.date-birth.value" },
        new DocGenFieldItem { Display = $"Profile.date-birth.text", Value = "Birth Date", FieldCode = $"Profile.date-birth.text" },
        new DocGenFieldItem { Display = $"Profile.date-birth.raw-value", Value = "1973-05-09", FieldCode = $"Profile.date-birth.raw-value" },
        new DocGenFieldItem { Display = $"Profile.name-first.value", Value = "Terry", FieldCode = $"Profile.name-first.value" },
        new DocGenFieldItem { Display = $"Profile.name-first.text", Value = "First Name", FieldCode = $"Profile.name-first.text" },
        new DocGenFieldItem { Display = $"Profile.name-first.raw-value", Value = "Terry", FieldCode = $"Profile.name-first.raw-value" },
        new DocGenFieldItem { Display = $"Profile.name-first.value", Value = "Minnesota", FieldCode = $"Profile.state.value" },
        new DocGenFieldItem { Display = $"Profile.name-first.text", Value = "State", FieldCode = $"Profile.state.text" },
        new DocGenFieldItem { Display = $"Profile.name-first.raw-value", Value = "MN", FieldCode = $"Profile.state.raw-value" }
    };

    comboBoxItems.FormattingEnabled = true;
    comboBoxItems.DrawMode = DrawMode.OwnerDrawVariable;
    comboBoxItems.DropDownHeight = 44 * 5;
    // comboBoxItems.Font = new Font( "Microsoft Sans Serif", 12F, FontStyle.Regular, GraphicsUnit.Point, 0 );
    comboBoxItems.Font = new Font( "Segoe UI", 12F, FontStyle.Regular, GraphicsUnit.Point, 0 );
    comboBoxItems.Items.AddRange( docGenFields );

    comboBoxItems.DrawItem += new DrawItemEventHandler( comboBoxItems_DrawItem );
    comboBoxItems.MeasureItem += new MeasureItemEventHandler( comboBoxItems_MeasureItem );
}

private void comboBoxItems_DrawItem( object sender, DrawItemEventArgs e )
{
    // Draw the background of the item.
    e.DrawBackground();

    var listItem = comboBoxItems.Items[ e.Index ] as DocGenFieldItem;

    var searchTerm = "P";
    var matches = Regex.Split( listItem.Display, "(?i)" + searchTerm );

    var bold = new Font( e.Font.FontFamily, e.Font.Size, FontStyle.Bold );

    // e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
    e.Graphics.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit;

    var currentCharacter = 0;
    // float currentX = 0;
    var currentX = 0;
    var currentMatch = 0;
    var keyLength = searchTerm.Length;

    foreach ( var m in matches )
    {
        // If search term characters are first (like StartsWith) or last (like EndsWith) characters
        // then the match will be empty.  So if not empty, then need to render the characters 'between'
        // matches of search term in regular font
        if ( !string.IsNullOrEmpty( m ) )
        {
            // var p = new PointF( e.Bounds.X + currentX, e.Bounds.Y );
            // var mWidth = e.Graphics.MeasureString( m, e.Font, p, StringFormat.GenericTypographic );
            // e.Graphics.DrawString( m, e.Font, Brushes.Black, p );
            var p = new Point( currentX, e.Bounds.Y );
            var mWidth = TextRenderer.MeasureText( e.Graphics, m, e.Font );
            TextRenderer.DrawText( e.Graphics, m, e.Font, p, System.Drawing.Color.Black );
            currentX += mWidth.Width;
            currentCharacter += m.Length;
        }

        currentMatch++;

        // Render the search term characters (need to use 'substring' of current text to maintain
        // original case of text) *bold* in between matches.
        // string.IsNullOrEmpty( m ) && currentMatch == 1 - If the search term matches ENTIRE value
        // then currentMatch will = matches.Length (1) but the match of 'm' will be empty.
        if ( currentMatch < matches.Length || ( string.IsNullOrEmpty( m ) && currentMatch == 1 ) )
        {
            var mValue = listItem.Display.Substring( currentCharacter, keyLength );
                // var p = new PointF( e.Bounds.X + currentX, e.Bounds.Y );
            // var mWidth = e.Graphics.MeasureString( mValue, bold, p, StringFormat.GenericTypographic );
            // e.Graphics.DrawString( mValue, bold, Brushes.Black, p, StringFormat.GenericTypographic );

            var p = new Point( currentX, e.Bounds.Y );
            var mWidth = TextRenderer.MeasureText( e.Graphics, mValue, bold );
            TextRenderer.DrawText( e.Graphics, mValue, bold, p, System.Drawing.Color.Black );

            currentX += mWidth.Width;
            currentCharacter += keyLength;
        }
    }

    // Render a secondary 'info' line in the dropdown
    var b = new SolidBrush( ColorTranslator.FromHtml( "#636363" ) );
    var valueWidth = e.Graphics.MeasureString( "Value: ", bold );

    e.Graphics.DrawString( "Value: ", bold, b,
        new RectangleF( e.Bounds.X, e.Bounds.Y + 21, e.Bounds.Width, e.Bounds.Height )
    );
    e.Graphics.DrawString( listItem.Value, e.Font, b,
        new RectangleF( e.Bounds.X + valueWidth.Width, e.Bounds.Y + 21, e.Bounds.Width, 21 )
    );

    // Draw the focus rectangle if the mouse hovers over an item.
    e.DrawFocusRectangle();
}

private void comboBoxItems_MeasureItem( object sender, MeasureItemEventArgs e )
{
    e.ItemHeight = 44;
}
See Question&Answers more detail:os

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Answer

0 votes
by (71.8m points)

When TextRenderer is used to render text in a non-generic Graphics context, this context needs to be considered: for this reason, TextRenderer provides overloads of both MeasureText and DrawText that accept a Graphics context (IDeviceContext) argument.
The Graphics context contains information that TextRenderer can use to better adapt to the DC specifics.

Also, we need to pass to the methods a combination of TextFormatFlags values that define how we want to measure and/or render the Text.

  • Always declare the type of Alignment
  • Specify the clipping/wrapping behavior (e.g., we want the Text to wrap or we really don't want it to, we want it clipped instead)
  • If the Text should NOT be padded, specify TextFormatFlags.NoPadding, otherwise the Text will be stretched to fill the drawing bounds.
  • If the the drawing bounds are not arranged manually (to draw text in specific positions), specify TextFormatFlags.LeftAndRightPadding to add a predefined padding to the text. The padding this setting applies (based on the Font kerning), matches the distance between the text and the borders of standard Controls (e.g., the ListBox or ListView)

More information about TextFormatFlags is (partially :) available in the Docs.

I've moved all the drawing parts to a single method, RenderText().
All measures and drawings are performed here: this way, it should be simpler to understand what is going on when the items are drawn.

The code in the DrawItem handler calls this method, passing some value that are proper when specific conditions are met (as changing the FontStyle, the alternative ForeColor of parts of the Text etc.)

Resulting in:

ComboBox OwnerDrawVariable TextRenderer

? The Font used here is Microsoft YaHei UI, 12pt. Of course you can use whatever other Font, but the System Font series with the UI appendix are designed (well) for this.

? Remember to dispose of the Graphics objects you create, it's very important, more important when theses objects are used to provide custom functionality to Controls, so probably constantly generated. Don't count on the Garbage Collector for this, it can do nothing for you in this context.

string searchTerm = string.Empty;
TextFormatFlags format = TextFormatFlags.Top | TextFormatFlags.Left | 
                         TextFormatFlags.NoClipping | TextFormatFlags.NoPadding;

private Size RenderText(string text, DrawItemEventArgs e, FontStyle style, Color altForeColor, Point offset)
{
    var color = altForeColor == Color.Empty ? e.ForeColor : altForeColor;
    using (var font = new Font(e.Font, style)) {
        var textSize = TextRenderer.MeasureText(e.Graphics, text, font, e.Bounds.Size, format);
        var rect = new Rectangle(offset, e.Bounds.Size);
        TextRenderer.DrawText(e.Graphics, text, font, rect, color, e.BackColor, format);
        return textSize;
    }
}

private IEnumerable<(string Text, bool Selected)> BuildDrawingString(string itemContent, string pattern)
{
    if (pattern.Length == 0) {
        yield return (itemContent, false);
    }
    else {
        var matches = Regex.Split(itemContent, $"(?i){pattern}");
        int pos = itemContent.IndexOf(pattern, StringComparison.CurrentCultureIgnoreCase);
        for (int i = 0; i < matches.Length; i++) {
            if (matches[i].Length == 0 && i < matches.Length - 1) {
                yield return (itemContent.Substring(pos, pattern.Length), matches[i].Length > 0 ? false : true);
            }
            else {
                yield return (matches[i], false);
                if (i < matches.Length - 1) {
                    yield return (itemContent.Substring(pos, pattern.Length), true);
                }
            }
        }
    }
}

private void comboBoxItems_DrawItem(object sender, DrawItemEventArgs e)
{
    var listItem = (sender as ComboBox).Items[e.Index] as DocGenFieldItem;
    e.DrawBackground();

    int drawingPosition = 0;
    foreach (var part in BuildDrawingString(listItem.Display, searchTerm)) {
        var style = part.Selected ? FontStyle.Bold : FontStyle.Regular;
        drawingPosition += RenderText(part.Text, e, style, Color.Empty, new Point(drawingPosition, e.Bounds.Y)).Width;
    }

    var offsetBottom = new Point(0, e.Bounds.Bottom - e.Font.Height - 2);
    var valueSize = RenderText("Value: ", e, FontStyle.Bold, Color.FromArgb(64, 64, 64), offsetBottom);

    offsetBottom.Offset(valueSize.Width, 0);
    RenderText(listItem.Value, e, FontStyle.Regular, Color.FromArgb(63, 63, 63), offsetBottom);
    e.DrawFocusRectangle();
}

private void comboBoxItems_MeasureItem(object sender, MeasureItemEventArgs e) 
    => e.ItemHeight = (sender as Control).Font.Height * 2 + 4;

In relation to Graphics.MeasureString() and Graphics.DrawString() methods used in the question before the update:

  • When we measure Text with a specific StringFormat, then we draw the Text using the same StringFormat, if we want our drawings to respect the measured bounds.
  • Graphics.TextRenderingHint = TextRenderingHint.AntiAlias doesn't work really well when Text is rendered with Graphics.DrawString(): use TextRenderingHint.ClearTypeGridFit instead.
  • Possibly, avoid Microsoft Sans Serif as Font, use Segoe UI or Microsoft YaHei UI instead (for example): these Fonts are much better weighted and explicitly designed for this (the UI suffix gives it away).

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome to OStack Knowledge Sharing Community for programmer and developer-Open, Learning and Share
Click Here to Ask a Question

...