July 2011

Volume 26 Number 07

UI Frontiers - Page Transitions in Windows Phone 7

By Charles Petzold | July 2011

Charles PetzoldThe Rule of Three exists in several disparate disciplines. In comedy, for example, the Rule of Three dictates that a joke begin by engaging the audience’s interest, then raise anticipation and, finally, be somewhat less funny than anticipated.

In economics, the Rule of Three relates to the existence of three major competitors in a particular market. In programming, it’s more like a Three-Strike Rule: Whenever a chunk of similar code appears three times, it should be refactored into a common loop or method. (I originally heard this rule as “Three or more? Use a for.” Or perhaps I made up that jingle myself.)

This brings us to the subject of text layout. Sometimes programmers need to display a document that’s too long to fit on a single screen. Perhaps the least desirable approach involves shrinking the font size until the document fits. A more traditional solution is a scrollbar. But in recent years—and particularly on smaller devices such as tablets and phones—there’s been a trend toward paginating the document and letting the user flip through the pages as if reading a real printed book or magazine.

Displaying a paginated document also involves a Rule of Three: For the most fluid page transitions, the UI needs to support three distinct pages—the current page, the next page and the previous page. As the user pages forward, the current page becomes the previous page, and the next page becomes the current page. Going backward, the current page becomes the next page, and the previous page becomes the current page. In this article, I’ll describe how to implement this technique in a manner flexible enough for three different page transitions—a page slide, a 3D-like flip and a 2D page curl.

Back to Gutenberg

In the previous installment of this column (msdn.microsoft.com/magazine/hh205757), I demonstrated some fairly simple pagination logic in a Windows Phone 7 program named EmmaReader, so called because it lets you read Jane Austen’s novel “Emma” (1815). The program uses a plain-text file downloaded from the famous Project Gutenberg Web site (at gutenberg.org), which makes available more than 30,000 public-domain books.

Pagination is a non-trivial process that can require an appreciable amount of time. For that reason, EmmaReader only paginates on demand as the user progresses through the book. After creating a page, the program stores information indicating where in the book that page begins and ends. It can then use this information to let the user page backward through the book.

For the code in this article, I chose the George Eliot novel, “Middlemarch” (1874), and called the downloadable Windows Phone 7 project MiddlemarchReader. This program represents another step closer to a generalized e-book reader for Windows Phone 7 based on Project Gutenberg plain-text files. You won’t be able to use this e-book reader for current bestsellers, but it will certainly let you explore the classics of English literature.

The Project Gutenberg plain-text files divide each paragraph into multiple consecutive 72-character lines. Paragraphs are separated with a blank line. This means that any program that wishes to format a Project Gutenberg file for pagination and presentation needs to concatenate those separate lines into single-line paragraphs. In MiddlemarchReader, this occurs in the GenerateParagraphs method of the PageProvider class. This method is called each time the book is loaded, and it stores the paragraphs in a simple generic List object of type String.

This paragraph-concatenation logic falls apart in at least two cases: When an entire paragraph is indented, the program does not concatenate those lines, so each line is wrapped separately. The opposite problem occurs when a book contains poetry rather than prose: The program mistakenly concatenates those lines. These problems are impossible to avoid without examining the semantics of the actual text, which is fairly difficult without human intervention.

A New Chapter

Most books are also divided into chapters, and allowing a user to jump to a particular chapter is an important feature of an e-book reader. In EmmaReader, I ignored chapters, but the PageProvider class of MiddlemarchReader includes a GenerateChapters method that determines where each chapter begins. Generally, a Project Gutenberg file delimits chapters with three or four blank lines, depending on the book. For “Middlemarch,” three blank lines precede a line indicating the chapter title. The GenerateChapters method uses this feature to determine the particular paragraph where each chapter begins.

Dividing a book into chapters helps alleviate the pagination problem in an e-book reader. For example, suppose a reader is near the end of a book and decides to change the font size. (Neither EmmaReader nor MiddlemarchReader have this feature, but I’m speaking theoretically.) Without chapter divisions, the entire book up to that point needs to be repaginated. But if the book is divided into chapters, only the current chapter needs to be repaginated. Back in the days of mechanical type, division of a book into chapters helped the typesetters in exactly the same way when lines had to be inserted or deleted.

The MiddlemarchReader program saves information about the book in isolated storage. The main class for persisting this information is called AppSettings. AppSettings references a BookInfo object that contains a collection of ChapterInfo objects, one for each chapter. These ChapterInfo objects contain the title of the chapter and a collection of PageInfo objects for that chapter. The PageInfo objects indicate where each page begins based on a ParagraphIndex that references the List of strings for each paragraph, and a CharacterIndex within that indexed string. The first PageInfo object for a chapter is created in the GenerateChapters method I described previously. Subsequent PageInfo objects are created and accumulated as the chapter is progressively paginated as the user reads that chapter. Also saved in BookInfo is the user’s current page, identified as a chapter index and a page index within that chapter.

“Middlemarch” has 86 chapters, but the logic in the GenerateChapters method in PageProvider finds 110 chapters, including titles for the division of “Middlemarch” into eight “books,” and material at the beginning and end of the file. Formatted for the phone, the novel is approximately 1,800 pages, or about 20 pages per chapter.

Figure 1 shows the content panel in MainPage.xaml. There are two controls that occupy the single-cell Grid, but at any time only one of them is visible. Both controls contain bindings to an object of type MiddlemarchPageProvider defined as a resource. I’ll discuss the BookViewer control shortly. The ListBox displays a scrollable list of chapters, and you invoke it with a button on the program’s ApplicationBar. Figure 2 shows the list of chapters in MiddlemarchReader, scrolled to about the middle.

Figure 1 The Content Panel in MainPage.xaml

<phone:PhoneApplicationPage.Resources>
  <local:MiddlemarchPageProvider x:Key="pageProvider" />
</phone:PhoneApplicationPage.Resources>
...
<Grid x:Name="ContentPanel" Grid.Row="1">
  <local:BookViewer x:Name="bookViewer"
                    PageProvider="{StaticResource pageProvider}"
                    PageChanged="OnBookViewerPageChanged">
    <local:BookViewer.PageTransition>
      <local:SlideTransition />
    </local:BookViewer.PageTransition>
  </local:BookViewer>
  <ListBox Name="chaptersListBox"
           Visibility="Collapsed"
           Background="{StaticResource PhoneBackgroundBrush}"
           ItemsSource="{Binding Source={StaticResource pageProvider}, 
                                 Path=Book.Chapters}"
           FontSize="{StaticResource PhoneFontSizeLarge}"
           SelectionChanged="OnChaptersListBoxSelectionChanged">
    <ListBox.ItemTemplate>
      <DataTemplate>
        <Grid Margin="0 2">
          <Grid.ColumnDefinitions>
            <ColumnDefinition Width="Auto" />
            <ColumnDefinition Width="*" />
          </Grid.ColumnDefinitions>
                            
          <TextBlock Grid.Column="0"
                     Text="&#x2022;"
                     Margin="0 0 3 0" />
                        
          <TextBlock Grid.Column="1" 
                     Text="{Binding Title}"
                     TextWrapping="Wrap" />
        </Grid>
      </DataTemplate>
    </ListBox.ItemTemplate>
  </ListBox>
</Grid>

I wouldn’t have chosen to display these headings and titles with all capital letters, but that’s how they appear in the original file, and the program blindly pulls them out based solely on these lines being preceded by at least three blank lines.

The Scrollable List of Chapters

Figure 2 The Scrollable List of Chapters

The Back Page Problem

Like EmmaReader, MiddlemarchReader paginates on demand as the user reads the book, and the page information is stored so the user can page backward. No problem there. MiddlemarchReader also allows the user to jump to a particular chapter. No problem there, either, because the program already knows where each chapter begins.

However, suppose the user jumps to the beginning of a chapter and then decides to page backward to the last page of the previous chapter. If the previous chapter has not yet been paginated, that entire chapter must be paginated to show that last page. The paragraph might be short, or it could be quite long, depending on the book and the chapter. That’s a problem.

Although MiddlemarchReader paginates on demand, it actually goes a little beyond that. When the user is reading one page, the next page and the previous page are ready for paging forward or back. Normally, obtaining these additional pages isn’t a problem. But what should the program do if the user jumps to the beginning of a chapter? Should the program get the last page of the previous chapter to be ready for the possibility of paging backward? That’s wasteful: In most cases, the user will only page forward, so why bother?

Obviously, it gets a little tricky. I’ve already mentioned that the PageProvider class is responsible for parsing the original Project Gutenberg file and dividing it into paragraphs and chapters, but it’s also responsible for pagination. The MiddlemarchPageProvider class referenced in Figure 1 is tiny. It derives from PageProvider and simply accesses the “Middlemarch” file so it can be processed by PageProvider.

I wanted the BookViewer control to have as little knowledge of the internals of PageProvider as possible. For that reason, the PageProvider property of BookViewer is of type IPageProvider (an interface implemented by PageProvider), as shown in Figure 3.

Figure 3 The IPageProvider Interface

public interface IPageProvider
{
  void SetPageSize(Size size);
  void SetFont(FontFamily fontFamily, double FontSize);
  FrameworkElement GetPage(int chapterIndex, int pageIndex);
  FrameworkElement QueryLastPage(int chapterIndex, out int pageIndex);
  FrameworkElement GetLastPage(int chapterIndex, out int pageIndex);
}

Here’s how it works: The BookViewer object informs PageProvider of the size of each page and the font and font size to be used for creating the TextBlock elements that comprise the page. Most of the time, BookViewer obtains pages by calling GetPage. If GetPage returns null, the chapter index or page index is out of range and that page does not exist. This could indicate to BookViewer that it’s gone beyond the end of a chapter and needs to advance to the next chapter. Figure 4 shows MiddlemarchReader at the beginning of a chapter. (Each chapter in “Middlemarch” begins with an epigraph, some of them written by George Eliot herself.)

The BookViewer Control Displaying a Page

Figure 4 The BookViewer Control Displaying a Page

When BookViewer navigates to the beginning of a chapter, IPageProvider defines two methods for obtaining the last page of the previous chapter. QueryLastPage is the fast method. If the previous chapter has already been paginated, and that last page is available, then the method returns the page and sets the page index. If the page is not available, QueryLastPage returns null. This is the method that BookViewer calls to obtain the previous page when the user navigates to the beginning of a chapter.

If QueryLastPage returns null and the user then pages back to that missing page, BookViewer has no recourse but to call GetLastPage. If necessary, GetLastPage will paginate an entire chapter just to obtain the last page.

Why not paginate the previous chapter in a second thread of execution after the user navigates to the beginning of a chapter? Perhaps the second thread will be finished by the time the user decides to page back. Although that’s certainly a plausible solution, the pagination logic would need to be restructured a bit because it’s creating StackPanel and TextBlock objects, and these can’t be used by the primary thread.

The BookViewer Transitions

As I’ve mentioned, in the general case, BookViewer is juggling three pages at once. The control derives from UserControl, and Figure 5 shows the XAML file. The three Border elements with names of pageHost0, pageHost1 and pageHost2 are used as parents of the pages obtained from PageProvider. (These pages are actually StackPanel elements with a TextBlock for each paragraph on the page.) The other three Border elements with names beginning with pageContainer provide a white background and a little margin of white around the borders of the page. These are also the elements manipulated for page transitions. A GestureListener (available in the Silverlight for Windows Phone Toolkit, downloadable from CodePlex at bit.ly/cB8hxu) provides touch input.

Figure 5 The XAML File for BookViewer

<UserControl x:Class="MiddlemarchReader.BookViewer"
             xmlns="https://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="https://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:toolkit="clr-namespace:Microsoft.Phone.Controls;assembly=
               Microsoft.Phone.Controls.Toolkit">
  <UserControl.Resources>
    <Style x:Key="pageContainerStyle" TargetType="Border">
      <Setter Property="BorderBrush" Value="Black" />
      <Setter Property="BorderThickness" Value="1" />
      <Setter Property="Background" Value="White" />
    </Style>
    <Style x:Key="pageHostStyle" TargetType="Border">
      <Setter Property="Margin" Value="12, 6" />
      <Setter Property="CacheMode" Value="BitmapCache" />
    </Style>
  </UserControl.Resources>
  <Grid x:Name="LayoutRoot">
    <toolkit:GestureService.GestureListener>
      <toolkit:GestureListener GestureBegin="OnGestureListenerGestureBegin"
                               GestureCompleted=
                                 "OnGestureListenerGestureCompleted"
                               Tap="OnGestureListenerTap"
                               Flick="OnGestureListenerFlick" 
                               DragStarted="OnGestureListenerDragStarted"
                               DragDelta="OnGestureListenerDragDelta"
                               DragCompleted="OnGestureListenerDragCompleted" />
    </toolkit:GestureService.GestureListener>
    <Border Name="pageContainer0" Style="{StaticResource pageContainerStyle}">
      <Border Name="pageHost0" Style="{StaticResource pageHostStyle}" />
    </Border>
    <Border Name="pageContainer1" Style="{StaticResource pageContainerStyle}">
      <Border Name="pageHost1" Style="{StaticResource pageHostStyle}" />
    </Border>
    <Border Name="pageContainer2" Style="{StaticResource pageContainerStyle}">
      <Border Name="pageHost2" Style="{StaticResource pageHostStyle}" />
    </Border>
  </Grid>
</UserControl>

When BookViewer is created, it stores the three pageHost objects in an array named pageHosts. A field named pageHostBaseIndex is set to 0. As the user pages through a book, pageHostBaseIndex goes to 1, then 2, then back to 0. As the user pages backward, pageHostBaseIndex goes from 0 to 2, then 1, then back to 0. At any time, pageHosts[pageHostBaseIndex] is the current page. The next page is:

pageHosts[(pageHostBaseIndex + 1) % 3]

The previous page is:

pageHosts[(pageHostBaseIndex + 2) % 3]

With this scheme, once a particular page has been set in a page host, it stays there until it’s replaced. The program doesn’t need to transfer pages from one page host to another.

This scheme also allows fairly simple page transitions: Just use Canvas.SetZIndex so that pageHosts[pageHostBaseIndex] is on top of the other two. But that’s probably not the way you want to transition between pages. It’s rather bland and even somewhat hazardous. The imprecision of touch input is such that the user might accidentally move two pages ahead instead of one, and not even realize it. For this reason, more dramatic page transitions are desirable.

BookViewer achieves great flexibility with regard to page transitions by defining a PageTransition property of type PageTransition, an abstract class shown in Figure 6.

Figure 6 The PageTransition Class

public abstract class PageTransition : DependencyObject
{
  public static readonly DependencyProperty FractionalBaseIndexProperty =
    DependencyProperty.Register("FractionalBaseIndex",
      typeof(double),
      typeof(PageTransition),
      new PropertyMetadata(-1.0, OnTransitionChanged));
  public double FractionalBaseIndex
  {
    set { SetValue(FractionalBaseIndexProperty, value); }
    get { return (double)GetValue(FractionalBaseIndexProperty); }
  }
  public virtual double AnimationDuration 
  {
    get { return 1000; }
  }
  static void OnTransitionChanged(DependencyObject obj, 
                                  DependencyPropertyChangedEventArgs args)
  {
    (obj as PageTransition).OnTransitionChanged(args);
  }
  void OnTransitionChanged(DependencyPropertyChangedEventArgs args)
  {
    double fraction = (3 + this.FractionalBaseIndex) % 1;
    int baseIndex = (int)(3 + this.FractionalBaseIndex - fraction) % 3;
    ShowPageTransition(baseIndex, fraction);
  }
  public abstract void Attach(Panel containerPanel,
                              FrameworkElement pageContainer0,
                              FrameworkElement pageContainer1,
                              FrameworkElement pageContainer2);
  public abstract void Detach();
  protected abstract void ShowPageTransition(int baseIndex, double fraction);
}

PageTransition defines a single dependency property named FractionalBaseIndex that BookViewer is responsible for setting based on touch input. If the user taps the screen, FractionalBaseIndex is increased by a DoubleAnimation from pageHostBaseIndex to pageHostBaseIndex plus 1. Animations are also triggered if the user flicks the screen. If the user drags a finger along the screen, FractionalBaseIndex is set “manually” based on the distance the user has dragged his finger as a percentage of the total width of the screen.

The PageTransition class separates FractionalBaseIndex into an integer baseIndex and a fraction for the benefit of derived classes. The ShowPageTransition is implemented by derived classes in a way that’s characteristic of the particular transition. The default is SlideTransition, shown in Figure 7, which slides the pages back and forth. Figure 8 shows a page being slid into view. The class attaches TranslateTransform objects to the page containers during the Attach call and removes them during Detach.

Figure 7 The SlideTransition Class

public class SlideTransition : PageTransition
{
  FrameworkElement[] pageContainers = new FrameworkElement[3];
  TranslateTransform[] translateTransforms = new TranslateTransform[3];
  public override double AnimationDuration
  {
    get { return 500; }
  }
  public override void Attach(Panel containerPanel,
                              FrameworkElement pageContainer0, 
                              FrameworkElement pageContainer1, 
                              FrameworkElement pageContainer2)
  {
    pageContainers[0] = pageContainer0;
    pageContainers[1] = pageContainer1;
    pageContainers[2] = pageContainer2;
    for (int i = 0; i < 3; i++)
    {
      translateTransforms[i] = new TranslateTransform();
      pageContainers[i].RenderTransform = translateTransforms[i];
    }
  }
  public override void Detach()
  {
    foreach (FrameworkElement pageContainer in pageContainers)
      pageContainer.RenderTransform = null;
  }
  protected override void ShowPageTransition(int baseIndex, double fraction)
  {
    int nextIndex = (baseIndex + 1) % 3;
    int prevIndex = (baseIndex + 2) % 3;
    translateTransforms[baseIndex].X = -fraction * 
      pageContainers[prevIndex].ActualWidth;
    translateTransforms[nextIndex].X = translateTransforms[baseIndex].X + 
      pageContainers[baseIndex].ActualWidth;
    translateTransforms[prevIndex].X = translateTransforms[baseIndex].X – 
      pageContainers[prevIndex].ActualWidth;
  }
}

A Page Sliding into View

 Figure 8 A Page Sliding into View

You can select the other two page transitions from the application bar menu: FlipTransition is similar to SlideTransition, except that it uses the PlaneProjection transform for a 3D look. The CurlTransition displays the page as if the upper-right corner is pulled back. (I originally wrote the CurlTransition code to curl from the lower-right corner, and was able to convert it simply by adjusting all the horizontal coordinates. You can change it back to the lower-right curl by setting the curlFromTop constant to false and recompiling.)

Because MiddlemarchReader allows the user to jump to the beginning of a chapter, displaying actual page numbers at the top of the program is no longer feasible. Instead, the program displays percentages based on text lengths.

Fear of Pagination

In actual use, MiddlemarchReader is starting to look and feel much like a real e-book reader. However, it still suffers from the poor performance of its pagination logic. Indeed, the first page of the first chapter of “Middlemarch” is delayed a bit when the program is running on a Windows Phone 7 device because the chapter begins with a paragraph that goes on for several pages. Paging back after jumping to a new paragraph sometimes requires a second or so, and I haven’t yet been brave enough to introduce user-selected fonts or font sizes into the program because these features require repagination.

Is there a way to speed up the pagination? Perhaps. Undoubtedly such an improvement would be elegant, clever and more difficult than I anticipate.


Charles Petzold is a longtime contributing editor to MSDN Magazine*. His recent book, “Programming Windows Phone 7” (Microsoft Press, 2010), is available as a free download at bit.ly/cpebookpdf.*