Maintaining proportional scroll position

In the AdventureWorks Shopper app, we spent a lot of time making sure all GridView/ListView scroll viewers’ scroll positions behave as intuitively as possible. This means that if you have a GridView/ListView that has enough content to pan, and you pan through, for example 75% of the content, you should be able to rotate the device, go to split screen, or even go to minimal width and the scroll bar should be vertical but still be at 75%. This also means that if you navigate forward and then back, the scroll bar should also show 75%.

Implementing this functionality was made much simpler in the AdventureWorks Shopper app with the AutoRotatingGridView control that automatically uses a horizontal scroll bar in landscape mode and a vertical scroll bar in portrait mode.

Let’s take a look at the AdventureWorks Shopper HubPage. To capture the AutoRotatingGridView’s scroll viewer’s scroll position we listen to the LayoutUpdated event.

  1.  void itemGridView_LayoutUpdated(object sender, object e)
    {
        _scrollViewerOffsetProportion = 
             ScrollViewerUtilities.GetScrollViewerOffsetProportion(_itemsGridViewScrollViewer);
    }
    

Notice that the LayoutUpdated event handler uses the itemGridView’s embedded ScrollViewer which is captured after the itemGridView is loaded.

  1.  void itemGridView_Loaded(object sender, RoutedEventArgs e)
    {
        _itemsGridViewScrollViewer = VisualTreeUtilities.GetVisualChild<ScrollViewer>(itemGridView);
    }
    

Here is the source for the GetVisualChild<T> method:

  1.  public static T GetVisualChild<T>(DependencyObject parent) where T : DependencyObject
    {
        T child = default(T);
        int numVisuals = VisualTreeHelper.GetChildrenCount(parent);
        for (int i = 0; i < numVisuals; i++)
        {
            DependencyObject v = VisualTreeHelper.GetChild(parent, i);
            child = v as T;
            if (child == null)
                child = GetVisualChild<T>(v);
            if (child != null)
                break;
        }
        return child;
    }
    
    

Here is the source for the GetScrollViewerOffsetProportion method:

  1.  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;
    }
    

Now that we’ve captured the scroll offset proportion (_scrollViewerOffsetProportion) after the user has scrolled, let’s use this value to scroll to the same approximate position when the user changes rotates their device or resizes the frame. When the page size changes we will check to see if the scroll viewer has finished resizing. We need to let the scroll viewer settle before we can change its scroll offset. One way we found to determine when the scroll viewer has stabilized is to examine the ComputedHorizontalScrollBarVisibility and ComputedVerticalScrollBarVisibility properties.

  •  void Page_SizeChanged(object sender, SizeChangedEventArgs e)
    {
        var scrollViewer = VisualTreeUtilities.GetVisualChild<ScrollViewer>(itemsGridView);
    
        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;
            }
        }
    }
    

If these two properties equal Visibility.Visible then the scroll viewer is ready to be modified/scrolled. If not, then we use the DependencyPropertyChangedHelper to listen for changes in these two dependency properties.

 private void ScrollBarVisibilityChanged(object sender, DependencyPropertyChangedEventArgs e)
{
    var helper = (DependencyPropertyChangedHelper)sender;

    var scrollViewer = VisualTreeUtilities.GetVisualChild<ScrollViewer>(itemsGridView);

    if (((Visibility)e.NewValue) == Visibility.Visible)
    {
        ScrollViewerUtilities.ScrollToProportion(scrollViewer, _scrollViewerOffsetProportion);
        helper.PropertyChanged -= ScrollBarVisibilityChanged;
    };

    if (_isPageLoading)
    {
        itemsGridView.LayoutUpdated += itemsGridView_LayoutUpdated;
        _isPageLoading = false;
    }
}

In the ScrollBarVisibilityChanged handler, we check to see if the dependency property has changed to Visible. If so, we update the scroll viewer by calling its ChangeView method.

 public static void ScrollToProportion(ScrollViewer scrollViewer, double scrollViewerOffsetProportion)
{
    // Update the Horizontal and Vertical offset
    if (scrollViewer == null) return;
    var scrollViewerHorizontalOffset = scrollViewerOffsetProportion * scrollViewer.ScrollableWidth;
    var scrollViewerVerticalOffset = scrollViewerOffsetProportion * scrollViewer.ScrollableHeight;

    scrollViewer.ChangeView(scrollViewerHorizontalOffset, scrollViewerVerticalOffset, null);
}

To recap, we have:

  1. Captured the AutoRotatingGridView’s scroll viewer’s offset value as a proportion to its total size.
  2. After the user resizes the window either by rotating the device or changing the size of the frame, we apply the offset proportion value back to the AutoRotatingGridView.

Notice that if the user navigates to another page or the app is terminated, this offset proportion value is lost. In order to preserve this value, we utilize the SaveState and LoadState methods provided by the VisualStateAwarePage base class.

         protected override void SaveState(System.Collections.Generic.Dictionary<string, object> pageState)
        {
            if (pageState == null) return;

            base.SaveState(pageState);

            pageState["scrollViewerOffsetProportion"] = 
                ScrollViewerUtilities.GetScrollViewerOffsetProportion(_itemsGridViewScrollViewer);
        }
 
        protected override void LoadState(object navigationParameter, Dictionary<string, object> pageState)
        {
            if (pageState == null) return;

            base.LoadState(navigationParameter, pageState);

            if (pageState.ContainsKey("scrollViewerOffsetProportion"))
            {
                _scrollViewerOffsetProportion = 
                    double.Parse(pageState["scrollViewerOffsetProportion"].ToString());                
            }
        }

After the LoadState method populates the _scrollViewerOffsetProportion value, the Page_SizeChanged event handler mentioned above will update the scroll viewer to the appropriate scroll offset position.

I’ve tried to do this using separate GridView and ListView controls instead of a single AutoRotatingGridView control and the code is much more complicated and repetitive. You need to get the offset proportion from the GridView control and set it into the ListView control when the app goes from Landscape to Portrait, and vice versa when the app goes from Portrait to Landscape.