This article presents a Universal Windows 8.1 XAML ListView control with pull-to-refresh capabilities, inspired by the ones in the Twitter and Facebook apps. It’s a straightforward port of the pull-to-refresh ScrollViewer control by Deani Hansen. The PullToRefreshListView comes with the following characteristics:
- PullText (dependency property) : the text that appears when the user pulls down the ListView
- RefreshText(dependency property) : the text that appears when the ListView is ready to refresh
- RefreshHeaderHeight(dependency property) : the height of the header that contains the texts and the arrow
- RefreshCommand(dependency property) : the command that triggers the refresh
- ArrowColor(dependency property) : the color of the rotating arrow in the header
- RefreshContent (event) : the event that triggers the refresh
This snapshot from the sample app shows the ListView in action on a Windows phone:
Here’s how it is defined in the XAML page:
<controls:PullToRefreshListView ItemsSource="{Binding Episodes}"
RefreshCommand="{Binding RefreshCommand}"
ArrowColor="{StaticResource MagooShirtGreen}">
<controls:PullToRefreshListView.ItemTemplate>
<DataTemplate>
<!-- Item template details -->
<!-- ... -->
</DataTemplate>
</controls:PullToRefreshListView.ItemTemplate>
</controls:PullToRefreshListView>
Visual Studio 2015 RC did not allow me to create a copy from the default ListView template through the designer. So I opened the core generic.xaml from C:\Program Files (x86)\Windows Kits\8.1\Include\winrt\xaml\design and took it from there. The heart of the default ListView control is a ScrollViewer, so it was not hard to blend Deani’s pull-to-refresh ScrollViewer in it.
Here’s the XAML template structure: the ItemsPresenter that displays the ListView items is decorated with a header that displays the ‘pull to refresh’ and ‘release to refresh’ texts, and the rotating arrow:
Apart from the change of base class from ContentControl to ListView, I did not have to change a lot in the original source code. The principle is still the same. The new header is hidden by giving the entire control a negative margin:
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
scrollViewer = (ScrollViewer)GetTemplateChild(ScrollViewerControl);
scrollViewer.ViewChanging += ScrollViewer_ViewChanging;
scrollViewer.Margin = new Thickness(0, 0, 0, -RefreshHeaderHeight);
var transform = new CompositeTransform();
transform.TranslateY = -RefreshHeaderHeight;
scrollViewer.RenderTransform = transform;
containerGrid = (Grid)GetTemplateChild(ContainerGrid);
pullToRefreshIndicator = (Border)GetTemplateChild(PullToRefreshIndicator);
SizeChanged += OnSizeChanged;
}
That negative margin pushes the header above the control’s area, but still visible. The control needs to be clipped to make the header disappear:
private void OnSizeChanged(object sender, SizeChangedEventArgs e)
{
Clip = new RectangleGeometry()
{
Rect = new Rect(0, 0, e.NewSize.Width, e.NewSize.Height)
};
}
The pull-to-refresh behavior is implemented by two timers:
- One timer detects if the user is trying to pull the control down, and if he has passed a threshold in pixels.
- The second timer detects if the user has passed that pulling threshold for a long enough time.
The timers make sure that the –possibly expensive- refresh operation is not triggered accidentally. They only run when the control is at the top of the list (i.e. when it has no VerticalOffset):
private void ScrollViewer_ViewChanging(object sender, ScrollViewerViewChangingEventArgs e)
{
if (e.NextView.VerticalOffset == 0)
{
timer.Start();
}
else
{
// reset everything
// ...
}
}
The first timer constantly verifies if the pulling pixel threshold is reached. If that’s the case, the second timer is started. Only when both timers were fired, the actual refresh is invoked:
private void Timer_Tick(object sender, object e)
{
if (containerGrid != null)
{
Rect elementBounds = pullToRefreshIndicator.TransformToVisual(containerGrid).TransformBounds(new Rect(0.0, 0.0, pullToRefreshIndicator.Height, RefreshHeaderHeight));
var compressionOffset = elementBounds.Bottom;
if (compressionOffset > offsetTreshhold)
{
if (isCompressionTimerRunning == false)
{
isCompressionTimerRunning = true;
compressionTimer.Start();
}
isCompressedEnough = true;
}
else if (compressionOffset == 0 && isReadyToRefresh == true)
{
InvokeRefresh();
}
else
{
isCompressedEnough = false;
isCompressionTimerRunning = false;
}
}
}
The second timer brings the control to ‘release-to-refresh’ mode:
private void CompressionTimer_Tick(object sender, object e)
{
if (isCompressedEnough)
{
VisualStateManager.GoToState(this, VisualStateReadyToRefresh, true);
isReadyToRefresh = true;
}
else
{
isCompressedEnough = false;
compressionTimer.Stop();
}
}
The VisualStateManager in the control’s template deals with the visual transitions - the visibility of the messages, and the rotation of the arrow:
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="VisualStateGroup">
<VisualStateGroup.Transitions>
<VisualTransition GeneratedDuration="0:0:0.2"
To="ReadyToRefresh" />
</VisualStateGroup.Transitions>
<VisualState x:Name="Normal" />
<VisualState x:Name="ReadyToRefresh">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Visibility)"
Storyboard.TargetName="TextPull">
<DiscreteObjectKeyFrame KeyTime="0">
<DiscreteObjectKeyFrame.Value>
<Visibility>Collapsed</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Visibility)"
Storyboard.TargetName="TextRefresh">
<DiscreteObjectKeyFrame KeyTime="0">
<DiscreteObjectKeyFrame.Value>
<Visibility>Visible</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
</ObjectAnimationUsingKeyFrames>
<DoubleAnimation Duration="0"
To="0.5"
Storyboard.TargetProperty="(UIElement.RenderTransform).(CompositeTransform.Rotation)"
Storyboard.TargetName="Arrow"
d:IsOptimized="True" />
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
The PullToRefreshListView publishes its request for a refresh by raising an event –to notify its view- and executing a command – to notify its MVVM viewmodel:
private void InvokeRefresh()
{
isReadyToRefresh = false;
VisualStateManager.GoToState(this, VisualStateNormal, true);
if (RefreshContent != null)
{
RefreshContent(this, EventArgs.Empty);
}
if (RefreshCommand != null && RefreshCommand.CanExecute(null) == true)
{
RefreshCommand.Execute(null);
}
}
The control not only works on the phone, but also on Windows 8.1. It works as expected on tablets and touch screens: pull the ListView down, wait half a second to release, and its request for refresh is published. Unfortunately this only works in touch mode: with a mouse it’s impossible to pull down the ListView. So to make the PullToRefreshListView a little more universal, I added the following code: when no touch screen is detected (by checking TouchCapabilities.TouchPresent), then the control displays a refresh button in the top right corner:
Here’s the corresponding code:
private void PullToRefreshScrollViewer_Loaded(object sender, RoutedEventArgs e)
{
// Show Refresh Button on non-touch device.
if (new Windows.Devices.Input.TouchCapabilities().TouchPresent == 0)
{
var refreshButton = (Button)GetTemplateChild(RefreshButton);
refreshButton.Visibility = Visibility.Visible;
refreshButton.Click += RefreshButton_Click;
}
// Timers setup
// ...
}
private void RefreshButton_Click(object sender, object e)
{
InvokeRefresh();
}
The control and its sample app live here on GitHub. The solution was created with Visual Studio 2015 RC and is compatible with Visual Studio 2013.
Enjoy!
XAMLBrewer