Saturday 27 April 2013

Issues When Regularly Updating Windows Forms DataGridView

A couple of (for me) none-obvious things about using the Windows Forms DataGridView, in particular when you want to display data that is updated regularly. In my case, I have an IronPython application that is monitoring some messages that it has sniffed off the network, and I want to use a DataGridView to show some details of those messages and update it about once a second. Since I don't want to be doing much work on the GUI thread, I have a little thread whose job it is to get the new data that I want to be added to the DataTable that my DataGridView is bound to.

Don't Update the Data Source on the GUI Thread


Point 1: if you are gathering data for your DataGridView on another thread, don't use that thread to update the data source (such as DataTable) that your DataViewGrid is bound to. While anyone doing Windows Forms programming should know that you shouldn't update a Windows Forms Control from a non-GUI thread, it might not be completely obvious that you also can't update the data source that your Control is bound to from a non-GUI thread. Here's an example of what not to do - this is IronPython, but the same principle applies in other .NET languages:
class DataTrackerThread(threading.Thread):
    
    def __init__(self, main_form):
        super(DataTrackerThread, self).__init__()
        self.main_form = main_form
        self.empty_data_table = self.main_form.empty_data_table
            
    def run(self):
        try:
            # Assume that fresh_data gets populated with your new data, and is a list of
            # iterable objects, each one of which corresponds to a row we want merged
            # into our table:
            fresh_data = some_method_to_get_data()
            # First we'll put our new data into its own, clean table:
            new_dt = self.empty_data_table.Clone()
            for item in fresh_data:
                new_dt.Rows.Add(*[str(val) for val in item])
            # Now we want to merge our new table with the existing table - this
            # will cause the DataGridView bound to it to be updated:
            self.main_form.data_table.Merge(new_dt) # DON'T DO THIS!
            time.sleep(1)
        except Exception, e:
            logging.exception("Exception in DataTrackerThread.run")
Here, we have a problem because we've modified the main form's 'data_table' from a non-GUI thread, which really means that we're causing the DataGridView that is bound to the DataTable to change from a non-GUI thread. Interestingly, at least when I made this mistake, this caused a very specific and irritating problem: the vertical scrollbar of the DataGridView wouldn't display properly or allow scrolling (the horizontal scrollbar appeared to be fine). Here you can see the non-painting vertical scrollbar - this problem is caused entirely by udpating the DataGridView's DataTable from a non-GUI thread!
Furthermore, the entire form became very sluggish and would show the "... Not Responding" message in the title bar when I dragged it around.

So instead we need to update the DataTable in a thread-safe way, which we can do with the BeginInvoke mechanism. For my IronPython application, I added this method to the form containing the DataGridView and DataTable:
def invoke_merge_data_table(self, new_data_table):
    def do_it(new_data_table):
        self.data_table.Merge(new_data_table)
    delegate = CallTarget0(lambda: do_it(new_data_table))
    self.BeginInvoke(delegate)
and then all we need to do is replace this line:
self.main_form.data_table.Merge(new_dt)
with this:
self.main_form.invoke_merge_data_table(new_dt)
in our non-GUI thread.

Note that this problem is of course avoided if we don't have a non-GUI thread, and just do everything we need to do to update our data source in the GUI thread. However, if you're doing any non-trivial amount of work to update your data, and the chances are that you are, then you really shouldn't be doing it on the GUI thread, as you risk your application being unresponsive while it is doing the data gathering.

The DataGridView Auto-Scrolls when the DataTable is Updated


The second problem when frequently updating the DataGridView's underlying data source is that DataGridView automatically scrolls the currently selected row into view when its rows are modified. So if we need to scroll down to see rows that are currently outside the grid's viewable area, we find that the vertical scrollbar is effectively useless as it jumps back to the currently selected row when the grid's data is updated, which is every second in my case. Unfortunately, there isn't a simple way to turn this behaviour off. This seems to be a fairly well-attested problem - see here for example:

http://social.msdn.microsoft.com/Forums/en-US/csharpgeneral/thread/1a9ea234-a67b-42de-91b2-9658b170afd5
http://www.hightechtalks.com/dotnet-framework-winforms-controls/how-disable-auto-scroll-datagridview-540601.html
http://social.msdn.microsoft.com/forums/en-US/winformsdatacontrols/thread/9b78a1fa-207f-4cb6-8e1e-38579d2d0932

But I couldn't find a simple solution. The best suggestion appeared to be here:

http://stackoverflow.com/questions/1521396/how-do-i-prevent-a-datagridview-from-autoscrolling-when-the-datasource-changes

but unfortunately while he describes the solution he doesn't actually give an example. So for those having the same problem, here's an implementation of the solution suggested on that stackoverflow page, in IronPython. First, we need to add a method something like this to the form that owns the DataGridView:
def on_data_table_row_changed(self, sender, event):
    def do_it(restore_index):
        if restore_index > -1:
            self.myDataGridView.FirstDisplayedScrollingRowIndex = restore_index
    i = self.myDataGridView.FirstDisplayedScrollingRowIndex
    delegate = CallTarget0(lambda: do_it(i))
    self.BeginInvoke(delegate)
This is a handler for the DataTable's RowChanged event, so we need to assign it to that event like this in the form's constructor:
self.data_table.RowChanged += self.on_data_table_row_changed
All our handler does is use BeginInvoke to post a message to the form's message queue that when processed will execute our delegate which wraps our imaginatively named 'do_it' method. All 'do_it' does is manually set the scroll position according to the paramater passed to it. Note that when we handle the RowChanged event, the auto-scrolling hasn't yet occurred, so at the point when we set "i" to self.myDataGridView.FirstDisplayedScrollingRowIndex we have the 'correct' value for FirstDisplayedScrollingRowIndex - i.e. it hasn't been nobbled by auto-scrolling yet. That's why there's no point trying to change FirstDisplayedScrollingRowIndex in the on_data_table_row_changed handler itself. Instead we send a Windows Message (via 'BeginInvoke') that will get executed later, after auto-scrolling has nobbled the FirstDisplayedScrollingRowIndex value, and when this message gets handled 'do_it' gets called and sets FirstDisplayedScrollingRowIndex back to what it should be. Essentially the same idea is explained here (although he's not talking about a DataGridView) and that blog entry is referred to by the stackoverflow post that inspired my example.

This works pretty well, and when I tested it I could scroll vertically without the scrollbar jumping back up to the selected row. The only slight issue is that when we manually set the value of FirstDisplayedScrollingRowIndex the user's own manual scrolling with the vertical scrollbar is halted. Since I end up setting FirstDisplayedScrollingRowIndex about once a second, I can drag the vertical scrollbar for about a second, then it stops and I have to start dragging again. This is not ideal, but precisely how annoying it is will depend on how often your RowChanged handler gets called, which in turn depends how often your data source is updated. If you update, say, every 10 seconds I think you'd scarcely notice the scrollbar 'sticking', but if you update, say, every 0.1 seconds then it will be pretty annoying and make dragging the scrollbar handle not work very well at all. In any case, the 'jumping' of the scrollbar back to the selected row is fixed by this, so it's definitely an improvement, albeit not a perfect solution.

No comments:

Post a Comment