ObjectListView Update (1.0.0.6)
Posted on December 27, 2006
Note: You can download the complete implementation here.
The DataGridView New Row
When you set the DataGridView property AllowUserToAddRows to true (and the IBindingList data source AllowNew property is also true), an empty row will be displayed at the bottom of the grid. In an earlier posting, I described how the IBindingList method AddNew() supports this “new row”.
When I implemented AddNew in ObjectListView, I noted this comment in the MSDN documentation of the method:
If the objects in this list implement the IEditableObject interface, calling the CancelEdit method should discard an object, not add it to the list, when the object was created using the AddNew method. The object should only be added to the list when the IEditableObject.EndEdit method is called. Therefore, you must synchronize the object and the list carefully.
I interpreted this to mean that list items added via AddNew() (at least IEditableObject items) should not actually be added to the underlying list until EndEdit() is called on the list item.
Unfortunately, this doesn’t work correctly. Once AddNew() has been called, the DataGridView expects to be able to access the newly added row. The item needs to be available through the view class, but also needs to be kept in a provisional state so that it can be removed should the user later cancel the add with a call to IEditableObject.CancelEdit(). DataView handles this nicely by creating a new DataRow with a call to DataTable.NewRow(), which leaves the row in a Detached state, and not in the DataTable. Once the row is committed, the DataRow is added to the DataTable. DataView also is careful to make the row available through it’s indexer, so that the new DataRow appears to have been added to the collection.
I also observed that although ObjectListView.AddNew() returns an IEditableObject wrapper to allow calls to IEditableObject.EndEdit() and CancelEdit() to be captured, DataGridView ignores the returned object and accesses the underlying wrapped object through the ObjectListView indexer. This means that newly added list items are never committed. This also got me wondering how I could support a cancelable AddNew() operation for list items that don’t implement IEditableObject.
ICancelAddNew
When DataGridView commits a row that was added with AddNew(), it first checks to see if the list item implements IEditableObject. If so, IEditableObject.EndEdit() is called. Next, the data source is checked to see if it implements ICancelAddNew. If so, ICancelAddNew.EndNew() is called. Similarly, canceling the previous AddNew() is done by calling IEditableObject.CancelEdit() and ICancelAddNew.CancelNew(). Since the methods of ICancelAddNew are expected to be provided by the data source, we can implement them very directly. Contrast this to committing/canceling through the list item methods of IEditableObject, which requires wrapping the list item in an IEditableObject implementation that also raises an event or has some special knowledge of the view class. DataView supplies a DataRowView wrapper that has a reference to the DataView, and can thus inform the DataView when EndEdit() or CancelEdit() is called. Key to the DataView/DataRowView implementation is the fact that DataView exposes the DataRowView wrapper as the list item type in all cases. AddNew() returns a DataRowView, as does the indexer and the enumerator.
A goal of ObjectListView is to support as transparent a view as possible over the user-supplied collection. This means that the methods referring to list items always return or take as a parameter the actual list item, not a wrapper. This led me to reject the DataRowView-style wrapper approach of DataView, with the exception of AddNew(), which must support commit/cancel through IEditableObject.
By adding support for ICancelAddNew, we gain the ability to cancel list additions with no penalty to the usage model. The implementation is straightforward:
void ICancelAddNew.CancelNew(int itemIndex) { Lock(); try { if (this.IsPendingNewItem(itemIndex)) CancelAddNew(); } finally { Unlock(); } RaiseEvents(); } void ICancelAddNew.EndNew(int itemIndex) { Lock(); try { if (this.IsPendingNewItem(itemIndex)) FinishAddNew(); } finally { Unlock(); } RaiseEvents(); }
In AddNew(), we save a reference to the newly added item so that we can refer to it here. As you might note, CancelNew() and EndNew() operate on a particular list position. If the item at that position is not the previously added item, the call is ignored.
The actual commit and cancel methods are shared for both ICancelAddNew and IEditableObject approaches. This allows us to use IEditableObject list items and commit them programmatically, or use the new row of DataGridView and commit implicitly via the support for ICancelAddNew:
private void FinishAddNew() { if (this.isEditableObject) { ((IEditableObjectEvents)this.newItemPending).Ended -= new EventHandler(editableListItem_Ended); ((IEditableObjectEvents)this.newItemPending).Cancelled -= new EventHandler(editableListItem_Cancelled); } // Raise the second ListItemChanged / ItemAdded event. if (this.isEditableObject) { int index = this.IndexOf(this.newItemPending.Object); this.QueueEvent( new ListChangedEventArgs(ListChangedType.ItemAdded, index)); } this.newItemPending = null; // Now that the item is committed, reposition it in the sort, // and potentially filter it out of the list. if (this.IsFiltered) ApplyFilter(); if (this.IsSorted) ApplySortCore(); } private void CancelAddNew() { if (this.isEditableObject) { ((IEditableObjectEvents)this.newItemPending).Ended -= new EventHandler(editableListItem_Ended); ((IEditableObjectEvents)this.newItemPending).Cancelled -= new EventHandler(editableListItem_Cancelled); } ObjectView temp = this.newItemPending; this.newItemPending = null; this.Remove(temp.Object); } private bool IsPendingNewItem(int listIndex) { return (this.newItemPending != null && list.IndexOf(this.newItemPending.Object) == listIndex); }
This is reasonably self-explanatory. If the list item type implements IEditableObject, the added item is a wrapper class instance that provides Ended and Cancelled events. AddNew() subscribed to these events, and they are unwired here. For IEditableObject list items, the MSDN documentation (and the observed behavior of our DataView reference model) requires us to raise the ListChanged (ItemAdded) event a second time during the commit.
Other Special Aspects of the New Row
The list item represented by the “new row” has some other subtleties. Consider what happens when the grid is sorted. If you’re entering data in the new row, you wouldn’t expect that row to move somewhere else in the grid while you are typing. However, if the list item has been added to the view, it will be repositioned when the sort demands it. To prevent this, I changed the item add logic to put the new item at the end of the view collection, and simply append that position to the sort indexes:
private int OnItemAdded(int listIndex) { // Invalidate enumerators. this.version++; // Subscribe to the item change events // (either INotifyPropertyChanged or xxxChanged). this.WirePropertyChangedEvents(list[listIndex]); // If the add is the result of AddNew(), don't sort. // The item will be put into the sort when the add is committed with // EndNew() or IEditableObject.EndEdit(). if (IsPendingNewItem(listIndex)) { this.AppendNewItemToSortIndexes(listIndex); this.QueueEvent( new ListChangedEventArgs(ListChangedType.ItemAdded, listIndex)); return listIndex; } else { // Sort and filter. this.RebuildSortIndexes(); int sortedIndex = this.GetSortedPositionOfListIndex(listIndex); this.QueueEvent( new ListChangedEventArgs(ListChangedType.ItemAdded, sortedIndex)); return sortedIndex; } }
In the above code, note that sorting and filtering are not performed as a result of a call to AddNew(). The new item appears as the last item in the view, and is visible even if the filter would normally exclude it. This allows the user to enter all data in the row before it is relocated by the sort or hidden by the filter.
If the user should change the sort before committing the new row, we just abandon the new row with the equivalent of a call to CancelNew(). Finally, changes to list item properties are not raised as ListChanged events if the changed item is the newly added, uncommitted list item.
private void OnItemChanged(int listIndex, string propertyName) { // Don't re-sort, filter, or raise change notifications // for an uncommitted new row. if (!this.IsPendingNewItem(listIndex)) { OnItemChangedCore(propertyName); int index = this.GetSortedPositionOfListIndex(listIndex); QueueEvent(new ListChangedEventArgs(ListChangedType.ItemChanged, index)); } }
This prevents a sort or filter operation from being triggered.
Using ObjectListView with the New Row
There’s nothing special to do, other than setting DataGridView.AllowUserToAddRows to true. If you want to see an example in code, I’ve updated the Master/Details demo (included in the ObjectListView.zip download) to optionally use the new row.
Happy Holidays and Happy Viewing!
Got something to say?