Display PDFs with the View State on iOS

PDFViewState is the class we use to capture, store, and eventually restore what the user is looking at during resizes of a PDFViewController’s view (PDF view). Even if your app doesn’t actively resize the PDF view itself, iOS will do this when the device is rotated or when split-screen multitasking is active.

This guide aims to provide a more in-depth explanation of the concepts behind PDFViewState and how it’s used inside PSPDFKit. It also provides an example of how to leverage it to mirror all of the displayed content to an external screen.

What’s in a View State?

Every view state has a page index. This is the currently visible dominant page. As with PDFViewController, the view state’s pageIndex that relates to the first page in a document is 0.

In addition to the page index, a view state may have a viewPort, which is the currently visible rectangle of the page defined in normalized PDF coordinates.

  • The dominant page is the page that’s most visible. PSPDFKit uses various criteria to determine this.

  • UIKit and the PDF format use different coordinate systems. While PDF uses right-handed Cartesian coordinates with the origin in the lower-left corner, UIKit features a flipped y axis and moves the origin to the upper-left corner.

  • The PDF coordinate space is considered normalized because pages in a PDF can be rotated, and the corner of the visible area can be offset from the origin of the unnormalized page coordinate space using a crop box. The PDF coordinate space used by PSPDFKit always places the origin in the bottom-left corner of the displayed portion of the page.

  • Given our layout of the pages, we can transform the bounds rectangle of the PDF view from UIKit coordinates to the coordinates of the dominant page. Note that this rectangle can extend even beyond the media box of a page: Its origin can be negative in x and y, just as its height and width can be greater than the dimensions of the page (for greater detail on the various “boxes” of a page, see section 14.11.2, Page Boundaries, in the Portable Document Format reference.

With that out of the way, let’s explore the viewport and its behaviors.

The Viewport

The following graphic shows two different viewports on the same page of a document in continuous scrolling mode. For the sake of simplicity, the PDF view is displayed fullscreen on a stylized iPad. In red, you see the coordinate system of that view, while the coordinate systems of the pages are displayed in violet. Offscreen content is dimmed.

Two viewports on the same page.

As briefly mentioned before, not every view state has a viewport. For example, if your PDFViewController is configured to use the page curl transition, we currently don’t support capturing more than the page, and when restoring a view state, any viewport will be discarded. In any other configuration, a captured view state will contain a viewport only if the PDF view is zoomed in.

Restoring a View State

Restoring a view state without (or when discarding) a viewport is identical to just setting the page on PDFViewController. In any other case, we try to restore the viewport as best as possible.

Given that the aspect ratio of the stored viewport and the PDF view match, the precision of this is limited only by rounding. If, however, there’s a change in the aspect ratio — for example, when the device has been rotated, or when the view state that should be restored on an iPad has been captured on an iPhone — we preserve the center and width of the viewport. This means that when the width/height ratio shrinks, more content will be visible than before. The behavior here is comparable to UIKit’s UIViewContentModeScaleAspectFit. When that ratio increases, less content will be visible; the behavior matches UIViewContentModeScaleAspectFill.

The image below shows this behavior for rotation on an iPad using the same document. The dotted blue rectangle shows the effective viewport on the same document after rotation of the device. Note how the preview of the viewport after rotation on the left is smaller than the current one but remains centered and keeps it relative width — some of the currently visible content will be clipped. On the other hand, the viewport preview on the right reveals more of the content and remains centered.

Restoring a viewport during a change in aspect ratio

This is a deliberate choice. The rationale behind it is as follows:

  1. Rotating the device to another orientation and back should behave as if nothing happened. If we always use .scaleAspectFit, we’d downscale the content with every turn of the device until we hit the minimum zoom scale. If, instead, we always use .ScaleAspectFill, we’d upscale the content until we hit the maximum zoom scale.

  2. Because we tend to focus our attention on what’s in the center of our field of vision, zooming in to an image will most likely result in the most relevant part of it being in the center.

  3. The surroundings of the center then provide additional context.

  4. Most scripts lay out information horizontally and then vertically, so when zooming in to text, this is most likely to happen in a way that aligns well with the text’s columns.

  5. Again, the vertical surroundings of a line provide additional context.

Example: Presenter Mode — Implementing a Custom Restoration Scheme

As described in the previous section, the scaling behavior for mismatches in the aspect ratio depends on whether the aspect ratio increases or decreases. For some applications, however, this may not be appropriate.

Assume we’re building an app that mirrors the reading position to an external display. That external screen will likely have a different aspect ratio than our own device, and we’ll always want to see everything that’s visible on our presenter device on that external screen.

The image below shows this for the presentation of an iPad’s content on a 16:9 display.

Aspect-fit scaling to a wider display.

Since the external display itself isn’t interactive, we can safely assume that .scaleAspectFit is the correct way to bring the content we’re viewing on the device to the secondary screen. So we set the root view controller of the window for our external display to a PDFViewController that uses the same transition as our primary view controller, but that doesn’t display any chrome:

// Early on, create the view controller that drives the presentation.
let document = ...
let singlePageScrolling = PDFConfiguration { builder in
    builder.pageMode = .single
    builder.pageTransition = .scrollPerSpread
}
...

// After the screen has been attached when we should start presenting,
// create the view controller for the mirrored content.
let chromeLess = singlePageScrolling.configurationUpdated { builder in
    // Disable all chrome:
    builder.userInterfaceViewMode = .never
}
let mirror = PDFViewController(document: primary.document, configuration: chromeLess)

// Display the mirror on the external window without making it key:
let externalWindow = ...
externalWindow.rootViewController = mirror
externalWindow.hidden = false
// Early on, create the view controller that drives the presentation.
PSPDFDocument *document = ...;
PSPDFConfiguration *singlePageScrolling = [PSPDFConfiguration configurationWithBuilder:^(PSPDFConfigurationBuilder *builder){
	builder.pageMode = PSPDFPageModeSingle;
	builder.pageTransition = PSPDFPageTransitionScrollPerSpread;
}];

PSPDFViewController *primary = [[PSPDFViewController alloc] initWithDocument:document configuration:singlePageScrolling];

...

// After the screen has been attached when we should start presenting,
// create the view controller for the mirrored content.
PSPDFConfiguration *chromeLess = [singlePageScrolling configurationUpdatedWithBuilder:^(PSPDFConfigurationBuilder *builder){
	// Disable all chrome:
	builder.userInterfaceViewMode = PSPDFUserInterfaceViewModeNever;
}];
PSPDFViewController *mirror = [[PSPDFViewController alloc] initWithDocument:primary.document configuration:chromeLess];

// Display the mirror on the external window without making it key:
UIWindow *externalWindow = ...;
externalWindow.rootViewController = mirror;
externalWindow.hidden = NO;

To mirror the content of the primary view controller to the external display, we can now capture its view state and aspect fit it into mirror.

The following method shows how to guarantee .scaleAspectFit behavior:

/*!
 Creates a view state to mirror the contents displayed by a PDF view controller in another one using aspect-fit scaling.

 Assuming `primary` and `mirror` from the example above, use the following:

    let adjustedState = viewStateForAspectFitting(primary, in: mirror)
    mirror.apply(adjustedState, animateIfPossible: true)
 */
func viewStateForAspectFitting(_ source: PDFViewController, in destination: PDFViewController) -> PDFViewState? {
    let ratio: (CGRect) -> CGFloat = {
        return $0.width / $0.height
    }
    let sourceRatio = ratio(source.view.frame)
    let destinationRatio = ratio(destination.view.frame)
    guard let capturedState = source.viewState else { return nil }

    guard capturedState.hasViewPort && destinationRatio > sourceRatio else {
        return capturedState
    }

    var viewport = capturedState.viewPort

    // Now we can adjust its width/offset …
    let xGrowth = viewport.width * (destinationRatio / sourceRatio - 1)
    viewport.size.width += xGrowth
    viewport.origin.x -= xGrowth / 2

    return PDFViewState(pageIndex: capturedState.pageIndex, viewPort: viewport)
}
/*!
 Creates a view state to mirror the contents displayed by a PDF view controller in another one using aspect-fit scaling.

 Assuming `primary` and `mirror` from the example above, use the following:

    PSPDFViewState *adjustedState = [self viewStateForAspectFittingSource:primary inDestination:mirror];
    [mirror applyViewState:adjustedState animateIfPossible:YES];
 */
- (PSPDFViewState *)viewStateForAspectFittingSource:(PSPDFViewController *const)source inDestination:(PSPDFViewController *const)destination {
	CGFloat (^ratio)(CGRect) = ^(CGRect rect){
		return CGRectGetWidth(rect) / CGRectGetHeight(rect);
	};
	CGFloat sourceRatio = ratio(source.view.frame);
	CGFloat destinationRatio = ratio(destination.view.frame);
	PSPDFViewState *capturedState = source.viewState;

	if (!capturedState.hasViewPort || destinationRatio <= sourceRatio) {
		return capturedState;
	}

	CGRect viewport = capturedState.viewPort;

	// Now we can adjust its width/offset …
	CGFloat xGrowth = CGRectGetWidth(viewport) * (destinationRatio / sourceRatio - 1);
	viewport.size.width += xGrowth;
	viewport.origin.x -= xGrowth / 2;

	return [[PSPDFViewState alloc] initWithPageIndex:capturedState.pageIndex viewPort:viewport];
}