Saturday, February 26, 2011

Silverlight Navigation with 100% Custom Transitional Animations

Chris Domino, Director, Enterprise Architect

Something that's always impressed me about Silverlight (and has made my life a lot easier) has been the browser integration, both in terms of the HTML bridge and the navigation history functionality. I want to focus on the latter; the former has been beaten to death, and at this point, it's hard for me to get excited about yet another way to sling JavaScript.

Clicking the "back" browser button is one of the handiest UX innovations I've ever seen. At the same time, it creates quite an onus for the web developer, as it can easily put a poorly-designed application into an indeterminate state. This can be a rude awakening (it was for me) the first time you need to start messing with Page.IsPostBack and Control.AutoPostBack. Then throw some AJAX into the mix. If you are not diligent with your page transitions, you could get a poor user experience, or worse, garbage data when the user clicks the seemingly harmless back button.

Now in Silverlight applications (verses a web page that has some Silverlight on it for purposes of sex appeal alone), the back button takes on a different role. If you aren't using the Silverlight Navigation Framework, then much like a hard page refresh, "backing" to a Silverlight page will cause the control to reload from scratch, as if it was the first time it had been visited. However, the nav framework will nestle itself into the browser's history quite nicely, using hashed "relative" URLs, making your Silverlight app behave as though it were a standard website comprised of distinct physical pages.

This enables SEO, browser forward/back navigation, true MVC-like architecture with the UriMapper, quasi-master pages, and lots of other enterprise-ish application features. Behind the scenes, all that's happening is that URLs are mapped to relative folder locations for XAML files within the XAP, and the corresponding file is loaded into a particular content frame. There are a bunch of posts out there regarding how to transition smoothly between pages while remaining within the Silverlight Navigation Framework, but none quite worked out for our situation.

We were building our second Silverlight application for a client. The first one was a SharePoint intranet, with lots of isolated Silverlight controls (ie, many XAPs) on the page. There were some major performance ramifications to this, as we had to pay a price for the spin up of each XAP's app domain. In other words, a separate version of the Silverlight runtime was loaded into memory for each control on the page. Yeah. Ouch.

So when we began architecting the second app, which was pure Silverlight, the first thing we decided upon was one XAP that contains the entire UI. This made start up times faster, which is a good first step in a development project where performance was considered a feature, not an optimization. Additionally, the app demanded very intense styling and animations, so almost every control we created has a custom template of some sort. Basically, we had to do more, and do it faster. For the navigation, pages fly in and out of the screen and the background scrolls in the opposite direction behind the "chrome" of the application. Here's a Paint masterpiece of what we had to do. (The blue rectangles represent the viewable area of the app in the browser, the solid-colored boxes are stacked images, and the back rectangles are user controls.)

As the user navigates to different "pages," the user control that corresponds to it animates "down" into view, while the appropriate background slides "up" behind it. (The word "pages" above is in quotes because, remember, everything is physically on the same page, within the same XAP.) However, the magic that happens is that browser back/forward navigation, browser tab titles, and deep linking all work!

In other words, the technique I will show allows us to essentially browser navigate around a single user control in Silverlight!

No idea what I'm talking about? Here's an example. Even though the application seems to navigate normally, notice how the pages animate feely (instead of one disappearing and another appearing, a la some cheesy PowerPoint slide transition) and smoothly. The source code will be included at the end of this post.

Since we're not actually moving to different pages or loading different controls, the sky is the limit for animated transitions; we don't need to rely on any third party controls or limited OOTB functionality. Instead all we need is the chrome (the main user control; the RootVisual of the Silverlight application itself), the child user controls that act as "pages" (but aren't Pages a la Silverlight 3 Navigation), and a custom Navigation Loader that does the magic of making the browser think it's going to different pages when it's really staying in the same place.

The "chrome" of the application is more or less like a master page: it contains all the UI that remains stationary while the content pages animate around it. This includes menus, tab strips, footer links, mastheads, etc. It also includes the Silverlight Navigation Framework pieces: the aforementioned UriMapper and Navigation Loader, as well as the Frame, which, in conventional Silverlight navigation terms, is the area where the content for each actual page is loaded inside the chrome. Think of it just like an HTML iframe whose source is set with JavaScript.

Inside the chrome is some sort of Panel that contains all of the controls that act as our pages. This panel can be as simple as a Canvas wherein its children have their Y coordinates animated, or a custom panel (as was our case) that arranged pages all over the place and used interfaces and attributes to facilitate communication and positioning. This detail, however, is out of the scope of this post. What's important is that all the pages live inside a single panel in the chrome. Alongside this panel is the navigation frame, which, as its children, has the UriMapper busily mapping URIs to XAML file relative paths, a navigation loader, and the content of the page being loaded.

I hope that last sentence sounded strange. If we have a panel that's animating child controls around to show different pages, then what content are we loading into the frame itself? The answer: nothing. The things we need the frame for are to fire navigation events and have its Navigate method called. This method allows, for example, a button click on one page to kick off a nav to another. But there's no rule that the frame's Content property ever needs to be set! That's right: we basically have a collapsed frame on the page that does nothing except listens for URL changes and fires events. It doesn't have to display anything!

How can this be so? Because frames have these navigation loader things we've been talking about. Their actual job is to asynchronously turn a URI into, essentially, a UserControl. Frames have a default navigation loader: an instance of PageResourceContentLoader. This puppy cranks out actual Silverlight pages reflectively instantiated from the passed in Uri, and sets them to the content of the frame. Here is where conventional frame transition animations will be implemented. However, like I said, the fact that these animations are linked to the frame, and not the pages themselves, makes them not flexible enough. So my thinking: simply don't show the frame at all and handle the UI myself.

(Besides, if I'm animating a transition from one page to another, it makes my head hurt conceptualizing that fact that I only have one page at time in the traditional transition model: the current page is destroyed and then the new page is materialized. With my technique, we actually have all of the physical page UserControls sitting next to each other; it's much easier to conceive and work with.)

The only problem is that it doesn't work. That is, we need to do a little more than simply ignore the frame's content. Without a custom loader in place, Silverlight's default frame loader will still "try" to navigate around, and with no content, blank pages will be displayed. So what I did was create a custom loader that EXPLICITLY loads nothing. It's sort of like the different between a null string and an empty string where nulls are not supported. Leaving the frame "null" didn't work; using a custom loader to ensure the frame that it was okay to be "empty" did the trick! Here's what the loader looks like:

  1. using System;
  2. using System.Threading;
  3. using System.Windows.Controls;
  4. using System.Windows.Navigation;
  5. namespace UI.Infrastructure
  6. {
  7. public class NavigationListener : INavigationContentLoader
  8. {
  9. #region Members
  10. private class LoadAsyncResult : IAsyncResult
  11. {
  12. #region Members
  13. public object AsyncState { get; set; }
  14. public WaitHandle AsyncWaitHandle
  15. {
  16. get { return null; }
  17. }
  18. public bool CompletedSynchronously
  19. {
  20. get { return true; }
  21. }
  22. public bool IsCompleted
  23. {
  24. get { return true; }
  25. }
  26. #endregion
  27. }
  28. #endregion
  29. #region Public Methods
  30. public IAsyncResult BeginLoad(Uri targetUri, Uri currentUri, AsyncCallback userCallback, object asyncState)
  31. {
  32. //oblige callback
  33. userCallback(new LoadAsyncResult() { AsyncState = asyncState });
  34. //fake result
  35. return null;
  36. }
  37. public bool CanLoad(Uri targetUri, Uri currentUri)
  38. {
  39. //TODO: security?
  40. return true;
  41. }
  42. public void CancelLoad(IAsyncResult asyncResult)
  43. {
  44. //TODO: cancellation?
  45. }
  46. public LoadResult EndLoad(IAsyncResult asyncResult)
  47. {
  48. //fake result
  49. return new LoadResult(new Page());
  50. }
  51. #endregion
  52. }
  53. }

As you can tell, this is more or less a fake class. The only notable lines are #35, where we actually return null for the result (which is okay) and #49, where we return a harmless new Page as our content. You'll see why we need something there later, even though the frame in XAML is collapsed. But that's it. The rest of it is fluff to embody the interface. Even our implementation of IAsyncResult with LoadAsyncResult (Line #'s 10-27) is fake; it's the bare minimum we need to get the meaningless IAsyncResult we need to pass our fake Page. It's actually kind of depressing how abusive I am to this sub-system; all this work to nullify its functionality.

So right now, we have a frame that's properly updating the browser's history as we nav around the app, but isn't showing anything. Next we need to wire in the event handlers so that we can tell our panel how to animate our pages. On your frame, handle any of the following events, per your requirements:

  • Navigating
  • Navigated
  • NavigationFailed
  • NavigationStopped

The first two adhere to the familiar .NET event "-ing" and "-ed" paring pattern. Navigating fires first, and like other "-ing" events, gives you the opportunity to cancel the navigation. The event args (NavigatingCancelEventArgs) contain the new Uri. If you keep track of the current Uri in your code behind (or custom navigation panel, as I did) and have a mechanism to associate your page user controls to URIs (via attribution on the user control itself, as I did), you can pretty easily come up with a nice little "page lifecycle" model for your controls. In fact, all of my pages (which, as I will keep reminding you, are normal UserControls, not Silverlight Pages) have a custom attribute that provides metadata containing which URL each responds to, an ordinal to make calculating coordinates easier, etc.

The code behind of each such page control looks like this:

  1. [NavPage("/NewCustomerForm", 1)]
  2. public partial class NewCustomerPage : UserControl
  3. {
  4. }

So when Navigating fires, I will use the current Uri to pull the corresponding UserControl from my panel via reflection. (Basically, find everything decorated with "NavPage" in my panel, and give me the one whose Uri matches.) Then I could do things like error validation, clean up, etc. on the current control, and, using the Uri from the event args, pull the destination control and start loading data. Finally, we kick off the animation.

Here's what the frame looks like in XAML:

  1. <navigation:Frame
  2. x:Name="frmContent"
  3. Visibility="Collapsed"
  4. Navigating="frmContent_Navigating"
  5. NavigationFailed="frmContent_NavigationFailed">
  6. <navigation:Frame.ContentLoader>
  7. <infrastructure:NavigationListener />
  8. </navigation:Frame.ContentLoader>
  9. <navigation:Frame.UriMapper>
  10. <uriMapper:UriMapper>
  11. <uriMapper:UriMapping Uri="" MappedUri="/Views/Home.xaml" />
  12. <!-- other page mappings here -->
  13. </uriMapper:UriMapper>
  14. </navigation:Frame.UriMapper>
  15. </navigation:Frame>
  16. <Canvas x:Name="canNavPanel">
  17. <Canvas.Clip>
  18. <!-- something here to hide controls that are "off the screen" -->
  19. </Canvas.Clip>
  20. <!-- page here at (X,Y) -->
  21. <!-- page here at (X,Y + 500 or however much needed to be off screen) -->
  22. </Canvas>