Monday, 10 March 2014

Capturing and setting a ScrollViewer’s scroll position to maintain scroll position across view states

Previously I’ve written about how Prism for the Windows Runtime can be used to save and restore view state. This is achieved by overriding the SaveState and LoadState methods in a page that derives from the VisualStateAwarePage class, and then saving the desired state. I extended the sample photo viewer app so that the user will see the page content scrolled to the exact location it was at prior to termination or navigation, regardless of whether the page orientation has changed in between termination and reactivation. However, I didn’t document how the scroll position is obtained from a control, and re-applied when the window size changes.

In this blog post I’ll explain how the scroll position is obtained from a control, and re-applied when the window size changes. The approach is to capture the ScrollViewer’s offset value as a proportion of its total size. Then, once the user rotates the device or changes the size of the frame (thus effectively resizing the window), the offset proportion is restored to the control. This means that if a GridView control has enough content to pan, and you pan to 80% of the content, you can rotate the device, or switch to MinimalLayout, and the scroll bar should be vertical but still at 80% of the content. In addition, if you navigate forward and then back, the scroll bar should still be at 80% of the content.

This technique was devised as an intuitive user experience for the AdventureWorks Shopper reference implementation.

Implementation

When the page loads the ScrollViewer embedded in the GridView is captured by the Loaded event handler for the page. The Loaded event fires when the control has been constructed and added to the object tree, meaning that it’s ready for interaction.

private void photosGridView_Loaded(object sender, RoutedEventArgs e)
{
    _photosGridViewScrollViewer = VisualTreeUtilities.GetVisualChild<ScrollViewer>(photosGridView);
}

This method uses the GetVisualChild method in the VisualTreeUtilites class (not shown here). In this usage the method walks the visual tree to obtain the ScrollViewer instance located in the GridView instance.

The LayoutUpdated event handler is then used to capture the ScrollViewer’s scroll position. The LayoutUpdated event fires when the layout of the visual tree changes, due to layout-relevant properties changing value or some other action that refreshes the layout.

private void photosGridView_LayoutUpdated(object sender, object e)
{
    _scrollViewerOffsetProportion = ScrollViewerUtilities.GetScrollViewerOffsetProportion(_photosGridViewScrollViewer);
}

This method simply uses the GetScrollViewerOffsetProportion in the ScrollViewerUtilites class to calculate the offset proportion.

public static double GetScrollViewerOffsetProportion(ScrollViewer scrollViewer)
{
    if (scrollViewer == null) return 0;
 
    var horizontalOffsetProportion = (scrollViewer.ScrollableWidth == 0) ? 0 : (scrollViewer.HorizontalOffset / scrollViewer.ScrollableWidth);
    var verticalOffsetProportion = (scrollViewer.ScrollableHeight == 0) ? 0 : (scrollViewer.VerticalOffset / scrollViewer.ScrollableHeight);
 
    var scrollViewerOffsetProportion = Math.Max(horizontalOffsetProportion, verticalOffsetProportion);
    return scrollViewerOffsetProportion;
}

The offset proportion is calculated as the maximum of either the HorizontalOffset divided by ScrollableWidth or the VerticalOffset divided by the ScrollableHeight. This value can then be restored when the user rotates their device or resizes the frame, in order to scroll to the same location.

After the page size changes the ScrollViewer can be set with the offset proportion. However, we must first wait for the ScrollViewer to finish rendering its items. One way to determine this is to examine the ComputedHorizontalScrollBarVisibility and ComputedVerticalScrollBarVisibilty properties in the SizeChanged event handler. The SizeChanged event fires when either the ActualHeight or ActualWidth property value changes on the page.

private void Page_SizeChanged(object sender, SizeChangedEventArgs e)
{
    var scrollViewer = VisualTreeUtilities.GetVisualChild<ScrollViewer>(photosGridView);
 
    if (scrollViewer != null)
    {
        if (scrollViewer.ComputedHorizontalScrollBarVisibility == Visibility.Visible && scrollViewer.ComputedVerticalScrollBarVisibility == Visibility.Visible)
        {
            ScrollViewerUtilities.ScrollToProportion(scrollViewer, _scrollViewerOffsetProportion);
        }
        else
        {        
            DependencyPropertyChangedHelper horizontalHelper = new DependencyPropertyChangedHelper(scrollViewer, "ComputedHorizontalScrollBarVisibility");
            horizontalHelper.PropertyChanged += ScrollBarVisibilityChanged;
 
            DependencyPropertyChangedHelper verticalHelper = new DependencyPropertyChangedHelper(scrollViewer, "ComputedVerticalScrollBarVisibility");
            verticalHelper.PropertyChanged += ScrollBarVisibilityChanged;
        }
    }
}

Here, if the ComputedHorizontalScrollBarVisibility and ComputedVerticalScrollBarVisibility both equal Visibility.Visible then the ScrollViewer can be scrolled by calling the ScrollToProportion method of the ScrollViewerUtilities class. If the two properties are not set to Visibility.Visible, then the DependencyPropertyChangedHelper class is used to listen for changes in the two properties. The ScrollBarVisibilityChanged event handler is executed when either of the two properties change.

private void ScrollBarVisibilityChanged(object sender, DependencyPropertyChangedEventArgs e)
{
    var helper = (DependencyPropertyChangedHelper)sender;
    var scrollViewer = VisualTreeUtilities.GetVisualChild<ScrollViewer>(photosGridView);
 
    if (((Visibility)e.NewValue) == Visibility.Visible)
    {
       ScrollViewerUtilities.ScrollToProportion(scrollViewer, _scrollViewerOffsetProportion);
        helper.PropertyChanged -= ScrollBarVisibilityChanged;
    }
 
    if (_isPageLoading)
    {
        photosGridView.LayoutUpdated += photosGridView_LayoutUpdated;
        _isPageLoading = false;
    }
}

This event handler checks if either of the two properties have changed to Visibility.Visible. If they have, the ScrollViewer is updated by calling the ScrollToProportion method of the ScrollViewerUtilities class.

public static void ScrollToProportion(ScrollViewer scrollViewer, double scrollViewerOffsetProportion)
{
    if (scrollViewer == null) return;
    var scrollViewerHorizontalOffset = scrollViewerOffsetProportion * scrollViewer.ScrollableWidth;
    var scrollViewerVerticalOffset = scrollViewerOffsetProportion * scrollViewer.ScrollableHeight;
 
    scrollViewer.ChangeView(scrollViewerHorizontalOffset, scrollViewerVerticalOffset, null);
}

In the ScrollToProportion method the offset is calculated as the offset proportion multiplied by the ScrollableHeight, and the offset proportion multiplied by the ScrollableWidth (one for landscape, one for portrait). Then the ChangeView method is called on the ScrollViewer instance to load a new view into the viewport using the offsets.

If the user navigates to another page or the app is terminated, the offset proportion is lost. Therefore, the SaveState and LoadState methods are used to preserve this value. For more information about this see one of my previous blog posts on this topic.

Summary

In this blog post I’ve explained how the scroll position is obtained from a control, and re-applied when the window size changes. The approach is to capture the ScrollViewer’s offset value as a proportion of its total size. Then, once the user rotates the device or changes the size of the frame, the offset proportion is restored to the control

The sample app can be downloaded here.

No comments:

Post a comment