Pull-down-to-refresh a WP7 ListBox or ScrollViewer
Update 12/19/2011: Updated the attached demo project to target the WP 7.1 (Mango) SDK. It still requires setting ManipulationMode=Control on the ScrollViewer.
I really like the pull-down-and-release-to-refresh gesture seen in various smartphone apps. In my opinion it doesn't conflict with the Metro UI guidelines, so I don't see any reason why it shouldn't be used in a WP7 app. Here is an explanation of how it can be done in WP7 without much difficulty. If you just want the complete source code, jump to the end to download the source along with a demo project.
The WP7 ScrollViewer control has a built-in stretchy feel to it if you try to pull it down from the top or up from the bottom, so it is already well-suited to this kind of interaction. What we need to do is detect when and how far the ScrollViewer is pulled down from the top, when it is released from that point, and also reveal an indicator control "behind" the ScrollViewer to provide visual feedback as it is pulled down. And since a ListBox uses a ScrollViewer internally to scroll its items, the same solution will work just as well with a ListBox.
Detecting a pull-down motion
The ScrollViewer class does not directly publish any events about its scrolling motion. So, how can we know when the ScrollViewer is being "pulled down"? With a little experimentation, I found that the ScrollViewer applies a CompositeTransform to its Content for vertical translation (and subtle compression) when the contents are dragged down from the top or up from the bottom. But for normal scrolling through the length of the list, the CompositeTransform is just equivalent to an identity transform (0 translation, 1.0 scale). This makes it very easy to know when and how far the ScrollViewer is pulled down from the top: just look for a positive Y translation on the RenderTransform of the content.
Caution: This technique relies on undocumented internal behavior of the ScrollViewer. It's possible that a future revision of the ScrollViewer could change its internal scroll rendering in a way that breaks this kind of pull-to-refresh code. I even hesitate to blog this for that reason, however in my estimation the risk is low, and can be mitigated in a couple ways: Any code depending on the current scroll rendering behavior should degrade gracefully (don't crash when something expected is not found), and the app should provide some alternate means of refreshing the scrolled contents such as a context menu etc. I have no official knowledge on this subject, and I can't even guarantee that an app that implements this gesture will pass the marketplace review process. So let me know how it works out!
Moving beyond the disclaimer, here's a snippet of code showing how to handle a ScrollViewer's mouse-move event to detect a pull-down gesture.
void targetScrollViewer_MouseMove(object sender, MouseEventArgs e)
{
UIElement scrollContent = (UIElement)this.targetScrollViewer.Content;
CompositeTransform ct = scrollContent.RenderTransform as CompositeTransform;
if (ct != null)
{
if (ct.TranslateY > this.PullThreshold)
{
// Show the ready-to-release indicator
}
else if (ct.TranslateY > 0)
{
// Show the pull-down indicator
}
else
{
// Hide the indicators
}
}
}
Detecting a release is very similar: handle a mouse-up event and check whether the Y translation at the time of release is greater than the minimum distance.
Revealing a pull-down indicator
There are a few ways to reveal another control as the ScrollViewer is pulled down. The easiest method is to layout the control directly behind the ScrollViewer, and make the ScrollViewer contents opaque so that the control is normally covered. But I wanted to allow for a transparent background on scrolled items, as is very typical. So, the solution I settled on has a control that is initially 0-height and has its height dynamically adjusted as the ScrollViewer is pulled down. To implement this, I created a PullDistance DependencyProperty that gets dynamically updated by the mouse-move event handler, and bound the Height property of the pulling-down indicator to that. For additional visual effect, I created a similar PullFraction property and bound it to the indicator's Opacity, so that the indicator fades in proportional to the pull-down.
The full XAML is too verbose to post inline, but here's a small snippet from the control template that shows the bindings mentioned above.
<StackPanel x:Name="PullingDownPanel"
Height="{TemplateBinding PullDistance}" Opacity="{TemplateBinding PullFraction}"
Margin="{Binding PullDistance, RelativeSource={RelativeSource TemplatedParent},
Converter={StaticResource NegativeValueConverter}, ConverterParameter=Bottom}">
<ContentPresenter ContentTemplate="{TemplateBinding PullingDownTemplate}" />
</StackPanel>
Note the Margin binding, which is a bit tricky. The purpose of that is to apply a negative bottom margin opposite to the height, in order to keep the layout height of the control at 0. The custom value-converter class converts a double value into a Thickness object with negative margin of the same magnitude. Without it, the entire ScrollViewer would get "pushed" down as the control before it was enlarged.
Putting it all together
With the two tricky parts done, the rest is pretty typical for building custom controls. I'm not going to walk though the details here, as the concepts are covered well in other places. But here's a brief list of what else went into my PullDownToRefreshPanel custom control:
- A RefreshRequested event to signal the app when the user has completed the refresh gesture
- DataTemplate properties for each of the three indicators (pulling down, ready to release, refreshing) to allow customizing those visuals without needing to re-template the whole control
- Visual-state definitions in the control template XAML to control the visibility of each of the templates according to the current visual state
- An IsRefreshing property to allow the app to easily enter and exit the refreshing visual state (which shows an indeterminate progress bar by default)
- Automatic detection of a nearby ScrollViewer or ListBox -- just put the PullDownToRefreshPanel in the same parent container and it will hook itself up
Demo
For demonstration purposes, I've added the PullDownToRefreshPanel code to my ReorderListBox demo app. It should work just as well with any other ListBox or ScrollPanel. Sorry, it won't work with a LongListSelector -- that control does custom scrolling without using a ScrollViewer. I've attached to this post a ZIP containing source code for both controls and the demo project.