Wednesday, February 2, 2011

Designing for Browser-Zoom: Part 2

[UPDATED: Fixed broken links for download]

The ability to adjust the magnification level in your browser (using zoom + or -) has the potential to ruin your carefully crafted UI design. In Part 1 we took a look at the mechanisms involved in browser zoom and the effect it can have on your Silverlight app (particularly the EnableAutoZoom setting).

In this part we are going to handle browser zoom with visual states and use an attached behaviour that allows us to integrate zoom management into our views and control templates from Blend, without having to write any code.

A Demonstration

Here is the sample application we will be working with. This is just a mock layout of a simple utility app – it is contrived for the purposes of showing how scaling can affect your design:

to launch this in a separate window and use the the browser’s zooming feature to zoom in to 150%, 200%, then back to 100%. This demonstrates the desired behaviour. You will see the spacing content change at each of those zoom levels – and at 200% the images are no longer visible. There is a link for the source code at the end of the article.

If this app is zoomed into 200% within the same available space, without adjusting the design for the zoom level, then the result is the undesired behaviour which looks like this:

ZoomWithoutResizerCapture200

As you can see, the title bar now takes up half the available height, a complete row of buttons is missing, and the button captions are cut off. Remember, if the user has their DPI settings set to show large text this could already be happening to your existing Silverlight apps!

Using Visual States for Zoom Levels

I’m going to make an assumption here for the sake of demonstration. I’m going to assume that some user research was carried out and showed that people were zooming in (or it was happening automatically based on the host computer’s DPI setting) because they liked large text – the images are not important to them.

Given this assumption, I need to be able to adjust the layout for different zoom levels, giving priority to the text in the application (headings, button captions etc). Visual States are ideal for showing the same information in a different way. I’ll use Blend to show how I have set up the visual states here:

ZoomStates

The image above shows the VisualStateGroup “ZoomStates” with three VisualStates in the group: Unzoomed, Zoomed150, and Zoomed200. In each of these states I have adjusted the spacing available for the header and adjusted the margins around each of the panels (which are HeaderedContentControls).

I have a problem though: I have customized the control templates for the HeaderedContentControl and the Button controls. How do I include changes to the control templates as part of a visual state for the main view? I can’t! I could create 3 different control templates for each control I was customizing, but that would quickly become a maintenance nightmare for a decent sized project.

There is another answer to this problem that I will discuss below, but for now we have to work out how to switch states as the browser zoom changes.

The Application.Host.Content.Zoomed Event

The Content class in the System.Windows.Interop namespace (part of the Silverlight System.Windows assembly) is described by the documentation as follows:

“Reports details about the Silverlight-based application's instantiation settings, and exposes some of the HTML DOM values for the hosted Silverlight plug-in instance. System.Windows.Interop.Content contains APIs that specifically relate to the Silverlight content area (the area declared by the width and height in the initialization).”

One of its members is the Zoomed event. If the browser starts up and responds to the host system’s DPI settings, or if the user manually zooms, then this event is fired. We can hook into this event in the Loaded event of our main view and respond to changes in the zoom like this:

   1: public partial class MainPage : UserControl
   2: {
   3:     public MainPage()
   4:     {
   5:         // Required to initialize variables
   6:         InitializeComponent();
   7:         Application.Current.Host.Content.Zoomed += this.Content_Zoomed;
   8:     }
   9:  
  10:     private void Content_Zoomed(object sender, EventArgs e)
  11:     {
  12:         var zoom = Application.Current.Host.Content.ZoomFactor;
  13:         if (zoom <= 1)
  14:         {
  15:             VisualStateManager.GoToState(this, "Unzoomed", true);
  16:         }
  17:         else if (zoom <= 1.5)
  18:         {
  19:             VisualStateManager.GoToState(this, "Zoomed150", true);
  20:         }
  21:         else if (zoom <= 2)
  22:         {
  23:             VisualStateManager.GoToState(this, "Zoomed200", true);
  24:         }
  25:     }
  26: }


The above code hooks into the Zoomed event and, when it fires, checks what the current ZoomFactor is. It then uses that value to determine which visual state it should transition to.


Now we have a mechanism to adjust our design to the current zoom level, but we still have a few problems:



  1. We still can’t reach into control templates to adjust margins and content

  2. We have to re-write this code (paying careful attention to state names) for every view we create

  3. We have to write code! Not very friendly for dedicated designers who are working with Blend


Behavioural Zoom Management



An attached behaviour could help us solve all three of these problems. Let's look at each problem in reverse order:


Unfriendly to a designer: An attached behaviour is drag-n-drop easy and we can use a Blend SDK attribute to help us pick the state to transition to.


Re-writing code: An attached behaviour encapsulates the code and lets us reuse it with ease.


Reaching into the control template: Hmmm, this one is a little more interesting. A control template already comes with pre-defined states (some controls have none) and using VisualStateManager.GoToState does not work for any additional states you add by hand (our ZoomedStates group). So if we customize a control template and add our new zoom states to it, can we find a way to transition to those states? As it turns out, there is a way: A visual state is a wrapper around a storyboard – so we could write our attached behaviour to locate the appropriate state, reach inside it for the storyboard, and just play the storyboard. This actually works, but there is a caveat that I will discuss below.


You can download the code for the ZoomResponder attached behaviour here. Without giving a full listing, I’ll go over the main parts of the code.


First up, here are the dependency properties for the attached behaviour:



   1: public static readonly DependencyProperty ZoomRangeStartProperty = DependencyProperty.Register(
   2:     "ZoomRangeStart", typeof(double), typeof(BrowserZoomStateBehavior), null);
   3:  
   4: public static readonly DependencyProperty ZoomRangeEndProperty = DependencyProperty.Register(
   5:     "ZoomRangeEnd", typeof(double), typeof(BrowserZoomStateBehavior), null);
   6:  
   7: public static readonly DependencyProperty StateNameProperty = DependencyProperty.Register(
   8:     "StateName", typeof(string), typeof(BrowserZoomStateBehavior), null);


Each dependency property also has a public class property with a getter and a setter.


This attached behaviour is responsible for one zoom state (“StateName”). To respond to the 150% and 200% zoom levels we need 3 of these attached behaviours (the third one is to transition back to the unzoomed state). The “ZoomRangeStart” is inclusive and the “ZoomRangeEnd” is exclusive. Therefore, the behaviour for switching to the 150% will have ZoomRangeStart = 1.5 and ZoomRangeEnd = 2 since there will be a different instance of the behaviour responsible for switching to 200%.


The public class property for the StateName property is decorated with the CustomPropertyValueEditor attribute from the System.Windows.Interactivity namespace:



   1:  
   2: [CustomPropertyValueEditor(CustomPropertyValueEditor.StateName)]
   3: public string StateName
   4: {
   5:     get
   6:     {
   7:         return (string)this.GetValue(StateNameProperty);
   8:     }
   9:     set
  10:     {
  11:         this.SetValue(StateNameProperty, value);
  12:     }
  13: }


That attribute lets Blend know that the property is the name of an existing state in the XAML. It changes the property editor for the the StateName property from a text box to a drop down combo box that lists the available states that have been defined. Handy!


So the view will have three of these templates attached to its root:AttachedZoomResponderBehaviors



Here is the main code that handles the state change:



   1: private void UpdateZoomedState()
   2: {
   3:     var zoomed = Application.Current.Host.Content.ZoomFactor;
   4:     if (zoomed >= this.ZoomRangeStart && zoomed < this.ZoomRangeEnd)
   5:     {
   6:         if (!VisualStateManager.GoToState(
   7:             this.AssociatedObject, 
   8:             this.StateName,
   9:             true))
  10:         {
  11:             if (null == this.AssociatedObject.Parent &&
  12:                 VisualTreeHelper.GetParent(this.AssociatedObject) is Control)
  13:             {
  14:                 // play the storyboard - we are probably inside a control template
  15:                 var stateGroups = VisualStateManager.GetVisualStateGroups(
  16:                     this.AssociatedObject);
  17:                 foreach (VisualStateGroup stateGroup in stateGroups)
  18:                 {
  19:                     foreach (VisualState visualState in stateGroup.States)
  20:                     {
  21:                         if (null != visualState.Storyboard &&
  22:                             visualState.Name.Equals(this.StateName))
  23:                         {
  24:                             visualState.Storyboard.Begin();
  25:                             return;
  26:                         }
  27:                     }
  28:                 }
  29:             }
  30:         }
  31:     }
  32: }


The behaviour first tries to use the VisualStateManager to transition to the associated state. If the result of the call to VisualStateManager.GoToState is false then we know the call failed, possibly because the associated object is inside a control template (as discussed above). The code checks to see if the logical parent of the associated object is null and if the visual parent of the associated object is a control. If both of these conditions are true then we will assume we are attached to a control template and try to find a matching state associated with it. If we find one, we grab its storyboard and play it.


Caveats



There is one thing to be aware of when using this inside a control template. Do not use storyboards with “From” values in the key frames, otherwise you will end up with bizarre animations playing when they should not. For example, if you had the visual state for “Unzoomed” specify a “From” value for a key frame, you would end up playing that storyboard regardless of the zoom factor the browser was last set at, including values that fall between the different states (e.g. 125%).


Summary



In these two posts I have shown how the browser zoom can play a big part in how your design appears to the end user. Introducing visual states to accommodate different zoom levels is one way to handle it. An attached behaviour that wraps up the management of switching states in response to the zoom event provides an elegant response to this issue that designers can use.


You can grab the code here.

3 comments:

  1. Hello Phil,

    the download link is broken. I get an HTTP 404.

    ReplyDelete
  2. I should have pointed out that I tested this in IE. The default zoom levels jumps are different in Chrome and the start and end zoom ranges for the various states need a bit of adjusting to work in both.

    ReplyDelete