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:
- without a collection control or
- 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)
{
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
item.MatchString(spara)
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);
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)
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)
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