If you’ve ever tried to pull this off, you’ve likely either:
- pulled your hair out then drank ruthlessly in celebration or
- researched what it takes and said “yeah… looks like we’re going with a button”
I was the same way.
While perusing through NuGet yesterday, I found a (relatively) new project, PullToRefresh.UWP. “WHAT?!” I thought and immediately hit the project page link. Unfortunately, the entire site is in Chinese (though Internet Explorer does a decent job of translating w/ the Bing Bar) so I thought I’d write this to help out other devs that might be trying to use it, and show how I threw it in to my latest UWP.
Obviously, first things first:
nuget install PullToRefresh.UWP
Once you’ve done that, it’s time to integrate! As the project page shows with its XAML example, the most basic implementation looks like:
<pr:PullToRefreshBox x:Name="pr"
RefreshInvoked="PullToRefreshBox_RefreshInvoked">
<ListView x:Name="lv"
ItemTemplate="{StaticResource ColorfulRectangle}" />
</pr:PullToRefreshBox>
But let’s break this apart into the parts that matter. If you look at what PullToRefresh.UWP
offers you, you have a few other controls, namely PullToRefreshScrollViewer
. But alas, they’re deprecated and tell you to use the Box. I like this approach as it means you can literally shove *any* scrolling container into the Box and then the work is done for you.
When you use the base implementation, your refresh trigger height is 80px (you can find this by inspecting an instance of PullToRefreshBox
and looking at RefreshThreshold
). The nice thing is you can change this to be whatever you want.
The default implementation also includes a nice “progressively drawn” circle showing the progress toward the threshold, but alas the “Pull
” and “Refresh
” wording is in Chinese. To change this, you need to template the Top Indicator of the Box like so:
<ptr:PullToRefreshBox Grid.Row="1"
RefreshInvoked="PullToRefreshBox_RefreshInvoked">
<ptr:PullToRefreshBox.TopIndicatorTemplate>
<DataTemplate>
<ptr:PullRefreshProgressControl Progress="{Binding}"
PullToRefreshText="Pull"
ReleaseToRefreshText="Release" />
</DataTemplate>
</ptr:PullToRefreshBox.TopIndicatorTemplate>
There are a couple of things at play here:
- The
TopIndicatorTemplate
is set to contain a default instance of PullRefreshProgressControl
, with the Pull
and Release
text set to what we want
- The
Progress
property of the ProgressControl
is bound to the datacontext of the IndicatorTemplate
. This is by default set to the % complete the box has been “pulled
” relative to its threshold.
When the Progress
value is set, the Control
is doing work internally to paint the circle that gradually completes, and then switch the Visual State Manager to a “ReleaseToRefresh
” state value. This state is what changes the text from the PullToRefreshText
value to the ReleaseToRefreshText
value. On the project page, you can see a fully templated instance of the progress control which uses only text and reacts to this Visual State change.
But what if we want to go to the next level and make the style completely our own?
It’s as simple as our good friend UserControl
. I created one like this:
<UserControl xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:MyApp.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:Foundation="using:Windows.Foundation"
x:Name="userControl"
x:Class="MyApp.Controls.PullToRefresh"
mc:Ignorable="d">
<Grid VerticalAlignment="Bottom">
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="VisualStateGroup">
<VisualStateGroup.Transitions>
<VisualTransition GeneratedDuration="0:0:0.3"
To="ReleaseToRefresh">
<VisualTransition.GeneratedEasingFunction>
<QuarticEase EasingMode="EaseIn" />
</VisualTransition.GeneratedEasingFunction>
<Storyboard>
<ObjectAnimationUsingKeyFrames
Storyboard.TargetProperty="(UIElement.Visibility)"
Storyboard.TargetName="PullTextBlock">
<DiscreteObjectKeyFrame KeyTime="0">
<DiscreteObjectKeyFrame.Value>
<Visibility>Visible</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
<DiscreteObjectKeyFrame KeyTime="0:0:0.3">
<DiscreteObjectKeyFrame.Value>
<Visibility>Collapsed</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
</ObjectAnimationUsingKeyFrames>
<ObjectAnimationUsingKeyFrames
Storyboard.TargetProperty="(UIElement.Visibility)"
Storyboard.TargetName="ReleaseTextBlock">
<DiscreteObjectKeyFrame KeyTime="0">
<DiscreteObjectKeyFrame.Value>
<Visibility>Visible</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
<DiscreteObjectKeyFrame KeyTime="0:0:0.3">
<DiscreteObjectKeyFrame.Value>
<Visibility>Visible</Visibility>
</DiscreteObjectKeyFrame.Value>
</DiscreteObjectKeyFrame>
</ObjectAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Opacity)"
Storyboard.TargetName="PullTextBlock">
<EasingDoubleKeyFrame KeyTime="0"
Value="1">
<EasingDoubleKeyFrame.EasingFunction>
<QuarticEase EasingMode="EaseIn" />
</EasingDoubleKeyFrame.EasingFunction>
</EasingDoubleKeyFrame>
<EasingDoubleKeyFrame KeyTime="0:0:0.3"
Value="0" />
</DoubleAnimationUsingKeyFrames>
<DoubleAnimationUsingKeyFrames Storyboard.TargetProperty="(UIElement.Opacity)"
Storyboard.TargetName="ReleaseTextBlock">
<EasingDoubleKeyFrame KeyTime="0"
Value="0" />
<EasingDoubleKeyFrame KeyTime="0:0:0.3"
Value="1" />
</DoubleAnimationUsingKeyFrames>
</Storyboard>
</VisualTransition>
</VisualStateGroup.Transitions>
<VisualState x:Name="Normal" />
<VisualState x:Name="ReleaseToRefresh">
<VisualState.Setters>
<Setter Target="PullTextBlock.(UIElement.Visibility)"
Value="Collapsed" />
<Setter Target="ReleaseTextBlock.(UIElement.Visibility)"
Value="Visible" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<Grid.RowDefinitions>
<RowDefinition Height="30" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<RelativePanel x:Name="IconPanel"
HorizontalAlignment="Center"
RenderTransformOrigin="0.5,0.5">
<SymbolIcon x:Name="SyncSymbol"
Symbol="Sync"
RelativePanel.AlignVerticalCenterWithPanel="True"
RelativePanel.AlignHorizontalCenterWithPanel="True"
RenderTransformOrigin="0.5,0.5"
Style="{x:Bind SymbolStyle, Mode=OneWay}">
<SymbolIcon.RenderTransform>
<CompositeTransform ScaleX="1.5"
ScaleY="1.5" />
</SymbolIcon.RenderTransform>
</SymbolIcon>
<SymbolIcon x:Name="UpArrow"
Symbol="Up"
RelativePanel.AlignHorizontalCenterWithPanel="True"
RelativePanel.AlignVerticalCenterWithPanel="True"
RenderTransformOrigin="0.5,0.5"
Style="{x:Bind SymbolStyle, Mode=OneWay}">
<SymbolIcon.RenderTransform>
<CompositeTransform Rotation="180"
ScaleX="0.75"
ScaleY="0.75" />
</SymbolIcon.RenderTransform>
</SymbolIcon>
</RelativePanel>
<TextBlock x:Uid="PullToRefreshTextBox"
x:Name="PullTextBlock"
Grid.Row="1"
Text="Pull to Refresh_zz"
HorizontalAlignment="Center"
Style="{x:Bind TextStyle, Mode=OneWay}" />
<TextBlock x:Name="ReleaseTextBlock"
x:Uid="ReleaseTextBox"
x:DeferLoadStrategy="Lazy"
Grid.Row="1"
HorizontalAlignment="Center"
Text="Release_zz"
Visibility="Collapsed"
Style="{x:Bind TextStyle, Mode=OneWay}" />
</Grid>
</UserControl>
using System;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Media;
namespace MyApp.Controls
{
public sealed partial class PullToRefresh : UserControl
{
public PullToRefresh()
{
this.InitializeComponent();
}
public double PullProgress
{
get { return (double)GetValue(PullProgressProperty); }
set { SetValue(PullProgressProperty, value); }
}
public static readonly DependencyProperty PullProgressProperty =
DependencyProperty.Register("PullProgress",
typeof(double), typeof(PullToRefresh), new PropertyMetadata(0, (o, p) =>
{
var ptr = o as PullToRefresh;
if (ptr != null)
{
var percentProgress = (double)p.NewValue;
var rotationAmount = Math.Min(percentProgress * 180, 180);
ptr.IconPanel.RenderTransform = new RotateTransform
{
Angle = rotationAmount
};
VisualStateManager.GoToState(ptr, (percentProgress >= 1) ?
"ReleaseToRefresh" : "Normal", true);
}
}));
public Style SymbolStyle
{
get { return (Style)GetValue(SymbolStyleProperty); }
set { SetValue(SymbolStyleProperty, value); }
}
public static readonly DependencyProperty SymbolStyleProperty =
DependencyProperty.Register("SymbolStyle",
typeof(Style), typeof(PullToRefresh), new PropertyMetadata(null));
public Style TextStyle
{
get { return (Style)GetValue(TextStyleProperty); }
set { SetValue(TextStyleProperty, value); }
}
public static readonly DependencyProperty TextStyleProperty =
DependencyProperty.Register("TextStyle",
typeof(Style), typeof(PullToRefresh), new PropertyMetadata(null));
}
}
Here are the notables:
- I give a
DependencyProperty
to bind to the Progress
value, same as the out-of-the-box ProgressControl
that was shown earlier.
- I provide Style
DependencyProperties
for the Iconography and the Text of the control and bind the Style property of the Icon and Text elements of the control to those.
- Using Blend, I set up the Visual State Manager with transitions that cross-fade the “Pull” and “Refresh” texts when the user hits the threshold.
- In the Handler for the
Progress DependencyProperty
, I rotate the RelativePanel in which I put the ‘Sync’ and ‘Up’ icons based on the progress value, with a max value of 180 (this “rotates” the iconography to where the ‘Up’ icon goes from pointing down (due to its initial state of being flipped) to pointing up when the user should release to perform the refresh.
The usage of my UserControl
within the Box looks like:
<ptr:PullToRefreshBox.TopIndicatorTemplate>
<DataTemplate>
<myControls:PullToRefresh PullProgress="{Binding}"
VerticalAlignment="Bottom">
<myControls:PullToRefresh.SymbolStyle>
<Style TargetType="SymbolIcon">
<Setter Property="Foreground"
Value="{StaticResource
ApplicationSecondaryForegroundThemeBrush}" />
</Style>
</myControls:PullToRefresh.SymbolStyle>
<myControls:PullToRefresh.TextStyle>
<Style TargetType="TextBlock"
BasedOn="{StaticResource ArticleListItemSummary}">
<Setter Property="Foreground"
Value="{StaticResource
ApplicationSecondaryForegroundThemeBrush}" />
</Style>
</myControls:PullToRefresh.TextStyle>
</myControls:PullToRefresh>
</DataTemplate>
</ptr:PullToRefreshBox.TopIndicatorTemplate>
Which simply puts my control in where the ProgressControl was for the OEM example. Then, I style it to set the foreground of the symbol and text to my app’s secondary foreground theme brush.
Finally, there was one nuance I fought with, which was the main thing that compelled me to write this post: the RefreshThreshold
value. This value must be less than the total height of the contents of the IndicatorTemplate
. Here’s what I mean:
If you have this:
1: <ptr:PullToRefreshBox Grid.Row="1"
2: RefreshInvoked="PullToRefreshBox_RefreshInvoked"
3: RefreshThreshold="160">
4: <ptr:PullToRefreshBox.TopIndicatorTemplate>
5: <DataTemplate>
6: <myControls:PullToRefresh PullProgress="{Binding}"
7: VerticalAlignment="Bottom">
8: <myControls:PullToRefresh.SymbolStyle>
9: <Style TargetType="SymbolIcon">
10: <Setter Property="Foreground"
11: Value="{StaticResource ApplicationSecondaryForegroundThemeBrush}" />
12: </Style>
13: </myControls:PullToRefresh.SymbolStyle>
14: <myControls:PullToRefresh.TextStyle>
15: <Style TargetType="TextBlock"
16: BasedOn="{StaticResource ArticleListItemSummary}">
17: <Setter Property="Foreground"
18: Value="{StaticResource ApplicationSecondaryForegroundThemeBrush}" />
19: </Style>
20: </myControls:PullToRefresh.TextStyle>
21: </myControls:PullToRefresh>
22: </DataTemplate>
23: </ptr:PullToRefreshBox.TopIndicatorTemplate>
Notice line #3: it specifies that the user should pull down 160px before refreshing kicks in. The problem is the size of my PullToRefresh control is going to be ‘Auto’ and not set to be 160 high, so when it gets the “Visual Size” of the element, it stops at some value < 160, so Progress never hits 1.0 (complete).
Logical step? Set the height of my control:
1: <ptr:PullToRefreshBox Grid.Row="1"
2: RefreshInvoked="PullToRefreshBox_RefreshInvoked"
3: RefreshThreshold="160">
4: <ptr:PullToRefreshBox.TopIndicatorTemplate>
5: <DataTemplate>
6: <myControls:PullToRefresh PullProgress="{Binding}"
7: Height="160"
8: VerticalAlignment="Bottom">
Making lines 3 & 7 equal seems right, but doesn’t quite do it. Why? I have no idea, honestly. What I had to do to make this work was make the height of the Indicator Template at least +1 from the value of RefreshThreshold.
<ptr:PullToRefreshBox Grid.Row="1"
RefreshInvoked="PullToRefreshBox_RefreshInvoked"
RefreshThreshold="160">
<ptr:PullToRefreshBox.TopIndicatorTemplate>
<DataTemplate>
<myControls:PullToRefresh PullProgress="{Binding}"
Height="161"
VerticalAlignment="Bottom">
Once I did this, everything worked.
Behold, the magic!
I hope this helps you to integrate this desperately-needed component in to your UWP! It’s worth noting this will work on any UWP when touch interaction is enabled. For instance, when using a mouse in an app, you can’t click & pull a scroll viewer, so that doesn’t work, but switching to touch interaction (e.g.: using your finger on a Surface Pro vs the trackpad), it works just like it does on Phone.