Pull to refresh for WinRT

'Pull to refresh' has been a standard UX pattern which is expected in all apps, on all platforms and I've made it available for Windows Phone 8.1 and Windows 8.1 (Universal) apps. 

The solution has been wrapped in a single control based on a ScrollViewer which is ready to use right out of the box.

<controls:PullToRefreshScrollViewer Grid.Row="1">
 ..Content goes here..
</controls:PullToRefreshScrollViewer>

Download and try the source

Pro tip: The sample works for Windows 8.1 apps as well! 

Should you need Pull to refresh for Listviews and gridviews it modifying this implementation should be fairly easy. So don't hesitate giving it a shot! 



 

Pull to refresh implementation explained

<controls:PullToRefreshScrollViewer
Grid.Row="1"
RefreshContent="PullToRefreshScrollViewer_RefreshContent"
RefreshCommand="{Binding RefreshCommand}"
ArrowColor="#004050"
RefreshText="Release to refresh"
PullText="Pull to refresh" />

This is the available properties in the control (PullToRefreshScrollViewer.cs)

  • RefreshContent (event)
  • RefreshCommand (Command)
  • ArrowColor (Brush)
  • RefreshText (string)
  • PullText (string)

The Command and event is invoked when the user pulls down and releases appropriately. 
ArrowColor is the brush of the arrow that animates once the user pulls down. During the pull there's two different texts - RefreshText and PullText
 

Deep dive

As far as I know there isn't a smooth and easy way to learn from the scrollviewer if the scrollviewer offset is negative.
After endless amounts of googling I couldn't figure out a way to do it so I decided to tweak the scrollviewer using two timers, negative margin, composite translate transform and some code to figure out whether the scrolling has a negative offset and hereby means to trigger the pull to refresh.
This solution is far from ideal (bit of a hack really) but get's the job done.


Loading the control and the timers

The control requires some timers in order to check whether the user scrolls into negative space or not. 
Note: The timers will only run as long as the scrolling offset is 0.

private void PullToRefreshScrollViewer_Loaded(object sender, RoutedEventArgs e)
{
    timer = new DispatcherTimer();
    timer.Interval = TimeSpan.FromMilliseconds(100);
    timer.Tick += Timer_Tick;

    compressionTimer = new DispatcherTimer();
    compressionTimer.Interval = TimeSpan.FromSeconds(1);
    compressionTimer.Tick += CompressionTimer_Tick;

    timer.Start();
}


Working with the offset

Most of the magic comes from the ScrollViewers ViewChanged event which is invoked when the user scrolls. The event is fired continuously, multiple times in the second, and it tells us about the current offset. This is event looks whether we're at the top (offset 0) or not.
If we're at the top we start some timers used to determine if the users decides to pull to refresh.

private void ScrollViewer_ViewChanging(object sender, ScrollViewerViewChangingEventArgs e)
{
    if (e.NextView.VerticalOffset == 0)
    {
        timer.Start();
    }
    else
    {
        if (timer != null)
        {
            timer.Stop();
        }

        if (compressionTimer != null)
        {
            compressionTimer.Stop();
        }

        isCompressionTimerRunning = false;
        isCompressedEnough = false;
        isReadyToRefresh = false;

        VisualStateManager.GoToState(this, VisualStateNormal, true);
    }
}

Determining pull to refresh by using the timers

The timer will tick every 100 ms and will use the TransformToVisual method to detect it's bound and compare it with the expected offset. If the user scrolls far enough and stays there for 1 second - which is checked by using another timer (compression timer) - a boolean property, IsReadyToRefresh, is set to true.
Once the offset goes back to 0 we check IsReadyToRefresh is true and invoke the refresh events to the world. 

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;
        Debug.WriteLine(compressionOffset);

        if (compressionOffset > offsetTreshhold)
        {
            if (isCompressionTimerRunning == false)
            {
                isCompressionTimerRunning = true;
                compressionTimer.Start();
            }

            isCompressedEnough = true;
        }
        else if (compressionOffset == 0 && isReadyToRefresh == true)
        {
            InvokeRefresh();
        }
        else
        {
            isCompressedEnough = false;
            isCompressionTimerRunning = false;
        }
    }
}

We use a visual state to change the text and rotate the arrow. If we after a second stayed long enough within the right bounds, we've set isReadyToRefresh to true and show the "release ready" visual state telling the user it will refresh once released.

private void CompressionTimer_Tick(object sender, object e)
{
    if (isCompressedEnough)
    {
        VisualStateManager.GoToState(this, VisualStateReadyToRefresh, true);
        isReadyToRefresh = true;
    }
    else
    {
        isCompressedEnough = false;
        compressionTimer.Stop();
    }
}


Telling the page and ViewModel about the refresh

The final part is telling the world that it should refresh the content. The world outside the control can listen by using an event or a command. If either is available they'll be raised once the InvokeRefresh is called and the control returns to it's original state.

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);
    }
}

Final words

This is a quite hacky way to do it, but I couldn't find any other way when working with a scrollviewer. The solution is plug an play and works right out of the box. It get's the job done and hopefully this will be a lot simpler with Windows 10.

I'd love to hear your thoughts, improvement and feedback in the comments below - so don't hesitate writing something! 

P.S Make sure you follow me on twitter @deanihansen for more news, design tips, articles and how-to's.