ObjectListView Demo: Master/Details

Posted on December 3, 2006

In the latest release of ObjectListView, I included a new demo that illustrates some fine points in real-world data binding with DataGridView.  Namely, binding a list (in this case our ObjectListView) to the grid and binding the current list item (i.e. current grid row) to some other data entry controls.  I also wanted to show how to manage the current grid row position as the list is sorted, and how to do multiple column sorts with the DataGridView.

For this demo, I created a simple but realistic data entry form for customer data:

MasterDetailsDemo

The grid is bound to an ObjectListView over a list of customers.  The textboxes at the top of the form are bound to different properties of the current list item.  You can edit customer data through the textboxes and by clicking in a grid cell.  The New button adds a new customer to the list.  You can load and save the customer list to a file with the File / Load and File / Save menu options.  Finally, you can change the current list item by clicking on a row in the grid, or by using the navigation controls at the bottom of the form.

Conceptually, this is all very straightforward, but I want to zoom in on a few data binding details that can make a big difference in the user experience.

 Performance

Consider what happens when we load the list of customers from a file.  First we clear the list, then we’ll add each customer item one by one.  That means we have at least as many ListChanged events raised as items being added to the list.  Add one for the reset when the list is initially cleared.  If you have event handlers doing anything when ListChanged is raised, your code could end up being very busy.

To ameliorate this situation, ObjectListView supports the BeginUpdate() and EndUpdate() methods.  Users of the WinForms ComboBox, ListBox and ListView controls will find these very familiar.  BeginUpdate() suppresses any ListChanged events until EndUpdate() is called.  If there were list changes that should have resulted in a ListChanged event, EndUpdate() will raise a single ListChanged event with a ListChangedType of Reset.  Here’s the code in the demo:

Stream strm = null;
 
try
{
    strm = File.OpenRead("customers.dat");
    BinaryFormatter fmtr = new BinaryFormatter();
    BindingList newCustomers =
        (BindingList)fmtr.Deserialize(strm);
 
    this.view.BeginUpdate();
 
    this.customers.Clear();
    foreach (Customer c in newCustomers)
        this.customers.Add(c);
 
    this.view.EndUpdate();
}
catch (IOException)
{
}
finally
{
    if (strm != null)
        strm.Close();
}

Multi-column Sorting

You may have noticed that with the SortMode of your DataGridViewColumns set to Automatic, the DataGridView supports sorting on only one column.  Both ObjectListView and DataView support multiple sort columns.  How do we enable multiple-column sorting in DataGridView?

First, what do we want the user experience to be?  The third-party grids that I’ve used support multi-column sorting with keyboard modifiers.  When a grid column header is clicked with the mouse, the Shift and Control key states control how that column participates in the sort:

  • No key modifier: all columns are removed from the sort, except the clicked column.  If the clicked column was already sorted, the sort order is reversed.
  • Shift key pressed: columns already in the sort stay in the sort.  The clicked column is added to the sort.  If the clicked column was already sorted, the sort order for that column is reversed.
  • Control key pressed: columns already in the sort stay in the sort.  The clicked column is removed from the sort.  If the clicked column was the only column in the sort, the grid is returned to its original unsorted state

To implement this behavior, we must first set the SortMode on each DataGridViewColumn to Programmatic.  This tells the DataGridView not to perform sorting when the column headers are clicked.  Secondly, we need to add an event handler for the DataGridView ColumnHeaderMouseClick event.  In this handler, we’ll determine the list item properties that correspond to the sort columns, and apply the appropriate sort to the ObjectListView (note that this strategy also works when binding to a DataView).  We also have to manually set the sort glyphs in the sort columns.  The sort glyphs are the little triangles in the column headers that indicate the sort direction.  Here’s the code:

// When a column header is clicked, update the sort on the view,
// and manually set the sort glyphs in the DataGridView columns.
// This is required to support multi-column sorting in the DataGridView.
// To support single-column sorting, remove this event
// handler and set the sort mode to automatic for each
// DataGridView column.
private void dataGridView_ColumnHeaderMouseClick(object sender, DataGridViewCellMouseEventArgs e)
{
    bool shiftPressed = (Control.ModifierKeys == Keys.Shift);
    bool ctrlPressed = (Control.ModifierKeys == Keys.Control);

    string sortPropName = this.dataGridView.Columns[e.ColumnIndex].DataPropertyName;
    PropertyDescriptor sortProp = TypeDescriptor.GetProperties(typeof(Customer)).Find(sortPropName, false);

    List newSorts = new List();

    bool clickedColumnInExistingSort = ctrlPressed;
    foreach (ListSortDescription desc in this.view.SortDescriptions)
    {
        if (desc.PropertyDescriptor.Name == sortPropName)
        {
            if (!ctrlPressed)
            {
                ListSortDirection dir = (desc.SortDirection ==
                                         ListSortDirection.Ascending ?
                                         ListSortDirection.Descending :
                                         ListSortDirection.Ascending);
                newSorts.Add(new ListSortDescription(sortProp, dir));
            }
            clickedColumnInExistingSort = true;
        }
        else if (shiftPressed || ctrlPressed)
            newSorts.Add(desc);
    }

    if (!clickedColumnInExistingSort)
        newSorts.Add(new ListSortDescription(sortProp, ListSortDirection.Ascending));

    this.view.ApplySort(new ListSortDescriptionCollection(newSorts.ToArray()));

    foreach (ListSortDescription desc in this.view.SortDescriptions)
    {
        foreach (DataGridViewColumn column in this.dataGridView.Columns)
        {
            if (column.DataPropertyName == desc.PropertyDescriptor.Name)
            {
                column.HeaderCell.SortGlyphDirection =
                    (desc.SortDirection == ListSortDirection.Ascending ?
                                           SortOrder.Ascending :
                                           SortOrder.Descending);
                break;
            }
        }
    }
}

Retaining the Current Position After a Sort

For me, this is a big one.  By default, after sorting the DataGridView, the current row remains at the same position that it was before the sort.  My expectation is that the current row should move to the row containing the data item that was current before the sort.  This preserves the connection with the data item in any other bound controls besides the grid.  For example, suppose the grid in our demo is sorted on Contact.  If I’m editing the contact name in the textbox above the grid, and tab to the next control, I have a strong expectation that I’m still editing the data for the same customer.  But, if editing the contact name caused the item to change position in the sort, this won’t be the case!

We can correct this behavior by handling two events: the PositionChanged event of the BindingSource that the grid is bound to, and the Sorted event of the DataGridView.

Aside:  In this demo, I’ve tried to construct a realistic scenario.  The BindingSource is used only because the BindingNavigator requires one.  The BindingNavigator gives us the nice VCR-like controls at the bottom of the screen.  Thus, the DataGridView is bound to a BindingSource, which is bound to ObjectListView, which is bound to a BindingList<Customer>.  If the BindingNavigator wasn’t needed, we could bind the DataGridView directly to the ObjectListView, and look up the CurrencyManager for the data source and use the PositionChanged event of the CurrencyManager instead.  Whew.

What we want to do is to record the current list item (the customer corresponding to the current grid position) as we navigate among the grid rows.  Then, after the ObjectListView is sorted, we’ll find the new position of the formerly current item, and set the position in the BindingSource accordingly:

// Remember the current item as the current grid row changes,
// so that if the grid is sorted, we can restore the current item.
private void bindingSource_PositionChanged(object sender, EventArgs e)
{
    if (this.bindingSource.Position < 0)
        this.current = null;
    else
        this.current = this.bindingSource.Current as Customer;
}

// After sorting, move the current grid row to the list item that was
// current before the sort.
// If the sort mode on the DataGridView columns was automatic,
// we could handle the DataGridView.Sorted event instead of the view
// event. Because the sort mode is programmatic,
// the DataGridView.Sorted event is not raised.
private void view_Sorted(object sender, EventArgs e)
{
    if (this.current != null)
        this.bindingSource.Position = this.bindingSource.IndexOf(this.current);
}

Note that we have to handle the Sorted event of ObjectListView rather than the Sorted event of DataGridView.  This is because we’ve changed the SortMode of the DataGridViewColumns from Automatic to Programmatic.  In Programmatic mode, we’re doing the sorting, not the DataGridView.  Thus, the event is not raised.

Setting the Current Position for a New Item

When adding a new item to the list, the item will be presented in the grid in the correct sorted position.  We want to set the current grid position to the new item, wherever it ends up.  Otherwise, the user has to know to click on the newly added row before starting to enter data in the data entry controls.  This is easily handled in the New button Click event handler:

// Add a new customer to the list, and move the current grid row
// to the new item.
private void buttonNew_Click(object sender, EventArgs e)
{
    int position = ((ObjectListView)this.bindingSource.DataSource).Add(new Customer());
    this.bindingSource.Position = position;
}

You may ask, why do we even have a New button when we could set the AllowUserToAddRows property on the DataGridView to true, and just let the user enter data directly in the “new” row of the grid?  I don’t like that approach because any sorting on the grid changes the position of the new row as soon as data is entered.  With a New button, we can create a new row and set focus on the first data entry control to let the user begin editing.  Because we’ve already handled the change of the current position after grid sorting, the data entry controls stay correctly bound even if the sort causes the edited new item to change position in the grid.  My preference is to provide data entry (“detail”) controls, make the grid read-only, and not allow the “new” row at all.

Designer Experience

If you’ve used ObjectListView or any object data source to bind to a DataGridView, you might have observed that the designer experience isn’t great.  You can’t set the DataSource property correctly in the designer view, so you don’t get auto-generated columns.

Eventually, I’ll add nice design component capabilities to ObjectListView, but for now there’s an easy workaround.  In the designer property window, set the BindingSource’s DataSource property to the list item type.  Just click on the pull-down menu button and select “Add Project Data Source”.

AddProjectDataSource

By selecting the object type that will be present in the list (and thus in ObjectListView), the appropriate columns will appear in the DataGridView and you can edit them in design mode to your heart’s content.  Elsewhere in your code, you’ll need to set the DataSource for the BindingSource back to the ObjectListView.  In the demo, this is done in the form’s constructor:

public MainForm()
{
    InitializeComponent();

    customers = new BindingList();

    view = new ObjectListView(customers);
    view.Sorted += new EventHandler(view_Sorted);

    this.bindingSource.DataSource = view;
}

What Else?

That wraps it up for the demo.  If there are other ObjectListView scenarios that you’d like to see demonstrated, just let me know.


No Replies to "ObjectListView Demo: Master/Details"


    Got something to say?

    Some html is OK