Freigeben über


An Rx-Enabled WPF AutoComplete TextBox - Part 2/2

In my last blog post I described the general idea of using Rx to handle typical query situation with all their pitfalls. Let us take the idea one step further in developing a WPF control that directly makes use of Rx. The source code excerpts in this post are simplified and I tried to focus just on the important aspects. The final code looks a bit different and can be downloaded from SkyDrive.

Basic Control Architecture

 Let us start with the general control architecture of our AutoCompleteTextBox. I really like WPF's Lego pieces nature in this regard. Instead of doing everything from scratch we can utilize existing controls to implement a new one: We need a TextBox for the input, a Popup for providing a panel for our results and a ListBox to show the actual results. The one thing that we are going to implement is a custom control that orchestrates the existing controls. The following figure should clarify this:

The AutoCompleteTextBox is not really TextBox, that is it does not inherit from TextBox but from control. It declares the parts mentioned above via the TemplatePart attribute:

 [TemplatePart(Name = PartTextBox, Type = typeof(TextBox))]
[TemplatePart(Name = PartPopup, Type = typeof(Popup))]
[TemplatePart(Name = PartListBox, Type = typeof(ListBox))]
public class AutoCompleteTextBox : Control
{
    public const string PartTextBox = "PART_TextBox";
    public const string PartPopup = "PART_Popup";
    public const string PartListBox = "PART_ListBox";
     [...]

As always when working with parts you need to get hold on them in the OnApplyTemplate method:

 public override void OnApplyTemplate()
{
    base.OnApplyTemplate();
            
    partTextBox = this.GetTemplateChild(PartTextBox) as TextBox;
    if (partTextBox == null)
    {
        throw new InvalidOperationException("Associated ControlTemplate has a bad part configuration. Expected a TextBox part.");
    }
     [...] // grab all other parts 

 I am being a bit careful here since just declaring our Parts as Meta-Information does not guarantee that they are really present. Failing immediately with an exception makes it easier to spot the misconfiguration in the Control Template. Speaking of the Control Template - here is an simplified version of it:

 <Style TargetType="{x:Type local:AutoCompleteTextBox}">
    <Setter Property="Focusable" Value="False"></Setter>
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type local:AutoCompleteTextBox}">
                <Grid>
                    <TextBox x:Name="PART_TextBox" />
                    <Popup x:Name="PART_Popup">
                         <Grid>
                             <ListBox x:Name="PART_ListBox" 
                                      ItemsSource="{TemplateBinding Results}" />
                        </Grid>
                    </Popup>
                </Grid>
        </Setter.Value>
    </Setter>
</Style>

Very good - all required parts are present in the control template. With the visuals and outlets in place let us investigate the internals of the control.

Control Internals

So far we have a custom control called AutoCompleteTextBox that has access to a TextBox, Popup and ListBox. Now let us apply some Rx (I won't explain the Rx code, see my previous post for background information). What we would like to achieve is that we can combine the TextBox' TextChanged event stream with a query function that returns query results. Hooking up the TextChanged events is simple enough:

public class AutoCompleteTextBox : Control
{
     public override void OnApplyTemplate()    
    { 
               base.OnApplyTemplate();

         // [...]
         var textChangedObsevable = Observable.FromEventPattern<TextChangedEventArgs>(partTextBox, "TextChanged")
                .Select(evt => ((TextBox)evt.Sender).Text);
  var constraintTextChangedObservable = textChangedObsevable
                          .Where(text => text != null && text.Length >= this.MinimumCharacters)
                          .DistinctUntilChanged()
                          .Throttle(TimeSpan.FromMilliseconds(this.Delay));
        // [...]
     }
}

The highlighted properties in the code snippet are simple Dependency Properties declared on the AutoCompleteTextBoxControl. They constraint the text event stream to avoid too frequent query polling. Next thing we need to do is use the stream of text input to call into a query result provider. And here the control follows a rather unusual path. It basically allows you to bind a provider function that looks like this:

 Func<AutoCompleteQuery, IObservable<IEnumerable<IAutoCompleteQueryResult>>>

with the AutoCompleteQuery defined as: 
 public class AutoCompleteQuery
{
    public AutoCompleteQuery(string term)
    {
        this.Term = term;
    }
 
    public string Term { get; private set; }
}
 and IAutoCompleteQueryResult defined as: 
 public interface IAutoCompleteQueryResult
{
    string Title { get; }
}  
  

This deserves some explanation. The function that takes an AutoCompleteQuery as input. This is in the end the input a user has put into the TextBox. In response it returns an Observable which yields IEnumerables of query results. I use an interface here to make sure that there is a textual representation of the query result which can in the end be put into the TextBox. The result provider function is not directly defined as a dependency property because the WPF designer had some issues in the preview although run-time and compile-time wise everything would be fine. Therefore there is a little wrapper that works around this issue. The code of the dependency property:

 public AutoCompleteQueryResultProvider AutoCompleteQueryResultProvider
{
    get
    {
        return (AutoCompleteQueryResultProvider)GetValue(QueryResultFunctionProperty);
    }
    set
    {
        SetValue(QueryResultFunctionProperty, value);
    }
}
 
public static readonly DependencyProperty QueryResultFunctionProperty = DependencyProperty.Register(
    "AutoCompleteQueryResultProvider",
    typeof(AutoCompleteQueryResultProvider),
    typeof(AutoCompleteTextBox),
    new UIPropertyMetadata(AutoCompleteQueryResultProvider.Empty));
  
  ...and the wrapper class AutoCompleteQueryResultProvider :       
 public class AutoCompleteQueryResultProvider
{
    [...]
  
    /// <summary>
    /// Initializes a new instance of the <see cref="AutoCompleteQueryResultProvider" /> class.
    /// </summary>
    /// <param name="getResults">The get results.</param>
    public AutoCompleteQueryResultProvider(Func<AutoCompleteQuery, 
                   IObservable<IEnumerable<IAutoCompleteQueryResult>>> getResults)
    {
        this.GetResults = getResults;
    }
 
    public Func<AutoCompleteQuery, IObservable<IEnumerable<IAutoCompleteQueryResult>>> GetResults { get; private set; }
} 

And finally the combined text changed event stream and the query result provider:

 var textChanged = this.textChangedObservable
                .Where(text => text != null && text.Length >= this.MinimumCharacters)
                .DistinctUntilChanged()
                .Throttle(TimeSpan.FromMilliseconds(this.Delay));
 
var getResultsFunc = this.AutoCompleteQueryResultProvider.GetResults;
var resultsObservable = from searchTerm in textChanged
                        from suggestions in
                            getResultsFunc(new AutoCompleteQuery(searchTerm)).TakeUntil(textChanged)
                        select suggestions; 

Now we just need to subscribe to the resultsObservable and add the query results to our Results collection (which is the ItemsSource for the ListBox):

 resultsObservable.ObserveOn(this).Subscribe(
        results =>
        {
            this.Results.Clear();
 
            foreach (var result in results)
            {
                this.Results.Add(result);
            } 
        });

Hooking the Control Up to a ViewModel

The control is ready but it requires an external query provider to work (the AutoCompleteQueryResultProvider). This is now really easy and very view model and test-friendly. Just define a ViewModel like this:

 public class RottenTomatoesViewModel : ViewModelBase
{
    public AutoCompleteQueryResultProvider AutoCompleteQueryResultProvider
    {
        get
        {
            return
                new AutoCompleteQueryResultProvider(
                    query => Observable.FromAsync(() => this.SearchRottenTomatoes(query.Term)));
        }
    }
 
 
    private async Task<IEnumerable<AutoCompleteQueryResult>> SearchRottenTomatoes(string term)
    {        
        string address = string.Format(
            "https://api.rottentomatoes.com/api/public/v1.0/movies.json/?apikey={0}&q={1}&page_limit={2}&page={3}",
            "<<<YOURAPIKEY>>>", 
            term, 
            10, 
            1);
 
        var client = new HttpClient(new LegacyJsonMediaTypeConverterDelegatingHandler() 
                                   { InnerHandler = new HttpClientHandler() });
 
        HttpResponseMessage response = await client.GetAsync(address);
        response.EnsureSuccessStatusCode();
        JObject content = await response.Content.ReadAsAsync<JObject>();
 
        var res = new List<AutoCompleteQueryResult>();
        foreach (var movieDescription in content["movies"])
        {
            var movie = new AutoCompleteQueryResult();
            movie.Title = movieDescription["title"].Value<string>();
            movie.Thumbnail = movieDescription["posters"]["thumbnail"].Value<string>();
            res.Add(movie);
        }
 
        return res;
    }
}

 

Perform a DataBinding in the associated view:

 <ctrls:AutoCompleteTextBox
    Grid.Row="1"
    AutoCompleteQueryResultProvider="{Binding AutoCompleteQueryResultProvider}"     
 Margin="10" FontSize="20" PopupHeight="300">      
    <ctrls:AutoCompleteTextBox.ItemTemplate>
        <DataTemplate>
            <Grid Height="100">
                <Grid.ColumnDefinitions>
                    <ColumnDefinition Width="100"></ColumnDefinition>
                    <ColumnDefinition Width="*"></ColumnDefinition>
                </Grid.ColumnDefinitions>
                <Image Grid.Column="0" Stretch="None" Source="{Binding Thumbnail}"></Image>
                <TextBlock MaxWidth="280" Margin="5 0 0 0" TextWrapping="Wrap" 
                           Grid.Column="1" Text="{Binding Title}"></TextBlock>
            </Grid>
        </DataTemplate>
    </ctrls:AutoCompleteTextBox.ItemTemplate>
</ctrls:AutoCompleteTextBox>

 

... and the result:

 

Compared to the solution in the first post, it is very re-usable: Changing the backend just requires a change of the provider function and all the other parts stay the same.

Closing Words

The control is by no means perfect. It does not use the Visual State Manager, it does not support paging nor does it support nice UI candy such as showing the matched string highlighted in the query results. A little spinner animation would also be great... And an error reporting in the UI for remote queries would be nice, too. For the moment I leave these features as an exercise for the happy reader :-) I think as a demo that Rx can also have a place inside a control it has accomplished its purpose.

Source code on SkyDrive and GitHub.