Instant Searching and Filtering in .Net – Part 4

This is the final part of my series on instant searching and filtering using C#. The only further issue that I wanted to cover was efficiently using a ListView when the items  will change so often.

ListViews already have the concept of a virtual mode, where the consumer of the class must supply the items that are displayed.

When the VirtualMode property is set to true, you also need to set the VirtualListSize property to tell the control how many items it needs to think it has.

With this simple starting point, we can have something like the following:

 

   1: _listControl.VirtualMode = true;
   2: _listControl.RetrieveVirtualItem += 
   3:     new RetrieveVirtualItemEventHandler(list_RetrieveVirtualItem);

and list_RetrieveVirtualItem could look like this:

 

   1: void list_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e)
   2: {
   3:     e.Item = new ListViewItem();
   4:     e.Item.Text = "Item Text Here";
   5: }

Using this, it would be fairly easy to hook up our filtered results and away we go. But we have a few problems:

  • We don’t want to generate a new ListViewItem every time the list control asks us for one. This is extremely wasteful–thousands of objects would be springing to life and dying constantly, which is a very inefficient use of the garbage collector.
  • On the other hand, we don’t want to cache a ListViewItem object for every single line of text that was indexed. Only a few of them will be on the screen at a time, and we’ve already cached the text, so we should use that.

Therefore, we need a way to efficiently cache a limited number of ListViewItem objects and use them as necessary, updating their Text and Tag properties as necessary.

With that introduction, let’s get into the code.

Our own ListView

First things first: there’s a bug in the .Net framework that impacts virtual mode on ListView. So let’s derive our own to resolve it:

 

   1: class QuickList : System.Windows.Forms.ListView
   2: {
   3:     /// <summary>
   4:     /// This is needed to fix a bug in the framework
   5:     /// </summary>
   6:     public new int VirtualListSize
   7:     {
   8:         get
   9:         {
  10:             return base.VirtualListSize;
  11:         }
  12:         set
  13:         {
  14:             if (Items.Count > 0)
  15:             {
  16:                 EnsureVisible(0);
  17:             }
  18:             base.VirtualListSize = value;
  19:         }
  20:     }
  21: }
 

That’s all there is to that. If you would like more information about the bug, Shahar Prish wrote up an excellent description and workarounds.

With that out of the way, most of the action we’re concerned with will take place in a separate class, which I’ll call…

QuickListController

This controller class is responsible for coordinating the filter text, filtered results, and their display in the list control. In my own use of these concepts, I’ve further factored out the filtering mechanism to be outside of this controller, but in this sample it’s better to combine it all for ease of understanding.

So how will this work? We want a cache of only the items that will be displayed. Unfortunately, the ListView control is going to ask us for item 30,775 for example, if we’re scrolled down a huge list. We don’t want to cache 30,775 items when only the surrounding 20 items are displayed. Fortunately, it’s easy to track a minimal number of items if we know the first visible index

Here are the fields and properties we’ll need:

 

   1: class QuickListController
   2: {
   3:     private int _topIndex = -1;
   4:     private int _bottomIndex = -1;
   5:     private IIndexer<string> _indexer;
   6:     private IList<string> _allLines;
   7:     private IList<string> _currentResults;
   8:     private QuickList _listControl;
   9:     List<ListViewItem> _itemCache = new List<ListViewItem>();
  10:     private string _filter;
  11:     
  12:     #region Properties
  13:     public IList<string> CurrentResults
  14:     {
  15:         get
  16:         {
  17:             return _currentResults;
  18:         }
  19:     }
  20:     public string FilterText
  21:     {
  22:         get
  23:         {
  24:             return _filter;
  25:         }
  26:         set
  27:         {
  28:             _filter = value;
  29:         }
  30:     }
  31:     public IIndexer<string> Indexer
  32:     {
  33:         get
  34:         {
  35:             return _indexer;
  36:         }
  37:         set
  38:         {
  39:             _indexer = value;
  40:         }
  41:     }
  42:     public IList<string> AllLines
  43:     {
  44:         get
  45:         {
  46:             return _allLines;
  47:         }
  48:         set
  49:         {
  50:             _allLines = value;
  51:         }
  52:     }
  53:     #endregion
  54:     //methods, etc.
  55: }

Ok, lots of boring code. We track the top and bottom indexes of the visible items, we store our indexer, a list of all the results (for when no text is entered–no point in searching for nothing). _currentResults will point to either _allLines or the actual results. _itemCache is a list of ListViewItem objects that will serve as our cache. And, of course, we need the text we want to filter on.

You’ll notice that the filtered results are now strings instead of bools. In previous articles, we didn’t care about the actual results–just a count of them. Now, we need to display them, so the filtered items are the same as the keys–the lines of the text in a file.

Constructor

Construction is likewise straightforward:

   1: public QuickListController(QuickList list)
   2: {
   3:     _listControl = list;
   4:     _listControl.VirtualMode = true;
   5:     _listControl.CacheVirtualItems += 
   6:         new CacheVirtualItemsEventHandler(list_CacheVirtualItems);
   7:     _listControl.RetrieveVirtualItem += 
   8:         new RetrieveVirtualItemEventHandler(list_RetrieveVirtualItem);
   9: }

Our controller requires an instance of a QuickList object, which it then sets to virtual mode and listens for two events. The first, CacheVirtualItems, is triggered when the ListView needs a new range of objects. It gives us an opportunity to see what the new top and bottom will be and update our cache accordingly.

The RetrieveVirtualItem event is called when the ListView needs to get the item to display. As we’ll see below, handling both of these events is fairly straightforward.

CacheVirtualItems

 

   1: void list_CacheVirtualItems(object sender, CacheVirtualItemsEventArgs e)
   2: {
   3:     _topIndex = e.StartIndex;
   4:     _bottomIndex = e.EndIndex;
   5:  
   6:     int sizeNeeded = _bottomIndex - _topIndex + 2;//extra 2
   7:     int toCreate = sizeNeeded - _itemCache.Count;
   8:     if (toCreate > 0)
   9:     {
  10:         if (_itemCache.Capacity < sizeNeeded)
  11:         {
  12:             _itemCache.Capacity += toCreate;
  13:         }
  14:         for (int i = 0; i < toCreate; i++)
  15:         {
  16:             _itemCache.Add(new ListViewItem());
  17:         }
  18:     }
  19: }

We save the top and bottom indexes (we actually never use the bottom index), and we then calculate how many total items we need, how many new ones we need to create, and then create new ones if necessary.

You’ll notice that there is no mechanism for removing items from the cache. As we know, another name for cache with a bad (no) removal policy is a memory leak, but since the size of this cache is limited by screen characteristics, and in the most dire circumstance shouldn’t be more than a few hundred, we’ll let it slide (just this once).

RetrieveVirtualItem

 

   1: void list_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e)
   2: {
   3:     if (e.ItemIndex < _topIndex)
   4:     {
   5:         //should never get here
   6:         Debug.Assert(false);
   7:         _topIndex = e.ItemIndex;
   8:     }
   9:     
  10:     e.Item = GetListViewItem(e.ItemIndex, _topIndex);
  11: }

You’ll notice that if block shouldn’t ever be called, but during debugging I did have an instance where that was the case. It may have been a bug that has since been fixed, but I left it in for safety.

The actual work is done another function:

GetListViewItem

 

   1: private ListViewItem GetListViewItem(int itemIndex, int topIndex)
   2: {
   3:     if (itemIndex < 0 || topIndex < 0)
   4:     {
   5:         return null;
   6:     }
   7:     int cacheIndex = itemIndex - topIndex;
   8:     
   9:     Debug.Assert(cacheIndex < _itemCache.Count);
  10:     ListViewItem item = _itemCache[cacheIndex];
  11:     item.Text = _currentResults[itemIndex];
  12:     //could set tag if you have more complex objects
  13:     //item.Tag = _currentResults[itemIndex];
  14:     return item;
  15: }

Now that you understand how this is working, this function is really simple. Again, we do a simple sanity check. We calculate the index into our cache, which is simply the offset from the top index. After retrieving the pre-created item, we set its text (you could also set the item’s tag, color, or any other property here).

Now, some of you are wondering, “but wait, where do you set the VirtualListSize property?” Yes, that is rather important, isn’t it? How is the ListView control supposed to know how many items to ask for? For that matter, where is the actual filtering done?

The Filter

We have one more function, this one a public interface to the class’s consumer:

 

   1: public void RefreshList()
   2: {
   3:     if (string.IsNullOrEmpty(_filter))
   4:     {
   5:         _currentResults = _allLines;
   6:     }
   7:     else
   8:     {
   9:         _currentResults = _indexer.Lookup(_filter);
  10:     }
  11:     _listControl.VirtualListSize = _currentResults.Count;
  12: }

If the filter text is empty, we set our current results to the full set of lines. Otherwise, run the filter. Then we set our control’s virtual size to the number of results.

Conclusion

If you’ve struggled this far, congratulations. If you find anything in any of these articles useful, please leave a comment and let me know! If you find bugs or problems with my approach, I’d love to hear about better ways of accomplishing things.

The project download contains a GUI that hooks up these classes to an easy-to-use interface.

Also, if you’ve found this useful, please Buy me a Lego!

Download the project. (run the GuiSearch project–I changed TestSearch to a library file)

Download Les Misérables for testing.

 

Technorati Tags: , , , ,

Check out my latest book, the essential, in-depth guide to performance for all .NET developers:

Writing High-Performance.NET Code by Ben Watson. Available now in print and as an eBook at:

8 thoughts on “Instant Searching and Filtering in .Net – Part 4

  1. Pingback: Philosophical Geek » Instant Searching and Filtering in .Net - Part 1

  2. Leon Breedt

    Hi Ben,

    Another thing that we have encountered in virtual mode is that for large numbers of records, where there is a considerable perf hit to retrieving even just a subset of the records, the ListView scrolling behaviour makes the list less usable.

    While WPF has deferred scrolling behaviour, this isn’t available by default (at least not that I could find) in the WinForms listview.

    A bit of a hack, but thanks to http://www.microsoft.com/msj/1299/c/c1299.aspx I was able to cook something up that makes the WinForms listview also use “deferred” scrolling behaviour (e.g. only when the thumb is released, does the scroll happen), which has improved the user experience immensely.

    Here’s the code (no idea how it’s going to render as a comment though):

    public class DeferredScrollingListView : System.Windows.Forms.ListView
    {
    const int WM_VSCROLL=0x0115;
    const int SB_THUMBPOSITION=0x4;
    const int SB_THUMBTRACK=0x5;

    protected override void WndProc(ref System.Windows.Forms.Message m)
    {
    if( m.Msg==WM_VSCROLL )
    {
    int wParam=m.WParam.ToInt32();
    if( ((short)wParam)==SB_THUMBTRACK )
    {
    if( ignoreThumbTracks_ )
    {
    return;
    }
    }
    else if( ((short)wParam)==SB_THUMBPOSITION )
    {
    ignoreThumbTracks_=false;
    SendMessage(m.HWnd,WM_VSCROLL,MakeWParam(SB_THUMBTRACK,((short)wParam<<16)),m.LParam);
    ignoreThumbTracks_=true;
    }
    }

    base.WndProc(ref m);
    }

    [DllImport("user32.dll",CharSet=CharSet.Auto)]
    static extern IntPtr SendMessage(IntPtr hWnd,int msg,IntPtr wParam,IntPtr lParam);

    static IntPtr MakeWParam(int loWord,int hiWord)
    {
    return new IntPtr((loWord&0xFFFF)+((hiWord&0xFFFF)<<16));
    }

    bool ignoreThumbTracks_=true;
    }

  3. Rich

    Hi Ben,

    In your RetrieveVirtualItem section you mention that the if block should never be called, but you’ve seen it once during debugging – using the scroll wheel on a mouse I see this block hit multiple times.

    Rich

  4. Mauro

    Hi! first of all, i know this thread is old but it help me a lot.

    Q: Can i use it as a fork. I’m developing a file indexer, it this is too Wow! I tried to buy you a lego, but the web doesn’t exist anymore.

    I hope you can read this.

    Thanks!

  5. Subodh

    Thanks for sharing..

    I could not understand if you are creating index locally for all the items in the file, then what is the use of Virtual concept here.. It will crash for the files having size more then 1 GB.
    Please clarify my doubt…

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>