In-Depth
Filtering List Boxes with a Little Help from XAML
Information equals data plus context, and at this point in mankind's history, we have way too much data. But there are ways to constrain it all and make it usable, including through software. Consider the Web site Google.com -- perhaps you've heard of it. Despite having its fingers in billions of Web pages, it can filter them down pretty quickly as you type, showing you the most likely matches for whatever you've entered so far.
Such filtering isn't limited to the World Wide Web; you can use such features in your own XAML app. The example presented here displays a list of American cities, allowing the user to see a subset of choices by typing a filtering text value. The code will apply that filter to both the city name and one or more ZIP codes included in each town's jurisdiction. Figure 1 shows the running program, with a city displayed based on a partial ZIP code value.
Naturally, the first step is to figure out how to identify matching cities from the text pattern. Create a new WPF App (.NET Framework) in C#, and add a new interface file called ITextFilter.cs. It will define a single member, a method that indicates whether an object is a match for a supplied string pattern:
interface ITextFilter
{
bool IsMatch(string pattern);
}
Next, create the class that will contain each city name and associated ZIP codes. Add a class file named CityEntry.cs to the project, and apply the ITextFilter interface to its definition. The class will include two public members: a city name, and a collection of ZIP codes, each stored as a numeric string:
public class CityEntry : ITextFilter
{
public string CityState { get; set; }
public List ZipCode { get; set; }
}
A custom constructor appears solely for programmer convenience, and accepts a city name, plus zero or more postal codes:
public CityEntry(string cityName, params string[] zipCodes)
{
this.CityState = cityName;
this.ZipCode = new List();
if (zipCodes != null)
this.ZipCode.AddRange(zipCodes);
}
Each instance of CityEntry determines whether it matches the filtering pattern by implementing the ITextFilter.IsMatch interface member:
public bool IsMatch(string pattern)
{
if (string.IsNullOrEmpty(pattern))
return false;
else if ((this.CityState != null) &&
(this.CityState.ToLower().Contains(pattern.ToLower())))
return true;
else if (this.ZipCode != null)
{
foreach (string oneZip in this.ZipCode)
if (oneZip.Contains(pattern))
return true;
}
return false;
}
Each instance will also determine its default content presentation, which is just the city name:
public override string ToString()
{
return this.CityState;
}
That's it for the individual data objects. Next, create the class that will host the collection of city objects, and present them to the display form. Add a new class file to the project called CityData.cs, and apply the INotifyPropertyChanged interface to enable XAML data binding:
// Assumes: using System.ComponentModel;
public class CityData : INotifyPropertyChanged
{
}
The class exposes two public properties for binding, one for the list of cities, and one to set the filter pattern. I won't bore you with all of the boilerplate view-notification code -- download the sample project for this article to see those details -- but here is how the two properties will look to any user of a CityData instance:
// Not the full code...
public ObservableCollection MatchingCities { get; set; }
public string Filter { get; set; }
The constructor for CityData builds the list of cities and ZIP codes and stores them in a private collection called AllCities. Those objects are then dumped into MatchingCities, since in the absence of a filter pattern all records should appear:
public CityData()
{
this.AllCities = new List<CityEntry>()
{
new CityEntry("Albuquerque, NM", "87121"),
new CityEntry("Anaheim, CA", "92804"),
// ...items left out...
new CityEntry("Bronx, NY", "10452", "10453", "10456",
"10458", "10462", "10467", "10468"),
new CityEntry("Brooklyn, NY", "11206", "11207", "11212",
"11214", "11219", "11226", "11230", "11234", "11235"),
// ...more items left out...
new CityEntry("Yuma, AZ", "85364")
};
// ----- All cities match by default.
this.MatchingCities = new ObservableCollection();
foreach (CityEntry oneCity in this.AllCities)
this.MatchingCities.Add(oneCity);
}
When the user enters a partial or full search string, the form updates CityData's Filter property. Code in that property's set accessor queries each city object, checking to see if there are matches:
this.MatchingCities.Clear();
if (this.LastFilter.Trim().Length == 0)
{
// ----- No filters. Add all cities.
foreach (CityEntry oneCity in this.AllCities)
this.MatchingCities.Add(oneCity);
}
else
{
// ----- Limit cities by filter.
var filteredCities =
from oneCity in this.AllCities
where oneCity.IsMatch(this.LastFilter)
select oneCity;
foreach (CityEntry oneCity in filteredCities)
this.MatchingCities.Add(oneCity);
}
That completes the back-end code. The only thing left to do is to craft the code for the form. And it turns out that there's no code to write, because everything that’s still needed will appear in XAML. I'm convinced that XAML was invented by Microsoft programmers who wanted to get out of the tedium of developing UI code. But whatever the reason, the following XAML markup, when used to replace the default tag in the MainWindow.xaml content, will connect the form up to a CityData instance:
<Window.DataContext>
<local:CityData/>
</Window.DataContext>
<Canvas>
<Label Content="City:" Canvas.Left="8" Canvas.Top="8" />
<TextBox Width="290" Height="23" Canvas.Left="56" Canvas.Top="11"
Text="{Binding Path=Filter, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"/>
<ListBox Width="290" Height="180" Canvas.Left="56" Canvas.Top="38"
ItemsSource="{Binding Path=MatchingCities}"/>
</Canvas>
The markup adds a list box to the form, connecting it to the exposed MatchingCities collection. It also includes a text box control, with a two-way connection to the Filter property. Instantiating the source data through the <Window.DataContext> tag has one side benefit, that of showing the list of cities while in design mode.
Run the program and type either a partial city name or a partial ZIP code into the text box. As you type, the selection of cities will adjust itself to just those that match the criteria. The sample project includes less than 100 ZIP codes and related cities, so the display updates quickly. If you had a system with, say, billions of Web sites behind the filter, you might want to make some minor adjustments to the code to speed things up.
About the Author
Tim Patrick has spent more than thirty years as a software architect and developer. His two most recent books on .NET development -- Start-to-Finish Visual C# 2015, and Start-to-Finish Visual Basic 2015 -- are available from http://owanipress.com. He blogs regularly at http://wellreadman.com.