TRULY Understanding Dynamic Controls (Part 4)
Part 1: Dynamic vs. Static
Part 2: Creating Dynamic Controls
Part 3: Adding Dynamic Controls to the Control Tree
Part 4: Because you don't know what to render at design time
UPDATE: Click here to download the Sample Code referenced in this article.
I started part 1 of this series so long ago. My original plan was to have all 4 parts done over four weeks. It's been a lot longer than that! Sorry folks!
The new plan is that four parts is not enough! I realized while writing this part that there's just too much to cover!
There are three main reasons I can think of why you would want to create controls dynamically instead of declaratively.
-
Because the types and/or number of controls to be rendered is not known at design time. The form may be driven by a database or through configuration, or it is dynamically determined based on other data the user has provided.
-
You know what controls may be rendered but there are a large number of possibilities and/or the controls are expensive to load, so you don't want to load them all.
-
Because you are creating a custom server control and you don't have a choice.
In this part, we will cover #1 and #2. Part 5, hopefully the last part, will cover #3.
Example: You don't know "what" or "how many" controls should be rendered at design time
First and foremost -- ask yourself whether the problem is that you don't know "what" controls should be rendered, or if it's just "how many" controls should be rendered. If it's a question of how many, you can still solve the problem declaratively thanks to Templating.
This is a real scenario that someone emailed me about. They were attempting to solve the problem with dynamic controls. They were having various problems with their implementation and asked for my help. And of course, it turned out to be much simpler with templating.
The scenario is as follows:
You want users to be able to upload files to your site. They do this from a file manager page, where they can see a listing of the files that already exist. Next to each file is a button to delete the file. At the bottom of the list is a file upload control they can use to upload a new file. Any time a file is deleted or uploaded, the listing is updated.
To display the files, we will add rows dynamically to a table that is declared on the form. When a file is deleted we'll give some feedback via a label.
<asp:Label ID="lblStatus" runat="server" EnableViewState="false" />
<table id="tblFiles" runat="server" cellpadding="4" cellspacing="4" border="1"></table>
Here is the implementation I was given. In red, because it has problems!
public partial class _Default : Page {
protected override void OnLoad(EventArgs e) {
string[] files = Directory.GetFiles(Server.MapPath("~/uploaded"));
foreach (string path in files) {
// strips directory from path
string fileName = Path.GetFileName(path);
HtmlTableRow row = new HtmlTableRow();
// file name cell
HtmlTableCell cell = new HtmlTableCell();
cell.InnerText = fileName;
row.Cells.Add(cell);
// delete cell
LinkButton cmdDelete = new LinkButton();
cmdDelete.Text = "delete";
cmdDelete.CommandArgument = fileName;
cmdDelete.Command += new CommandEventHandler(cmdDelete_Command);
cell = new HtmlTableCell();
cell.Controls.Add(cmdDelete);
row.Cells.Add(cell);
// add row to table
this.tblFiles.Rows.Add(row);
}
base.OnLoad(e);
}
private void cmdDelete_Command(object sender, CommandEventArgs e) {
// command argument contains the file name to delete
string fileName = Path.GetFileName((string)e.CommandArgument);
string path = Path.Combine(Server.MapPath("~/uploaded"), fileName);
File.Delete(path);
this.lblStatus.Text = "Deleted " + fileName;
// now remove the table row for the file
LinkButton cmdDelete = (LinkButton)sender;
// link button's parent = cell, parent.parent = row
HtmlTableRow row = (HtmlTableRow)cmdDelete.Parent.Parent;
this.tblFiles.Rows.Remove(row);
}
}
It seems pretty straight forward. The first cell shows the file name, the second shows a link we can click on to delete it. When deleting a file, we remove it from the file system and then remove the row from the table. And of course, we let the user know the file was deleted successfully. Let's see how it runs...
Cool -- let's delete "InfinitiesLoop.gif", who wants that thing anyway?
So far so good! Now, let's delete "TheFamily.jpg". I was blinking during that picture, and I don't appreciate the bunny ears over my head, sister. Bah...
WHAT?! I swear I clicked delete on TheFamily.jpg, but it deleted my TODO list! Nooooo.....! Oh well I guess I can just go home now. Dare I try to delete something else? Who knows what it's going to do next...
So, what went wrong?
When the page first renders, there are 4 rows with 4 link buttons. We never specified an ID for those link buttons when we created them. That means they get automatically generated IDs. The rows and cells do, too, but only the link button actually requires an ID to be rendered, because it must cause a postback via javascript (since links don't naturally do postbacks). If you view the html source, you will see the link button's href looks like "javascript:__doPostBack('ctl01', '')". In this case, ctl01 is the LinkButton ID.
So let's say the link buttons have IDs ctl01, ctl02, ctl03, and ctl04 (the actual IDs are different because of the controls between them, but you get the idea). We click on ctl02 to delete "InfinitiesLoop.jpg". When the postback is processed, first we recreate the table with all 4 rows again. The link buttons will still have the same IDs they did last time. ctl02 raises its command event, we delete the file, and then remove the row from the table. But the link buttons have already determined what their IDs are going to be -- the rendered table will now have link buttons with these IDs: ctl01, ctl03, ctl04. Hmmm. ctl03 is the link button the represents TheFamily.jpg. It will now appear 2nd in the table.
Now we click to delete TheFamily.jpg (ctl03). First the table gets recreated -- only, this time there is no InfinitiesLoop.jpg in the Upload directory. So we only create 3 link buttons this time -- ctl01, ctl02, ctl03. At this stage, ctl03 is the link button that corresponds to TODOList.txt. See the problem yet?
ASP.NET knows who raised the postback event via its ID. But we've pulled the rug out from under it -- the controls on this request have different IDs than they did on the previous request. There happens to be a control with the ID that was posted, but it isn't the one we intended it to be. ASP.NET finds ctrl03, and we delete TODOList.txt. Oops.
This problem can manifest itself in many different ways. If we had textboxes in this table, you might find that they lose their values after certain postbacks, or that their values seem to shift in position.
What to do about it
It's important that the structure of the control tree be the same after a postback as it was before the postback. We actually were not really violating that rule in this case -- we rendered out 3 rows when InfinitiesLoop.jpg was deleted, and we created 3 rows after the postback. But we cheated -- we created 4 rows and removed one, and then on the next request we just created 3 right off the bat. One way we could solve this is by giving each LinkButton a specific ID that will never change. In this case the simplest way would be to make it's ID equal to the file name. There's no way you can confuse one link button with another then. But it's far from ideal to have to stuff the file name into the control ID. The name might be really long. And if we were accessing multiple directories, we could have duplicate file names, so now we'd have to include path information, too. There may be other controls in each row, such as checkboxes that show the file's attributes like whether it is marked as ReadOnly. We'd have to apply this trick to those controls, too. Yuck. Yuck. Yuck.
Really what you want is to be able to "reset" the ID scheme of all the controls in the table. When we remove a row, all the controls after it should 'shift up' with their IDs, so that they will render with the same ID they will have on the next postback. So, what if when we deleted a row, we completely rebuilt the table from the start? Just clear out all the rows and then rebuild it like we originally did...
protected override void OnLoad(EventArgs e) {
BuildTable();
base.OnLoad(e);
}
private void cmdDelete_Command(object sender, CommandEventArgs e) {
// command argument contains the file name to delete
string fileName = Path.GetFileName((string)e.CommandArgument);
string path = Path.Combine(Server.MapPath("~/uploaded"), fileName);
File.Delete(path);
this.lblStatus.Text = "Deleted " + fileName;
// removed a row, throw away the table and rebuild it
this.tblFiles.Rows.Clear();
BuildTable();
}
private void BuildTable() {
string[] files = Directory.GetFiles(Server.MapPath("~/uploaded"));
foreach (string path in files) {
// strips directory from path
string fileName = Path.GetFileName(path);
HtmlTableRow row = new HtmlTableRow();
// file name cell
HtmlTableCell cell = new HtmlTableCell();
cell.InnerText = fileName;
row.Cells.Add(cell);
// delete cell
LinkButton cmdDelete = new LinkButton();
cmdDelete.Text = "delete";
cmdDelete.CommandArgument = fileName;
cmdDelete.Command += new CommandEventHandler(cmdDelete_Command);
cell = new HtmlTableCell();
cell.Controls.Add(cmdDelete);
row.Cells.Add(cell);
// add row to table
this.tblFiles.Rows.Add(row);
}
}
"A" for effort (affort?), but there's still a problem. Now when we click to delete TheFamily.jpg after deleting InfinitiesLoop.jpg, this is what we get.
Nothing happens except the postback. If you examine the control IDs this time, you will see that instead of resetting the IDs by clearing out the table and rebuilding it, the rows simply continued the ID sequence. That's because Controls.Clear() does not reset the automatic ID counter unless the control implements INamingContainer. HtmlTable does not implement INamingContainer.
Aside: What is INamingContainer?
INamingContainer is a marker interface, meaning it has no methods to implement. A control "implements" this interface to let the framework know that it plans on giving it's child controls really specific IDs. It's important to the framework, because if a two instances of the same control are on the same page, and the control gives its child controls some specific ID, there'd end up being multiple controls with the same ID on the page, which is going to cause problems. So when a Control is a naming container, the UniqueID for all controls within it will have the parent's ID as a prefix. This scopes it to that control. So while a child control might have ID "foo", its UniqueID will be "parentID$foo" (where parentID = the ID of the parent). Now even if this control exists twice on the page, everyone will still have a UniqueID.
INamingContainer also has the property that any controls within it that do not have a specific ID will have its ID automatically determined based on a counter that is scoped to it. So if there were two naming containers, foo and bar, they might have child controls with UniqueIDs foo$ctl01 and bar$ctl01. Each naming container gets its own little counter.
Note that the ID "foo$ctl01" does not necessarily imply that ctl01 is a direct child control of foo! All it means is that foo is ctl01's naming container (control.NamingContainer). It's parent might be another control which is not a naming container.
So we can solve this problem once and for all by using a custom HtmlTable control that implements INamingContainer. I won't bother showing that... because there's an even better solution.
Remember this scenario is that you don't know "how many" controls there should be at design time. We do, however, know "what" the controls are. You can do it dynamically, if you manage the control tree correctly. But when you know what the controls will be ahead of time, Templating can step in and take care of all of this for you automatically!
<asp:Label ID="lblStatus" runat="server" EnableViewState="false" />
<asp:Repeater ID="rptFiles" runat="server" EnableViewState="false">
<HeaderTemplate>
<table cellpadding="4" cellspacing="4" border="1">
</HeaderTemplate>
<ItemTemplate>
<tr>
<td><%# Path.GetFileName((string)Container.DataItem) %></td>
<td>
<asp:LinkButton runat="server" Text="delete"
CommandArgument="<%# Path.GetFileName((string)Container.DataItem) %>" />
</td>
</tr>
</ItemTemplate>
<FooterTemplate>
</table>
</FooterTemplate>
</asp:Repeater>
Using a repeater, we declare what each row will look like. This is what I mean by "what" vs "how many". We know what, so we can express "what" via the repeater ItemTemplate. The "how many" will come from databinding the repeater, which will repeat the template once for each item we bind to it.
protected override void OnLoad(EventArgs e) {
this.rptFiles.ItemCommand += new RepeaterCommandEventHandler(rptFiles_ItemCommand);
BindRepeater();
base.OnLoad(e);
}
private void rptFiles_ItemCommand(object source, RepeaterCommandEventArgs e) {
// command argument contains the file name to delete
string fileName = Path.GetFileName((string)e.CommandArgument);
string path = Path.Combine(Server.MapPath("~/uploaded"), fileName);
File.Delete(path);
this.lblStatus.Text = "Deleted " + fileName;
// removed a row, rebind the repeater
BindRepeater();
}
private void BindRepeater() {
string[] files = Directory.GetFiles(Server.MapPath("~/uploaded"));
this.rptFiles.DataSource = files;
this.rptFiles.DataBind();
}
This is what Databound controls are good at. Let them do their job. Doing things dynamically when you don't really need to only complicates things. This design is so much better in so many ways. For one, notice we really have no UI related code in the code-behind (except for the 'status' message). ASP.NET's code-behind model is meant to separate code and UI. Also, notice that there's considerably less code! Why? Because the repeater takes care of the following things for us: (1) it implements the foreach loop. We give it something to enumerate over and call DataBind, it does the rest. (2) it implements the creation of the controls for us. Controls are still being created dynamically at runtime, but we've handed that responsibility to the repeater by describing for it what an item should look like. (3) it implements INamingContainer. Calling DataBind on a databound control throws away its contents and rebuilds it from scratch, just like we had to do. All we need to worry about is maintaining the data and letting the repeater know when the data has changed.
Example: You don't know "what" controls should be rendered at design time, or you want to avoid loading controls you don't need because of performance or because there are too many possibilities.
If you don't know what controls will be rendered in the first place, you have to use dynamic controls, right? Well, even then, it depends. Maybe you don't know what control you will need, but the possibilities are limited. In that scenario, you can avoid dynamic control complexities by simply declaring every possible control with Visible=false, then switch the one you want on by making it visible. Like the repeater example above, this takes the burden of being responsible for the control tree out of your hands. It will also work well if it's possible for the control that is loaded to change during a postback due to a change in state. Since you're loading them all anyway, it doesn't matter.
What about performance?
I've suggested this solution a number of times to readers who sent me their problems. A lot of the time, they were doing it dynamically instead of using the visibility trick because they considered it better for performance. True, loading two controls is more work than loading one, especially when you can know one isn't needed early on. But you're splitting hairs, my friend! Controls, whether they be built in controls, custom server controls, or user controls, are efficient. It doesn't take many resources to instantiate one. It doesn't take many resources to add it to the control tree.
The only time I'd be worried about the performance of a control on the page that doesn't need to exist is if the code in the control is going to do something it doesn't need to do, or if it has a lot of ViewState associated with it. Most of the time, that's not the case. If all the control does is render some html and maybe process some postback data, it isn't worth the effort. If the problem is the amount of viewstate it contains... well, then turn it off, and just enable it for the one control that you do need. If the problem is the operations the control is going to do, like query a database, then that control isn't coded correctly. Control's typically shouldn't go off and do things on their own. They should be told when to do things by the page it lives on. Controls should almost never call databind on themselves, unless they are designed for a really specific purpose where you just want the control to do its own thing. Refactor the control so it has to be told when to do that expensive operation, such as by putting the logic in the DataBind method.
Ok. Let's move on. Say you can't just load them all ahead of time. Maybe the control to load depends on a setting in your database. Maybe there are hundreds of possibilities based on input. The next question to ask is whether the control to load is dependent on state information on the page.
For example, if your database holds the path to a user control which acts as the footer to your layout, that's probably pretty static. It probably doesn't depend on any state information. So all you have to do is load the control and add it to the tree every request. No issues. Just make sure you do it as soon as you can in the page life cycle. OnInit preferably.
If the control(s) to load depend on state data, then that's another story. In this example, we have two radio buttons and a place holder. The dynamic control is loaded into the placeholder, but which control we load depends on which radio button is selected. If you wish, you can also imagine that the control we load is dependent on a setting in web.config. But for this example we'll just use two hard controls.
First we'll define two user controls, UserControl1.ascx and UserControl2.ascx.
<%@ Control Language="C#" %>
<script runat="server">
private void ButtonClick(object sender, EventArgs args) {
this.lbl.Text = "Clicked UserControl1";
}
</script>
UserControl1<hr />
<asp:Label ID="lbl" runat="server" /><br />
<asp:Button ID="cmd" runat="server" Text="Click" OnClick="ButtonClick" />
UserControl2 looks the same except has "UserControl2" instead of "UserControl1". Each has a label and a button. When the button in control 1 is pressed, it updates the label in control 1. When the button in control 2 is pressed, it updates the label in control 2. Keep in mind that each control has its very own button and label. There is no label on the form:
<%@ Page Language="C#" %>
<script runat="server">
protected override void OnLoad(EventArgs e) {
if (this.opt1.Checked) {
ph.Controls.Add(LoadControl("~/UserControl1.ascx"));
}
else if (this.opt2.Checked) {
ph.Controls.Add(LoadControl("~/UserControl2.ascx"));
}
base.OnLoad(e);
}
</script>
<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
<title>Pick-a-control</title>
</head>
<body>
<form id="form1" runat="server">
<div>
<asp:RadioButton ID="opt1" runat="server" Text="Number 1"
AutoPostBack="true" GroupName="g" Checked="true" />
<asp:RadioButton ID="opt2" runat="server" Text="Number 2"
AutoPostBack="true" GroupName="g" />
<hr />
<asp:PlaceHolder ID="ph" runat="server" />
</div>
</form>
</body>
</html>
The devil is in the details. Let's load it up. UserControl1 will load by default, so lets just go ahead and make sure it's working by clicking on the button:
So far so good. It says "Clicked UserControl!" like we expected. It loaded, we posted back, it reloaded, and it successfully processed the click as if it were a declared control. Beautiful. Let's swap over to UserControl2 now... but we won't click on its button yet...
What? That's weird. According to this, I've loaded UserControl2, but it's label has the data I put into UserControl1's label! Voodoo!
That's nothing. Now let's have some fun. Change the label control in UserControl2 to a TextBox. Don't even give it the same ID.
<%@ Control Language="C#" %>
<script runat="server">
private void ButtonClick(object sender, EventArgs args) {
this.txt1.Text = "Clicked UserControl2";
}
</script>
UserControl2<hr />
<asp:TextBox ID="txt1" runat="server" /><br />
<asp:Button ID="cmd" runat="server" Text="Click" OnClick="ButtonClick" />
Now what happens when we switch from control 1 to control 2?
Ohhh yah... we're hacking now. We've successfully loaded the ViewState for the Label in UserControl1 into the TextBox in UserControl2. They both have a "Text" property, which both happen to use "Text" as the ViewState key to remember the value in. *High five*
I mean this to drive home an important point. The control tree into which viewstate is loaded must basically match the control tree which was used to save that viewstate. Coming from more procedural web frameworks (like ASP), you might tend to think of the life of a control to be over once it renders itself. But really, I like to think of it as if a control's lifecycle straddles the request/response boundary. It does indeed get re-instantiated upon every request, but because ASP.NET manages state data for us, the control created on a postback is intimately connected with its "predecessor", for lack of a better word. This more "logical" lifecycle ends after the control has loaded its ViewState and its postback data from its previous life, if any. After that, but before it begins its next life in PreRender, you can make persistent changes to the control tree with no worries.
So here is the solution to the above problem. Every scenario is different, so this isn't necessarily a real general pattern you should follow to the tee, but it shows how we do things the right way for this scenario. Hopefully you can adapt the solution to your specific needs.
<%@ Page Language="C#" %>
<script runat="server">
protected override void OnLoad(EventArgs e) {
if (!Page.IsPostBack) {
// no viewstate on initial request, load the default control
ViewState["state"] = opt1.Checked ? 1 : 2;
LoadUserControl();
}
base.OnLoad(e);
}
protected override void LoadViewState(object savedState) {
base.LoadViewState(savedState);
// viewstate loaded, now we know which control to show.
LoadUserControl();
}
private void CheckChanged(object sender, EventArgs args) {
// state has changed. Remove the loaded control and load the new one.
ViewState["state"] = opt1.Checked ? 1 : 2;
ph.Controls.Clear();
LoadUserControl();
}
private void LoadUserControl() {
Control c;
int state = (int)ViewState["state"];
if (state == 1) {
c = LoadControl("~/UserControl1.ascx");
}
else {
c = LoadControl("~/UserControl2.ascx");
}
// id assigned to avoid shifting IDs when control changed on a postback
c.ID = "foo";
ph.Controls.Add(c);
}
</script>
<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
<title>Pick-a-control</title>
</head>
<body>
<form id="form1" runat="server">
<div>
<asp:RadioButton ID="opt1" runat="server" Text="Number 1"
AutoPostBack="true" GroupName="g" Checked="true"
OnCheckedChanged="CheckChanged" />
<asp:RadioButton ID="opt2" runat="server" Text="Number 2"
AutoPostBack="true" GroupName="g"
OnCheckedChanged="CheckChanged" />
<hr />
<asp:PlaceHolder ID="ph" runat="server" />
</div>
</form>
</body>
</html>
The idea is to load the control that existed on the previous request by utilizing a ViewState field to remember which control was active. We override LoadViewState, then immediately after calling base.LoadViewState, we can look for the ViewState value to tell us which control existed previously. On the initial request, there is no ViewState and therefore no call to LoadViewState, so we detect this in OnLoad and make sure the default control is loaded at first. Then, we listen for the CheckChanged event on the radio buttons to tell us when the control to be loaded has changed (note: I actually really dislike the CheckChanged event, but that's a different discussion. It works well for this simple scenario).
At the time the CheckChanged event fires, we will have already loaded a user control -- whichever one was active previously. So we have to remove it before adding the new one. That is why we call Controls.Clear() on the placeholder. And finally, we assign the control a specific ID so there's no way we can run into the ID problem mentioned earlier, which would happen when removing the old control and adding the new one in response to the CheckChanged event. There's no way we could accidentally post data from one control into another as you switch from one control to another, because we're loading the correct control prior to the loading of post data. Post data is loaded right after LoadViewState. That is why we don't do the logic from OnLoad -- the user control would miss that phase. Actually, ASP.NET loads post data in two phases, one before OnLoad / after LoadPostData, and one right after OnLoad. The purpose of the 2nd pass after OnLoad is to load postdata for any controls that may have been dynamically added (or created through databinding) during OnLoad. That would actually work just fine for our scenario, but there are consequences to this late loading that are best avoided if possible. Normally, you can rely on post data to have been completely loaded from the OnLoad phase. But for late-comers, that isn't the case. It's best to provide consistent behavior if you can, so LoadViewState is where it's at!
Actually, this very closely emulates what data bound controls do. If you were to examine the code for the Repeater, for example, you would find that from its CreateChildControls method it examines a ViewState field. If it exists, it calls its CreateControlHiearchy method, which rebuilds its control tree based on ViewState. When it is DataBound, it clears the control collection and rebuilds it again, calling the same CreateControlHiearchy method.
THAT'S ALL FOLKS. In the next part we'll cover custom server controls and some of the things to watch out for there.
UPDATE: Click here to download the Sample Code referenced in this article.
One more thing...
Many of you apparently were so anxious to read part 4 of the series, you cleverly deduced that the url to the article must be the same as Part 3, only with a "4". You URL HAXXOR, you!!!!111... You see, I've been working on a draft of this article for a long time now (which as it turns out has been completely redone), and I unknowingly had the article saved in a state that would allow you to access it if you happened to know the address to it!
In all, by the time I realized what was happening, this article received 121 hits to it even before it was published! All you hackers got to read my embarrassingly terrible draft. I thwarted you by renaming the article temporarily. I added "abc" to the end. I was waiting for someone to guess that, too. If you did, there would have been a surprise in it for you. But no winners. Oh well....
Until next time.... part 5 will come much sooner than part 4 did, I promise.