Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

(Much) Faster Silverlight Scrolling

0.00/5 (No votes)
10 Dec 2008 1  
Improving scrolling performance in Silverlight

Introduction

If you are involved in designing and/or developing software applications, I'd recommend keeping an eye on Silverlight. Despite its original positioning as an alternative to Flash, the recently released Silverlight 2 makes it clear that there are more ambitious goals in mind. Targeting the Silverlight runtime enables developers to write complex C# applications that are cross-platform and browser agnostic.

The motivation for this article stems from recent project requirements - I have been re-architecting the user interface of a data slicing + dicing application using Silverlight. One aspect of the system is that it allows users to import arbitrary text-encoded data sets (CSV/Excel/XML/etc). Users needed to preview the data before it was imported into SQL Server tables. When I tried this using a Listbox or a Datagrid, the entire interface quickly became unusable. Load times were long and scrolling performance quickly fell to unusable levels.

This article will demonstrate a simple technique for improving scrolling performance in Silverlight controls. I will walk through a complete project that could be the core of a Win32 style grid control. Please note that it is not a complete Grid implementation - I'm still working on that.

How much better does it perform? The example control get a sustained frame rate of ~45FPS when scrolling 3000 rows of data using the scrollbar thumb, as opposed to under 2FPS using more obvious mechanisms.

Scroller control demo

The snapshot above shows the example control in a Silverlight page along with 2 combo boxes. The left hand combo allows you to select the number of rows of data to display (30/300/3000) whilst the right-hand combo selects the display mode.

Please note that the FPS value only seems to appear in Internet Explorer 7 (bottom left corner) - my installations of FireFox 3 and Chrome do not display the frame counter.

Exploring the Code

The Control Hierarchy

The core requirements are that we have a grid with both horizontal and vertical scrollbars, along with the standard row and columns headers. The fundamental design is simple enough:

  • Derive the new class, ScrollViewerEx, from a UserControl as is standard practice for Silverlight projects
  • Use a Grid to layout the 5 control elements in a flexible and resizable manner. The grid will be the LayoutRoot
  • Use a Canvas control, ElementContent, to display rows of data
  • Have 2 Scrollbars controls, one oriented horizontally, the other vertically , HScroll and VScroll respectively
  • Have 2 additional ScollPanels which could be used to manage row and column headers

I use Microsoft Blend to do the initial design - here is a snapshot of the layout:

Scroller design in Blend

As noted above, the various Canvas and ScrollBar controls are arranged using a Grid. This means we can take advantage of the automated layout system in Silverlight, which results in good flowing and resizing as the main control container is moved and resized. We do not have to write any code to do this, Layout positioning and resizing behaviour is specified in the XAML markup.

The CanvasClipper control instance has two tasks - the first is to ensure that the main content is clipped to the central grid cell when it is drawn. We also take advantage of the control to help simplify calculating scrollbar sizes and ranges.

Modelling Control Content

It seemed most natural to model grid content as a collection of rows, with each row managing a collection of cells. For the purposes of this demonstration, I'll omit niceties like variable cell width and column height and focus on the basics of arranging, displaying and scrolling some data in the most efficient manner possible.

The initial implementation was straightforward:

  • Create N rows of content and add to the main canvas' list of children.
  • Set the size of the main canvas to equal the total height of all the rows of content.
  • Clip the canvas so the viewable rectangle would be constrained to the size of the central layout grid cell
  • Set the range of the scroll bars to equal the number of rows/columns minus the number of rows/columns visible in the clipping rectangle
  • When scrolling events are raised, simply offset the canvas viewport by applying a TranslateTransform to the canvas' RenderTransform property

One wrinkle worth noting at this stage - a Canvas control does not clip its content when drawing. For reasons that should become clearer later in the article, I deferred the responsibility for clipping to another Canvas-derived control called CanvasClipper. The relationship between the two controls is simple as can be seen from the XAML markup:

<local:CanvasClipper x:Name="ElementContentClipper" Grid.Row="1" Grid.Column="1">
	<Canvas x:Name="ElementContent"
	Grid.Row="1" Grid.Column="1"
	Background="White"></Canvas>
</local:CanvasClipper>

This conceptual layout can be seen in a more concrete form below. The rows of content are arranged in a vertical stack as children of the ElementContent canvas control. The clipper is shown in semi-translucent blue and represents the viewport which contains those rows which will actually fit in a window of arbitrary size. Clipped rows, therefore, lie outside of the ElementContentClipper rectangle and are not displayed.

Scroller Clipper

In terms of the actual implementation, we end up with 3 new classes. These are:

1. The Clipper

// to help with clipping
public class CanvasClipper : Panel
{
	private RectangleGeometry _clippingRectangle;

	public CanvasClipper()
	{
		_clippingRectangle = new RectangleGeometry();
	}

	// Let the canvas arrange itself. Once it has worked out
	// the right size cache the clipping rectangle dimensions in the
	// ClippingRect property so the main control can establish how many
	// rows and columns to display and what ranges to set for the scroll bars
	protected override Size ArrangeOverride(Size finalSize)
	{
		// the base class does all the dirty work for us
		finalSize = base.ArrangeOverride(finalSize);
		ClippingRect = new Rect(0, 0, finalSize.Width, finalSize.Height);
		_clippingRectangle.Rect = ClippingRect;
		// set the actual graphical clipping rectangle property
		Clip = _clippingRectangle;
		return finalSize;
	}

	// so we know the final size of the clipping rect
	// and thus the true display size
	public Rect ClippingRect
	{
		get; set;
	}
}

Nothing controversial here - the only reason we have a new derived class is to simplify access to its clipping rectangle. Once base.ArrangeOverride() has been called, we know the final size of the clipper - we save that rectangle in the ClippingRect property so our scroll control can access it as needed.

2. ScrollRowCanvas

This is, for the moment, literally a simple placeholder. At construction time, we get it to create and arrange a fixed number of TextBlock controls that represent a cell in the grid. Each cell has a fixed width and height and we get the ScrollRowCanvas to automatically generate some text for display purposes.

Again there is little unusual here - the TextBlock instances are positioned absolutely along the X axis of the canvas at fixed cellWidth intervals. The ScrollRowCanvas class is itself derived from Canvas so it too can be positioned in an absolute fashion.

For the example, I've added some Mouse event handling to show it would be relatively simple to wire up either Storyboarding with animated effects or something more old-fashioned like changing the background brush colour for cell roll-over effects.

public class ScrollRowCanvas : Canvas
{
// what is our row number?
private int _row;

// constructor - column count and dimensions are fixed at the time of creation
// for demo purposes
public ScrollRowCanvas(int row,int cols, int cellWidth, int cellHeight)
{
	_row = row;
	double xoff = 0;
	double yoff = 0;
	for (int col = 0; col < cols; col++)
	{
		TextBlock tb = new TextBlock();
		tb.Text = "TextBlock " + row.ToString() + "." + col.ToString();
		tb.Width = cellWidth;
		tb.Height = cellHeight;
		tb.HorizontalAlignment = HorizontalAlignment.Left;
		// add to the canvas
		base.Children.Add(tb);
		// position the new textblock on the x axis
		// equivalent to <TextBlock Canvas.Left="xoff"
		tb.SetValue(Canvas.LeftProperty, xoff);
		// equivalent to <TextBlock Canvas.Top="yoff"
		tb.SetValue(Canvas.TopProperty, yoff);
		xoff += cellWidth;
	}
	// set the total canvas width and height
	this.Width = cols * cellWidth;
	this.Height = cellHeight;

	// add some event handlers so we get debug spew at runtime
	MouseLeftButtonDown += delegate(object sender, MouseButtonEventArgs e)
	{
		OnMouseLeftButtonDown(e);
	};
	MouseEnter += delegate(object sender, MouseEventArgs e)
	{
		OnMouseEnter(e);
	};
	MouseLeave += delegate(object sender, MouseEventArgs e)
	{
		OnMouseLeave(e);
	};
}

public bool IsMouseOver { get; private set; }

/// <summary>
/// Called when the user presses the left mouse button over the ListBoxItem.
/// </summary>
/// <param name="e">The event data.</param>
protected virtual void OnMouseLeftButtonDown(MouseButtonEventArgs e)
{
	if (!e.Handled)
	{
		e.Handled = true;
		Debug.WriteLine("OnMouseLeftButtonDown: " + _row.ToString());
	}
}

/// <summary>
/// Called when the mouse pointer enters the bounds of this element.
/// </summary>
/// <param name="e">The event data.</param>
protected virtual void OnMouseEnter(MouseEventArgs e)
{
	IsMouseOver = true;
	Debug.WriteLine("OnMouseEnter " + _row.ToString());
}

/// <summary>
/// Called when the mouse pointer leaves the bounds of this element.
/// </summary>
/// <param name="e">The event data.</param>
protected virtual void OnMouseLeave(MouseEventArgs e)
{
	IsMouseOver = false;
	Debug.WriteLine("OnMouseLeave " + _row.ToString());
}
}

3. The ScrollViewer

The main player in our example is the ScrollViewerEx control. The key methods in the class are:

  1. The constructor
  2. Event handlers for keyboard and mouse interaction
  3. A custom handler for ArrangeOverride
  4. Code to switch scrolling strategies at runtime - simplifies performance comparison
  5. Some helper code to enable the control to sink MouseWheel events - see MouseWheel.cs

Note that there is no explicit code to wire up event handlers for the scrollbars here. This is actually done in the XAML markup and as I was using Blend it generated stub functions, etc. for me.

3.1 The Constructor

/// <summary>
/// ScrollViewerEx - hook up basic event handlers,
/// listener for the mouse wheel etc
///
/// </summary>
public ScrollViewerEx()
{
	InitializeComponent();

	Debug.Assert(ElementContent != null);
	Debug.Assert(ElementContentClipper != null);
	// Page_Loaded will be fired when the control is visible
	this.Loaded += new RoutedEventHandler(Page_Loaded);
	// event handlers
	KeyDown += delegate(object sender, KeyEventArgs e)
	{
		OnKeyDown(e);
	};
	GotFocus += delegate(object sender, RoutedEventArgs e)
	{
		OnGotFocus(e);
	};
	LostFocus += delegate(object sender, RoutedEventArgs e)
	{
		OnLostFocus(e);
	};

	// list of *all* row items we manage
	VisibleItems = new List<UIElement>();
	// apply the scrolling translation
	Translation = new TranslateTransform();

	// mouse wheel listener
	WheelMouseListener.Instance.AddObserver(this);
}

3.2 Initial layout

The Page_Loaded event handler does the bulk of the layout work at startup. We create a fixed number of rows of data to be displayed and lay them out on the main canvas exactly as discussed in the section on the Control Hierarchy.

/// Initialiser code - lay out the content
void Page_Loaded(object sender, RoutedEventArgs e)
{
	// this is our recursion lock
	Locked = true;
	// x and y offsets for each row
	double xoff = 0;
	double yoff = 0;
	for (int row = 0; row < rows; row++)
	{
		// create a new item
		ScrollRowCanvas sr = new
			ScrollRowCanvas(row, cols, cellWidth, cellHeight);
		// magic - see section 3.3
		sr.Visibility = Visibility.Collapsed;
		// add to the canvas
		ElementContent.Children.Add(sr);
		// equivalent to <ScrollRowCanvas Canvas.Left="xoff">
		sr.SetValue(Canvas.LeftProperty, xoff);
		// equivalent to <ScrollRowCanvas Canvas.Top="yoff">
		sr.SetValue(Canvas.TopProperty, yoff);
		// next vertical slot
		yoff += cellHeight;
	}
	// no recursion lock
	Locked = false;
	// use the fast mode
	FastMode = true;
	// switch strategies to FastMode
	SwitchStrategy(false);
}

3.3 Setting Clipping and Scroll Ranges

This section discusses the implementation of our custom ArrangeOverride virtual function. The sequence of events here is simple and to an extent mirrors what we did in the CanvasClipper class.

  1. Let the base class establish the overall size of the layout grid.
  2. Query the CanvasClipper instance and get its dimensions.
  3. Use the width and height of the clipper to set the ranges of the scroll bars.
  4. We want each vertical scroll increment to equal 1 row of data
  5. 4. implies that the vertical scroll range is set to the total row count - number of rows on the page.
  6. The same calculation is used for the horizontal scroller.

Note the call to ApplyLayoutOptimiser(). It is discussed next.

// establish how many rows and columns we can display and
// set scroll bars accordingly
protected override Size ArrangeOverride(Size finalSize)
{
	// let the base class handle the arranging
	finalSize = base.ArrangeOverride(finalSize);
	// here's the magic ...
	ApplyLayoutOptimiser();
	//
	return finalSize;
}

3.4 Optimising

Finally the heart of the matter: Scrolling performance apparently degrades in direct proportion to the number of scrollable items in a canvas. With 30 rows, performance is snappy enough, with 300 the scrollbar thumb starts to lag behind the mouse, with 3000 rows of data the control is completely unusable.

The solution to the problem is simple: hide all content until absolutely required.

In a nutshell, ApplyLayoutOptimiser() ensures that only those rows of data within the viewport are actually visible. That is to say, the code traverses the list of children and modifies the visibility state as required. The resulting performance gain is startling - as can be seen from the snap of the control in the introduction, the scrolling frame rate is anywhere between 40 and 50 FPS when managing thousands of rows of data.

The code here could actually be optimised further. There is no need to maintain a separate list of visible items if we track the first and last rows to be displayed - if these values are stored between calls to ApplyLayoutOptimiser() we only have to collapse rows that have been scrolled out of view and make visible those scrolled into view. An obvious next step that might yield a few more frames per second.

/// change the visibility of rows to be displayed
private void ApplyLayoutOptimiser()
{
  // recursion guard - this is a property of the control
  if (Locked == False)
  {
    //
    Locked = True;
	// set up the scroll bar ranges
    SetScrollRanges();
    // hide the visible items
    foreach (UIElement uie in VisibleItems)
    {
    	uie.Visibility = Visibility.Collapsed;
    }
    // remove from list
    VisibleItems.Clear();
    // layout a page worth of rows
    int maxRow = System.Math.Min(
    		VertPosition + RowsPerPage,
    		ElementContent.Children.Count);
    for (int row = VertPosition; row < maxRow; row++)
    {
        // get the item at the row index
        UIElement uie = ElementContent.Children[row];
        // make it visible - this triggers recursive calls to
        // ArrangeOverride, hence the guard
        uie.Visibility = Visibility.Visible;
        //
        VisibleItems.Add(uie);
    }
    // finally scroll things
    HandleScrolling();
    //
    Locked = False;
  }
}

3.5 Scrolling

For the sake of completeness, here we have the function that actually scrolls the canvas. This is done by applying a translation in X and Y to the RenderTransform property of the canvas. In Win32 speak this is identical to scrolling a device context by moving the viewport origin at runtime. In order to scroll down one line, we set the Y value of the TranslateTransform to be the negative of the value of the vertical scroller multiplied by the height of a row. A negative Y translation moves the entire canvas upwards, thus creating the scrolling effect.

/// <summary>
/// Set the vertical and horizontal scroll bar ranges
/// </summary>
protected void SetScrollRanges()
{
	// what is the view-port size?
	Rect clipRect = ElementContentClipper.ClippingRect;
	// how many integral lines can we display ?
	int rowsPerPage = (int)(clipRect.Height / cellHeight);
	// set the scroll count
	VScroll.Maximum = (_rows - rowsPerPage);
	// and do the same for the columns
	int colsPerPage = (int)(clipRect.Width / cellWidth);
	HScroll.Maximum = (_cols - colsPerPage);
}

/// <summary>
/// transform according to h and v scroll bar settings
/// <summary>
protected void HandleScrolling()
{
	// create a transform which translates in X and Y
	TranslateTransform tr = new TranslateTransform();
	// offset by scroll positions
	tr.X = -(HScroll.Value * cellWidth);
	tr.Y = -(VScroll.Value * cellHeight);
	// apply the transform to the content container
	ElementContent.RenderTransform = tr;
}

3.6 It's a Wrap

That pretty much covers the basic issues. The example project contains a little more code to accommodate both display methods - you can run it and see how the two approaches perform with different amounts of data.

Conclusions and Matters Arising

The article discusses the implementation of a faster Silverlight scrolling control. Using the example project, it is easy to demonstrate that scrolling performance degrades as a linear function of the number of visible children of a canvas control. By ensuring that only those children that are within the current viewpoint are visible, both scrolling and drawing performance can be maintained at expected levels. This means large data sets can be scrolled in realtime using the scrollbar thumb or a mouse wheel.

The mechanism used to enhance performance is simple and can, I believe, be applied to many of the available List and Grid implementations. It would be interesting to know why the default behaviour seems so poor. Any comments from suitably informed readers would be most welcome.

I would also be interested in how the demo runs on different hardware. The snaps here were all taken on a Windows XP machine (quad core Q6600, 4GB RAM, 1GB video).

Silverlight needs more in the way of performance and memory profiling tools. There are no high performance timers available for measuring execution time internally. There are no documented mechanisms for checking resource allocation. The only easily accessible monitoring tool is the very basic frame rate counter (though see reference 3 below for a more complex solution).

And, despite a wealth of Web-based material, Silverlight needs the equivalent of a 'Programming Windows' - something that helps promote best practice whilst keeping the 'good stuff' consistent, concise and concentrated. Although the Silverlight community is vibrant, much of the best information available is via Silverlight blogs - there are a great number of these, some which make it easy to download example code and others that make it impossible (i.e. video-based walk-throughs). Equally the Silverlight.Net website covers a vast number of issues but much of it is locked up in small threads - it would be excellent to have a documentation czar who could pull all of this together for distribution.

Addressing these will help ensure the successful deployment of business-critical Silverlight solutions.

References

  1. The Silverlight website should be the first port of call for asking questions, etc.
  2. For Silverlight tools and development kits, etc., go here.
  3. The MSDN article on Silverlight performance tips is worth visiting.
  4. It is worth knowing the Frame Rate Counter trick - I first encountered a how-to here.
  5. There is XPerf for those using Vista and Windows Server 2003.

History

  • Version 1.00 4 December 2008

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here