Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

A Highlightable WPF/MVVM TextBlock in C#/VB.Net

0.00/5 (No votes)
22 Jan 2018 1  
Implementing text highlighting in a WPF TextBlock control with MVVM

Introduction

We review a WPF Behavior [3] that can highlight text in a WPF TextBlock control. This behavior can be used pretty much anywhere where you can use a TextBlock control. So, although we look at using it in a ListBox in this article, the behavior can also be used:

  1. without a collection control or
  2. within any control that represents a collection (e.g.: a TreeView - see Bonus Track in [1])

Other requirements that I wanted to support are:

  • MVVM architecture pattern
  • Default Support for Black and Light Skin
  • Custom highlighting color configuration

The screenshots below show the sample application that was developed on the idea in [2]. You should be able to select a string in the left list and the list on the right side should highlight the corresponding items.

Background

We had developed this WPF Tree View [1] filter application that works quite quickly on around 100,000 nodes in the tree and I was wondering how I could highlight a TextBlock item since it was sometimes a bit difficult to actually see the matched text. So, I went on my search and found a few promising samples, none would support MVVM or dark and light skin, but the sample in [2] appeared most promising to me so I tried to develop this solution into a more robust and MVVM conformant solution.

Using the Code

The sample code attached to this article contains 2 projects:

  • WpfApplication4
  • HighlightableTextBlock

where the first WpfApplication4 project is pretty much the original code from [2], while the project HighlightableTextBlock contains the behavior that I would like to develop in this article. You should be able to just set each project as Start-up project in Visual Studio, start the project and click around in the listboxes to get a feel for the way in which these samples work.

Points of Interest

I have attached both versions hoping that it will help others to get a feeling for the conversion process that is sometimes needed to develop a standard (Winforms compliant) solution into a MVVM compliant solution.

So, let's inspect the code in HighlightableTextBlock project to understand how things work. The definition of the left ListBox in MainWindow.xaml file is a good place to start.

<ListBox Grid.Column="0" Grid.Row="1"

         ItemsSource="{Binding List1}"

         SelectionMode="Single"

         VerticalAlignment="Stretch" HorizontalAlignment="Stretch"

         Margin="3"

         behav:SelectedItemsBahavior.ChangedCommand="{Binding ListSelectionChangedCommand}"

/>

The above XAML specifies that the ListBox should get its items from the List1 property in the AppViewModel object. The SelectedItemsBehavior is invoked whenever a selection in the left ListBox is changed. The behavior in turn invokes the ListSelectionChangedCommand in the AppViewModel object (sending the selected item as command parameter) when the user clicks on an item in the left list of the MainWindow. The code in the ListSelectionChangedCommand looks like this:

public ICommand ListSelectionChangedCommand
{
  get
  {
    if (_ListSelectionChangedCommand == null)
    {
      _ListSelectionChangedCommand = new RelayCommand<object>((p) =>
      {
        var spara = p as string;

        if (spara == null)
            return;

        foreach (var item in List2)  // Evaluate selected string in left List1
        {                            // against each item in List2
            item.MatchString(spara);
        }
      });
    }

    return _ListSelectionChangedCommand;
  }
}
Public ReadOnly Property ListSelectionChangedCommand As ICommand
  Get
    If _ListSelectionChangedCommand Is Nothing Then
      _ListSelectionChangedCommand = New RelayCommand(Of Object)(
        Sub(p)
          Dim spara = TryCast(p, String)
          If spara Is Nothing Then Return

          For Each item In List2       '' Evaluate selected string in left List1
            item.MatchString(spara)  '' against each item in List2
          Next
        End Sub)
    End If

    Return _ListSelectionChangedCommand
  End Get
End Property

The spara variable contains the string that represents the selected item from the left ListBox (List1). The foreach loop scans through each item in the right ListBox (List2) and adjusts the match indicator properties Range property of the StringMatchItem class:

public bool MatchString(string searchString)
{
  if (string.IsNullOrEmpty(DisplayString) == true &&
      string.IsNullOrEmpty(searchString) == true)
  {
      Range = new SelectionRange(0,0);
      return true;
  }
  else
  {
    if (string.IsNullOrEmpty(DisplayString) == true ||
        string.IsNullOrEmpty(searchString) == true)
    {
      Range = new SelectionRange(-1, -1);
      return false;
    }
  }

  int start;
  if ((start = DisplayString.IndexOf(searchString)) >= 0)
  {
    Range = new SelectionRange(start, start + searchString.Length);
    return true;
  }
  else
  {
    Range = new SelectionRange(start, -1);
    return false;
  }
}
Public Function MatchString(ByVal searchString As String) As Boolean
  If String.IsNullOrEmpty(DisplayString) = True AndAlso String.IsNullOrEmpty(searchString) = True Then
    Range = New SelectionRange(0, 0)
    Return True
  Else
    If String.IsNullOrEmpty(DisplayString) = True OrElse String.IsNullOrEmpty(searchString) = True Then
        Range = New SelectionRange(-1, -1)
        Return False
    End If
  End If

  Dim start As Integer
  start = DisplayString.IndexOf(searchString)
  If start >= 0 Then
    Range = New SelectionRange(start, start + searchString.Length)
    Return True
  Else
    Range = New SelectionRange(start, -1)
    Return False
  End If
End Function

All items in the right ListBox (List2) are objects of the StringMatchItem type. This class contains a Range property that adheres to the ISelectionRange interface definition. The TextBlock in each item of the right ListBox is bound to each viewmodel item's Range property in the StringMatchItem class like so:

 <ListBox Grid.Column="1" Grid.Row="1"

         ItemsSource="{Binding List2}" 

         VerticalAlignment="Stretch" HorizontalAlignment="Stretch"

         Margin="3">
  <ListBox.ItemTemplate>
    <DataTemplate>
      <TextBlock Text="{Binding DisplayString}" 

                 behav:HighlightTextBlockBehavior.Range="{Binding Range}"

                 />
    </DataTemplate>
  </ListBox.ItemTemplate>
</ListBox>

The HighlightTextBlockBehavior behavior in turn listens to changes on the Range property and reacts on them highlighting the areas that we would like to give special attention to:

private static void OnRangeChanged(DependencyObject d,
                                   DependencyPropertyChangedEventArgs e)
{
  TextBlock txtblock = d as TextBlock;

  if (txtblock == null)
    return;

  var range = GetRange(d);  // Get the bound Range value to do highlighting

  // Standard background is transparent
  SolidColorBrush normalBackGround = new SolidColorBrush(Color.FromArgb(00, 00, 00, 00));
  if (range != null)
  {
    if (range.NormalBackground != default(Color))
      normalBackGround = new SolidColorBrush(range.NormalBackground);
  }

  var txtrange = new TextRange(txtblock.ContentStart, txtblock.ContentEnd);
  txtrange.ApplyPropertyValue(TextElement.BackgroundProperty, normalBackGround);

  if (range == null)
    return;

  if (range.Start < 0 || range.End < 0) // Nothing to highlight here :-(
    return;

  try
  {
    Color selColor = (range.DarkSkin ? Color.FromArgb(255, 254, 252, 200) :
                                       Color.FromArgb(255, 208, 247, 255));

    Brush selectionBackground = new SolidColorBrush(selColor);
    if (range != null)
    {
      if (range.SelectionBackground != default(Color))
          selectionBackground = new SolidColorBrush(range.SelectionBackground);
    }

    TextRange txtrangel = new TextRange(
            txtblock.ContentStart.GetPositionAtOffset(range.Start + 1)
          , txtblock.ContentStart.GetPositionAtOffset(range.End + 1));

    txtrangel.ApplyPropertyValue(TextElement.BackgroundProperty, selectionBackground);
  }
  catch (Exception exc)
  {
    Console.WriteLine(exc.Message);
    Console.WriteLine(exc.StackTrace);
  }
}
Private Sub OnRangeChanged(ByVal d As DependencyObject,
                           ByVal e As DependencyPropertyChangedEventArgs)
  Dim txtblock As TextBlock = TryCast(d, TextBlock)

  If txtblock Is Nothing Then Return

  Dim range = GetRange(d)  '' Get the bound Range value to do highlighting

  Dim normalBackGround As SolidColorBrush = New SolidColorBrush(Color.FromArgb(0, 0, 0, 0))

  If range IsNot Nothing Then
    If range.NormalBackground <> Nothing Then normalBackGround = New SolidColorBrush(range.NormalBackground)
  End If

  Dim txtrange = New TextRange(txtblock.ContentStart, txtblock.ContentEnd)

  txtrange.ApplyPropertyValue(TextElement.BackgroundProperty, normalBackGround)

  If range Is Nothing Then Return

  If range.Start < 0 OrElse range.[End] < 0 Then Return
  Try
    Dim selColor As Color = (If(range.DarkSkin, Color.FromArgb(255, 254, 252, 200),
                               Color.FromArgb(255, 208, 247, 255)))

    Dim selectionBackground As Brush = New SolidColorBrush(selColor)
    If range IsNot Nothing Then
        If range.SelectionBackground <> Nothing Then selectionBackground = New SolidColorBrush(range.SelectionBackground)
    End If

    Dim txtrangel As TextRange = New TextRange(txtblock.ContentStart.GetPositionAtOffset(range.Start + 1), txtblock.ContentStart.GetPositionAtOffset(range.[End] + 1))
    txtrangel.ApplyPropertyValue(TextElement.BackgroundProperty, selectionBackground)
  Catch exc As Exception
    Console.WriteLine(exc.Message)
    Console.WriteLine(exc.StackTrace)
  End Try
End Sub

We can see here that the behavior is using the WPF TextRange class, which is normally used to select text in a WPF FlowDocument, but can also be used here since a WPF TextBlock also provides the required properties and methods.

Conclusions

Re-using the code should not require more than the:

  • HighlightTextBlockBehavior behavior
  • ISelectionRange interface, and the
  • implementing viewmodel class SelectionRange

The application of these 3 items should be straightforward and flexible. It turns out that we can also use the interface to compute match ranges in a background thread without having additional problems when binding to UI collection items, as we will see in a seperate Bonus Track [1] section.

At this point, I am not sure, if the above 3 items should be part of an extra Nuget package library, but I'll think about it. I would appreciate getting some feedback about this question and the code project developed here.

There is a reader in the  Forum below who suggested implementing this in a different way - mainly doing the string matching in the attached behavior rather than in the viewmodel. Meshack Musundi followed that line and implemented this alternative solution here. At this point I am not sure which solution is better, I guess it depends :-) I always prefer doing the data processing (string matching) in the viewmodel and have the view worry about the display, but there are different ways to archive this and here we have 2 interesting alternatives for you to choose.

Reference

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here