A useful feature for most data-bound items presenting controls is the ability to page their data, by showing only the first n elements and requesting the rest on demand as the users scrolls down the view. This is specially useful when downloading each item is costly, and waiting until the whole collection is obtained to show the user the result demands too much patience from him.
So I decided to implement this on Silverlight, as a way of continuing my old post on data binding, which ended with an unfulfilled promise of a sequel.
First thing to do was check how controls (DataGrid and ListBox) behave when bound to a collection. I created a mock class to populate the controls with:
public class Client { public String Name { get; set; } public int Age { get; set; } public bool Vip { get; set; } public override string ToString() { return String.Format("Client {0} {1}", Name, Age); } }
I won't be creating a DataTemplate for this example, so I just override the ToString() method to display something human-friendly.
Next thing to do was creating a read-only wrapper for a Client collection, that implemented ICollection<Client>. The point was returning a custom iterator that just wrapped the underlying collection but also logged every action performed by the control.
public class ClientsEnumerator : IEnumerator<Client> { public string Name { get; private set; } public int Count { get; private set; } public int Index { get; private set; } private Client[] clients; public ClientsEnumerator(string name, Client[] clients) { Debug.WriteLine("Enumerator {0} created.", name); this.Name = name; this.Count = clients.Length; this.clients = clients; } #region IEnumerator<Client> Members public Client Current { get { Debug.WriteLine("Enumerator {0} requested item {1}.", Name, Index); return clients[Index]; } } #endregion #region IDisposable Members public void Dispose() { Debug.WriteLine("Enumerator {0} disposed.", Name); } #endregion #region IEnumerator Members object System.Collections.IEnumerator.Current { get { Debug.WriteLine("Enumerator {0} requested item {1}.", Name, Index); return clients[Index]; } } public bool MoveNext() { Index++; Debug.WriteLine("Enumerator {0} moved to {1}.", Name, Index); return (Index < Count); } public void Reset() { Index = 0; Debug.WriteLine("Enumerator {0} reset to {1}.", Name, Index); } #endregion }
Nothing too fancy as you see. The test consisted in binding both a DataGrid and a ListBox to this collection, and check which items were requested initially, and which ones as I scrolled.
The results with the ListBox were predictable. It contains a panel that handles the layout of its items, and a ScrollViewer that controls which part of the panel is visible. This panel "believes" that it has all the space it needs to render, and the ScrollViewer draws a rectangular window (the viewport) that moves through it. Therefore, the ListBox makes a single initial call requesting all elements and displaying them. Scrolling makes no change to the already loaded items collection.
However, the results for the DataGrid were most surprising. It did implement some sort of paging, since requests to the collection were made as I scrolled down (although many repeated requests were made). Anyway, even if with repetitions it would be possible to make it work, there was also the initial sweep through the entire collection.
Considering that both controls did the same initial sweep which I had to avoid, and the ListBox (being much more lightweight than the DataGrid) contained all the functionality I needed, I decided to go and try to implement paging on listboxes.
Step one was detecting when the user scrolled down to the bottom of the ListBox, to fire an event requesting for another page. This proved much more difficult than I expected.
The ListBox itself doesn't expose any property regarding its visible items, it is therefore necessary to obtain its internal ScrollViewer. As I was extending the ListBox control (remember to set DefaultStyleKey in the constructor when you extend from a control!), I had access to the protected GetTemplateChild(name) method, which searches for a control with the specified name inside the control's current template. And thanks to the Silverlight convention on elements' names in a styled control's visual tree, we know that the ScrollViewer is (or should be) always named "ScrollViewer" (remember that it was "ScrollViewer Element" in beta 1). The LayoutUpdated event of the control is a nice spot to obtain the reference.
Now there is full access to the VerticalOffset property of the ScrollViewer, which returns the position of the viewport. The problem is Silverlight offers no way to listen to changes in a dependency property you have not declared. You can only specify the OnChange callback when you register it. Note that if you are working in WPF, it is possible to do this with the DependencyPropertyInfo class.
There are some ugly things you can do. There is always the possibility to attach with a two-way binding to the chosen property (actually, you would only need one-way-to-source, but it is only available in WPF) and listen to the setter of a certain property in a mock data context. But this doesn't work if the dependency property you are trying to listen to is read-only, as the Binding object will try to assign to it the value in the DataContext when it is first attached, and throw an exception. Needless to say, VerticalOffset is read only.
Going with reflector inside the ScrollViewer, I found there is a useful ScrollChanged event, but it is internal to the System.Windows.Controls assembly. Extending ScrollViewer and re-writing most of its functionality to gain access to this event is not an option, since it is a sealed class, and ListBox requires that it the control used as a ScrollViewer must be a, well, ScrollViewer.
Luckily, this issue had already been discussed in the Silverlight forums (which I strongly recommend to read whenever you find a problem) and was solved: it is not the ListBox, neither the ScrollViewer you have to attach to to get the Scroll event. It is the ScrollBar.
So, I had to do something like this to finally attach to the Scroll event:
void PagingListBox_LayoutUpdated(object sender, EventArgs e) { ScrollViewer viewer = base.GetTemplateChild("ScrollViewer") as ScrollViewer; FrameworkElement viewerRoot = (FrameworkElement)VisualTreeHelper.GetChild(viewer, 0); vertical = ((ScrollBar)viewerRoot.FindName("VerticalScrollBar")); if (vertical != null) vertical.ValueChanged += verticalHandler; }
Since the ScrollBar exposes a Maximum property, it is easy to attach to its ValueChanged event and fire an event requesting for an extra page if the new value and the maximum value match.
The only trick here is that the ValueChanged event fires multiple times whenever you scroll (reason unknown), and it is important to ensure that only one page is requested when the user scrolls to the bottom. This is easily solved by manually keeping track of the last value received.
void ScrollBar_ValueChanged(object sender, RoutedPropertyChangedEventArgs<double> e) { ScrollBar bar = sender as ScrollBar; if (e.NewValue == bar.Maximum && e.NewValue != lastValue) { // Fire an event requesting an extra page here! } lastValue = e.NewValue; }
The final step here is actually appending the new page to the list box wherever you handle the event. The easiest way of doing this is by using an ObservableCollection<T> so the ListBox automatically adds any items added to the underlying data source. Just add the items to the collection and the scroll will shrink and move slightly up, indicating that new elements have been added.
That is the only issue I have found so far with this implementation: it looks too much like SQL's view of a table, where it shows a limited amount of rows, and when you scroll down new rows are added and the scrollbar's thumb shrinks. It is a wise idea to show the user the total amount of items somewhere, so it knows whether the scrolling will eventually end or he may spend his whole life requesting additional pages.