How to make a Win8.1 app with ScrollViewer respond to size changes

Users expect Windows8.1 apps to resize gracefully - either when they "snap" the app to a particular size, or when they change the orientation of their device. This turns out surprisingly difficult with a ScrollViewer. The details are below...

  • Download full source code - ScrollViewerKeepPosition.zip [592k, VB, requires Win8.1/VS2013]
  • Sorry I don't have C# code. But you should be able to read the VB and figure out how to do it in C#.

 

My app displays a collection of full-screen images. The user can swipe left and right to look through them. I do this with a ScrollViewer that contains a horizontal StackPanel.

If the user shrinks down my app, or turns their device on the side, I'd obviously like it to keep showing the same image:

   

First, here's the XAML code for the images. Note that I use "HorizontalSnapPointsType=MandatorySingle" to make sure it snaps exactly to an image, so the user can't scroll half-way between two images.

<ScrollViewer x:Name="scroll1" HorizontalScrollBarVisibility="Auto" ZoomMode="Disabled"
    VerticalScrollMode="Disabled" HorizontalSnapPointsType="MandatorySingle"
    HorizontalAlignment="Left" VerticalAlignment="Top" Background="Black">
    <StackPanel x:Name="panel1" Orientation="Horizontal">
        <Image Source="Assets/pic1.jpg" Stretch="Uniform"/>
        <Image Source="Assets/pic2.jpg" Stretch="Uniform"/>
<Image Source="Assets/pic3.jpg" Stretch="Uniform"/>
<Image Source="Assets/pic4.jpg" Stretch="Uniform"/>
    </StackPanel>
</ScrollViewer>

How to resize the images in response to the app changing size

The first task is to resize the images in response to the app's size changing (including changes due to changing the orientation of the device). I'll do this in response to my page's SizeChanged event:

Sub OnSizeChanged(sender As Object, e As SizeChangedEventArgs) Handles Me.SizeChanged
    For Each i In panel1.Children.OfType(Of Image)()
i.Width = e.NewSize.Width : i.Height = e.NewSize.Height
      Next
End Sub

How to keep looking at the same image

The second task is to make it so that, even when you resize, the ScrollViewer remains looking at the same image.

You'd think this would be easy: you could read the current value of scroll1.HorizontalOffset at the start of the SizeChanged, figure out which image the user has on-screen, resize each image, calculate what should be the new HorizontalOffset for the same image, and scroll to it.

But it's not easy. That's because the ScrollViewer has its own logic for when to scroll in response to a window-size-change event, and we have to battle against this logic...

  • First, if its ScrollViewer.HorizontalSnapPointsType property isn't "None" then it will automatically scroll to the wrong new position.
  • Second, if the ScrollViewer's HorizontalAlignment or VerticalAlignment is set to Stretch, that can let it resize itself automatically in response to a window-size-change, it in response to this it automatically scrolls to the wrong position.
  • Third, even in response to a manual change of size, the ScrollViewer will automatically scroll to the wrong new position
  • Fourth, if you manually scroll it to a new position but leave "disableAnimation := False" then its own automatic scrolling will stomp on top of your own.

So: you're going to have to resize the ScrollViewer manually, and you're going to have to wait until exactly the right moment to scroll to the correct new position, and you have to do it in the correct way. My solution is below.

Take care! I don't know the internals of how XAML works. This isn't an official Microsoft answer on how to tame ScrollViewer. I've just figured out this code through trial and error. Many of my earlier attempts seemed to work most of the time, but then failed sometimes. I've spent a full 30 minutes just dragging and resizing with the code below, and it hasn't failed yet.

Note: In the following code I've used "TaskCompletionSource" so I can await until after the ScrollViewer.SizeChanged event has fired. This is a common technique to turn event-based programming (hard!) into await-based programming (easier!)

Async Sub OnSizeChanged(sender As Object, e As SizeChangedEventArgs) Handles Me.SizeChanged
    ' I will remember which picture the user is currently viewing
    Dim index = Math.Round(If(e.PreviousSize.Width = 0, 0, scroll1.HorizontalOffset / e.PreviousSize.Width))
    scroll1.HorizontalSnapPointsType = SnapPointsType.None ' *** If you don't disable snapping then it doesn't work

    ' Next, I resize all images so each one is again full-window-sized
    For Each i In panel1.Children.OfType(Of Image)()
        i.Width = e.NewSize.Width
        i.Height = e.NewSize.Height
    Next

    ' Now that the images have changed size, I will scroll to the same one that the user was previously viewing
    Dim tcs As New TaskCompletionSource(Of Object)
    Dim lambda As SizeChangedEventHandler = Sub(sender2, e2) tcs.TrySetResult(Nothing)
    AddHandler scroll1.SizeChanged, lambda
    scroll1.Width = e.NewSize.Width
    scroll1.Height = e.NewSize.Height
    Await tcs.Task ' *** if you don't await the ScrollViewer.SizeChanged event then it doesn't work
    RemoveHandler scroll1.SizeChanged, lambda
    scroll1.ChangeView(e.NewSize.Width * index, Nothing, Nothing, disableAnimation:=True) ' *** if "disableAnimation:=False" then it doesn't work
    scroll1.HorizontalSnapPointsType = SnapPointsType.MandatorySingle ' *** if this is done before scrolling then it doesn't work
End Sub