Drawing multicolor string with wrapping in WinForms app

In the last post I said, that drawing multicolor string with wrapping in WinForms application is not an easy task if you don't want to use GDI+ engine. But let's imagine, that your boss came to you and said, that you should do such a feature until tomorrow of he will cut your salary twice. Is there any chance to survive instead of running around of office and screaming? Yes, there is one trick. We will use the power of WPF in our WinForms app.

Yep, I wrote it right: we can use WPF controls in WinForms app and vica versa. The main door to the WPF world from WinForms is ElementHost control. I'll illustrate this on a simple example: highlighting all occurrences of a given substring in a text. First things first, do the following:

  1. Create a new WinForms project.
  2. Add ElementHost control to the form.
  3. Add 2 TextBoxes, one will be called mainTextBox (where reference text will be placed), second - findTextBox (here we will place text to highlight).
  4. Add CheckBox, it will indicate whenever text direction should be right to left or not (there is not only English language in the world).
  5. Add a new WPF UserControl to the project with the following XAML markup:

    <UserControl 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"
             xmlns:local="clr-namespace:TestWinForms"
             x:Class="TestWinForms.MyUserControl"
             mc:Ignorable="d"
             d:DesignHeight="300" d:DesignWidth="300">

        <TextBlock TextWrapping="Wrap" Text="Hello world!"/>
    </UserControl>

The next thing we should do is to setup ViewModel since WPF follows MVVM pattern:

namespace TestWinForms
{
    public class ViewModel : INotifyPropertyChanged
    {
        private string mainText = string.Empty;
        public string MainText
        {
            get { return mainText; }
            set
            {
                mainText = value;
                OnNotifyPropertyChanged("MainText");
            }
        }

        private string findText = string.Empty;
        public string FindText
        {
            get { return findText; }
            set
            {
                findText = value;
                OnNotifyPropertyChanged("FindText");
            }
        }

        private FlowDirection flowDirection = FlowDirection.LeftToRight;
        public FlowDirection FlowDirection
        {
            get { return flowDirection; }
            set
            {
                flowDirection = value;
                OnNotifyPropertyChanged("FlowDirection");
            }
        }

        public event PropertyChangedEventHandler PropertyChanged;

        protected virtual void OnNotifyPropertyChanged(string propertyName)
        {
            var handler = PropertyChanged;
            if (handler != null)
            {
                handler(this, new PropertyChangedEventArgs(propertyName));
            }
        }
    }
}

Now it's time to bind WPF and WinForms; we should create one handler for Form.Load, 2 handlers for TextBox.TextChanged events and 1 handler for CheckBox.CheckedChanged:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace TestWinForms
{
    public partial class MyForm : Form
    {
        private ViewModel viewModel;

        public MyForm()
        {
            InitializeComponent();
        }

        private void MyForm_Load(object sender, EventArgs e)
        {
            var wpfControl = new MyUserControl();
            viewModel = new ViewModel();
            elementHost.Child = wpfControl;
            wpfControl.DataContext = viewModel;
        }

        private void mainTextBox_TextChanged(object sender, EventArgs e)
        {
            viewModel.MainText = mainTextBox.Text;
        }

        private void findTextBox_TextChanged(object sender, EventArgs e)
        {
            viewModel.FindText = findTextBox.Text;
        }

        private void directionCb_CheckedChanged(object sender, EventArgs e)
        {
            if (directionCb.Checked)
            {
                viewModel.FlowDirection = System.Windows.FlowDirection.RightToLeft;
            }
            else
            {
                viewModel.FlowDirection = System.Windows.FlowDirection.LeftToRight;
            }
        }

    }
}

Everything is pretty straightforward. On the Load event we initialize WPF control and attach it to the ElementHost. Also we create the instance of our ViewModel and assign it to the DataContext property. TextChanged events update corresponding properties in a ViewModel. And a little change at the WPF control markup:

<UserControl
         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" 
         xmlns:local="clr-namespace:TestWinForms"
         x:Class="TestWinForms.MyUserControl" 
         mc:Ignorable="d" 
         d:DesignHeight="300" d:DesignWidth="300">
    <TextBlock TextWrapping="Wrap" Text="{Binding MainText}">
    </TextBlock>
</UserControl>

Build and run the app. Now when you type text at the mainText TextBox it should be shown at the WPF control. But it comes the question: How to display formatted text? Ok, let's take a look on the TextBlock class description. What we need is Inlines property, but it is not a dependency property (which only can be the target of binding). This issue may be solved by creating Attached Property:

using System;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;

namespace TestWinForms
{
    public static class TextBlockEx
    {
        public static Inline GetFormattedText(DependencyObject obj)
        {
            return (Inline)obj.GetValue(FormattedTextProperty);
        }

        public static void SetFormattedText(DependencyObject obj, Inline value)
        {
            obj.SetValue(FormattedTextProperty, value);
        }

        public static readonly DependencyProperty FormattedTextProperty =
            DependencyProperty.RegisterAttached(
                "FormattedText",
                typeof(Inline),
                typeof(TextBlockEx),
                new PropertyMetadata(null, OnFormattedTextChanged));

        private static void OnFormattedTextChanged(
            DependencyObject o,
            DependencyPropertyChangedEventArgs e)
        {
            var textBlock = o as TextBlock;
            if (textBlock == null) return;

            var inline = (Inline)e.NewValue;
            textBlock.Inlines.Clear();
            if (inline == null) return;
            textBlock.Inlines.Add(inline);
        }
    }
}

Using it we can update TextBlock.Inlines collection. But we also need to create Inline object somehow, keeping in mind that we have 2 strings as input: string with main text and string with text to find. Converters to the rescue. Here comes implementation of IMultiValueConverter interface:

using System;
using System.Collections.Generic;
using System.Text;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Media;

namespace TestWinForms
{
    public class TextToInlineConverter : IMultiValueConverter
    {
        public object Convert(object[] values, Type targetType, object parameter, System.Globalization.CultureInfo culture)
        {
            var mainText = (string)values[0];
            var findText = (string)values[1];
            if (string.IsNullOrEmpty(findText))
            {
                return CreateSimpleInline(mainText);
            }
            var index = mainText.IndexOf(findText, StringComparison.OrdinalIgnoreCase);
            var regions = new List<Region>();
            while (index != -1)
            {
                var match = new Region(index, findText.Length);
                regions.Add(match);
                index = mainText.IndexOf(findText, index + findText.Length, StringComparison.OrdinalIgnoreCase);
            }
            var span = new Span();
            if (regions.Count == 0)
            {
                return CreateSimpleInline(mainText);
            }
            else
            {
                var position = 0;
                for (int i = 0; i < regions.Count; i++)
                {
                    span.Inlines.Add(mainText.Substring(position, regions[i].StartIndex - position));
                    position += regions[i].StartIndex - position;
                    span.Inlines.Add(new Run(mainText.Substring(regions[i].StartIndex, regions[i].Length))
                    {
                        Foreground = Brushes.Red,
                        Background = Brushes.Yellow
                    });
                    position += regions[i].Length;
                    if (i == regions.Count - 1)
                    {
                        span.Inlines.Add(new Run(mainText.Substring(position)));
                    }
                }
            }
            return span;
        }

        private Inline CreateSimpleInline(string text)
        {
            return new Run(text);
        }

        public object[] ConvertBack(object value, Type[] targetTypes, object parameter, System.Globalization.CultureInfo culture)
        {
            throw new NotImplementedException();
        }

        private struct Region
        {
            public int StartIndex { get; set; }
            public int Length { get; set; }

            public Region(int startIndex, int length)
                : this()
            {
                StartIndex = startIndex;
                Length = length;
            }
        }
    }
}

Wiring up these pieces together in XAML markup:

<UserControl
             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" 
             xmlns:local="clr-namespace:TestWinForms"
             x:Class="TestWinForms.MyUserControl" 
             mc:Ignorable="d" 
             d:DesignHeight="300" d:DesignWidth="300">
    <TextBlock TextWrapping="Wrap" FlowDirection="{Binding FlowDirection}">
        <TextBlock.Resources>
            <local:TextToInlineConverter x:Key="HighlightConverter"/>
        </TextBlock.Resources>
        <local:TextBlockEx.FormattedText>
            <MultiBinding Converter="{StaticResource ResourceKey=HighlightConverter}">
                <Binding Path="MainText"/>
                <Binding Path="FindText"/>
            </MultiBinding>
        </local:TextBlockEx.FormattedText>
    </TextBlock>
</UserControl>

We've got the desired behavior, congratulations:

By the way, using this approach you can easily create components with complex UI in WinForms apps if you don't have enough time or resources to do them using WinForms, I don't think it will blow away your karma unless you are old-fashioned pirate programmer, who prefers only "pure" apps.

comments powered by Disqus