支持多选的 GridView
扩展GridView,允许跨多页选择多行。
引言
在涉足 C# ASP.NET 编程两年后,我积累了无数的见解,其中许多我都一直希望能写下来并与大家分享,一来可以巩固自己,二来也能帮助他人,因为几个月后我常常会忘记最初促使我写下这些东西的初衷。但最终,要么是没有时间,要么是没有兴趣,要么是解释其背景会耗费太多精力,以至于不值得付出的努力。
偶尔,会出现一个非常棒的挑战,其解决方案完全独立且可重用,也许,值得我将解决方案分享给他人。
以 ASP.NET 2.0 中的 GridView
控件为例。我们经常使用它来绑定数据源,并以一种清晰、整洁的方式将数据呈现给最终用户,它内置了分页、排序、选择、编辑和删除功能。它是大多数现代 .NET Web 应用程序的基石。可能正是因为它被广泛使用,我们有时会希望它能做得更多一点。例如,为什么我们不能开箱即用地使用 GridView
选择多条记录呢?您可以设置 DataKeyNames
属性来包含数据项的主键字段名称,并检索单个选中行的 DataKey
对象。然而,不支持像 ListBox
那样的多行选择。
背景
许多人已经提出了解决此问题的方法,包括客户端和服务器端。 Dino Esposito 通过在每行添加一个复选框来扩展 GridView
,以允许用户选择多行,并提供一个方法来检索 SelectedIndices
,该方法通过遍历渲染的行并返回带有选中复选框的索引。然而,我发现我的解决方案并非完全令人满意。它只检索当前数据页面的选定索引,页面的第一个记录索引为 0,就像现有的 SelectedIndex
属性一样。我希望能够跨多页选择记录,并检索所有选定的 DataKey
,而不仅仅是索引。这是我的解决方案。
使用代码
我没有闭门造车,而是选择基于 Dino 的代码,定义一个新类继承自 GridView
。
MultiSelectGridView
namespace blive.Controls
{
public class MultiSelectGridView : GridView
{...
}
}
然后,我实现了 Dino 的两个公共属性:一个布尔值,用于指定是否向每行添加复选框;一个整数,用于指定放置它的列索引。
公共属性
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; }
}
我从 Dino 那里“窃取”的其他功能是实际添加复选框字段的代码。和 Dino 一样,我定义了一个名为 InputCheckBoxField
的类,它继承自 GridView
列中的 CheckBoxField
,这段代码是他的 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:}
最后,是新 GridView
类中实际执行添加操作的方法。和 Dino 一样,我们重写了基类的 CreateColumns()
方法,并使用一个名为 AddCheckboxColumn()
的方法将我们的字段添加到它返回的集合中。
重写 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: }
现在,我们有了一个带有复选框列的 GridView
,就像 Dino 的一样。
我们只需要一种方法来检索选定的 DataKey
。这是棘手的部分,因为我之前已经说过,我希望能够跨多页进行选择。我不确定我提出的解决方案是最好的,但它确实实现了这个目标。
我首先定义了两个私有属性,我将使用它们来将选定的 DataKey
持久化到 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: }
没什么特别的。SelectedDataKeysArrayList
存储我们的 DataKey
,而 SelectedDataKeysViewstate
是一个 DataKeyArray
,它包装了这个集合,我将用它来进行实际的持久化。我们希望在 viewstate 中存储 DataKey
的集合,而 DataKeyArray
是 DataKey
对象的强类型集合,它实现了 IStateManager
接口。为什么要重写代码呢?!
现在,我们需要逻辑来将用户选择的行添加到我们选定的 DataKey
集合中。处理此功能的函数名为 CalculateSelectedDataKeys()
。
CalculateSelectedDataKeys
1: private void CalculateSelectedDataKeys()
2: {
3: // Make sure the GirdView has rows so that we can look for the checkboxes
4: EnsureChildControls();
5:
6: // Add the DataKeys of any selected rows to the SelectedDataKeysArrayList
7: // collection and remove those of any unselected rows
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: }
此函数首先通过调用 EnsureChildControls()
来确保它能找到行的集合。然后,它遍历行,使用下面的私有函数 IsRowSelected()
检查每行是否被选中,将任何选中行的 DataKey
添加到集合中(如果尚未添加等效的 DataKey
),并删除与未选中行等效的任何 DataKey
。我使用另一个名为 CompareDataKeys()
的函数来检查集合中的匹配项,因为我找不到更有效的方法。
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: // If the number of values in the key is different
4: // then we already know they are not the same
5: if (objA.Values.Count != objB.Values.Count)
6: return false;
7:
8: // Continue to compare each DataKey value until we find
9: // one which isn’t equal, keeping a count of matches
10: int equalityIndex = 0;
11: while (equalityIndex < objA.Values.Count &&
12: objA.Values[equalityIndex].Equals(objB.Values[equalityIndex]))
13: equalityIndex++;
14:
15: // if every value was equal, return true
16: return equalityIndex == objA.Values.Count;
17: }
还有一个名为 SelectedDataKeys
的公共属性,以便该功能实际上可用。该属性使用一个私有的布尔字段来跟踪 CalculateSelectedDataKeys()
是否已被调用,如果尚未调用,则调用它将选定的行添加到集合中,然后返回我们的 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: }
我们现在差不多完成了。我们的下一个挑战是使用 viewstate 将 SelectedDataKeys
集合跨 postback 持久化。保存到 viewstate 很简单。利用 DataKeyArray
实现 IStateManager
的事实,我们可以利用此功能,重写我们控件中的 SaveViewState
函数,将 SelectedDataKeys
的一个可用于 viewstate 的表示添加到 StateBag 中。
重写 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: }
以及恢复它
重写 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: }
最后,我们重写 OnDataBound()
和 OnDataBinding()
方法,在数据绑定时检查任何先前被选中的行,并在新行取代它们之前存储任何选中行的键。
重写 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: }
当新行被数据绑定后,我们遍历所有这些行,然后在 SelectedDataKeysArrayList
中查找匹配的 DataKey
。然后,我们使用另一个私有函数 SetRowSelected()
来将行上的复选框设置为正确的值。
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: }
最后,我们只需要确保在调用 DataBind
之前计算所有选定的行,以便在行消失之前存储它们。为此,我们重写了 OnDataBinding()
。但还有其他事情要做。基类 GridView
的内部函数 ClearDataKeys()
在 Page
或 Sort
事件发生时,在 OnDataBinding
之前被调用,该函数会清除内部的 _dataKeysArrayList
。我们可以通过在更早的阶段,仅仅引用内置的 DataKeys
属性来绕过这个问题,将此集合的内容拉入其中。因此,我有些笨拙的 OnLoad()
重写。
重写 OnDataBinding 和重写 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: }
好了,我们完成了。现在我们有了一个 GridView
,它允许我们选择多行,并且可以在分页或排序数据时保留选择。
我想,这就是我的第一篇编程文章的全部内容了。希望它有所帮助,欢迎大家在此基础上进行扩展!
关注点
您可能希望恢复集合的操作比实际看起来更简单。当时我被难住了,当尝试以下操作时,我收到了一个 Index Out Of Range 异常。
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: }
通过 .NET Reflector 快速查看 DataKeyArray
类的内部实现,让我明白了需要做什么。DataKeyArray
中 IStateManager.LoadViewState
的实现如下:
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
是内部的 ArrayList
集合,包含 DataKey
对象。在我们的例子中,它等于 SelectedDataKeysArrayList
,而由于我当时尚未填充它,第 10 行抛出了 Out Of Range Index 异常。
您可能还想深入了解 DataKey
类本身的 IStateManager
实现。出于某种原因,如果设置了 _keyNames
属性,它将仅持久化内部字典的值,而不是 Pair
对象,并且期望 _keyNames
在加载回 DataKey
时再次被设置。这实际上不是一个问题,但它导致了我最终解决方案之前的一些更棘手的异常:预先用正确数量的空白 DataKey
和指定的 _keyNames
填充 SelectedDataKeysArrayList
。