Introduction
Having spent two years dabbling with C# ASP.NET programming, I have picked up countless insights, many of which I always wish I had a chance to write down and share, for my benefit as much as for others, as I often forget a few months down the line what it is that actually got me wanting to write something down in the first place. In the end, though, I never have the time or the inclination, or it would just take too long to explain the background behind the insight to make it worth the effort.
Occasionally, a really nice challenge will crop up for which the solution is entirely self-contained and re-useable, and maybe, it is just worth sharing my solution with others.
Take the GridView
control in ASP.NET 2.0. We use it all the time to bind to data sources, and represent the data to the end user in a clean and tidy way, with built-in paging, sorting, selecting, editing, and deleting. It is the corner-stone of most modern-day web applications written in .NET. It is probably because it is used so much that we sometimes wish it could do just that little bit more. Why, for instance, can we not use the GridView
to select multiple records from the data out of the box? You can set the DataKeyNames
property to contain the names of the primary key fields for the data items, and retrieve the DataKey
object for a single selected row. Multi row selections like those of a ListBox
, however, are not supported.
Background
Several people have come up with solutions to this problem, both client-side and server side. Dino Esposito expands on GridView
by adding a checkbox to each row to allow the user to select multiple rows, and exposes a method to retrieve the SelectedIndices
by looping through the rendered rows and returning the indexes of those with checked checkboxes. For my solution, however, I didn’t find this wholly satisfactory. It only retrieves the selected indices from the current page of data, with the first record of the page being 0, just like the existing SelectedIndex
property. I wished to be able to select records over multiple pages and retrieve all of the selected DataKey
s instead of just the indices. This is my solution.
Using the code
Not one to reinvent the wheel, I opted to base my code on Dino’s, defining a new class to inherit from GridView
.
MultiSelectGridView
namespace blive.Controls
{
public class MultiSelectGridView : GridView
{...
}
}
I then implemented Dino’s two public properties, a boolean to specify whether or not to add a checkbox to each row, and an integer to specify a column index to place it in.
Public properties
public bool AutoGenerateCheckboxColumn
{
get
{
return (null != ViewState["AutoGenerateCheckboxColumn"]) ?
(bool)ViewState["AutoGenerateCheckboxColumn"] : false;
}
set { ViewState["AutoGenerateCheckboxColumn"] = value; }
}
public int CheckboxColumnIndex
{
get
{
return (null != ViewState["CheckboxColumnIndex"]) ?
(int)ViewState["CheckboxColumnIndex"] : 0;
}
set { ViewState["CheckboxColumnIndex"] = (value < 0) ? 0 : value; }
}
The other functionality I shamelessly stole from Dino is the code to actually add the checkbox fields. Like Dino, I defined a class called InputCheckBoxField
inheriting CheckBoxField
to the GridView
columns, and this is a carbon copy of his code converted to C#.
InputCheckBoxField
1: internal sealed class InputCheckBoxField : CheckBoxField
2: {
3:
4: public InputCheckBoxField()
5: {
6: }
7:
8: public const string CheckBoxID = "CheckBoxButton";
9:
10: protected override void InitializeDataCell(
11: DataControlFieldCell cell,
12: DataControlRowState rowState)
13: {
14: base.InitializeDataCell(cell, rowState);
15:
16: if ((cell.Controls.Count == 0))
17: {
18: CheckBox chk = new CheckBox();
19: chk.ID = InputCheckBoxField.CheckBoxID;
20: cell.Controls.Add(chk);
21: }
22: }
23:}
And finally, the methods within the new GridView
class to actually do the adding. Like Dino, we override the base CreateColumns()
method and use a method called AddCheckboxColumn()
to add our field to the collection it returns.
Override CreateColumns
1: protected override System.Collections.ICollection CreateColumns(
2: PagedDataSource dataSource, bool useDataSource)
3: {
4: ICollection ret = base.CreateColumns(dataSource, useDataSource);
5: if (AutoGenerateCheckboxColumn)
6: ret = AddCheckboxColumn(ret);
7: return ret;
8: }
AddCheckboxColumn
1: protected virtual ArrayList AddCheckboxColumn(ICollection columns)
2: {
3: ArrayList ret = new ArrayList(columns);
4: InputCheckBoxField fldCheckBox = new InputCheckBoxField();
5: fldCheckBox.HeaderText = "<input type="checkbox"/>";
6: fldCheckBox.ReadOnly = true;
7:
8: if (CheckboxColumnIndex > ret.Count)
9: CheckboxColumnIndex = ret.Count;
10:
11: ret.Insert(CheckboxColumnIndex, fldCheckBox);
12:
13: return ret;
14: }
Now, we have a GridView
with checkbox columns, just like Dino.
We just need a way of retrieving the selected DataKey
s. This is the tricky bit as I have already stated that I wished to be able to select over multiple pages of data. I am not sure that the solution I came up with is the best one, but it does achieve this goal.
I started by defining two private properties which I would use to persist the selected DataKey
s in the viewstate.
private DataKey collection
1: private ArrayList _selectedDataKeysArrayList;
2: private ArrayList SelectedDataKeysArrayList
3: {
4: get
5: {
6: if (null == _selectedDataKeysArrayList)
7: _selectedDataKeysArrayList = new ArrayList();
8: return _selectedDataKeysArrayList;
9: }
10: set
11: {
12: _selectedDataKeysArrayList = value;
13: }
14: }
15:
16: private DataKeyArray _selectedDataKeysViewstate;
17: private DataKeyArray SelectedDataKeysViewstate
18: {
19: get
20: {
21: _selectedDataKeysViewstate =
new DataKeyArray(SelectedDataKeysArrayList);
22: return _selectedDataKeysViewstate;
23: }
24: }
Nothing terribly exciting. SelectedDataKeysArrayList
holds our DataKey
s, and SelectedDataKeysViewstate
is a DataKeyArray
wrapped around this collection, which I will use to do the actually persisting. We wish to store a collection of DataKey
s in the viewstate, and DataKeyArray
is a strongly typed collection of DataKey
objects, which implements the IStateManager
interface. Why rewrite code?!
Now, we need logic to add to our collection of selected DataKey
s any rows the user selects. The function to handle this is called CalculateSelectedDataKeys()
.
CalculateSelectedDataKeys
1: private void CalculateSelectedDataKeys()
2: {
3:
4: EnsureChildControls();
5:
6:
7:
8: for (int i = 0; i < Rows.Count; i++)
9: {
10: if (IsRowSelected(Rows[i]))
11: {
12: bool contains = false;
13: foreach (DataKey k in SelectedDataKeysArrayList)
14: {
15: if (CompareDataKeys(k, DataKeys[i]))
16: {
17: contains = true;
18: break;
19: }
20: }
21: if (!contains)
22: SelectedDataKeysArrayList.Add(DataKeys[i]);
23: }
24: else
25: {
26: int removeindex = -1;
27: for (int j = 0; j < SelectedDataKeysArrayList.Count; j++)
28: {
29: if (CompareDataKeys((DataKey)SelectedDataKeysArrayList[j],
DataKeys[i]))
30: {
31: removeindex = j;
32: break;
33: }
34: }
35: if (removeindex > -1)
36: SelectedDataKeysArrayList.RemoveAt(removeindex);
37: }
38: }
39: }
This function first ensures that it will find a collection of rows by calling EnsureChildControls()
. It then loops through the rows, checking whether each one is selected, using the private function below called IsRowSelected()
, adding the DataKey
s of any checked rows if an equivalent DataKey
isn’t already added, and removing any DataKey
s equivalent to those of unselected rows. I use another function called CompareDataKeys()
to check for matches in the collection, as I couldn’t find a more efficient way.
IsRowSelected
1: private bool IsRowSelected(GridViewRow r)
2: {
3: CheckBox cbSelected =
4: r.FindControl(InputCheckBoxField.CheckBoxID) as CheckBox;
5: return (null != cbSelected && cbSelected.Checked);
6: }
CompareDataKeys
1: private bool CompareDataKeys(DataKey objA, DataKey objB)
2: {
3:
4:
5: if (objA.Values.Count != objB.Values.Count)
6: return false;
7:
8:
9:
10: int equalityIndex = 0;
11: while (equalityIndex < objA.Values.Count &&
12: objA.Values[equalityIndex].Equals(objB.Values[equalityIndex]))
13: equalityIndex++;
14:
15:
16: return equalityIndex == objA.Values.Count;
17: }
And, a public property called SelectedDataKeys
so that the functionality is actually useable. The property makes use of a private boolean field to track whether CalculateSelectedDataKeys()
has been called, and if it hasn’t, then it calls it to add the selected rows to the collection before returning our SelectedDataKeysViewstate DataKeyArray
.
SelectedDataKeys
1: private bool _hasCalculatedSelectedDataKeys = false;
2: public DataKeyArray SelectedDataKeys
3: {
4: get
5: {
6: if (false == _hasCalculatedSelectedDataKeys)
7: {
8: CalculateSelectedDataKeys();
9: _hasCalculatedSelectedDataKeys = true;
10: }
11: return SelectedDataKeysViewstate;
12: }
13: }
We are now almost done. Our next challenge is to persist the SelectedDataKeys
collection across postbacks using the viewstate. Saving to the viewstate is easy. Using the fact that DataKeyArray
implements IStateManager
to our advantage, we can leverage this functionality while overriding the SaveViewState
function in our control to add a viewstate-ready representation of SelectedDataKeys
to the StateBag.
Override SaveViewState
1: protected override object SaveViewState()
2: {
3: object[] ret = new object[2];
4: ret[0] = base.SaveViewState();
5: ret[1] = ((IStateManager)SelectedDataKeys).SaveViewState();
6: return ret;
7: }
And to restore it:
Override LoadViewState
1: protected override void LoadViewState(object savedState)
2: {
3: object[] stateArray = (object[])savedState;
4: base.LoadViewState(stateArray[0]);
5:
6: if (null != stateArray[1])
7: {
8: int capacity = ((ICollection)stateArray[1]).Count;
9: for (int i = 0; i < capacity; i++)
10: SelectedDataKeysArrayList.Add(
11: new DataKey(new OrderedDictionary(),DataKeyNames));
12:
13: ((IStateManager)SelectedDataKeysViewstate).LoadViewState(
14: stateArray[1]);
15: }
16: }
Finally, we override the OnDataBound()
and OnDataBinding()
methods to check any rows which have previously been selected when they were databound and store the keys of any selected rows before new ones take their place.
Override OnDataBound
1: protected override void OnDataBound(EventArgs e)
2: {
3: base.OnDataBound(e);
4: EnsureChildControls();
5: for (int i = 0; i < Rows.Count; i++)
6: {
7: bool isSelected = false;
8: foreach (DataKey k in SelectedDataKeysArrayList)
9: {
10: if (CompareDataKeys(k, DataKeys[i]))
11: {
12: isSelected = true;
13: break;
14: }
15: }
16: SetRowSelected(Rows[i], isSelected);
17: }
18: }
When new rows have been databound, we loop through them all and then look for a matching DataKey
in SelectedDataKeysArrayList
. We then use another private function called SetRowSelected()
to set the checkbox on the row to the correct value.
SetRowSelected
1: private void SetRowSelected(GridViewRow r, bool isSelected)
2: {
3: CheckBox cbSelected =
r.FindControl(InputCheckBoxField.CheckBoxID) as CheckBox;
4: if (null != cbSelected)
5: cbSelected.Checked = isSelected;
6: }
Finally, we just need to make sure that we calculate all selected rows before a DataBind
is called so that we can store them before the rows disappear. To do this, we override OnDataBinding()
. There is one other thing we need to do, however. The internal ClearDataKeys()
function of the base GridView
is called on a Page
or Sort
event before OnDataBinding
, and this function clears the internal _dataKeysArrayList
. We can bypass this issue by pulling the contents of this collection into the built-in DataKeys
property before this stage, by simply referencing it somewhere previously. Hence, my rather pathetic OnLoad()
override.
Override OnDataBinding and override OnLoad
1: protected override void OnDataBinding(EventArgs e)
2: {
3: CalculateSelectedDataKeys();
4: base.OnDataBinding(e);
5: }
6:
7: protected override void OnLoad(EventArgs e)
8: {
9: DataKeys.ToString();
10: base.OnLoad(e);
11: }
And, we’re done. We now have a GridView
which enables us to select multiple rows and allows us to page or sort the data while retaining the selection.
And, I suppose that’s it for my first ever programming article. Hopefully, it’s of use, feel free to expand upon it!
Points of interest
You would hope that restoring the collection would be simpler than it proved. I was stumped then, when I received an Index Out Of Range exception when I attempted the following:
1: protected override void LoadViewState(object savedState)
2: {
3: object[] stateArray = (object[])savedState;
4: base.LoadViewState(stateArray[0]);
5: ((IStateManager)SelectedDataKeysViewstate).LoadViewState(
6: stateArray[1]);
7: }
A quick peek into the internals of the DataKeyArray
class using the .NET Reflector, however, showed me what needed to be done. The implementation of IStateManager.LoadViewState
in DataKeyArray
is as follows:
1: void IStateManager.LoadViewState(object state)
2: {
3: if (state != null)
4: {
5: object[] objArray = (object[]) state;
6: for (int i = 0; i < objArray.Length; i++)
7: {
8: if (objArray[i] != null)
9: {
10: ((IStateManager) this._keys[i]).LoadViewState(
11: objArray[i]);
12: }
13: }
14: }
15: }
_keys
is the internal ArrayList
of DataKey
s. In our case, it is equal to SelectedDataKeysArrayList
, and as I had yet to populate it then, line 10 was throwing the out of range index expression.
You may also be interested in delving further into the IStateManager
implementation of the DataKey
class itself. For some reason, if the _keyNames
property is set, then it will only persist the values of the internal dictionary instead of Pair
objects, and expects the _keyNames
to be set again for the DataKey
it is loaded back into. This isn’t a problem really, but it was the cause of a few more interesting exceptions before I arrived at my final solution of pre-populating SelectedDataKeysArrayList
with the correct number of blank DataKey
s with the _keyNames
specified.