Wednesday 3 June 2009

SelectedIndexChanged and again! and again! and again!

If you have a ListView, and it has MultiSelect enabled and you select a row, then scroll down, say, 100 rows, and shift-click, how many SelectedIndexChanged events do you expect? How many do you get? 100, of course, one for every row that had a selection change. You get no special EventArg to let you know this is one of a set, or how many you might expect. As far as the eventing is concerned each highlighted row is wonderous occasion in its own right.

I've got a ListView that shows TFS changesets, when I click on one (in the middle in this example) another list populates that shows all the files in that changeset (nifty, eh?). When I shift-click on another row way down the list, I want to see all the files in that set of changesets. Using my trusted SelectedIndexChanged to let me know the selection has changed, I get oodles of lovely events and can refresh my list of files. If I shift-click right up near the top of the list I get an event for each of the rows being de-selected on one for each of the new rows being selected.

This is not what I want. Now I know that I can use each event to add/remove files from my other list, but because I'm merging data where the same file is in two changesets (to get a combined list of change types), this gets complicated. All I want is one event to tell me that SomeSelectionIndicesChanged. Then I can clear the file list, and re-populate it.

Here is some code to do just that. I don't like the gratuitous use of threads like this, but in WinForm apps, you rarely have more than one instance of the app, and users don't normally do complex multi-row selections on several ListViews at once, so there is little danger of weird concurrency issues. All it does is every time a SelectedIndexChanged is raised, it waits 1/10th second and then raises a new AfterMultiSelect event, if another SelectedIndexChanged occurs in that time, it stops and starts waiting again. If a SelectedIndexChanged occurs after the 1/10 second, but before the AfterMultiSelect has finished being handled, that doesn't matter because it's being handled back on the controls own thread.

Anyway, the code:

using System;
using System.Windows.Forms;
using System.Threading;

namespace Project.ControlManagers
{
public class ListManager
{
private ListView listView;
private delegate void SelectionChange();

public event EventHandler AfterMultiSelection;

public ListController(ListView listView)
{
this.listView = listView;
// listen for all the change events
listView.SelectedIndexChanged += new EventHandler(listView_SelectedIndexChanged);
}

Thread t = null;
void listView_SelectedIndexChanged(object sender, EventArgs e)
{
ThreadStart ts = new ThreadStart(QueueSelectionChange);
// if we already had a thread, kill it
if (t != null)
t.Abort();

// start a new thread to process the event
t = new Thread(ts);
t.Start();
}

void QueueSelectionChange()
{
// have a nap. in this time if another SelectedIndexChanged event is fired this thread will
// be aborted and nothing more will happen.
Thread.Sleep(100);
// invoke the call to raise the new event on the ListViews thread, not this one
// otherwise things will go awry
SelectionChange sc = ProcessSelectionChange;
listView.Invoke(sc);
t = null;
}

void ProcessSelectionChange()
{
// if anyone is listening raise the AfterMultiSelection event
if (AfterMultiSelection != null)
AfterMultiSelection(listView, new EventArgs());
}
}
}


I wouldn't be at all surprised if someone didn't have a good reason not to do this. The same technique can be used to do something after a user has stopped scrolling or resizing something if the work you want to do post scroll/resize is rather expensive and looks crappy if it happens on every scroll/resize event.


Footnote: This is too slow for hundreds of rows, but seems fine for a couple of dozen or so.