Sometimes we need a row-based control that can dynamically add/remove rows on the fly, without being bound to a database. For example, we give the user a
row of text-boxes to enter a car Year, Make, and Model. If they need more than one row to enter multiple cars, they click an Add button for each extra car they need. Each row also has a Remove button in case the user needs to remove a car. We'll take this even one step further by adding Move Up / Move Down buttons on each row; although that might not be useful in this particular example, there are many occasions where it is convenient to be able to reorder rows.
To achieve the desired effect, we could resort to Javascript and dynamically add/remove table rows via the DOM. But instead let's stick with VB.NET. I've come across this example by Rana Faisal which allows you to create your own datatable, and on each postback it saves the rows back into the datatable to reload it. But as the author says, this is not a best practice method, and I've had considerable issues with it in a complex application. There's also this one by Vinz, but it contains a lot of manual design work, and the additional overhead of storing the DataTable in ViewState. We should be able to do far better...
Instead, we are going to utilize the power of classes and collections, build our own class which inherits the CollectionBase class, and simply bind a basic Repeater control to our custom class instead! Our class will have methods to take care of Adds and Removes, and the repeater will simply keep up as it would if bound to a typical data source. It's just as easy as it sounds! And we aren't forced to use a Repeater; the concept works the same with any of the row-based databound controls (GridView, DataList, etc.).
So sticking with our Car / Make / Model example, make yourself a new class, "Cars", that inherits the CollectionBase class. Give it some default operations for adding, removing, and accessing it using the indexor (Cars(0) etc.):
Public Class Cars Inherits CollectionBase Public Function Add(ByVal car As Car) As Integer Return List.Add(car) End Function Public Sub Remove(ByVal car As Car) List.Remove(car) End Sub Public Sub InsertAt(ByVal car As Car, ByVal index As Integer) List.Insert(index, car) End Sub Default Public Property Item(ByVal index As Integer) As Car Get Return DirectCast(List(index), Car) End Get Set(ByVal value As Car) List(index) = value End Set End Property End Class
Now nested within this class, create another class, "Car", which will define the individual properties per car. Give the class Property accessors to expose these properties:
Public Class Car Private _year As String Private _make As String Private _model As String 'property accessors for private variables Public Property year() As String Get Return TryCast(_year, String) End Get Set(ByVal value As String) _year = value End Set End Property Public Property make() As String Get Return TryCast(_make, String) End Get Set(ByVal value As String) _make = value End Set End Property Public Property model() As String Get Return TryCast(_model, String) End Get Set(ByVal value As String) _model = value End Set End Property Public Sub New() 'default constructor, needed if overriding (below) End Sub 'extra constructor that takes all car info as parameters and intializes itself Public Sub New(ByVal year As String, _ ByVal make As String, _ ByVal model As String) _year = year _make = make _model = model End Sub End Class
That's it for the class. Now in the Page Load event of your ASPX page, we are going to create an instance of the Cars class, and bind it to the repeater. We could pull some initial data from a database, XML file, or whatever you would like. For the sake of this example, I'm just going to add a few cars manually:
Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs) Handles Me.Load If Not Page.IsPostBack Then 'add some cars manually 'alternatively, you would load your values from a database or other data source here 'we must do a DataBind on the repeater here to force the repeater to at least show the header/footer Dim cars As New Cars() Dim car1 As New Cars.Car("2004", "Dodge", "Neon") Dim car2 As New Cars.Car("2009", "BMW", "135i") cars.Add(car1) cars.Add(car2) repeater1.DataSource = cars repeater1.DataBind() End If End Sub
And this is what our repeater looks like (excuse the poor formatting). If you're using AJAX, feel free to wrap the Repeater within an Update Panel:
TIP: If using a GridView in place of the Repeater, use Container.DataItemIndex instead of Container.ItemIndex in the button CommandArguments above.
Go ahead and run your project so far. You should see a few rows with the car year/make/model we manually added in the page load event.
If we needed to do any conditional formatting, we could easily do so in the Repeater's ItemDataBound event:
Protected Sub repeater1_ItemDataBound(ByVal sender As Object, ByVal e As System.Web.UI.WebControls.RepeaterItemEventArgs) Handles repeater1.ItemDataBound Dim car As Cars.Car = DirectCast(e.Item.DataItem, Cars.Car) If car IsNot Nothing Then '(optional) do any data manipulation or conditional formatting here End If End Sub
To this point, we've simply bound a Repeater to our custom collection class. Now we need to handle events like Add, Remove, Move Up, and Move Down. We will do so inside the ItemCommand event of our Repeater, which is triggered each time a button is clicked from within the Repeater:
Protected Sub repeater1_ItemCommand(ByVal source As Object, ByVal e As System.Web.UI.WebControls.RepeaterCommandEventArgs) Handles repeater1.ItemCommand 'the magic happens here for adding/removing/reordering rows If e.CommandName = "Add" Or e.CommandName = "Remove" Or e.CommandName = "Up" Or e.CommandName = "Down" Then Dim carLastEntered As New Cars.Car() 'this will hold the last entered values Dim cars As New Cars For Each item As RepeaterItem In repeater1.Items 'loop through the repeater 'putting the entered values into a car object Dim car As New Cars.Car car.year = DirectCast(item.FindControl("txtYear"), TextBox).Text car.make = DirectCast(item.FindControl("txtMake"), TextBox).Text car.model = DirectCast(item.FindControl("txtModel"), TextBox).Text 'Add the existing row, or move it to new position, or skip it if it's the row Remove was clicked on If (e.CommandName = "Up" Or e.CommandName = "Down") AndAlso item.ItemIndex = CInt(e.CommandArgument) Then cars.InsertAt(car, item.ItemIndex - 1) ElseIf Not (e.CommandName = "Remove" AndAlso item.ItemIndex = CInt(e.CommandArgument)) Then cars.Add(car) End If carLastEntered = car 'save last entered values to use as default for new row if needed Next 'Add new row with some defaults equal to the last car we added If e.CommandName = "Add" Then cars.Add(carLastEntered) repeater1.DataSource = cars repeater1.DataBind() End If End SubNOTE: If, for whatever reason, you need this example to work with a paging control, replace all occurances above of CInt(e.CommandArgument) with CInt(e.CommandArgument) - (YourControl1.PageSize * YourControl1.PageIndex)
All this routine is doing is looping through the repeater and saving each item as a Car object into our Cars collection class. If the user clicked Remove, skip saving that row. If the user clicked Move Up or Move Down, we use the InsertAt(...) function of our Cars class to move the row up/down inside the collection. And when the Add button is clicked, we simply add a new row. As a bit of flair, we save the last values used and put them into the new row as defaults. Again, this is probably not useful for cars (how many people have two of the same car?), but there are many other times when this comes in handy. (If you want a blank row instead, just add a new Car object with empty strings for the Year, Make, and Model.)
Once our Cars collection class has all the Car objects in it, we bind our Repeater to the newly updated Cars object.
For fine tuning, let's hide the header row ("Year", "Make", "Model") when there are no items, and disable the Move Up / Move Down buttons when they shouldn't be available (because we can't move the first item up or the last item down!)
Protected Sub repeater1_PreRender(ByVal sender As Object, ByVal e As System.EventArgs) Handles repeater1.PreRender 'hide the header text when there are no items - just show the Add button If repeater1.Items.Count = 0 Then Dim trHeader As HtmlTableRow = DirectCast(repeater1.Controls(0).FindControl("trHeader"), HtmlTableRow) trHeader.Visible = False Else 'disable move up on first item, disable move down on last item DirectCast(repeater1.Items(0).FindControl("btnMoveUp"), Button).Enabled = False DirectCast(repeater1.Items(repeater1.Items.Count - 1).FindControl("btnMoveDown"), Button).Enabled = False End If End SubPutting it all together, we end up with a nice "control" that can dynamically resize without the need for a database. Our rows are object oriented (the Cars and Car classes), and through the power of inheritence we are able to bind our rows to the repeater using native techniques (i.e. without any trickery).
The final step would be to have a "Submit" button, which simply loops through the Repeater, grabs all the values, and saves them to the data source of choice. I'll leave that exercise to you. Enjoy!
The complete source code can be downloaded here: Cars.zip
Sources:
Dynamically adding / removing textboxes in ASP.NET Repeater
Implementing Custom Data Bindable Classes: CollectionBase
Autonumbering ASP.NET grid controls. Read more...