65.9K
CodeProject 正在变化。 阅读更多。
Home

MVC 自定义 Select 控件

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.69/5 (6投票s)

2014年5月2日

CPOL

7分钟阅读

viewsIcon

20998

downloadIcon

329

MVC HtmlHelper 类与 JQuery 插件结合使用,生成一个自定义的下拉选择控件,该控件提供复杂的属性回发、项目的键盘过滤、分组和分层显示、可选的 AJAX 加载以及项目的 CSS 样式设置。

引言

标准的 HTML select 控件存在一些我希望克服的限制,包括:

  • 它只显示一个属性,并且只回发一个属性。
  • 对列表项的样式设置能力有限。
  • 搜索对象的能力有限(只能通过“开头是”进行搜索,如果你要搜索的不仅仅是第一个字符,你需要非常快速地输入)。

该 HtmlHelper 与 JQuery 插件结合使用,生成一个自定义的 HTML select 控件,该控件:

  • 如果附加到复杂属性,当选择一个项目时,将更新并回发该类的所有属性。
  • 如果没有选择项目,则阻止回发该类的所有属性(即,复杂属性的值将为 null)。
  • 允许对下拉列表中的项目进行 CSS 样式设置。
  • 根据传递给帮助器的属性类型和集合类型,将呈现平面、分组或分层项目显示。
  • 具有键盘搜索和过滤功能(包括根据输入的字符数搜索项目中的任意位置),并且可以从容地进行搜索。
  • 通过传递控制器、操作方法和参数名称,允许基于搜索文本进行可选的 AJAX 加载。
  • 可以通过单独的 javascript 调用使用 JSON 数据填充其项目(例如,创建级联选择)。
  • 当选定的项目更改时以及向列表中添加项目时,会引发事件。
  • 具有与标准 HTML select 控件相似的行为(鼠标和键盘导航)。

用法

该帮助器有许多重载,用于呈现不同类型的集合结构。

@Html.SelectFor(m => m.MyEnum)

将为枚举呈现一个 select,列表包含枚举的每个值。如果枚举值已应用 System.ComponentModel.DataAnnotations DescriptionAttribute 属性,则会显示描述文本,从而允许用户友好的名称。

@Html.SelectFor(m => m.ValueType, MyCollection as IEnumerable)

将呈现和回发一个简单属性(我想不到其他用途,除了 string,但可以使用任何值类型)。集合可以是任何类型(最明显的是 IEnumerable<string>,但也可以是复杂类型),并且使用其 .ToString() 方法进行显示和回发。

@Html.SelectFor(m => m.MyComplexProperty, MyCollection as IEnumerable, 
  "idProperty", "displayProperty")

将呈现和回发一个复杂属性。帮助器为类的每个属性渲染一个隐藏输入(如果属性本身是复杂属性,则递归调用),该输入在进行选择时更新。集合必须是通用类型,该类型是(或派生自)属性类型。如果复杂属性包含一个自身类型集合的属性,则显示分层列表。如果集合是 IDictionary<string, IEnumerable>,则使用字典键作为组标题来显示分组列表(参见下图),否则显示平面列表。idProperty 是复杂类型中唯一标识集合中模型的属性名称,displayProperty 是用于显示项目的属性名称。

@Html.SelectFor(m => m.MyProperty, "idProperty", "displayProperty", 
  "controller", "action", "actionParameter")

将呈现和回发一个值类型或复杂类型,其中项目是基于搜索文本使用 AJAX(jQuery.getJSON(...))加载的。JSON 数据的结构决定了列表是平面、分组还是分层的。

最后,要将该控件连接到插件,需要以下 javascript:

$('#MyPropertyName').select()

该插件包含以下选项:

  • 在过滤包含搜索文本的项目之前需要输入的字符数(与以搜索文本开头的项目相对)。
  • 要显示的最大项目数(在创建可滚动列表之前)。
  • 在分组或分层列表中用作分隔符的字符(参见上图)。
  • 在发出 AJAX 调用之前需要输入的字符数。

工作原理

下载包含一个解决方案,其中包含每个帮助器重载和列表类型的示例、包括 jQuery 插件和样式表的完整源代码,以及一个帮助文件,该文件解释了键盘导航、选项、控件引发的事件以及使用的 HTML 类名。代码(我认为)注释得很好(作为一个断断续续的业余程序员,我早就认识到它的价值了),而且无论如何也太长了,无法在此处重现,但我将介绍其工作原理的一些关键方面。

HTML

帮助器生成的 HTML 类似于此(省略了值):

<!--Top level container with data attributes used by the plugin-->
<div class="select" data-displayproperty="" data-idproperty="" 
  data-propertyname="">
  <div class="select-input" style="position:relative;">
    <input autocomplete="off" id="" style="color:transparent;" 
      type="text" value=""/>
    <div style="position:absolute;overflow:initial;
      text-overflow:initial;"></div>
    <button class="drop-button" style="position:absolute;" 
      tabindex="-1" type="button"></button>
    <input name="" type="hidden" value="" disabled="">
    <!--More hidden inputs for each property of the model-->
  </div>
  <div class="select-validation">
    <span class="field-validation-error"></span>
  </div>
  <div class="select-list" style="position:relative;">
    <ul style="position: absolute; display: none; z-index: 1000;">
      <!--The item container (data attributes for each property)-->
      <li data-id="" data-name="" style="margin:0;padding:0;">
        <!--The item itself (contains spans)-->
        <div></div>
        <!--Container for group and hierarchical display -->
        <ul>
          <li><div></div></li>
        </ul>
      </li>
    </ul>
  </div>
</div>

HTML 的关键元素是:

  • 为模型的每个属性呈现一个隐藏输入。
  • 为列表中的每个项目呈现一个相应的数据属性(用于在进行选择时更新隐藏输入)。
  • 呈现必要的样式属性,以防止它们在样式表中被覆盖。
  • 项目是 div 元素,其中包含至少一个 span 元素用于显示文本,以及(可选)前面有 span 元素用于分组或分层列表中的父文本,以及一个 em 元素用于突出显示任何搜索文本,从而灵活地使用样式表对显示进行样式设置。

帮助器:

在每个重载中,我们首先获取元数据和属性的全限定名。

ModelMetadata metaData = ModelMetadata.FromLambdaExpression(expression, 
  helper.ViewData);
string fieldName = ExpressionHelper.GetExpressionText(expression);

然后我们确定需要显示哪种类型的列表。

private static SelectListType GetListType(ModelMetadata metaData, 
  IEnumerable items)
{
  if (metaData.ModelType.IsEnum)
  {
    return SelectListType.EnumList;
  }
  // Other checks for value list and empty list
  ...
  if (items is IDictionary)
  {
    IDictionary dictionary = items as IDictionary;
    Type[] arguments = dictionary.GetType().GetGenericArguments();
    // Checks the key is a string and the value is IEnumerable 
    // and throws exception if not
    ....
    // Get the items type
    Type type = null;
    if (arguments[1].IsGenericType)
    {
      type = arguments[1].GetGenericArguments()[0];
    }
    // Other checks
    ...
    // Now the key bit. The model type must be the same as 
    // (or be assignable from an instance of) the items type 
    if (!metaData.ModelType.IsAssignableFrom(type))
    {
      // Throw exception
    }
    return SelectListType.GroupedList;
  }
  else
  {
    // Get the type and check the ModelType.IsAssignableFrom(type)
    ...
    // Determine if it's a hierarchical list
    metaData = metaData.Properties.FirstOrDefault(m => m.ModelType
      .IsGenericType && m.ModelType
      .GetGenericArguments()[0] == metaData.ModelType);
    if (metaData != null)
    {
      return SelectListType.HierarchialList;
    }
    else
    {
      return SelectListType.FlatList;
    }
  }
}

然后进行更多检查以验证 id 和显示属性。

// The values must be provided for a complex type
if (metaData.IsComplexType && idProperty == null)
{
  // Throw exception
}
// And it must exist
if (idProperty != null && !metaData.Properties
  .Any(m => m.PropertyName == idProperty))
{
  // Throw exception
}

如果使用最后一个重载,则会构造 URL。

string root = HttpRuntime.AppDomainAppVirtualPath;
string url = string.Format("{0}{1}/{2}", root, controller, action);

然后调用各种方法来构造各个 HTML 元素。最让我头疼的是生成分层列表项的递归方法。

private static string HierarchialList(IEnumerable items, 
  ModelMetadata selectedItem, string idProperty, string displayProperty)
{
  // Flag selected item has been found
  bool selectionFound = false;
  // Get the property that is the hierarchical property
  string hierarchialProperty = selectedItem.Properties
    .First(m => m.ModelType.IsGenericType && m.ModelType
    .GetGenericArguments()[0] == selectedItem.ModelType).PropertyName;
  // Build the html for each item
  StringBuilder html = new StringBuilder();
  foreach (var item in items)
  {
    // Get the metadata of the item
    ModelMetadata metaData = ModelMetadataProviders.Current
      .GetMetadataForType(() => item, selectedItem.ModelType);
    // Append the list item
    html.Append(HierarchialItem(metaData, selectedItem, idProperty, 
      displayProperty, hierarchialProperty, ref selectionFound));
  }
  // Return the html
  return SelectList(html.ToString());
}

它使用:

private static string HierarchialItem(ModelMetadata item, 
  ModelMetadata selectedItem, string idProperty, string displayProperty, 
  string hierarchialProperty, ref bool selectionFound)
{
  StringBuilder html = new StringBuilder();
  // Build the display text
  TagBuilder text = new TagBuilder("div");
  if (!selectionFound)
  {
    if (IsSelected(item, selectedItem, idProperty))
    {
      selectionFound = true;
      text.AddCssClass("selected");
    }
  }
  text.InnerHtml = GetDisplayText(item, displayProperty);
  html.Append(text.ToString());
  // Build list item
  TagBuilder listItem = new TagBuilder("li");
  foreach (ModelMetadata property in item.Properties)
  {
    if (property.PropertyName == hierarchialProperty)
    {
      StringBuilder innerHtml = new StringBuilder();
      // Flag to indicate if there are child items to display
      bool hasChildren = false;
      IEnumerable children = property.Model as IEnumerable;
      foreach (var child in children)
      {
        // Signal there are child items to display
        hasChildren = true;
        // Get the metadata
        ModelMetadata childItem = ModelMetadataProviders.Current
          .GetMetadataForType(() => child, item.ModelType);
        // Recursive call to build the child items
        innerHtml.Append(HierarchialItem(childItem, selectedItem, 
          idProperty, displayProperty, hierarchialProperty, 
          ref selectionFound));
      }
      if (hasChildren)
      {
        TagBuilder list = new TagBuilder("ul");
        list.InnerHtml = innerHtml.ToString();
        html.Append(list.ToString());
      }
      else
      {
        html.Append(innerHtml.ToString());
      }
    }
    else
    {
      // Add attributes
      string attributeName = string
        .Format("data-{0}", property.PropertyName);
      string attributeValue = string.Format("{0}", property.Model);
      listItem.MergeAttribute(attributeName, attributeValue);
    }
  }
  listItem.InnerHtml = html.ToString();
  // Add essential style properties
  listItem.MergeAttribute("style", "margin:0;padding:0;");
  // Return the html
  return listItem.ToString();
}

它使用:

private static bool IsSelected(ModelMetadata item,
  ModelMetadata selectedItem, string idProperty)
{
  if (item.Model == null || selectedItem.Model == null)
  {
    return false;
  }
  if (idProperty == null)
  {
    return Object.ReferenceEquals(item.Model, selectedItem.Model);
  }
  if (item.IsComplexType)
  {
    return item.Properties.First(m => m.PropertyName == idProperty)
      .Model.Equals(selectedItem.Properties
      .First(m => m.PropertyName == idProperty).Model);
  }
  return item.Model.Equals(selectedItem.Model);
}

来确定模型是否与列表中的某个项目匹配,因此是“选定的”项目,以及

public static string GetDisplayText(ModelMetadata item, string
  displayProperty)
{
  if (displayProperty == null)
  {
    // Use the .ToString() method of the model
    return string.Format("{0}", item.Model);
  }
  else
  {
    return string.Format("{0}", item.Properties
      .FirstOrDefault(m => m.PropertyName == displayProperty).Model);
  }
}

来确定要显示的文本。

插件:

插件将所有内容连接起来。一个关键功能是允许回发复杂类型的全部属性的递归方法。其参数是选定项目的数据(项目父 li 元素的 data-attributes)和属性的名称。在第一次迭代中,name 将是插件附加到的属性的名称(例如,ContactPerson)。如果找到匹配的输入,则该属性必须是值类型,因此输入将被更新,然后我们退出函数。如果没有找到匹配项,则将属性的键附加到 name(例如,ContactPerson.ID),然后我们再次搜索匹配的输入名称并更新它(如果找到)。如果找不到匹配项,因为 ContactPerson 包含一个复杂属性 Organisation,然后递归调用该函数(例如,查找 ContactPerson.Organisation.ID 的匹配项)。

select.prototype.updateInputs = function (data, name) {
  var self = this;
    $.each(data, function (attr, value) {
      // Find the matching hidden input ('values' is the jQuery collection 
      // of hidden input elements) and set it's value
      var input = self.values.filter(function () {
        return $(this).attr('name').toLowerCase() === attr.toLowerCase();
      }).val(value);
      if (input.length === 1) {
      // It's a value type so there is only one input
        return;
      }
      // Build the property name
      var propertyName = name + '.' + attr;
      // If the value is an object then we are dealing with complex 
      // properties within complex properties
      if (typeof (value) === 'object') {
        // Recursive call
        self.updateInputs(value, propertyName);
      }
      else {
        // Get and set the matching input (as above)
        input = self.values.filter(function () {
          return $(this).attr('name').toLowerCase() === propertyName
            .toLowerCase();
      }).val(value);
    }
  });
}

现在我理解递归函数了!

使用代码

来自下载(Visual Studio 2012,MVC 4.0):

  1. 构建项目(下载不包含 packagesobjbin 文件夹,但这些将在第一次构建时恢复)。
  2. 在你的项目中添加对 Sandtrap.dllSandtrap.Web.dll 的引用。
  3. sandtrap-select-v1.0.js(或其压缩版本)和 sandtrap-select-v1.0.css 文件复制到你的项目中。
  4. 将命名空间添加到你的 web.config 文件中。
    <system.web>
      <namespaces>
       <add namespace="Sandtrap.Web.Html">
    
  5. 在你的视图中,添加对 js 文件的引用。

其余的应该可以通过检查下载中的示例和帮助文件来清楚。

已知问题

  1. 我一直无法让 jQuery unobtrusive validation 工作。如果属性具有 [Required] 属性(这是对复杂属性有意义的唯一验证属性),并且与控件关联了 @Html.ValidationMessageFor(...),那么在回发时,如果模型为 null 并且视图被返回,它将正确显示验证消息。如果然后选择了一个项目,则有一个技巧可以找到消息并清除其内容。
  2. 如果模型包含一个集合属性,则不会为集合中的对象渲染输入(我最初包含此功能,但在测试一个具有分层结构的情况下,它渲染了 400 多个输入 - 我认为填充模型比发送和接收那么多额外的 HTML 更便宜)。

关注点

我已经在我为办公室开发的一个内联网应用程序中使用了这个控件三个多月了,没有遇到任何问题。然而,这是我第一次尝试制作一个主要的 HtmlHelper 和 jQuery 插件,我没有接受过编程培训(我所有的知识都来自 CodeProject、Stack Overflow 和我偶然发现的其他网站)。我毫不怀疑会有我没有考虑到的场景,以及代码或代码结构可以改进的地方。欢迎任何反馈。

如果你对 Sandtrap 项目中唯一的文件感到好奇,SandtrapSandtrap.Web 项目都是真实程序集的微小版本。我的下一篇文章将关于:

@Html.TableDisplayFor(MyCollection as IEnumerable)

@Html.TableEditorFor(MyCollection as IEnumerable)
© . All rights reserved.