PullToRefreshListView for UWP

Hello there developer who was just asked to incorporate the PullToRefresh behavior into one of your ListViews for the UWP app you are building. And of course you are looking to steal code on the internet. No blaming, I do it all the time. I actually want you to use this.

photo-15-642x322In case you just crawled out of your cave and aren’t really sure what PullToRefresh is here you go.

 

 

After evaluating the few solutions that were available on the internet I realized none of them fulfilled my requirements perfectly so I had to make my own. To be exact I had to steal code and was hoping that after some tweaking and refactoring I might be able to make it work.

After shamelessly stealing great code from  @dotMorten ‘s blog here RefreshBox for Windows Phone 7 as well as some interesting ideas from an otherwise mediocre Microsoft sample here XamlPullToRefresh I ended up with the code below.

Enjoy!

P.S Scroll to the bottom for some interesting details about this solution.


using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Windows.Input;
using Windows.Foundation;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls.Primitives;
using Windows.UI.Xaml.Media;
namespace PullToRefreshListView
{
/// <summary>
/// Refresh box. Pull down beyond the top limit on the listview to
/// trigger a refresh, similar to iPhone's lists.
/// </summary>
public class PullToRefreshListView : ListView
{
ScrollViewer _elementScrollViewer;
PullToRefreshOuterPanel _pullToRefreshOuterPanel;
PullToRefreshInnerPanel _pullToRefreshInnerPanel;
public PullToRefreshListView()
{
DefaultStyleKey = typeof(PullToRefreshListView);
}
ScrollViewer ElementScrollViewer
{
get { return _elementScrollViewer; }
set
{
if (_elementScrollViewer != null)
{
_elementScrollViewer.ViewChanging -= OnElementScrollViewerViewChanging;
_elementScrollViewer.DirectManipulationStarted -= OnElementScrollViewerManipulationStarted;
}
_elementScrollViewer = value;
if (_elementScrollViewer != null)
{
_elementScrollViewer.ViewChanging += OnElementScrollViewerViewChanging;
_elementScrollViewer.DirectManipulationStarted += OnElementScrollViewerManipulationStarted;
}
}
}
PullToRefreshInnerPanel PullToRefreshInnerPanel
{
get { return _pullToRefreshInnerPanel; }
set
{
if (_pullToRefreshInnerPanel != null)
_pullToRefreshInnerPanel.SizeChanged -= OnPullToRefreshInnerPanelSizeChanged;
_pullToRefreshInnerPanel = value;
if (_pullToRefreshInnerPanel != null)
_pullToRefreshInnerPanel.SizeChanged += OnPullToRefreshInnerPanelSizeChanged;
}
}
PullToRefreshOuterPanel PullToRefreshOuterPanel
{
get { return _pullToRefreshOuterPanel; }
set
{
if (_pullToRefreshOuterPanel != null)
_pullToRefreshOuterPanel.SizeChanged -= OnPullToRefreshOuterPanelSizeChanged;
_pullToRefreshOuterPanel = value;
if (_pullToRefreshOuterPanel != null)
_pullToRefreshOuterPanel.SizeChanged += OnPullToRefreshOuterPanelSizeChanged;
}
}
FrameworkElement PullToRefreshIndicator { get; set; }
/// <summary>
/// Is the scrollviewer currently being pulled?
/// </summary>
public bool IsPulling { get; private set; }
void OnPullToRefreshInnerPanelSizeChanged(object sender, SizeChangedEventArgs e)
{
PullToRefreshIndicator.Width = PullToRefreshInnerPanel.ActualWidth;
// Hide the refresh indicator
ElementScrollViewer.ChangeView(null, PullToRefreshIndicator.Height, null, true);
}
void OnPullToRefreshOuterPanelSizeChanged(object sender, SizeChangedEventArgs e)
{
PullToRefreshInnerPanel.InvalidateMeasure();
}
/// <summary>
/// Called when the scroll position of the scrollviewer has changed.
/// Used to figure out if the user has over-panned the scrollviewer and if so
/// schedule a refresh when the user releases their finger.
/// </summary>
void OnElementScrollViewerViewChanging(object sender, ScrollViewerViewChangingEventArgs e)
{
if (e.NextView.VerticalOffset == 0)
{
IsPulling = true;
ChangeVisualState(true);
}
else
{
IsPulling = false;
ChangeVisualState(true);
}
// check whether the user released their finger when VerticalOffset=0
// and the direction is up.
if (e.IsInertial &&
e.FinalView.VerticalOffset == PullToRefreshIndicator.Height &&
ElementScrollViewer.VerticalOffset == 0)
{
// wait for the manipulation to end before firing the Refresh event.
ElementScrollViewer.DirectManipulationCompleted += OnElementScrollViewerManipulationCompleted;
}
}
/// <summary>
/// Called when a user manipulation has started. Makes the pull indicator visible.
/// </summary>
void OnElementScrollViewerManipulationStarted(object sender, object e)
{
ElementScrollViewer.DirectManipulationStarted -= OnElementScrollViewerManipulationStarted;
// Hide the indicator until the user starts touching the scrollviewer for the first time.
PullToRefreshIndicator.Opacity = 1;
}
/// <summary>
/// Called when the user releases their finger from the screen after having overpanned the scrollviewer
/// </summary>
void OnElementScrollViewerManipulationCompleted(object sender, object args)
{
ElementScrollViewer.DirectManipulationCompleted -= OnElementScrollViewerManipulationCompleted;
OnRefreshed();
}
private void OnRefreshed()
{
ElementScrollViewer.ChangeView(null, 0, null, true);
IsPulling = false;
ChangeVisualState(true);
PullRefresh?.Invoke(this, EventArgs.Empty);
if (PullRefreshCommand?.CanExecute(null) == true)
PullRefreshCommand.Execute(null);
}
protected override void OnApplyTemplate()
{
base.OnApplyTemplate();
ElementScrollViewer = GetTemplateChild("ScrollViewer") as ScrollViewer;
PullToRefreshIndicator = GetTemplateChild("PullToRefreshIndicator") as FrameworkElement;
PullToRefreshInnerPanel = GetTemplateChild("InnerPanel") as PullToRefreshInnerPanel;
PullToRefreshOuterPanel = GetTemplateChild("OuterPanel") as PullToRefreshOuterPanel;
ChangeVisualState(false);
}
private void ChangeVisualState(bool useTransitions)
{
if (IsPulling)
{
GoToState(useTransitions, "Pulling");
}
else
{
GoToState(useTransitions, "NotPulling");
}
}
private bool GoToState(bool useTransitions, string stateName)
{
return VisualStateManager.GoToState(this, stateName, useTransitions);
}
/// <summary>
/// Gets or sets the refresh text. Ie "Pull down to refresh".
/// </summary>
public string RefreshText
{
get { return (string)GetValue(RefreshTextProperty); }
set { SetValue(RefreshTextProperty, value); }
}
/// <summary>
/// Identifies the <see cref="RefreshText"/> property
/// </summary>
public static readonly DependencyProperty RefreshTextProperty =
DependencyProperty.Register("RefreshText", typeof(string), typeof(PullToRefreshListView), new PropertyMetadata("Pull down to refresh…"));
/// <summary>
/// Gets or sets the release text. Ie "Release to refresh".
/// </summary>
public string ReleaseText
{
get { return (string)GetValue(ReleaseTextProperty); }
set { SetValue(ReleaseTextProperty, value); }
}
/// <summary>
/// Identifies the <see cref="ReleaseText"/> property
/// </summary>
public static readonly DependencyProperty ReleaseTextProperty =
DependencyProperty.Register("ReleaseText", typeof(string), typeof(PullToRefreshListView), new PropertyMetadata("Release to refresh…"));
/// <summary>
/// Sub text below Release/Refresh text. For example: Updated last: 12:34pm
/// </summary>
public string PullSubtext
{
get { return (string)GetValue(PullSubtextProperty); }
set { SetValue(PullSubtextProperty, value); }
}
/// <summary>
/// Identifies the <see cref="PullSubtext"/> property
/// </summary>
public static readonly DependencyProperty PullSubtextProperty =
DependencyProperty.Register("PullSubtext", typeof(string), typeof(PullToRefreshListView), null);
/// <summary>
/// Identifies the <see cref="PullIndicatorForeground"/> property
/// </summary>
public Brush PullIndicatorForeground
{
get { return (Brush)GetValue(PullIndicatorForegroundProperty); }
set { SetValue(PullIndicatorForegroundProperty, value); }
}
/// <summary>
/// Identifies the <see cref="PullIndicatorForeground"/> property
/// </summary>
public static readonly DependencyProperty PullIndicatorForegroundProperty =
DependencyProperty.Register("PullIndicatorForeground", typeof(Brush), typeof(PullToRefreshListView), null);
/// <summary>
/// Identifies the <see cref="PullRefreshCommand"/> property
/// </summary>
public ICommand PullRefreshCommand
{
get { return (ICommand)GetValue(PullRefreshCommandProperty); }
set { SetValue(PullRefreshCommandProperty, value); }
}
/// <summary>
/// Identifies the <see cref="PullRefreshCommand"/> property
/// </summary>
public static readonly DependencyProperty PullRefreshCommandProperty =
DependencyProperty.Register("PullRefreshCommand", typeof(ICommand), typeof(PullToRefreshListView), null);
/// <summary>
/// Triggered when the user requested a refresh.
/// </summary>
public event EventHandler PullRefresh;
}
/// <summary>
/// The PullToRefreshOuterPanel works together with the <see cref="PullToRefreshInnerPanel"/>
/// to workaround the problem where List Virtualization is disabled when ListView is hosted
/// inside a ScrollViewer.
/// Specifically the InnerPanel is able to report to the ListView as available height the OuterPanel's height
/// instead of infinity which would be the case when a ScrollViewer is hosted inside another ScrollViewer.
/// </summary>
internal class PullToRefreshOuterPanel : Panel
{
public Size AvailableSize { get; private set; }
public Size FinalSize { get; private set; }
protected override Size MeasureOverride(Size availableSize)
{
AvailableSize = availableSize;
// Children[0] is the outer ScrollViewer
this.Children[0].Measure(availableSize);
return this.Children[0].DesiredSize;
}
protected override Size ArrangeOverride(Size finalSize)
{
FinalSize = finalSize;
// Children[0] is the outer ScrollViewer
this.Children[0].Arrange(new Rect(0, 0, finalSize.Width, finalSize.Height));
return finalSize;
}
}
/// <summary>
/// The PullToRefreshOuterPanel works together with the <see cref="PullToRefreshInnerPanel"/>
/// to workaround the problem where List Virtualization is disabled when ListView is hosted
/// inside a ScrollViewer.
/// Specifically the InnerPanel is able to report to the ListView as available height the OuterPanel's height
/// instead of infinity which would be the case when a ScrollViewer is hosted inside another ScrollViewer.
/// </summary>
internal class PullToRefreshInnerPanel : Panel, IScrollSnapPointsInfo
{
EventRegistrationTokenTable<EventHandler<object>> _verticaltable = new EventRegistrationTokenTable<EventHandler<object>>();
EventRegistrationTokenTable<EventHandler<object>> _horizontaltable = new EventRegistrationTokenTable<EventHandler<object>>();
protected override Size MeasureOverride(Size availableSize)
{
// need to get away from infinity
var parent = this.Parent as FrameworkElement;
while (!(parent is PullToRefreshOuterPanel))
{
parent = parent.Parent as FrameworkElement;
}
var pullToRefreshOuterPanel = parent as PullToRefreshOuterPanel;
// Children[0] is the Border that comprises the refresh UI
this.Children[0].Measure(pullToRefreshOuterPanel.AvailableSize);
// Children[1] is the ListView
this.Children[1].Measure(new Size(pullToRefreshOuterPanel.AvailableSize.Width, pullToRefreshOuterPanel.AvailableSize.Height));
return new Size(this.Children[1].DesiredSize.Width, this.Children[0].DesiredSize.Height + pullToRefreshOuterPanel.AvailableSize.Height);
}
protected override Size ArrangeOverride(Size finalSize)
{
// need to get away from infinity
var parent = this.Parent as FrameworkElement;
while (!(parent is PullToRefreshOuterPanel))
{
parent = parent.Parent as FrameworkElement;
}
var pullToRefreshOuterPanel = parent as PullToRefreshOuterPanel;
// Children[0] is the PullToRefreshIndicator
this.Children[0].Arrange(new Rect(0, 0, this.Children[0].DesiredSize.Width, this.Children[0].DesiredSize.Height));
// Children[1] is the ItemsScrollViewer
this.Children[1].Arrange(new Rect(0, this.Children[0].DesiredSize.Height, pullToRefreshOuterPanel.FinalSize.Width, pullToRefreshOuterPanel.FinalSize.Height));
return finalSize;
}
bool IScrollSnapPointsInfo.AreHorizontalSnapPointsRegular => false;
bool IScrollSnapPointsInfo.AreVerticalSnapPointsRegular => false;
IReadOnlyList<float> IScrollSnapPointsInfo.GetIrregularSnapPoints(Orientation orientation, SnapPointsAlignment alignment)
{
if (orientation == Orientation.Vertical)
{
var l = new List<float>();
l.Add((float)this.Children[0].DesiredSize.Height);
return l;
}
else
{
return new List<float>();
}
}
float IScrollSnapPointsInfo.GetRegularSnapPoints(Orientation orientation, SnapPointsAlignment alignment, out float offset)
{
throw new NotImplementedException();
}
event EventHandler<object> IScrollSnapPointsInfo.HorizontalSnapPointsChanged
{
add
{
var table = EventRegistrationTokenTable<EventHandler<object>>
.GetOrCreateEventRegistrationTokenTable(ref this._horizontaltable);
return table.AddEventHandler(value);
}
remove
{
EventRegistrationTokenTable<EventHandler<object>>
.GetOrCreateEventRegistrationTokenTable(ref this._horizontaltable)
.RemoveEventHandler(value);
}
}
event EventHandler<object> IScrollSnapPointsInfo.VerticalSnapPointsChanged
{
add
{
var table = EventRegistrationTokenTable<EventHandler<object>>
.GetOrCreateEventRegistrationTokenTable(ref this._verticaltable);
return table.AddEventHandler(value);
}
remove
{
EventRegistrationTokenTable<EventHandler<object>>
.GetOrCreateEventRegistrationTokenTable(ref this._verticaltable)
.RemoveEventHandler(value);
}
}
}
}


<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation&quot;
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml&quot;
xmlns:controls="using:PullToRefreshListView">
<Style TargetType="controls:PullToRefreshListView">
<Setter Property="IsTabStop"
Value="False" />
<Setter Property="TabNavigation"
Value="Once" />
<Setter Property="IsSwipeEnabled"
Value="True" />
<Setter Property="ScrollViewer.HorizontalScrollBarVisibility"
Value="Disabled" />
<Setter Property="ScrollViewer.VerticalScrollBarVisibility"
Value="Auto" />
<Setter Property="ScrollViewer.HorizontalScrollMode"
Value="Disabled" />
<Setter Property="ScrollViewer.IsHorizontalRailEnabled"
Value="False" />
<Setter Property="ScrollViewer.VerticalScrollMode"
Value="Enabled" />
<Setter Property="ScrollViewer.IsVerticalRailEnabled"
Value="True" />
<Setter Property="ScrollViewer.ZoomMode"
Value="Disabled" />
<Setter Property="ScrollViewer.IsDeferredScrollingEnabled"
Value="False" />
<Setter Property="ScrollViewer.BringIntoViewOnFocusChange"
Value="True" />
<Setter Property="PullIndicatorForeground"
Value="Gray"/>
<Setter Property="ItemContainerTransitions">
<Setter.Value>
<TransitionCollection>
<AddDeleteThemeTransition />
<ContentThemeTransition />
<ReorderThemeTransition />
<EntranceThemeTransition IsStaggeringEnabled="False" />
</TransitionCollection>
</Setter.Value>
</Setter>
<Setter Property="ItemsPanel">
<Setter.Value>
<ItemsPanelTemplate>
<ItemsStackPanel Orientation="Vertical"/>
</ItemsPanelTemplate>
</Setter.Value>
</Setter>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="controls:PullToRefreshListView">
<Border BorderBrush="{TemplateBinding BorderBrush}"
Background="{TemplateBinding Background}"
BorderThickness="{TemplateBinding BorderThickness}">
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="PullStates">
<VisualState x:Name="Pulling">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="pulling"
Storyboard.TargetProperty="Visibility">
<DiscreteObjectKeyFrame KeyTime="0">
<DiscreteObjectKeyFrame.Value>
<Visibility>Visible</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="notPulling"
Storyboard.TargetProperty="Visibility">
<DiscreteObjectKeyFrame KeyTime="0">
<DiscreteObjectKeyFrame.Value>
<Visibility>Collapsed</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
</ObjectAnimationUsingKeyFrames>
<DoubleAnimation Storyboard.TargetName="rotArrow"
Storyboard.TargetProperty="Angle"
To="0"
Duration="0:0:.25" />
</Storyboard>
</VisualState>
<VisualState x:Name="NotPulling">
<Storyboard>
<DoubleAnimation Storyboard.TargetName="rotArrow"
Storyboard.TargetProperty="Angle"
To="-180"
Duration="0:0:.25" />
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<controls:PullToRefreshOuterPanel x:Name="OuterPanel"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<ScrollViewer x:Name="ScrollViewer"
TabNavigation="{TemplateBinding TabNavigation}"
HorizontalScrollMode="{TemplateBinding ScrollViewer.HorizontalScrollMode}"
HorizontalScrollBarVisibility="{TemplateBinding ScrollViewer.HorizontalScrollBarVisibility}"
IsHorizontalScrollChainingEnabled="{TemplateBinding ScrollViewer.IsHorizontalScrollChainingEnabled}"
VerticalScrollMode="Enabled"
VerticalScrollBarVisibility="Hidden"
IsVerticalScrollChainingEnabled="{TemplateBinding ScrollViewer.IsVerticalScrollChainingEnabled}"
IsHorizontalRailEnabled="{TemplateBinding ScrollViewer.IsHorizontalRailEnabled}"
IsVerticalRailEnabled="{TemplateBinding ScrollViewer.IsVerticalRailEnabled}"
ZoomMode="{TemplateBinding ScrollViewer.ZoomMode}"
IsDeferredScrollingEnabled="{TemplateBinding ScrollViewer.IsDeferredScrollingEnabled}"
BringIntoViewOnFocusChange="{TemplateBinding ScrollViewer.BringIntoViewOnFocusChange}"
VerticalSnapPointsType="MandatorySingle"
VerticalSnapPointsAlignment="Near"
AutomationProperties.AccessibilityView="Raw">
<controls:PullToRefreshInnerPanel x:Name="InnerPanel"
VerticalAlignment="Stretch">
<Border x:Name="PullToRefreshIndicator"
Height="60"
HorizontalAlignment="Stretch"
Opacity="0">
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center"
VerticalAlignment="Bottom">
<FontIcon RenderTransformOrigin=".5,.5"
Margin="0,0,4,0"
FontSize="18"
Glyph="&#xE110;"
VerticalAlignment="Center"
Foreground="{TemplateBinding PullIndicatorForeground}"
UseLayoutRounding="False">
<FontIcon.RenderTransform>
<RotateTransform x:Name="rotArrow" />
</FontIcon.RenderTransform>
</FontIcon>
<StackPanel Orientation="Vertical">
<Grid>
<TextBlock Text="{TemplateBinding RefreshText}"
x:Name="notPulling"
FontSize="16"
HorizontalAlignment="Center"
Foreground="{TemplateBinding PullIndicatorForeground}" />
<TextBlock Text="{TemplateBinding ReleaseText}"
Visibility="Collapsed"
x:Name="pulling"
FontSize="16"
HorizontalAlignment="Center"
Foreground="{TemplateBinding PullIndicatorForeground}" />
</Grid>
<TextBlock Text="{TemplateBinding PullSubtext}"
HorizontalAlignment="Center"
Visibility="Collapsed"
FontSize="12"
Foreground="{TemplateBinding PullIndicatorForeground}" />
</StackPanel>
</StackPanel>
</Border>
<ScrollViewer x:Name="ItemsScrollViewer"
VerticalScrollMode="Enabled"
VerticalScrollBarVisibility="Hidden">
<ItemsPresenter Header="{TemplateBinding Header}"
HeaderTemplate="{TemplateBinding HeaderTemplate}"
HeaderTransitions="{TemplateBinding HeaderTransitions}"
Footer="{TemplateBinding Footer}"
FooterTemplate="{TemplateBinding FooterTemplate}"
FooterTransitions="{TemplateBinding FooterTransitions}"
Padding="{TemplateBinding Padding}" />
</ScrollViewer>
</controls:PullToRefreshInnerPanel>
</ScrollViewer>
</controls:PullToRefreshOuterPanel>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

<Geeking>

The first most interesting and important detail of this solution is the trick the Microsoft sample used to bypass the classic problem of putting a ListView inside a ScollViewer which effectively kills Virtualization. Since the PullToRefresh indicator needs to be inside and above the ScrollViewer that hosts the list the same problem applies here. To overcome this
the Microsoft sample used two custom panels (an Inner and Outer) that know each other and can “talk” to each other. The reason virtualization is killed is because when a ListView is hosted inside a ScrollViewer then it thinks it has infinite vertical space because that’s what the ScrollViewer is telling it. The solution above works around this problem by allowing the Inner panel to figure out the Outer panel’s actual visible height and thus correctly calculate it’s own actual visible height which it can get by simply subtracting the size of the PullToRefresh indicator from the Outer panel’s height.
Cool trick.

The second most interesting and important detail was figuring out if the user released their finger when the ScrollViewer was over-stretched.  The limitation of the sample is that the Refresh is scheduled to be triggered once the user overstretches even if the user decides to cancel by slowly unstretching the ScrollViewer back to the original position.
The original attempts to solve this failed because ScrollViewer only exposes the DirectManipulationStarted and DirectManipulationCompleted events and the second one fires too late. Loong after the user has released their finger. The DirectManipulationPointerReleased  event was not available unfortunately.
The trick to solve this turned out to be the use of the ViewChanging event (see line 114) and figuring out the exact moment the user released his/her finger from the screen.
When this occurs at the desired time then :

  1. The event would be Inertial.
  2. The ScrollViewer would be overstretched so VerticalOffset should be zero and
  3. The direction of the inertial movement should be upwards towards the non-stretched state.

This allowed users to change their minds if they didn’t really want to refresh.

</Geeking>

Update: Microsoft has released an updated PullToRefresh  sample that you might also want to evaluate here. There’s also the UWP Community Toolkit here.