Okay, this post isn’t really about creating a full blown Natural User Interface (NUI)… I just wanted to jump on the NUI bandwagon while it’s still the cool new thing! ๐ This post is actually about supporting a very specific natural user interaction within an ItemsControl.
Microsoft Surface was first publicly unveiled a couple of years ago. Since then, I have periodically been asked how one would go about creating a panel that behaves like the application launcher in Surface. If you have not seen the app launcher, I would describe the panel as simply a horizontal StackPanel that infinitely cycles its children.
I had some extra time this weekend, so I refactored one of my early looping panels into something that might be useful for a wider audience. This post also includes a simple demo showing how the LoopPanel can be used as the items host of a custom ListBox class. The LoopingListBox class supports dragging its items in a manner similar to the Surface app launcher. This demo brings the same type of natural user interaction to desktop WPF (via mouse) that you might find on Surface.
If you are actually developing for Surface, be sure to check out the Sidenote for Surface Developers section below for information on why you might prefer my panel over the sample supplied in the Surface documentation. ๐
If you have not yet read ‘P’ is for Panel, I would highly recommend it as a foundation for this article.
The LoopPanel Sample Application
To give you a better understanding of the panel examined within this post, you can run this ClickOnce LoopPanel sample application. (Yes, I could have published an XBAP sample, but I was not willing to give up the glow effect used for the selected character… which is, incidentally, achieved using a DropShadowEffect in the new effects pipeline.)
You can also download the entire project with full source code to build the demo yourself.
Sidenote and Tip: Even in the new pipeline, DropShadowEffect is a rather expensive effect because it requires multiple passes. The LoopPanel sample is very simple and is therefore not impacted much by the use of DropShadowEffect. If you experience poor performance using DropShadowEffect in a real app scenario, you can usually accomplish a very similar effect by applying a BlurEffect to a second copy of an element residing directly behind the original element.
The LoopPanel Layout Algorithm
Whenever I’m creating a new panel, I start by clearly defining its layout algorithm. This allows me to identify the exact properties that will be necessary to support the new layout.
In the LoopPanel class, I wanted to support both a horizontal and vertical orientation, just as StackPanel does. I also decided to use the same basic measuring algorithm as StackPanel, measuring each child to infinity in the stacking direction (the orientation direction) and measuring it to the panel’s available size in the nonstacking direction. (This just means a child can be whatever size it desires in the stacking direction, but it is constrained in the nonstacking direction.) To support these layout requirements, it was clear that I would need an Orientation property.
Of course, there are some key differences between a LoopPanel and a StackPanel. For example, a StackPanel arranges its children sequentially, beginning with the first child and stacking subsequent children one right after the other. However, a LoopPanel really eases the concept of a “first” child. There’s no reason that the nth child cannot appear first. So I needed a way to define the element that would be considered pivotal during a given layout pass. As such, I created the notion of a “pivotal child”.
The pivotal child is the element around which the remaining children are arranged. The pivotal child is placed first. Then its neighboring siblings (the subsequent and preceding members of the children collection) are placed, working away from the pivotal child in both directions until all remaining visible children are arranged. This allows for a balanced layout around the pivotal child. Nonvisible children (those that are outside the panel’s viewport) are then arranged using a layout rect with no size (both width and height are zero). This zero-sized rect provides a perf optimization (less layout processing and generated render data), especially for elements with elaborate subtrees, which you might have in an ItemsControl with a complex item template.
Obviously, any child could be the pivotal child. This meant I would need a property that could be used to identify the index of the pivotal child. I decided to call this the Offset property. The name might not make a lot of sense at first, but it should shortly. What might make even less sense is that the Offset property is of type double. So why would we need a double value to act as an index into a collection?
Actually, only the whole portion of the Offset value is considered when determining the index of the pivotal child. The fractional portion is then used to further offset that pivotal child. For example, given an Offset value of 6.25, the child at index 6 is the pivotal child and it is offset by 25% of its width (for a horizontally oriented LoopPanel) from the pivotal location. This means that if the Offset animates from 6 to 7, the child’s location animates by its entire extent. Then the child at index 7 becomes the pivotal child. Using this approach, the Offset property simply specifies the logical offset of the panel’s children.
Okay, shouldn’t the Offset value be constrained to only valid child indices? What happens when you animate the Offset from 6 to 7, but there are only 5 children? Well recall that the LoopPanel needs to cycle its children infinitely. To enable this infinite looping of children, the Offset property is completely unbounded. Any double value is valid. So to truly determine the index of the pivotal child, the Offset value is taken modulo the current count of children. So animating the Offset from 6 to 7 when you only have 5 children is equivalent to animating the Offset from 1 to 2.
The final layout issue was to determine exactly where to place the pivotal child within the panel’s available extent. More specifically, where is the pivotal location for the placement of the pivotal child. We could simply start at the beginning of the available space (similar to a StackPanel), but that is rather limiting, especially considering that the motivation for this panel is the app launcher in Surface, which puts the central focus at the center of the panel. To this end, I decided to allow the pivotal location to be specified using a RelativeOffset property on the LoopPanel.
RelativeOffset is a double value ranging from 0 to 1. A value of 0 (zero) means that the pivotal location is at the beginning of the panel’s available space. A value of 1 means the pivotal location is at the end of the panel’s available space. The default value is 0.5, which means the pivotal location is at the center of the panel.
That’s it for the description of the layout algorithm. We’ve identified three properties: Orientation, Offset, and RelativeOffset. To see how these properties are defined and actually used to implement the described layout algorithm, I refer you to the LoopPanel code within the sample project. As with every panel, the key methods that implement the layout are MeasureOverride() and ArrangeOverride().
Not a Typical ScrollViewer Paradigm
Once the LoopPanel has been implemented, the next logical question becomes how will we support scrolling its children? Clearly, we have an Offset property that can be used to this effect. The first inclination might be to leverage a ScrollViewer and use its horizontal or vertical offset to update the Offset property on the LoopPanel. There are a couple of problems with this approach.
First, a ScrollViewer measures its child to infinity in each of the scrollable directions (vertical and horizontal). In a typical ItemsControl scenario, this means that the items host gets to be whatever size it desires. The big problem here is that the LoopPanel doesn’t want to determine its own size in the non-orientation direction. Instead, it simply wants to layout its children using the space available.
The other big problem with using a ScrollViewer with the LoopPanel is that the native WPF ScrollViewer was designed to support scrolling across a finite range. In the LoopPanel, however, we want to support an infinite offset range in the orientation direction. As such, we must come up with a new method of looping through (scrolling) the children in our panel. The most logical choice is to allow a simple dragging gesture to update the Offset property. We will come back to this idea in a bit (see “Supporting Natural User Interactions” below).
Sidenote for Surface Developers
The latest Surface SDK documentation includes a topic called, “Creating a Continuously Panning List”. It demonstrates how you can build a panel similar to my LoopPanel that will work in conjunction with the SurfaceScrollViewer control. If you are developing exclusively for Surface, you might want to look at that sample.
For the record, I am not a fan of the layout logic used within that sample. The purpose of a panel is to provide layout for its children by arranging them within the panel. The sample panel provided in the Surface SDK actually applies a RenderTransform to itself to achieve a big piece of its layout. I strongly discourage developers from implementing controls that set their own RenderTransform or LayoutTransform properties. These properties do not belong to the control author. Rather, they belong to the person who is using the control. In the Surface sample, if someone tries to use the panel and they then apply their own RenderTransform to it, that transform will get trounced by the panel’s own layout logic.
I am also very partial to my own layout algorithm (go figure! :P), as it supports more options for aligning the children (left, right, center, or anywhere in between), it supports both vertical and horizontal looping of children, and it contains render optimizations by arranging nonvisible children using a zero-sized rect.
The approach described in this article will actually work well on both desktop PCs and on Surface. For the reasons outlined above, LoopPanel does not currently implement IScrollInfo or ISurfaceScrollInfo. (Well, the real reason is that I originally created the panel prior to the introduction of the ISurfaceScrollInfo interface.) However, it probably will support these interfaces in the future (see Potential Future Enhancements below).
Using the LoopPanel as an Items Host
Alright, our goal is to use our new panel as the items host for our custom LoopingListBox control. An ItemsControl, including our LoopingListBox, typically wraps its items host in a ScrollViewer. As we noted above, we don’t want our panel to be scrollable in the orientation direction. This means we need to customize the ScrollViewer within the template of the LoopingListBox to disable scrolling in the direction of the control’s orientation. The template below demonstrates how to achieve this:
<ControlTemplate TargetType="{x:Type dw:LoopingListBox}"> <Border Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" BorderThickness="{TemplateBinding BorderThickness}"> <ScrollViewer x:Name="sv" Padding="{TemplateBinding Padding}" HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto"> <dw:LoopPanel x:Name="lp" IsItemsHost="True" BringChildrenIntoView="True" Offset="{Binding Offset, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" Orientation="{TemplateBinding Orientation}" RelativeOffset="{TemplateBinding RelativeOffset}" /> </ScrollViewer> </Border> <ControlTemplate.Triggers> <Trigger Property="Orientation" Value="Vertical"> <Setter TargetName="sv" Property="HorizontalScrollBarVisibility" Value="Auto" /> <Setter TargetName="sv" Property="VerticalScrollBarVisibility" Value="Disabled" /> </Trigger> </ControlTemplate.Triggers> </ControlTemplate>
In this template, we have simply set the HorizontalScrollBarVisibility property to Disabled for a Horizontal LoopingListBox. If the Orientation is Vertical, we set the VerticalScrollBarVisibility property to Disabled. This achieves our aim.
Ensuring the Selection is Visible
Of course, there is another issue to consider. When an element within a ListBox becomes focused, the framework calls the BringIntoView() method on the element. This, in turn, raises a RequestBringIntoView event from the target element. Typically, this event bubbles up to the ScrollViewer, which handles it by calling the IScrollInfo.MakeVisible() method on the items host. If the items host implements IScrollInfo, it can then take the necessary action to scroll the element into view.
In our scenario, our panel does not implement IScrollInfo (see “Not a Typical ScrollViewer Paradigm”). As such, selecting an item that is not within the current view of the LoopPanel does not scroll it into view.
To make the LoopPanel class more useful, I have added an opt-in mechanism for it to directly handle the RequestBringIntoView event. If you set the BringChildrenIntoView property to true, then the panel will automatically update its Offset property when a descendant raises RequestBringIntoView. (In the template above, you’ll notice that BringChildrenIntoView is True on the LoopPanel.) This covers the typical ListBox usage scenario.
Supporting Dynamic Changes to the Children Collection
Another concern within our LoopPanel arises when you consider that the Children collection might dynamically change. Since the looping algorithm is built around the notion of an Offset property that positions other children around a single pivotal child, removing the pivotal child or adding/removing another child before the pivotal child will cause the visible children to shift positions (because the pivotal child changes).
This type of issue actually exists for all panels. Changing the Children collection causes a layout update. So whether you deal with this within the panel itself or leave it up to users of your panel to code around the issue is totally up to you.
In my usage scenario, I always wanted to prevent this layout jumping, so I modified the LoopPanel class, itself, by overriding its OnVisualChildrenChanged() method. When a child is added or removed, the Offset property is adjusted to keep the remaining elements in their current positions.
Supporting Natural User Interactions
Now for the fun part! We need to actually support a natural user experience that allows the panel to scroll its children by dragging them in the orientation direction.
There are a number of approaches that could be used here. I could have built the dragging (a.k.a. panning) support directly into the panel, but I’m kind of a purist when it comes to layout elements. A panel should do one thing… layout its children. In WPF, a panel does this by sizing and positioning (a.k.a., measuring and arranging) its children. A panel should not, in my humble opinion, respond directly to user input. For this reason, the LoopPanel does not directly support dragging by capturing or reacting to input events. It simply exposes a property called Offset that, as described earlier, provides an unbounded logical offset for the panel’s children.
In addition to the Offset property, the panel also provides a public method called Scroll(). The Scroll() method will internally update the logical offset to accommodate a specified delta of viewport units. This is important since most scrolling scenarios involve updating the panel’s offset using viewport units (e.g., pixels) rather than logical units. For example, you may want to pan the content 30 pixels in response to a drag delta of that distance. Our Offset property is not directly helpful in this case, since it refers to logical units. (30 logical units would represent 30 children, each with their own unique sizes.)
So if the LoopPanel does not respond directly to input events, how does it participate in the scrolling scenario? Well, as we’ve alluded to previously, a panel that needs to support scrolling typically does so by implementing IScrollInfo. This allows it to work in conjunction with a parent ScrollViewer. The ScrollViewer is the control that actually reacts to user input. As noted earlier, however, the concepts behind a ScrollViewer do not necessarily translate to our infinite scrolling scenario. And recall that when I first created this panel, the SurfaceScrollViewer did not yet exist, so implementing ISurfaceScrollInfo was not really an option. Even if it had been, I probably wouldn’t have opted for an approach that was limited to Surface.
Since I knew that I was going to use the LoopPanel as the items host of a ListBox, I decided to derive a custom LoopingListBox control that would interact directly with its items panel (the LoopPanel) to provide scrolling. The LoopingListBox class provides an attached behavior called IsDragHandle that can be attached to any UI element (a ListBoxItem, for example) within the subtree to make that element draggable. Dragging a drag handle causes the parent LoopingListBox to capture the Mouse (or Surface Contact) and update the scroll offset accordingly.
At a high level, the LoopingListBox supports a typical Windows drag operation. The input device (Mouse or Contact) is captured and subsequent changes are translated into deltas in the LoopPanel’s offset. This continues until the input device is released (the Mouse button is released, the Contact is lifted, or the device capture is otherwise lost). The mundane details can all be found in the provided sample code, so I won’t go over them here.
Using a LoopPanel on Surface
As mentioned previously, the LoopPanel class also works well on Surface. Since it does not yet support ISurfaceScrollViewer, I have derived a custom SurfaceLoopingListBox control from LoopingListBox. This class adds drag support for Surface input devices (a.k.a., Contact devices).
If you are lucky enough to have access to a Surface computer, feel free to download the Surface LoopPanel Sample here. If you have the Surface SDK installed, you can also simply run this sample within the Surface Simulator.
There are a couple of enhancements that could make LoopPanel even more useful, including ScrollViewer support (via IScrollInfo) and UI virtualization (by deriving from VirtualizingPanel).
IScrollInfo and ISurfaceScrollInfo
At some point in the future, I will likely update LoopPanel to support IScrollInfo. I will also likely derive a new SurfaceLoopPanel class that implements the additional members of ISurfaceScrollInfo. These interfaces enable a clean separation between the panel and the controlling input element (the ScrollViewer or SurfaceScrollViewer). This should be a super simple upgrade, given the current LoopPanel design. I will certainly update this article with the new code, once the panel supports IScrollInfo and/or ISurfaceScrollInfo. ๐
UI Virtualization
This panel is a very natural candidate for UI virtualization, since it already scrolls its children using logical units. (The Offset property can easily be thought of as a logical offset for the children.) In fact, I will likely use this panel as the basis for the upcoming ‘V’ is for Virtualization entry in this series, unless someone gives me a better idea.
I hope you find LoopPanel useful. As always, I welcome feedback and would love other ideas for improving the panel. If you find any scenarios where this panel does not meet your needs or where it simply misbehaves (a.k.a., bugs), please let me know!