Pull-to-refresh on a Windows 10 UWP

By | October 28, 2015

If you’ve ever tried to pull this off, you’ve likely either A) pulled your hair out then drank ruthlessly in celebration or B) 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 IE 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 thing’s 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 in to 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 in to 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:

  1. The TopIndicatorTemplate is set to contain a default instance of PullRefreshProgressControl, with the Pull and Release text set to what we want
  2. 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;

// The User Control item template is documented at http://go.microsoft.com/fwlink/?LinkId=234236

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); }
        }
        // Using a DependencyProperty as the backing store for PullProgress.  This enables animation, styling, binding, etc...
        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); }
        }
        // Using a DependencyProperty as the backing store for SymbolStyle.  This enables animation, styling, binding, etc...
        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); }
        }
        // Using a DependencyProperty as the backing store for TextStyle.  This enables animation, styling, binding, etc...
        public static readonly DependencyProperty TextStyleProperty =
            DependencyProperty.Register("TextStyle", typeof(Style), typeof(PullToRefresh), new PropertyMetadata(null));
    }
}

 

Here are the notables:

  1. I give a DependencyProperty to bind to the Progress value, same as the out-of-the-box ProgressControl that was shown earlier
  2. 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
  3. 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
  4. 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 (eg: using your finger on a Surface Pro vs the trackpad) it works just like it does on Phone.