A simple continuous PDF viewer component

Remember the simple PDF renderer I presented a few weeks ago? Back then, I promised to show two different versions of PDF viewers – well, here is the second one! And, as you might have guessed, in this one we’ll try to get rid of the navigation buttons and allow seemless scrolling through all pages.

Before we start, note that this means loading all pages at once – the good news is, in my experience this sounds worse performance-wise than it actually is: The crucial point is that actually loading and rendering pages might take a lot of time (we definitely need to address this in out code, I’ll come to it later), while displaying them is typically not a problem even for documents with several hundreds of pages. (Remember that – after rendering is finished – all we got are images, and displaying a lot of images in a row is something UWP is quite good at!) Nevertheless, it is good to remember that we can limit each page’s rendering result’s detail level by passing PdfPageRenderOptions to the PdfPage.RenderToStreamAsync method that include distinct values for DestinationHeight and DestinationWidth, and it might be a good idea to pass smaller width and height values for documents with a lot of pages (although I didn’t take this into account for this sample project, just to keep the code as short and readable as possible).

Alright, after all these warnings, let’s get started! Since we can skip the navigtation buttons this time, we can start with a simple class (no need to create a XAML user control) that inherits from ItemsControl. The latter is important, since after rendering we’ll have a bunch of images to be displayed, and ItemsControl is built for displaying a list of similar items:

public class ContinuousPdfViewer : ItemsControl
{
	public static readonly DependencyProperty SourceProperty = DependencyProperty.Register(
		"Source", typeof(string), typeof(ContinuousPdfViewer), new PropertyMetadata(default(string), SourceChangedCallback));

	private static async void SourceChangedCallback(DependencyObject sender, DependencyPropertyChangedEventArgs args)
	{
		var control = sender as ContinuousPdfViewer;
		var source = args.NewValue as string;

		await control.LoadDocument(source);
	}

	public string Source
	{
		get => (string)GetValue(SourceProperty);
		set => SetValue(SourceProperty, value);
	}

	private async Task LoadDocument(string source)
	{
		// TODO
	}

	private async Task<Image> LoadPage(PdfDocument document, uint page)
	{
		// TODO
	}
}

I already included the Source property (and its callback method that invokes LoadDocument), as well as the LoadDocument and LoadPage methods. All these won’t be new to you if you followed my other blog posts concerning PDF rendering, however there are a few things to point out in contrast to the SinglePagePdfViewer we discussed previously:

  • We don’t need any global class member variables, since we load everything at once, so there is no need to
    • keep track of the PdfDocument instance after loading has finished
    • store any temporary pre-loaded images
  • The PdfDocument is passed directly to the LoadPage method, due to the same reason.
  • The LoadPage method returns an Image control instead of a BitmapImage object: Previously, our PDF viewer component contained one global Image control to which different BitmapImage instances where assigned when switching pages. In contrast, our continuous PDF viewer will contain several Image components (one for each page) to be displayed in the ItemsControl discussed before.

In addition, we’ll want to propagate two values to the page / window / component that contains our PDF viewer, therefore we create to additional dependency properties:

public static readonly DependencyProperty PageCountProperty = DependencyProperty.Register(
	"PageCount", typeof(uint), typeof(ContinuousPdfViewer), new PropertyMetadata(1u));

public uint PageCount
{
	get => (uint)GetValue(PageCountProperty);
	set => SetValue(PageCountProperty, value);
}

public static readonly DependencyProperty PixelHeightProperty = DependencyProperty.Register(
	"PixelHeight", typeof(double), typeof(ContinuousPdfViewer), new PropertyMetadata(0.0));

public double PixelHeight
{
	get => (double)GetValue(PixelHeightProperty);
	set => SetValue(PixelHeightProperty, value);
}

The idea of passing the total number of pages of the currently displayed PDF document to the surrounding environment might make sense, but why would we want to publish internal information such as the viewer content’s height in pixels? Well, as mentioned earlier, rendering all pages of a huge PDF document might take a long time – and the term long time is meant literally, in case a document contains several hundred pages this might take several minutes. Don’t worry, this won’t affect usability too much, since usually the rendering process is still way faster than the user scrolling through the document, so all we need to do is: As soon as another page has finished rendering, append its image to the end of our ItemsControl, to ensure that the scrolling position stays as it is (this way, the user won’t even recognize that rendering hasn’t finished yet, other than noticing that the toolbar is constantly growing).

However, external components that encapsulate out PDF viewer might want to display the currently selected page number (or even allow jumping directly to certain page). To allow them doing the necessary calculations, they must be informed about the pixel height to page count ratio, which is why we register those two values as publicly readable dependency properties!

The LoadDocument is rather straightforward: We load the document, loop through its pages, render each one to an Image component, and append this to the end of our viewer class which actually is an ItemsControl.

private async Task LoadDocument(string source)
{
	PdfDocument document;
	try
	{
		StorageFile file = await StorageFile.GetFileFromPathAsync(source);
		document = await PdfDocument.LoadFromFileAsync(file);
	}
	catch (Exception e)
	{
		// TODO: Log and display exception
		return;
	}

	Items.Clear();
	PageCount = 0;
	PixelHeight = 0;
	for (uint i = 0; i < document.PageCount; i++)
	{
		Items.Add(await LoadPage(document, i));
	}
}

The only thing that seem strange to you is: Why are we setting both PageCount and PixelHeight to zero – and isn’t this contradicting what I just explained in the previous paragraph? Well, just have a look at the LoadPage method and everything will be clearer:

private async Task<Image> LoadPage(PdfDocument document, uint page)
{
	using (IRandomAccessStream stream = new MemoryStream().AsRandomAccessStream())
	{
		using (var pdfPage = document.GetPage(page))
		{
			PageCount++;
			PixelHeight += pdfPage.Size.Height;

			await pdfPage.RenderToStreamAsync(stream);
			BitmapImage bmp = new BitmapImage();
			bmp.SetSource(stream);
			Image img = new Image
			{
				Source = bmp,
				Margin = new Thickness(0, 0, 0, 5)
			};
			return img;
		}
	}
}

Even here, everything should clear (load page, render as image, add a little margin below it to visually separate pages from each other, return it) except for the two increment operations. As mentioned before, rendering each page and appending it to the list may take lots of time. For any (external) components that actually read and use the PixelHeight and PageCount properties, we need to ensure that these values are correct even during the loading phase, so we must update them continuously while adding pages to the list. The code snippet does exactly that: Set both values to 0 initially, consecutively incrementing them with each page that is rendered.

By now, only one thing is missing: Actually referencing the PDF viewer component in a UWP app! For testing purposes, I simply added it to an otherwise empty page (note that I set the page’s background color to black, to demonstrate the small gaps shown between pages):

<Page x:Class="MyApp.MainPage" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:MyApp">

    <Grid Background="Black">
        <ScrollViewer>
            <local:ContinuousPdfViewer Source="C:\Users\akits\AppData\Local\Packages\MyPagageId\LocalState\test.pdf" />
        </ScrollViewer>
    </Grid>
</Page>

It is important to include the PDF viewer within a ScrollViewer, since it can (and documents containing multiple pages almost always will) extend the page height!

While this works nicely and provides better usability than the version with navigation buttons we’ve built before, keep in mind that you’ll never be able to provide text selection, full text search and other features third party PDF components offer and many users might expect. However, for a simple show case or a prove-of-concept app, this solution should definitely be more than enough!