Maintain scroll position in Windows Phone 8.1, without caching

Ensuring that your users have a consistent view when they navigate through your application can be one of the most time consuming parts of development. This has been made even more difficult with Universal Apps in WP 8.1 due to a change in the navigation model.

Scenario

The issue becomes apparent when you try to use the same page twice in the same stack. The best example I’ve seen for this is one of TV shows. If your app is showing off seasons of a TV series, with a list of episodes which the user could scroll through and links to other shows that were relevant, then your application may have this kind of journey:

Awesome Show: Season 1 -> Awesome Show: Season 2 -> Also Starred In -> Fantastic Show: Season 1

From a url point of view this would translate into:

Season.xaml?s=1&e=1 -> Season.xaml?s=1&e=2 -> AlsoStarred.xaml?s=1 -> Season.xaml?s=2&e=1

In the old model, each of these unique urls would have had an instance stored in the stack, so that when your user tapped back to the Awesome Show episodes, the position on the page would be in the same position as when you left it. This is no longer the case.

Problem

With the new model in Universal apps, the stack uses the type of page, not the url. Often in posts regarding the issue of UI state, you see the following line of code applied:

NavigationCacheMode = NavigationCacheMode.Enabled;

This is fine if the page is used a single instance at a time, but in our scenario we have a navigation stack with multiple instances. The arguments in the url will allow you to know the season you’re looking at has changed, but each time you reset the data, the user starts at the beginning of the list again.

What this means is that you have to ensure the state is correctly recorded when you navigate away from the page, and then restored each time you go back. For a list of items, this can be a little tricky.

Solution

The first thing to do is to ensure that all your settings are either fresh each time, or coming from your code, so disable the NavigationCacheMode.

NavigationCacheMode = NavigationCacheMode.Disabled;

Then, for those pages where you’re expecting multiple instances, Alter the code where you’re loading the state (I’m going to assume you’re using a NavigationHelper class or equivalent). What this does is store the state of each instance based on the parameters you’re passing into that page (we want it to serialize is the app gets suspended, so stick with strings). Don’t worry about the JSON.NET, I use JObjects to store my state, but the same rules apply with the standard dictionary code.

private void OnLoadState(object sender, LoadStateEventArgs loadStateEventArgs)
		{
			if (loadStateEventArgs.NavigationParameter is string)
			{
				_parameter = (string)loadStateEventArgs.NavigationParameter;
				if (loadStateEventArgs.PageState != null && loadStateEventArgs.PageState[_parameter] != null)
				{
					((BasePage)Page).LoadState(loadStateEventArgs.PageState[_parameter].Value<JObject>());
				}
				else
				{
					((BasePage)Page).LoadState(loadStateEventArgs.PageState);
				}
			}
			else
			{
				((BasePage)Page).LoadState(loadStateEventArgs.PageState);
			}
		}

So at this stage each instance is running off it’s own object in terms of state management, so by the time your page has to save or load the details of the episode ListView control, you don’t have to worry about clashes.

When it comes to the ListView itself, the biggest mistake I made when trying to figure this out was storing the overall position of the internal ScrollViewer. With virtualization in the control, that will not accurately give you a position to restore as the height of the internal list changes so frequently depending on the loading scenario. The important pieces of information are:

  • The list of items in the ListView
  • The first visible item on screen
  • The offset of that item, so you know how much of the item is visible

With these three things, you can correctly restore the list so that the user believes they never left. How you restore the data is dependant on how you create your data source, but the important pieces are the methods that figure out the first item and its offset. These are below (I use a delimited string as a return variable simply so it can be placed directly against the page state). Please note that you have to ensure the ListView is loaded before setting the scroll position, otherwise you’ll be setting it before it’s started to render the items of your collection.

        public static string GetScrollPosition(this ListView view)
        {
            if (view == null)
            {
                return null;
            }

            if (view.Items != null && view.Items.Count == 0)
            {
                return null;
            }

            var panel = view.GetFirstDescendantOfType<ItemsStackPanel>();
            var scroller = view.GetFirstDescendantOfType<ScrollViewer>();

            var index = panel.FirstVisibleIndex;
            var container = (FrameworkElement)view.ContainerFromIndex(index);
            if (container == null || scroller == null)
            {
                return null;
            }
            var transform = container.TransformToVisual(scroller);
            var translated = transform.TransformPoint(new Point(0, 0));
            var reverseTransform = translated.Y < 0 ? Math.Abs(translated.Y) : 0 - translated.Y;

            if (index == 0 && reverseTransform == 0)
            {
                return null;
            }

            return string.Format("{0}_{1}", index, reverseTransform);
        }

        public static Task SetScrollPosition(this ListView view, string scrollPosition)
        {
            if (String.IsNullOrWhiteSpace(scrollPosition))
            {
                return Task.FromResult((object)null);
            }

            return ViewLoader(view, scrollPosition);
        }

        private static Task ViewLoader(ListView view, string scrollPosition)
        {
            var pieces = scrollPosition.Split('_');
            var index = int.Parse(pieces[0]);
            var offset = double.Parse(pieces[1]);
            view.ScrollIntoView(view.Items[index]);

            return view.Dispatcher.RunAsync(CoreDispatcherPriority.Low, () =>
            {
                var scroller = view.GetFirstDescendantOfType<ScrollViewer>();
                if(scroller != null)
                {
                scroller.ChangeView(null, scroller.VerticalOffset + offset, null, false);
                }
            }).AsTask();
        }

The offset scrolling has to be run through the dispatcher, otherwise ScrollIntoView won’t take affect before the offset is applied and you end up being off enough in your rendering that it can confuse the user, even minor offset issues can case problems. So play around with it and see what works.

Leave a Reply

Your email address will not be published. Required fields are marked *