Note: This article is submitted by Thomas Kirchmair for Silverlight: Write and Win contest.Thanks a lot, Thomas! Hello All, Please drop a comment if you like it.
This article is compatible with the latest version of Silverlight.
1. Introduction
My recent aim in coding with Microsoft's Silverlight is to support ASP.NET developers by solving common ASP.NET- and HTML-problems scenarios easily with Silverlight applications and tools. The project of this article here shows my idea of solving the annoying ASP.NET-DropDownList bandwidth- and ViewState-problem with a huge amount of option items inside the control.
Using the normal ASP.NET-DropDownList with enabled ViewState is quite simple and easy, but the time you fill your ASP.NET-DropDownList with a great amount of option items (e.g. all countries of the world, Postal Codes, Bank Identification Codes, …) the HTML code and the ViewState of your web page gets alarmingly heavy. Starting at 30kBytes and higher for each DropDownList and every PostBack is sometimes totally normal.
My first approach solving this problem was using AJAX-WebServices in combination with the AJAX-AutoCompleteTextBox. On each character the user entered inside the TextBox, I contacted the service and showed possible results inside an overlaid window – like google or amazon do. This resulted in heavy client-server communication and complex client side and server side input validation, if the user’s input has to be validated against existing data (and the entered data has to be correct). For me this approach were too complex and too expensive to implement.
So I began thinking about if there is a better common solution for this known problem…
1.1 My Requirements
- The transmission data size should be minimal in both directions (Render and PostBack).
- Functionality must be guarantied inside ASP.NET-ContentPages and ASP.NET-UpdatePanels
- Minimal changes to the ASP.NET server side concept
- Implementation effort into existing ASP.NET-Applications must be minimal
1.2 Idea
Imagine there exists a tiny Silverlight-Application inside your website which manages all the data of your ASP.NET-DropDownLists and stores all the option items locally inside the Silverlight’s Isolated Storage. If the ASP.NET-DropDownList option items do not exist already on the client side, a service will be contacted to fetch the needed data, or if the option items do exist already locally, nothing will be transmitted, and all the option items will be generated automatically in the background of the browser through the Silverlight-Application and JavaScript-Functions.
Furthermore the option items are reusable throughout your complete web site.
1.3 Live Demo and Sample Project
Online Demo
Source Code and Sample Project
2. The Solution
2.1 The Client-Side
The Silverlight part of this project represents the glue between the data service, the Isolated Storage, the JavaScript-Functions and the HTML-Controls at the client side of the web page. On the one hand the Silverlight-Application calls the JavaScript-Functions implemented in kirdropdown.js at specific moments, on the other hand the Silverlight-Application exposes management functions as ScriptableMembers to the browser, so they can be called from the JavaScript-Code.
The process of checking the existence of the needed option items and the filling them into the HTML select object is executed at two points of time: The Loaded_Event of the Silverlight-Application and the end event of the AsyncPostBack (in case the DropDownLists are hosted inside an ASP.Net-UpdatePanel).
DDLCache_Loaded = function(strSLCtrlID) {
g_strSLCtrlClientID = strSLCtrlID;
var slHost = document.getElementById(strSLCtrlID);
if (slHost != null) {
var ddLists = document.getElementsByTagName("select");
if (ddLists.length > 0) {
for (var nIndex = 0; nIndex < ddLists.length; nIndex++) {
var bActive = ddLists[nIndex].getAttribute("DDLActive");
if (bActive == "True") {
var strDataID = ddLists[nIndex].getAttribute("DataID");
if (strDataID != null) {
var strTemp = ddLists[nIndex].options[0];
var strSelecton = null;
if (strTemp != null)
strSelecton = strTemp.value;
slHost.content.kirDDLCache.GetData(strDataID, ddLists[nIndex].getAttribute("ID"), strSelecton);
}
}
}
}
}
}
This JavaScript-Code above gets all HTML select objects of the web page, iterates through them, looks if the needed attributes “DDLActive” and “DataID” are existing and active, and calls the scriptable function GetData(…) of the Silverlight-Application with the ControlID, the ID of the needed Data and the recently selected option item (to keep the selection after filling in).
[ScriptableMember]
public void GetData( string strDataID, string strCtrlID, string strSelected )
{
// ** first I check if I have my values already inside my storage
string[] arstrTexts, arstrValues;
if( _lsMyStore.LoadFromStorage( strDataID, out arstrTexts, out arstrValues ) == false )
{
// ** they are not here, so try to load them
// ** when the load is finished, the data will
// ** be filled in there
StartLoadingData( strDataID, strCtrlID, strSelected );
return;
}
// ** otherwise the data is already here, so I can fill in now
FillToDropDownList( strCtrlID, strSelected, arstrTexts, arstrValues );
}
The function GetData(…) on the Silverlight side, checks first if the Data with the given DataID is already stored inside the local Isolated Storage. In this project the Isolated Storage functions are completely implemented inside the C#-File LocalStorage.cs, as seen in the sample project. If the data is not existing in the local Isolated Storage, the data will be received from the service at StartLoadingData(…) with the given DataID - in the classic way.
private void StartLoadingData( string strDataID, string strCtrlID, string strSelected )
{
// ** connect to the service, set the event handler
DDLDataService.DDLDataSvcClient mySvcClient = new kir.dropdown.DDLDataService.DDLDataSvcClient();
mySvcClient.GetDropDownDataCompleted +=
new EventHandler<kir.dropdown.DDLDataService.GetDropDownDataCompletedEventArgs>( mySvcClient_GetDropDownDataCompleted );
// ** call the function
mySvcClient.GetDropDownDataAsync( strDataID, strCtrlID, strSelected );
}
void mySvcClient_GetDropDownDataCompleted( object sender, kir.dropdown.DDLDataService.GetDropDownDataCompletedEventArgs e )
{
// ** when I get something, I save it first
if( ( e.Error == null ) && ( e.Result == true ) )
{
// ** get the data
string[] arstrTexts = e.arstrOutTexts.ToArray<string>();
string[] arstrValues = e.arstrOutValues.ToArray<string>();
// ** save the data with recent validity
_lsMyStore.SaveToStorage( e.strOutDataID, arstrTexts, arstrValues );
// ** fill in (which won't be done in preload fake mode)
FillToDropDownList( e.strOutCtrlID, e.strOutSelected, arstrTexts, arstrValues );
}
}
The first time the data with the given DataID has reached the client side, it will be saved inside the local Isolated Storage combined with a timestamp, so changes to the option item list on the server side can be updated automatically to the local browser.
The time the data is on the client (inside two string arrays – one array for the values and one array for the texts of the option items), it will be filled into the HTML select item by calling the function FillToDropDownList(…).
private void FillToDropDownList( string strCtrlID, string strSelected, string[] arstrTexts, string[] arstrValues )
{
try
{
// ** do this only if there is no preload of the data from the service
if( strCtrlID != PreloadFakeCtrlID )
{
// ** check if there is really a selected value
string strSelectedValue = ( string.IsNullOrEmpty( strSelected ) == false ) ? strSelected : NothingSelectedFake;
// ** call the function to fill in
ScriptObject scrObject = (ScriptObject)HtmlPage.Window.GetProperty( "DDLCache_FillToDDL" );
scrObject.InvokeSelf( new object[] { strCtrlID, strSelected, arstrTexts, arstrValues, arstrTexts.GetLength( 0 ) } );
}
}
catch { }
}
At last, the function FillToDropDownList(…) calls the JavaScript side again (as seen in the code window above), which now generates each option item inside the select control of the HTML page and preserves the selection.
DDLCache_FillToDDL = function(strCtrlID, strSelected, arTexts, arValues, nLength) {
var ddDropDown = document.getElementById(strCtrlID);
if (ddDropDown != null) {
var liOptions = ddDropDown.options;
for (var nIndex = 0; nIndex < nLength; nIndex++) {
liOptions[nIndex] = new Option(arTexts[nIndex], arValues[nIndex], false, (arValues[nIndex] == strSelected));
}
}
}
And that’s it on the client side, except the time management for the AsyncPostBack needed for AJAX-UpdatePanels. In this case the update of the option items has to be started through a client side event because the HTML controls do not exist inside the HTML document the time the Silverlight application starts up. Furthermore this is the only possibility to receive the AJAX-UpdatePanel update event inside the browser.
DDLCache_AddPartialLoadedHandler = function() {
Sys.WebForms.PageRequestManager.getInstance().add_endRequest(DDLCache_PageEndRequestHandler);
}
DDLCache_PageEndRequestHandler = function(sender, args) {
setTimeout(DDLCache_DoPartialUpdate, 20);
}
2.2 The Server-Side
The implementation of the server side of this project is quite tricky, too…
At the beginning I will show the separation of the server side data store – which holds the data of the option list items, because the DropDownList data is needed on two points in this solution: inside the server application for initialization of the server control and for sending the data to the client through the service.
In my sample the central data point is implemented inside the DataHelper class (as seen in the project). It checks if there is an xml-file inside the Data directory on the web server and reads the data of the option items out of the file. For your application you can change this to any data storage you want (e.g. database, hard coded, …). The data validity here is represented by the last write time of the file, so just simply change and save the xml-file, and the data will be sent to the client again if your page calls the function DDLCache_PreloadData(…). This function calls the service for the recent data validity (from the client), and performs needed updates automatically. The best place for this function call is at the start page to load all data in the background. So all needed option items are stored on the client the time the user comes to fill in the forms containing your ASP.NET-DropDownLists.
using System;
using System.Collections.Generic;
using System.Web;
using System.Xml.Serialization;
using System.IO;
namespace kir.dropdown.Web.DataManager
{
public class DataHelper
{
public static bool GetData( string strDataID, out List<TextValue> liValues )
{
liValues = new List<TextValue>();
// ****************************************************
// ** Implement Your Data Selection Process here
// ****************************************************
try
{
LoadDataFromXmlFile( strDataID, out liValues );
return true;
}
catch
{
// ** no data is here
return false;
}
}
public static bool GetDataValidity( string strDataID, out DateTime dtValidity )
{
dtValidity = DateTime.MinValue;
// **************************************************************
// ** Implement the Data Selection, and the calculation of
// ** the validity date
// **************************************************************
try
{
dtValidity = GetDataValidityFromFile( strDataID );
return true;
}
catch
{
return false;
}
}
private static string _strPhysicalPath = "";
private static string MyPhysicalPath
{
get
{
if( string.IsNullOrEmpty( _strPhysicalPath ) == true )
{
_strPhysicalPath = ( ( (HttpContext)( HttpContext.Current ) ).Request.PhysicalApplicationPath );
}
return _strPhysicalPath;
}
}
private static string CalculateServerFileName( string strDataID )
{
return MyPhysicalPath + @"Data\" + strDataID + ".xml";
}
private static void LoadDataFromXmlFile( string strDataID, out List<TextValue> liValues )
{
// ** read the Data from the XML-File
XmlSerializer mySerializer = new XmlSerializer( typeof( List<TextValue> ) );
StreamReader myReader = new StreamReader( CalculateServerFileName( strDataID ) );
liValues = (List<TextValue>) mySerializer.Deserialize( myReader );
myReader.Close();
}
private static DateTime GetDataValidityFromFile( string strDataID )
{
FileInfo myInfo = new FileInfo( CalculateServerFileName( strDataID ) );
return myInfo.LastWriteTime;
}
}
}
The next important parts of this project are the changes to the ASP.NET-DropDownList on the server side. As written in my requirements list above, the developer handling of the cached DropDownList should almost be without changes to the handling of the original ASP.NET-DropDownList. To achieve this I derived from the original ASP.NET-DropDownList and implemented the additional needed functionality inside kirDropDown.cs (as seen in the project).
To control the client side local cache, two attributes have to be added to the ASP.NET-DropDownList. “DDLActive” tells that this select control should be managed by the local cache, and “DataID” tells which data for the option items has to be used. Both attributes are implemented as normal custom control properties of the kirDropDown class. To render both of them to the output stream, I changed the function AddAttributesToRender(…) of the base class to the following:
protected override void AddAttributesToRender( HtmlTextWriter writer )
{
// ** add the attributes to the HTML-Output
writer.AddAttribute( "DDLActive", DDLActive.ToString() );
writer.AddAttribute( "DataID", DataID );
base.AddAttributesToRender( writer );
}
The next thing I have to do is to suppress the rendering of the option items to the output stream – except of the selected one, so the DropDownList looks like it is filled already from the beginning, and the user of your web site does not see the process of the client side creation of the option items in the background of his browser. This is done by overriding the OnPreRender(…) function of the base class control:
protected override void OnPreRender( EventArgs e )
{
// ** all items - except the selcted one - will be removed before
// ** rendering, because the silverlight control will fill in
// ** all the values at the client side
if( this.Items.Count > 1 )
{
// ** remove all, except the selected one. This has to
// ** be done manually, because Items.Clear() removes
// ** the selection and fires an event.
for( int nIndex = 0 ; nIndex < this.Items.Count ; )
{
if( this.Items[ nIndex ].Selected == false )
this.Items.RemoveAt( nIndex );
else
nIndex++;
}
// ** set the selected index to the only one, that is left
if( this.Items.Count > 0 )
this.SelectedIndex = 0;
}
// ** if the focus has to be set to this control
if( RenderFocus == true )
{
// ** Register the javascript
if( Page.ClientScript.IsStartupScriptRegistered( "kirDropDownFocus" ) == false )
{
Page.ClientScript.RegisterStartupScript( typeof( Page ), "kirDropDownFocus",
"document.getElementById('" + this.ClientID + "').focus();", true );
}
}
base.OnPreRender( e );
}
As seen above I remove each item – except the selected one, so the rendering size amount of the control is minimal, regardless the amount of option items the DropDownList has to hold. All option items will be created on the local side through the Silverlight-Application and JavaScript code. The user of your web page will not see any difference – except your page responses faster and the page handling feels more naturally. Because of the permanent disabled ViewState of the DropDownList the option items do not require any size inside the ViewState which safes transmission size, too.
Remember: The ViewState of this control is always turned off. So there would not be any filled Items-Collection in case of a PostBack and no SelectedItem, either. To ensure the same server side functionality as the original ASP.NET-DropDownList I had to handle this functionality inside the derived class by my own. I did this by getting the data from the data store at the initialization process of the control and handling the selected index during the loading of the PostBack-Data.
protected override bool LoadPostData( string postDataKey, System.Collections.Specialized.NameValueCollection postCollection )
{
// ** read the selected value from the PostBack-Data
if( this.UniqueID != postCollection[ "__EVENTTARGET" ] )
this.SelectedValue = postCollection[ this.UniqueID ];
return base.LoadPostData( postDataKey, postCollection );
}
protected override void OnInit( EventArgs e )
{
// ** load the data into my control serverside
if( string.IsNullOrEmpty( _DataID ) == false )
{
// ** reset my items (maybe the last selected is back from the viewstate)
this.Items.Clear();
// ** get the data and fill in
List<TextValue> liValues = null;
if( DataHelper.GetData( _DataID, out liValues ) == true )
{
// ** fill in into my control
foreach( TextValue tvValue in liValues )
{
this.Items.Add( new ListItem( tvValue.Text, tvValue.Value ) );
}
}
}
base.OnInit( e );
}
3. Measurements
By using fiddler 2.x I did some traffic measurements on the Online Sample of this project. The first page is implemented with the invisible Silverlight DropDownList cache. On a quite slow internet connection (UMTS mobile modem – too emphasize the difference) the complete server side PostBack took about 2 seconds for the sum of 956 option items, and nearly 5 KB were totally transferred. This was very impressive.
On the opposite the measurement of the second page with the original ASP.NET-DropDownLists was quite impressive, too. Fiddler reports 85 KB of network traffic and the page took 13 seconds to refresh.
So for this tiny sample I achieved a transmission size reduction of the factor 17 and an improvement of speed of the factor 6.
4. Summary
With this article I hope I could demonstrate that Microsoft’s Silverlight is not just a development environment to improve the graphical experience of web sites and to play media content inside the browser. From my point of view Silverlight gives new possibilities to solve problems – without Silverlight I often despaired of using classic HTML and ASP.NET during my web development.
Greetings from Vienna, Austria, Europe.
Thomas Kirchmair.
kir.at.