Click here to Skip to main content
Click here to Skip to main content

Silverlight RIA Tasks 2: Dynamic View Models

, , , 18 Jul 2010 Ms-PL
Rate this:
Please Sign up or sign in to vote.
Creating multiple dynamic Views using View Model Style and the Silverlight Tab Control.

Live example: Click here

Also see

Dynamically Creating Views

When using "View Model Style" programming, you may find a need to dynamically create Views. The challenge is to create them dynamically, while also allowing a Designer to easily design the UI.

View Model Style

View Model Style allows a programmer to create an application that has absolutely no UI (user interface). The programmer only creates a View Model and a Model. A designer with no programming ability at all is then able to start with a blank page and completely create the View (UI) in Microsoft Expression Blend 4 (or higher).

img32.jpg

If you are new to View Model Style, it is suggested that you read Silverlight View Model Style: An (Overly) Simplified Explanation for an introduction.

RIA Tasks 2

This article uses much of the same code used in RIATasks: A Simple Silverlight CRUD Example.

While this article uses the same database and website code, it covers these additional things:

  • Dynamically creating Views (with View Models)
  • Using the Silverlight Tab Control
    • Creating a Design-time View for a dynamically created View Model
    • Programmatically setting the selected Tab
    • Programmatically creating TabItems and binding the Tab Control to them
    • Programmatically setting the Style of dynamically created Tabs to a Static Resource

The Application

The previous application (RIATasks: A Simple Silverlight CRUD Example) only allows you to edit one Task at a time.

This application allows you to create unlimited Tabs, that each contain an editable Task.

The Web Service

While we normally use a View Model so that we don't need to change the UI when the design changes, if the basic requirements change, we usually need to actually change the code. In this case, the requirements have changed.

For the Web Site, the only code that needed to be changed was the Web Service code. The GetsTask method was removed, and the GetTasks method was altered to return the Task Description.

[WebService(Namespace = "http://OpenLightGroup.net/")]
[WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
[System.ComponentModel.ToolboxItem(false)]
public class WebService : System.Web.Services.WebService
{
    #region GetCurrentUserID
    private int GetCurrentUserID()
    {
        int intUserID = -1;
        if (HttpContext.Current.User.Identity.IsAuthenticated)
        {
            // Get the current user
            intUserID = Convert.ToInt32(HttpContext.Current.User.Identity.Name);
        }
        return intUserID;
    }
    #endregion

    // Web Methods

    #region GetTasks
    [WebMethod]
    public List<Task> GetTasks()
    {
        // Create a collection to hold the results
        List<Task> colResult = new List<Task>();

        RIATasksDBDataContext DB = new RIATasksDBDataContext();

        var colTasks = from Tasks in DB.Tasks
                       where Tasks.UserID == GetCurrentUserID()
                       select Tasks;

        return colTasks.ToList();
    }
    #endregion

    #region DeleteTask
    [WebMethod]
    public string DeleteTask(int TaskID)
    {
        string strError = "";
        RIATasksDBDataContext DB = new RIATasksDBDataContext();

        try
        {
            var result = (from Tasks in DB.Tasks
                          where Tasks.TaskID == TaskID
                          where Tasks.UserID == GetCurrentUserID()
                          select Tasks).FirstOrDefault();

            if (result != null)
            {
                DB.Tasks.DeleteOnSubmit(result);
                DB.SubmitChanges();
            }
        }
        catch (Exception ex)
        {
            strError = ex.Message;
        }

        return strError;
    }
    #endregion

    #region UpdateTask
    [WebMethod]
    public string UpdateTask(Task objTask)
    {
        string strError = "";
        RIATasksDBDataContext DB = new RIATasksDBDataContext();

        try
        {
            var result = (from Tasks in DB.Tasks
                          where Tasks.TaskID == objTask.TaskID
                          where Tasks.UserID == GetCurrentUserID()
                          select Tasks).FirstOrDefault();

            if (result != null)
            {
                result.TaskDescription = objTask.TaskDescription;
                result.TaskName = objTask.TaskName;

                DB.SubmitChanges();
            }
        }
        catch (Exception ex)
        {
            strError = ex.Message;
        }

        return strError;
    }
    #endregion

    #region InsertTask
    [WebMethod]
    public Task InsertTask(Task objTask)
    {
        RIATasksDBDataContext DB = new RIATasksDBDataContext();

        try
        {
            Task InsertTask = new Task();

            InsertTask.TaskDescription = objTask.TaskDescription;
            InsertTask.TaskName = objTask.TaskName;
            InsertTask.UserID = GetCurrentUserID();

            DB.Tasks.InsertOnSubmit(InsertTask);
            DB.SubmitChanges();

            // Set the TaskID 
            objTask.TaskID = InsertTask.TaskID;
        }
        catch (Exception ex)
    {
            // Log the error
            objTask.TaskID = -1;
            objTask.TaskDescription = ex.Message;
        }

        return objTask;
    }
    #endregion
}

The Model

The Model was altered to not use Rx Extensions. While the code works either way, I was shown code by Richard Waddell and Shawn Wildermuth that requires about the same amount of code as Rx Extensions but does not require you at add any additional assemblies:

public class TasksModel
{
    #region GetTasks
    public static void GetTasks(EventHandler<GetTasksCompletedEventArgs> eh)
    {
        // Set up web service call
        WebServiceSoapClient WS = new WebServiceSoapClient();

        // Set the EndpointAddress
        WS.Endpoint.Address = new EndpointAddress(GetBaseAddress());

        WS.GetTasksCompleted += eh;
        WS.GetTasksAsync();
    }
    #endregion

    #region DeleteTask
    public static void DeleteTask(int TaskID, 
                  EventHandler<DeleteTaskCompletedEventArgs> eh)
    {
        // Set up web service call
        WebServiceSoapClient WS = new WebServiceSoapClient();

        // Set the EndpointAddress
        WS.Endpoint.Address = new EndpointAddress(GetBaseAddress());

        WS.DeleteTaskCompleted += eh;
        WS.DeleteTaskAsync(TaskID);
    }
    #endregion

    #region UpdateTask
    public static void UpdateTask(Task objTask, 
           EventHandler<UpdateTaskCompletedEventArgs> eh)
    {
        // Set up web service call
        WebServiceSoapClient WS = new WebServiceSoapClient();

        // Set the EndpointAddress
        WS.Endpoint.Address = new EndpointAddress(GetBaseAddress());

        WS.UpdateTaskCompleted += eh;
        WS.UpdateTaskAsync(objTask);
    }
    #endregion

    #region InsertTask
    public static void InsertTask(Task objTask, 
           EventHandler<InsertTaskCompletedEventArgs> eh)
    {
        // Set up web service call
        WebServiceSoapClient WS = new WebServiceSoapClient();

        // Set the EndpointAddress
        WS.Endpoint.Address = new EndpointAddress(GetBaseAddress());

        WS.InsertTaskCompleted += eh;
        WS.InsertTaskAsync(objTask);
    }
    #endregion

    // Utility

    #region GetBaseAddress
    private static Uri GetBaseAddress()
    {
        // Get the web address of the .xap that launched this application     
        string strBaseWebAddress = App.Current.Host.Source.AbsoluteUri;
        // Find the position of the ClientBin directory
        int PositionOfClientBin =
            App.Current.Host.Source.AbsoluteUri.ToLower().IndexOf(@"/clientbin");
        // Strip off everything after the ClientBin directory
        strBaseWebAddress = Strings.Left(strBaseWebAddress, PositionOfClientBin);
        // Create a URI
        Uri UriWebService = 
          new Uri(String.Format(@"{0}/WebService.asmx", strBaseWebAddress));
        // Return the base address
        return UriWebService;
    }
    #endregion
}

The View Model

This is where a lot of changes were made. Instead of one View Model, we now have three (we also have three Views).

Each View Model handles a different part of the application. Here is the overview:

  • MainPageModel.cs - This is the View Model for the main View that loads all the other embedded Views. The Add New Task button is on the View of this View Model, but it calls an ICommand that is in the TabControlModel's View Model (using View Model to View Model Communication described in this article: Silverlight View Model Communication).
  • TabControlModel.cs - This View Model dynamically creates TabItems and places an instance of the TaskDetails View, and its View Model (TaskDetailsModel.cs) on the TabItem.
  • TaskDetailsModel.cs - This View Model holds the details of a single Task.

MainPageModel

This class does not contain a lot of code. It mostly contains a property (TabControlVM) that will hold an instance of the TabControlModel.

This allows the MainPage View to call methods in the TabControlModel through its View Model (MainPageModel). This View Model to View Model Communication technique is covered in this article: Silverlight View Model Communication.

Here is the full code:

using System;
using System.ComponentModel;

namespace RIATasks
{
    public class MainPageModel : INotifyPropertyChanged
    {
        public MainPageModel()
        {

        }

        // Properties

        #region TabControlVM
        private TabControlModel _TabControlVM = new TabControlModel();
        public TabControlModel TabControlVM
        {
            get { return _TabControlVM; }
            private set
            {
                if (TabControlVM == value)
                {
                    return;
                }
                _TabControlVM = value;
                this.NotifyPropertyChanged("TabControlVM");
            }
        }
        #endregion

        // Utility

        #region INotifyPropertyChanged
        public event PropertyChangedEventHandler PropertyChanged;

        private void NotifyPropertyChanged(String info)
        {
            if (PropertyChanged != null)
            {
                PropertyChanged(this, new PropertyChangedEventArgs(info));
            }
        }
        #endregion
    }
}

TabControlModel

This class performs 90% of the work of the application.

The main purpose of this View Model is to expose the collection of Tasks to the View. In the original RIA Tasks application, this collection was ObservableCollection<Task>, but, for RIA Tasks 2 we decided to use the Tab Control and expose a collection of TabItems (ObservableCollection<TabItem>):

#region colTabItems
private ObservableCollection<TabItem> _colTabItems
    = new ObservableCollection<TabItem>();
public ObservableCollection<TabItem> colTabItems
{
    get { return _colTabItems; }
    private set
    {
        if (colTabItems == value)
        {
            return;
        }
        _colTabItems = value;
        this.NotifyPropertyChanged("colTabItems");
    }
}
#endregion

To fill the collection of TabItems, we use the GetTasks() method that calls the Model and retrieves the Tasks for the currently logged in user:

#region GetTasks
private void GetTasks()
{
    // Clear the current Tasks
    colTabItems.Clear();

    // Call the Model to get the collection of Tasks
    TasksModel.GetTasks((Param, EventArgs) =>
    {
        if (EventArgs.Error == null)
        {
            // loop thru each item
            foreach (var objTask in EventArgs.Result)
            {
                // Create a TaskItem from the Task
                TabItem objTabItem = CreateTaskItem(objTask);

                // Add it to the Collection of TabItems
                colTabItems.Add(objTabItem);
            }

            // Count the records returned
            if (colTabItems.Count == 0)
            {
                // If there are no records, indicate that
                Message = "No Records Found";
            }
            else
            {
                Message = "";
            }

            // If there is a CurrentTaskID set that 
            // as the Current task
            if (CurrentTaskID != -1)
            {
                // Locate the Task
                var objTabItem = (from TaskItem in colTabItems
                      let VM = (TaskItem.Content as TaskDetails).DataContext
                      where (VM as TaskDetailsModel).CurrentTask.TaskID == CurrentTaskID
                      select TaskItem).FirstOrDefault();

                if (objTabItem != null)
                {
                    // Set the CurrentTask as selected
                    objTabItem.IsSelected = true;
                }
            }
        }
    });
}
#endregion

The constructor for the View Model calls the GetTasks() method when the View is loaded:

public TabControlModel()
{
    AddNewTaskCommand = new DelegateCommand(AddNewTask, CanAddNewTask);
    DeleteTaskCommand = new DelegateCommand(DeleteTask, CanDeleteTask);
    UpdateTaskCommand = new DelegateCommand(UpdateTask, CanUpdateTask);

    // The following line prevents Expression Blend
    // from showing an error when in design mode
    if (!DesignerProperties.IsInDesignTool)
    {
        // Get the Tasks for the current user
        GetTasks();
    }
}

The constructor also sets up the ICommands that are used to Add, Update and Delete Tasks.

Here is the code for the ICommands:

#region AddNewTaskCommand
public ICommand AddNewTaskCommand { get; set; }
public void AddNewTask(object param)
{
    SetToNewTask();
}

private bool CanAddNewTask(object param)
{
    // Only allow a New Task to be created
    // If there are no other [New] Tasks

    var colNewTasks = from Tasks in colTabItems
                      where (Tasks.Header as string).Contains("[New]")
                      select Tasks;

    return (colNewTasks.Count() == 0);
}
#endregion

#region DeleteTaskCommand
public ICommand DeleteTaskCommand { get; set; }
public void DeleteTask(object param)
{
    // Get The Task
    Task objTask = GetTaskFromTaskDetails((param as TaskDetails));

    if (objTask.TaskID != -1)
    {
        // Delete Task
        DeleteTask(objTask);
    }
    else
    {
        RemoveTask(objTask.TaskID);
    }
}

private bool CanDeleteTask(object param)
{
    // Only allow this ICommand to fire 
    // if a TaskDetails was passed as a parameter
    return ((param as TaskDetails) != null);
}
#endregion

#region UpdateTaskCommand
public ICommand UpdateTaskCommand { get; set; }
public void UpdateTask(object param)
{
    // Get The Task
    Task objTask = GetTaskFromTaskDetails((param as TaskDetails));

    if (objTask.TaskID == -1)
    {
        // This is a new Task
        InsertTask(objTask);
    }
    else
    {
        // This is an Update
        UpdateTask(objTask);
    }
}

private bool CanUpdateTask(object param)
{
    // Only allow this ICommand to fire 
    // if a TaskDetails was passed as a parameter
    return ((param as TaskDetails) != null);
}
#endregion

These Commands use the following methods to call the Model and perform their operations:

#region DeleteTask
private void DeleteTask(Task objTask)
{
    // Call the Model to delete the Task
    TasksModel.DeleteTask(objTask.TaskID, (Param, EventArgs) =>
    {
        if (EventArgs.Error == null)
        {
            // Set the Error Property
            Message = EventArgs.Result;

            RemoveTask(objTask.TaskID);
        }
    });
}
#endregion

#region UpdateTask
private void UpdateTask(Task objTask)
{
    // Call the Model to UpdateTask the Task
    TasksModel.UpdateTask(objTask, (Param, EventArgs) =>
    {
        if (EventArgs.Error == null)
        {
            // Set the Error Property
            Message = EventArgs.Result;
        }
    });
}
#endregion

#region InsertTask
private void InsertTask(Task objTask)
{
    // Call the Model to Insert the Task
    TasksModel.InsertTask(objTask, (Param, EventArgs) =>
    {
        if (EventArgs.Error == null)
        {
            // Set the CurrentTaskID Property
            // So it can be selected when Tasks re-load
            CurrentTaskID = EventArgs.Result.TaskID;

            // Update the Tasks list
            GetTasks();

            Message = "";
        }
    });
}
#endregion

You will notice that the AddNewTask method calls the SetToNewTask() method:

#region SetToNewTask
private void SetToNewTask()
{
    // Unset selected for all Items
    foreach (var item in colTabItems)
    {
        item.IsSelected = false;
    }

    // Create a empty Task
    // so form will be blank
    Task objTask = new Task();

    // Set TaskID = -1 so we know it's a new Task
    objTask.TaskID = -1;

    // Create a TaskItem from the Task
    TabItem objNewTabItem = CreateTaskItem(objTask);

    // Set it as selected
    objNewTabItem.IsSelected = true;

    // Add it to the Collection of TabItems
    this.colTabItems.Add(objNewTabItem);
}
#endregion

Note: This method first sets all TabItems' IsSelected to false, then sets the new TabItem's IsSelected to true. This is how you make a Tab Control programmatically select a tab.

The SetToNewTask() method calls the CreateTaskItem(objTask) method (shown below) that converts a Task to TabItem. The SetToNewTask() method then adds the TabItem to the colTabItems collection (the Tab Control on the View is bound to this collection so that it can show the tabs):

#region CreateTaskItem
private TabItem CreateTaskItem(Task Task)
{
    // Create a Tasks Details
    TaskDetails objTaskDetails = new TaskDetails();
    // Get it's DataContext
    TaskDetailsModel objTaskDetailsModel = 
        (TaskDetailsModel)objTaskDetails.DataContext;
    // Call a method to set the Current Task at it's DataContext
    objTaskDetailsModel.SetCurrentTask(Task);

    // Create a TabItem 
    TabItem objTabItem = new TabItem();
    // Give it a name so it can be programatically manipulated
    objTabItem.Name = string.Format("DynamicTab_{0}", Task.TaskID.ToString());
    // Set the Style to point to a Resource that the Designer
    // can later change
    objTabItem.Style = (Style)App.Current.Resources["TabItemStyle1"];
    // Set it's Header
    string strTaskID = (Task.TaskID == -1) ? "[New]" : Task.TaskID.ToString();
    objTabItem.Header = String.Format("Task {0}", strTaskID);
    // Set it's Content to the Tasks Details control
    objTabItem.Content = objTaskDetails;
    return objTabItem;
}
#endregion
  • The CreateTaskItem(objTask) method creates an instance of the View, TaskDetails, and an instance of its View Model, TaskDetailsModel, and sets the current Task for it to the selected Task (using the SetCurrentTask(Task) method).
  • It then places this View on a dynamically created TabItem (it sets the View as the Content of the TabItem).
  • It then returns the TabItem so that the SetToNewTask() method can add it to the colTabItems collection.

Note: the line objTabItem.Style = (Style)App.Current.Resources["TabItemStyle1"]; is used to allow a Designer to alter the style of this dynamically created TabItem by changing the style for the key TabItemStyle1. In the RiaTasks2.zip project, that style is in the "RIATasks\Assets\TabControl.xaml" file.

TaskDetailsModel

The TaskDetailsModel View Model is very simple. It contains a property to hold the current Task and a method that allows it to be set:

public class TaskDetailsModel : INotifyPropertyChanged
{
    public TaskDetailsModel()
    {

    }

    public void SetCurrentTask(Task param)
    {
        CurrentTask = param;
    }

    #region CurrentTask
    private Task _CurrentTask = new Task();
    public Task CurrentTask
    {
        get { return _CurrentTask; }
        private set
        {
            if (CurrentTask == value)
            {
                return;
            }
            _CurrentTask = value;
            this.NotifyPropertyChanged("CurrentTask");
        }
    }
    #endregion

    #region INotifyPropertyChanged
    public event PropertyChangedEventHandler PropertyChanged;

    private void NotifyPropertyChanged(String info)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(info));
        }
    }
    #endregion
}

Note: The reason this class contains a method to set the Task property is that the View that is bound to the Task property will not update if you dynamically create the View and attempt to set the Task property directly.

The View

We will now create the Views.

Here is the overview:

  • MainPage.xaml - The View that loads all the other Views.
  • TabControl.xaml - The View that contains the Tab Control and displays the Tabs.
  • TaskDetails.xaml - The View that displays a single Task in a Tab.

MainPage View

MainPage contains an Add New Tasks button.

We drop an InvokeCommandAction behavior on the button.

When we Data bind the Command parameter of the behavior...

...we see that we are able to bind the ICommand that is in the TabControlModel View Model (that is stored in the TabControlVM property).

We can then go to Assets and drag and drop the TabControl View Model onto the page...

...and bind its DataContext to the TabControlVM property (this enables View Model to View Model Communication described in this article: Silverlight View Model Communication).

TabControl View

While the View Model for this View does most of the work for the application, the View is actually very straightforward.

The buttons are bound to the appropriate ICommands (using InvokeCommandAction behaviors), and the Tab Control is bound to the colTabItems collection.

It is important to note that the Update and Delete buttons, pass as a parameter, the currently selected TabItem (actually, they pass the Content of the TabItem which is the TaskDetails View and View Model).

This is how the methods know what Task to Update or Delete.

TaskDetails View

The binding for this View is also really simple.

However, this demonstrates the power of View Models:

  • The TabControlModel creates a Task and binds it to a dynamically created instance of the TaskDetailsModel
  • It then places it in a collection of TabItems and the Tab Control is bound to it
  • To Update or Delete the Task, the View is simply passed as a parameter to the appropriate method

Alternate Styles

The primary reason for using a View Model, besides that it is usually less code than using the code-behind style, is that you decouple the View from the code and allow a designer to create and re-create the View without altering any code.

Alan Beasley provided a style for the tabs and the buttons. The Tabs style styles the Tabs in all four positions (RIATasks2ABVersion.zip). To use it, we only needed to alter the resource files in the Assets directory. We have also specified the Tab Control to display the Tabs on the left side. This property is a standard property of the Tab Control.

Haruhiro Isowa created a Tab Control (RiaTasks2Hiro.zip) that displays all the Tabs on one scrollable row. He did have to create code to make his Tab Control scrollable, but the RIA Tasks 2 code was not altered at all (other than the .xaml files that the Designer would normally modify).

View Model - Not Hard At All

Hopefully you can see that View Model is not hard at all. It really is not complicated once you see how it is done. Expression Blend was designed to work in "View Model Style", so you should have an easier time using Expression Blend when you use this simple pattern.

We also demonstrated View Model Communication that you will hopefully find easy to understand. In addition, we covered dynamically creating Views while allowing a Designer full easy access to completely change the look of the application.

License

This article, along with any associated source code and files, is licensed under The Microsoft Public License (Ms-PL)

Share

About the Authors

defwebserver
Software Developer (Senior) http://ADefWebserver.com
United States United States
Michael Washington is a Microsoft MVP. He is a ASP.NET and
C# programmer.
He is the founder of
LightSwitchHelpWebsite.com

He has a son, Zachary and resides in Los Angeles with his wife Valerie.

He is the Author of:
Follow on   Twitter

Alan Beasley
User Interface Analyst
United Kingdom United Kingdom
I've been playing with computers since my first Acorn Electron, & after blowing up a few ZX Spectrums. I moved on to the C64 & Amiga, & eventually reluctantly on to the PC.
 
I have learnt a wide set of skills during my 38 years of existence, living in the UK, on the sunny south coast.
 
My main area of expertise is Graphic/Visual Design, Usability & UI Design. I am not a programmer, but am fairly technically minded due to studying Mechanical Engineering at Uni.
 
I have work both Freelance & for IBM as a Graphic Designer, & am skilled in the usual graphics packages like, PhotoShop, CorelDraw or Illustrator, Premier, Dreamweaver, Flash etc.
But I originally started with Lightwave & 3D animation.

hisowa
Software Developer
United States United States
No Biography provided

Comments and Discussions

 
NewsFinally Pinmemberabdurahman ibn hattab24-Jan-13 3:55 
GeneralMy vote of 5 PinmemberMohamad K. Ayyash16-Aug-10 16:19 
GeneralRe: My vote of 5 Pinmemberdefwebserver20-Sep-10 14:59 
GeneralGreat One again PinmentorBrij15-Aug-10 10:42 
GeneralRe: Great One again Pinmemberdefwebserver15-Aug-10 13:40 
GeneralMy vote of 5 PinmemberDinh Luyen9-Aug-10 0:00 
GeneralRe: My vote of 5 Pinmemberdefwebserver11-Aug-10 10:36 
GeneralMy vote of 5 PinmemberAbhinav S7-Aug-10 19:16 
GeneralRe: My vote of 5 Pinmemberdefwebserver11-Aug-10 10:36 
GeneralMy vote of 5 Pinmemberlinuxjr2-Aug-10 9:51 
GeneralRe: My vote of 5 Pinmemberdefwebserver4-Aug-10 19:04 
GeneralMy vote of 5 PinmemberNuri Ismail22-Jul-10 5:12 
GeneralRe: My vote of 5 Pinmemberdefwebserver25-Jul-10 4:12 
GeneralSilverlight RIA Tasks 2: Dynamic View Models PinmemberMember 165621416-Jul-10 12:46 
GeneralRe: Silverlight RIA Tasks 2: Dynamic View Models Pinmemberdefwebserver16-Jul-10 13:48 
GeneralMy vote of 5 Pinmemberramuknavap13-Jul-10 17:22 
GeneralRe: My vote of 5 Pinmemberdefwebserver18-Jul-10 4:09 
GeneralMy vote of 5 PinmentorKunalChowdhury10-Jul-10 9:52 
GeneralRe: My vote of 5 Pinmemberdefwebserver10-Jul-10 14:31 
GeneralRe: My vote of 5 PinmentorKunalChowdhury10-Jul-10 20:20 
GeneralNice articles boy, though 1 thing PinmvpSacha Barber9-Jul-10 5:27 
GeneralRe: Nice articles boy, though 1 thing Pinmemberdefwebserver9-Jul-10 8:30 
GeneralRe: Nice articles boy, though 1 thing PinmvpSacha Barber9-Jul-10 10:55 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.

| Advertise | Privacy | Terms of Use | Mobile
Web01 | 2.8.1411022.1 | Last Updated 18 Jul 2010
Article Copyright 2010 by defwebserver, Alan Beasley, hisowa
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid