Store and Load the View State

PSPDFViewState is the class that we use to capture, store and eventually restore what the user is currently looking at during resizes of a PSPDFViewController’s view (PDF view). Even if your app does not actively resize the PDF view itself, iOS will when the device is rotated or split–screen multi–tasking is active.

This guide aims to provide a more in–depth explanation of the concepts behind this class and how it is 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 PSPDFViewController, 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: the currently visible rectangle of the page defined in PDF coordinates.

  • The dominant page is the page that is 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. In addition to that, a page in a PDF document can be rotated in steps of 90°.
    In order to not define yet another coordinate system, we use the page coordinates directly — the result may surprise you, as we’ll see in a bit.
  • 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 into the same page of a document in continuous scrolling mode. For simplicity, the PDF view is displayed full–screen on a stylized iPad. In green, you see the coordinate system of that view, while the coordinate systems of the pages are displayed in black. Off–screen content is dimmed.
Two viewports into the same page

If the dominant page is rotated, so will be the viewport: The image below displays the dimensions of the viewport in orange, labeled with their meaning. The document on the left has a regular dominant page, whereas the one on the right has one that is rotated by 90°. Similar to the axes, the arrowheads of the viewport dimensions point in the direction of increasing width/height.
Dimensions of a viewport for regular and rotated page

As briefly mentioned before, not every view state has a viewport:
If your PSPDFViewController is configured to use the page curl transition, we currently do not 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 PSPDFViewController. In any other case we try to restore the viewport as well 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 is 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 orange rectangle shows the effective viewport into the same document after rotation of the device. Note how that preview of the viewport after rotation on the left is smaller than the current one but keeps the center and relative width — some of the currently visible content will be clipped. The viewport preview on the right, on the other hand, reveals more of the content — still keeping the center.
Restoring a viewport during 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 used .scaleAspectFit, we would downscale the content with every turn of the device until we hit the minimum zoom scale. If, instead, we always used .ScaleAspectFill, we’d upscale the content until we hit the maximum zoom scale, respectively.
  2. Because we tend to focus our attention on what’s in the center of our field of vision, zooming into 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 into 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 are 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 is not 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 PSPDFViewController that uses the same transition as our primary and but doesn’t display any chrome.

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Early on, create the view controller that drives the presentation
let document = ...
let singlePageScrolling = PSPDFConfiguration { builder in
    builder.pageMode = .single
    builder.pageTransition = .scrollPerSpread
}
let primary = PSPDFViewController(document: document, configuration: singlePageScrolling)

...

// 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 = PSPDFViewController(document: primary.document, configuration: chromeLess)

// Display the mirror on the external window without making it key:
let externalWindow = ...
externalWindow.rootViewController = mirror
externalWindow.hidden = false
Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 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;

In order 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. Note: Because the viewport is in coordinates of the page and the page can be rotated, ignoring the page’s rotation would lead to unexpected results in some documents.

The following method shows both: how to guarantee .scaleAspectFit behavior and how to handle rotation correctly.

Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/*!
 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 as follows:

    let adjustedState = viewStateForAspectFitting(primary, in: mirror)
    mirror.apply(adjustedState, animateIfPossible: true)
 */
func viewStateForAspectFitting(_ source: PSPDFViewController, in destination: PSPDFViewController) -> PSPDFViewState? {
    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
    }

    // To take rotation into account, get the page info and rotate the viewport
    guard let pageInfo = source.document?.pageInfoForPage(at: capturedState.pageIndex) else { return capturedState }
    var viewport = capturedState.viewPort.applying(pageInfo.rotationTransform)

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

    // … before transforming it back to page coordinates
    viewport = viewport.applying(pageInfo.rotationTransform.inverted())

    return PSPDFViewState(pageIndex: capturedState.pageIndex, viewPort: viewport)
}
Copy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/*!
 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 as follows:

    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.captureCurrentViewState;

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

    // To take rotation into account, get the page info and rotate the viewport
    PSPDFPageInfo *pageInfo = [source.document pageInfoForPageAtIndex:capturedState.pageIndex];
    CGRect viewport = CGRectApplyAffineTransform(capturedState.viewPort, pageInfo.rotationTransform);

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

    // … before transforming it back to page coordinates
    viewport = CGRectApplyAffineTransform(viewport, CGAffineTransformInvert(pageInfo.rotationTransform));

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