在自定义控件的嵌套 DropDownList 中保存 ViewState

Saving ViewState in Nested DropDownList in a Custom Control

我创建了一个自定义控件(称为 BoostrapDropDown),它基本上将一堆 boostrap 标记包裹在 asp.net DropDownList 周围。生成的控件层次结构基本上如下所示,除了 DropDownList 之外的所有内容都是 HtmlGenericControl:

        <div class="form-group viInputID">
            <label for="iInputID" class="control-label liInputID"></label>
            <a style="display: none;" class="vhiInputID" role="button" tabindex="0" data-toggle="popover" data-trigger="click" data-content-selector=".hiInputID" data-placement="top">
                <span class="glyphicon glyphicon-info-sign help-icon"></span>
            </a>
            <a style="display: none;" class="vsiInputID" role="button" tabindex="0">
                <span class="glyphicon glyphicon-volume-up"></span>
            </a>
            <div class="validator-container">
                <asp:DropDownList CssClass="form-control selectpicker show-tick iInputID" data-size="15" ID="iInputID" runat="server" DataSource='<%# DataSource %>' DataTextField="name" DataValueField="key"/>
                <span class="error-msg" data-toggle="tooltip" data-placement="top"></span>
            </div>
            <div class="hiInputIDTitle" style="display: none;"></div>
            <div class="hiInputID" style="display: none;"></div>
        </div>

我是 'passing through' 从我的控件到嵌套的 DropDownList 的数据源 属性 但是在回发时,我失去了所有的值。

这是尴尬的部分。一个月前,我在网上搜索并能够创建一个解决方案,但我没有很好地记录它。现在我找不到用于创建解决方案的页面。我不知道它是如何工作的,我希望有人能阐明一些问题。下面是相关的源代码。

更新:完整代码

// Preventing the EventValidation for dropdown lists b/c they could be populated *only* on the client side;
// 
public class DynamicDropDownList : DropDownList { }
public class DynamicListBox : ListBox { }

public class HtmlGenericControlWithCss : HtmlGenericControl
{
    public HtmlGenericControlWithCss(string tag) : base(tag) { }
    public HtmlGenericControlWithCss(string tag, string css) : this(tag)
    {
        Attributes["class"] = css;
    }
    public HtmlGenericControlWithCss(string tag, string css, string style) : this(tag, css)
    {
        Attributes["style"] = style;
    }
}
public class HtmlAnchorWithCss : HtmlAnchor
{
    public HtmlAnchorWithCss(string css) : base()
    {
        Attributes["class"] = css;
    }
    public HtmlAnchorWithCss(string css, string style) : this(css)
    {
        Attributes["style"] = style;
    }
}
public abstract class BootstrapInputBase : WebControl, INamingContainer
{
    protected HtmlGenericControl formGroup;
    protected bool isBootstrap4;

    public string HelpPlacement
    {
        get => (string)ViewState["HelpPlacement"] ?? "top";
        set => ViewState["HelpPlacement"] = value;
    }

    public string Label
    {
        get => (string)ViewState[nameof(Label)];
        set => ViewState[nameof(Label)] = value;
    }

    public string LabelCss
    {
        get => (string)ViewState[nameof(LabelCss)];
        set => ViewState[nameof(LabelCss)] = value;
    }

    public string HelpContent
    {
        get => (string)ViewState[nameof(HelpContent)];
        set => ViewState[nameof(HelpContent)] = value;
    }

    public override void RenderControl(HtmlTextWriter writer)
    {
        using (var sw = new StringWriter())
        using (var hw = new HtmlTextWriter(sw))
        {
            base.RenderControl(hw);
            // need formatted so browser renders it nice (otherwise wierd spacing issues if some of the whitespace is removed)
            var html = XElement.Parse(sw.ToString());
            writer.Write(html.ToString());
        }
    }

    public void AddControl(Control control)
    {
        EnsureChildControls();
        formGroup.Controls.Add(control);
    }

    protected override void CreateChildControls()
    {
        isBootstrap4 = true;

        /*
        <div class="form-group viInputID">
            <label for="iInputID" class="control-label liInputID"></label>
            <a style="display: none;" class="vhiInputID" role="button" tabindex="0" data-toggle="popover" data-trigger="click" data-content-selector=".hiInputID" data-placement="top">
                <span class="glyphicon glyphicon-info-sign help-icon"></span>
            </a>
            <a style="display: none;" class="vsiInputID" role="button" tabindex="0">
                <span class="glyphicon glyphicon-volume-up"></span>
            </a>
            <div class="validator-container"> [abstract] </div>
            <div class="hiInputIDTitle" style="display: none;"></div>
            <div class="hiInputID" style="display: none;"></div>
        </div>
        */
        formGroup = new HtmlGenericControlWithCss("div", "form-group v" + ID);
        Controls.Add(formGroup);

        formGroup.Controls.Add(CreateLabel());

        var help = new HtmlAnchorWithCss("vh" + ID, string.IsNullOrEmpty(HelpContent) ? "display: none;" : null);
        help.Attributes["role"] = "button";
        help.Attributes["tabindex"] = "0";
        help.Attributes["data-toggle"] = "popover";
        help.Attributes["data-trigger"] = "click";
        help.Attributes["data-content-selector"] = ".h" + ID;
        help.Attributes["data-placement"] = HelpPlacement;
        // Couldn't use server controls b/c it put <a><span .../></a> with no space, if newline before span, then HTML rendered a little break after the label
        // help.InnerHtml = Environment.NewLine + "<span class='glyphicon glyphicon-info-sign help-icon'></span>";
        formGroup.Controls.Add(help);

        help.Controls.Add(new HtmlGenericControlWithCss("span", isBootstrap4 ? "fal fa-question-circle help-icon" : "glyphicon glyphicon-info-sign help-icon"));

        var voice = new HtmlAnchorWithCss("vs" + ID, "display: none;");
        voice.Attributes["role"] = "button";
        voice.Attributes["tabindex"] = "0";
        // Couldn't use server controls b/c it put <a><span .../></a> with no space, if newline before span, then HTML rendered a little break after the label
        // voice.InnerHtml = Environment.NewLine + "<span class='glyphicon glyphicon-volume-up'></span>";
        formGroup.Controls.Add(voice);

        voice.Controls.Add(new HtmlGenericControlWithCss("span", isBootstrap4 ? "fal fa-volume-up" : "glyphicon glyphicon-volume-up"));

        formGroup.Controls.Add(CreateValidatorContainer());

        formGroup.Controls.Add(new HtmlGenericControlWithCss("div", "h" + ID, "display: none;") { InnerHtml = HelpContent });
        formGroup.Controls.Add(new HtmlGenericControlWithCss("div", "h" + ID + "Title", "display: none;"));
    }

    protected abstract HtmlGenericControl CreateValidatorContainer();
    public abstract string Value { get; set; }

    protected virtual HtmlGenericControl CreateLabel()
    {
        var label = new HtmlGenericControlWithCss("label", "control-label l" + ID + (!string.IsNullOrEmpty(LabelCss) ? " " + LabelCss : "")) { InnerHtml = Label, EnableViewState = true };
        label.Attributes["for"] = ID;
        return label;
    }

    protected virtual HtmlGenericControl CreateErrorMessage()
    {
        var errorMessage = new HtmlGenericControlWithCss("span", "error-msg");
        errorMessage.Attributes["data-toggle"] = "tooltip";
        errorMessage.Attributes["data-placement"] = "top auto";
        return errorMessage;
    }
}

public class BootstrapDropDown : BootstrapInputBase
{
    private ListControl inputControl;

    // If this is false and the client wants to postback to the server for processing,
    // I would need to try to grab values via Request.Form[ UniqueID + ":" + ID ]. 
    // But the CalcEngine would *have* to validate the item is inside a known list and
    // no malicious values were posted back to server.
    public bool SupportEventValidation
    {
        get => (bool?)ViewState[nameof(SupportEventValidation)] ?? true;
        set => ViewState[nameof(SupportEventValidation)] = value;
    }
    public bool AllowMultiSelect
    {
        get => (bool?)ViewState[nameof(AllowMultiSelect)] ?? false;
        set => ViewState[nameof(AllowMultiSelect)] = value;
    }
    public string DataTextField
    {
        get => (string)ViewState[nameof(DataTextField)];
        set => ViewState[nameof(DataTextField)] = value;
    }
    public string DataValueField
    {
        get => (string)ViewState[nameof(DataValueField)];
        set => ViewState[nameof(DataValueField)] = value;
    }
    public object DataSource { get; set; }

    ListItemCollection items;
    public virtual ListItemCollection Items
    {
        get
        {
            if (items == null)
            {
                items = new ListItemCollection();
                if (IsTrackingViewState)
                {
                    ((IStateManager)items).TrackViewState();
                }
            }
            return items;
        }
    }

    public ListControl ListControl
    {
        get
        {
            // Don't want this, would like to just use Items property
            // to clear/add items but wasn't working and I still don't understand
            // how my dropdown list is retaining view state.  SO Question:
            // 
            EnsureChildControls();
            return inputControl;
        }
    }

    protected override void LoadViewState(object savedState)
    {
        var allState = (object[])savedState;
        HelpContent = (string)allState[4];
        Label = (string)allState[3];
        Value = (string)allState[2];
        ((IStateManager)Items).LoadViewState(allState[1]);
        base.LoadViewState(allState[0]);
    }

    protected override object SaveViewState()
    {
        var allState = new object[5];
        allState[0] = base.SaveViewState();
        allState[1] = ((IStateManager)Items).SaveViewState();
        allState[2] = Value;
        allState[3] = Label;
        allState[4] = HelpContent;
        return allState;
    }

    public override string Value
    {
        get
        {
            EnsureChildControls();
            return inputControl.SelectedValue;
        }
        set
        {
            EnsureChildControls();
            inputControl.SelectedValue = value;
        }
    }

    public string SelectedValue => Value;

    public virtual string Text
    {
        get
        {
            EnsureChildControls();
            return inputControl.SelectedItem?.Text;
        }
    }

    protected override HtmlGenericControl CreateValidatorContainer()
    {
        /*
            <div class="validator-container">
                <asp:DropDownList CssClass="form-control selectpicker show-tick iInputID" data-size="15" ID="iInputID" runat="server" DataSource='<%# xDSHelper.GetDataTable( "TableTaxStatus" ) %>' DataTextField="name" DataValueField="key"/>
                <span class="error-msg" data-toggle="tooltip" data-placement="top"></span>
            </div>
        */
        var validatorContainer = new HtmlGenericControlWithCss("div", "validator-container");

        inputControl = SupportEventValidation
            ? AllowMultiSelect
                ? new ListBox() { CssClass = "form-control selectpicker show-tick " + ID, ID = ID, DataValueField = DataValueField, DataTextField = DataTextField, DataSource = DataSource, SelectionMode = ListSelectionMode.Multiple } as ListControl
                : new DropDownList() { CssClass = "form-control selectpicker show-tick " + ID, ID = ID, DataValueField = DataValueField, DataTextField = DataTextField, DataSource = DataSource } as ListControl
            : AllowMultiSelect
                ? new DynamicListBox() { CssClass = "form-control selectpicker show-tick " + ID, ID = ID, DataValueField = DataValueField, DataTextField = DataTextField, DataSource = DataSource, SelectionMode = ListSelectionMode.Multiple } as ListControl
                : new DynamicDropDownList() { CssClass = "form-control selectpicker show-tick " + ID, ID = ID, DataValueField = DataValueField, DataTextField = DataTextField, DataSource = DataSource } as ListControl;

        inputControl.Attributes["data-size"] = "15";

        if (AllowMultiSelect)
        {
            inputControl.Attributes["data-selected-text-format"] = "count > 2";
        }
        else
        {
            inputControl.Attributes["data-live-search"] = "true";
        }

        validatorContainer.Controls.Add(inputControl);

        if (DataSource != null)
        {
            inputControl.DataBind();
            Items.AddRange(inputControl.Items.Cast<ListItem>().ToArray());
        }

        validatorContainer.Controls.Add(CreateErrorMessage());

        return validatorContainer;
    }
}

并且通过以下方式在标记中使用该控件:

<mh:BootstrapDropDown runat="server" ID="iGroup" Label="Select Group Name" EnableViewState="true" DataSource='<%# Groups %>' DataTextField="Text" DataValueField="Value" />

然后在后面的代码中,有以下内容:

protected System.Collections.ArrayList Groups
{
    get
    {
        var al = new System.Collections.ArrayList();
        al.Add(new ListItem("[Select a Group]", ""));
        al.Add(new ListItem("Group A", "A"));
        al.Add(new ListItem("Group B", "B"));
        return al;
    }
}

所以这是我的困惑...

  1. CreateChildControls 期间,DataSource 只会出现在原始渲染中。所以我在嵌套的 DropDownList 上调用 DataBind 让它第一次填充,然后我将所有控件 Items 存储回 Items 属性.
  2. 我很确定我理解 Items 是如何从 ViewState 持久化 to/loaded 的。
  3. 我迷路了,my Items 属性 是如何习惯重新填充 DropDownList 的?我在想这可能是我添加 Load\SaveViewState(称为 base.Load\SaveViewState)的事实真正解决了我的问题,但是当我注释掉所有对我的 Items [=56 的引用时=],我又失去了下拉列表的值。

Items 是如何在回发时重新填充 inputControl.Items 的?!

我理解最终问题是:

How in the world is Items repopulating inputControl.Items on postback?!

不过,我认为这个问题不需要(或不应该)回答,原因有二:

  1. 您的初始需求声明:

    I created a custom control that essentially wraps a bunch of boostrap markup around a asp.net DropDownList.

  2. 您的代码(我指的是您的代码的原始版本,它对我们的讨论来说足够好且足够长)包含许多与持久化自定义控件属性有关的技术ViewState 中的复杂类型(LoadViewStateSaveViewStateTripletIStateManager 等),但其中大部分是 not 在你的情况下需要 因为(此时你的需求声明变得至关重要):

    BootstrapDropDown 只是一个复合自定义控件,它嵌入了 DropDownList 并且可以(并且应该)委托 对它进行所有工作!

事实上,您已经很好地完成了 TextValue 属性。为什么不为 Items 属性 也这样做呢?您的控件按 组合 工作。它不需要维护自己的 ListItemCollection 更不用说在 ViewState 中传递它了。

最后但同样重要的是,记住嵌入式服务器控件将自动管理它们自己的 ViewState 非常重要。换句话说,您无需手动管理 inputControl.

的 ViewState

话虽如此,这里有一个基于您的(原始)代码的示例,无需黑魔法即可运行:

public class BootstrapDropDown : WebControl, INamingContainer
{
    private DropDownList inputControl;

    public string DataTextField
    {
        get => (string)ViewState[nameof(DataTextField)];
        set => ViewState[nameof(DataTextField)] = value;
    }
    public string DataValueField
    {
        get => (string)ViewState[nameof(DataValueField)];
        set => ViewState[nameof(DataValueField)] = value;
    }

    public IEnumerable DataSource { get; set; }

    public virtual ListItemCollection Items
    {
        get
        {
            EnsureChildControls();
            return inputControl.Items;
        }
    }

    public virtual string Value
    {
        get
        {
            EnsureChildControls();
            return inputControl.SelectedValue;
        }
        set
        {
            EnsureChildControls();
            inputControl.SelectedValue = value;
        }
    }

    public virtual string Text
    {
        get
        {
            EnsureChildControls();
            return inputControl.SelectedItem?.Text;
        }
    }

    protected override void CreateChildControls()
    {
        /* Added other html markup controls described above */

        var validatorContainer = new HtmlGenericControl("div");
        validatorContainer.Attributes["class"] = "validator-container";

        inputControl = new DropDownList() {
            CssClass = "form-control selectpicker show-tick " + ID,
            ID = ID,
            DataValueField = DataValueField,
            DataTextField = DataTextField,
            DataSource = DataSource
        };

        inputControl.Attributes["data-size"] = "15";
        inputControl.Attributes["data-live-search"] = "true";

        validatorContainer.Controls.Add(inputControl);

        Controls.Add(validatorContainer);

        if (DataSource != null)
        {
            inputControl.DataBind();
        }

        /* Added other html markup controls described */
    }
}

ASPX:

<mh:BootstrapDropDown 
    runat="server" 
    ID="iGroup" 
    Label="Select Group Name" 
    DataSource='<%# Groups %>' 
    DataTextField="Text" 
    DataValueField="Value" />
<asp:Button ID="Button1" runat="server" Text="Button" OnClick="Button1_Click" /><br />
<asp:Label ID="Label1" runat="server" Text=""></asp:Label><br />
<asp:Label ID="Label2" runat="server" Text=""></asp:Label>

后面的代码:

protected System.Collections.ArrayList Groups
{
    get
    {
        var al = new System.Collections.ArrayList();
        al.Add(new ListItem("[Select a Group]", ""));
        al.Add(new ListItem("Group A", "A"));
        al.Add(new ListItem("Group B", "B"));
        return al;
    }
}

protected void Page_Load(object sender, EventArgs e)
{
    if (!IsPostBack)
    {
        DataBind();
    }
}

protected void Button1_Click(object sender, EventArgs e)
{
    Label1.Text = iGroup.Text;
    Label2.Text = iGroup.Value;
}

最后一件事值得一提。注意 inputControl 在 被添加到 Controls 集合后被数据绑定 。这很重要,因为将控件添加到集合也是控件开始跟踪其 ViewState 的点。您可以在这篇优秀的文章中阅读更多(或全部)相关内容:

https://weblogs.asp.net/infinitiesloop/Truly-Understanding-Viewstate

此外,我在 Dino Esposito 的这篇文章中找到了对 IStateManager 机制的引用:

https://www.itprotoday.com/web-application-management/inside-aspnet-control-properties