Back to Blog Home
← all posts

NativeScript Preview - PDF Viewer 📄🔍

October 18, 2022 — by Technical Steering Committee (TSC)

We are going to continue our journey to the center of the native earth as we explore useful ways to leverage NativeScript. We are going to show you how to render PDFs in your mobile app.

For the sake of brevity, we are going to focus on iOS but if you'd like us to unpack the Android version in another blog post, just let us know below as both iOS and Android are available in the demo.

StackBlitz Demo

You can use the "NativeScript Preview" app on your phone, available on App Store and Google Play, to scan the QR code in StackBlitz. As you edit your app, those changes will be pushed to your phone in real time. Pretty cool!

StackBlitz Demo

Reusable code in 'common.ts'

We can consolidate a lot of the common functionality between platform implementations into a single PDFViewCommon class.

//src/app/native-pdfview/common.ts

import { View, Folder, Property } from '@nativescript/core';

let tmpFolder: Folder;

export abstract class PDFViewCommon extends View {
  static loadStartEvent = 'loadStart';
  static loadEndEvent = 'loadEnd';

  src: string;

  abstract loadPDF(src: string);
}

export const srcProperty = new Property<PDFViewCommon, string>({
  name: 'src',
});
srcProperty.register(PDFViewCommon);

Our event names can be shared so we can define them here. We are also going to define a src property we will register as a Property object on PDFViewCommon. This is a really important concept we'll explain in a moment.

First let's address the need for temporary file creation.

We need to save the PDF to our device before we can render it. We will accomplish this within the createTempFile method. Once the directory has been cleared, we create a new file based on the current date and then write the base64data into it. Once the data is written to the file, we call loadPDF with the file path.

protected createTempFile(base64data?: any) {
  return new Promise<Folder>((resolve) => {
    if (!tmpFolder) {
      tmpFolder = knownFolders.documents().getFolder('PDFViewer.temp/');
    }
    tmpFolder.clear().then(() => {
      if (base64data) {
        const file = Folder.fromPath(tmpFolder.path).getFile(
          `_${Date.now()}.pdf`
        );
        file.writeSync(base64data);
        this.loadPDF(file.path);
      } else {
        resolve(tmpFolder);
      }
    });
  });
}

Important Concept: Bindable Properties

When building mobile applications, there is a clear handoff between the web application and the underlying native operating system. NativeScript has done a really good job of smoothing out those lines so that they are almost imperceptible. With that said, it is important to understand how we can register properties on a NativeScript component so that its operation is indistinguishable from an Angular component.

In the snippet below, we are using Angular bindings on a NativeScript component so that the bindings on the src attribute is triggered when the pdfUrl property is updated. Let us take a moment and discuss how this is working.

<PDFView
  [src]="pdfUrl"
  (loadStart)="onLoadStart()"
  (loadEnd)="onLoadEnd()"
></PDFView>

To create a bindable property, we instantiate a new Property object that takes a configuration object as a parameter. In this case, we are just going to set the name property to src. We are going to store the return object as a srcProperty variable and then register that reference with the PDFViewCommon class.

export const srcProperty = new Property<PDFViewCommon, string>({
  name: 'src',
});
srcProperty.register(PDFViewCommon);

This bindable property then works like a setter function on the component itself. When the src property is set, [srcProperty.setNative] is triggered and we pass the value parameter to our local loadPDF function.

export class PDFView extends PDFViewCommon {
  [srcProperty.setNative](value: string) {
    this.loadPDF(value);
  }

  loadPDF(src: string) {
    // ...
  }
}

If you are like us, you may be looking at the [srcProperty.setNative] bit of code and thinking "huh?". What is actually happening here is something that @nativescript/core provides which allows the setter to process on our class instance once the underlying "native" component is ready, in the case of iOS it's our WKWebView instance, which PDFView represents here in our example.

iOS

With the common functionality behind us, we can shift our focus on the the concrete implementation next. Because this is iOS, we are going to use the delegate pattern to handle some of the event delegation on our behalf. We defined a private member on the class which we will instantiate inside of the createNativeView method. We are also defining a getter function called ios which returns a reference to the nativeView instance of the component which also provides us the opportunity to strongly type it to it's pure native platform class, WKWebView.

import { PDFViewCommon, srcProperty } from './common';

export class PDFView extends PDFViewCommon {
  private delegate: PDFWebViewDelegate;

  // @ts-ignore
  get ios(): WKWebView {
    return this.nativeView;
  }

  createNativeView() {
    // ...
  }

  [srcProperty.setNative](value: string) {
    this.loadPDF(value);
  }

  loadPDF(src: string) {
    // ...
  }
}

There is a lot happening here and so we are going to step through this in blocks. Outside of the basic setup and defense, the most important thing to call out here is that we are calling the notify method which is available on all NativeScript classes which derive from Observable and passing in the PDFViewCommon.loadStartEvent.

loadPDF(src: string) {
  if (!src) {
    return;
  }

  let url: NSURL;

  this.notify({ eventName: PDFViewCommon.loadStartEvent, object: this });

  // ...
}

We are then instantiating our base64data property and creating our temporary file by calling createTempFile(base64data).

loadPDF(src: string) {
  // ...

  const base64prefix = 'data:application/pdf;base64,';
  if (src.indexOf(base64prefix) === 0) {
    // https://developer.apple.com/documentation/foundation/nsdata/1410081-initwithbase64encodedstring?language=objc
    const base64data = NSData.alloc().initWithBase64EncodedStringOptions(
      src.substr(base64prefix.length),
      0
    );
    this.createTempFile(base64data);
    return;
  }

  // ...
}

Based on the outcome of our attempt to create a temporary file, we are going to try to either load the file directly from the device or make an asynchronous call to load it.

loadPDF(src: string) {
  //... 

  if (src.indexOf('://') === -1) {
    url = NSURL.fileURLWithPath(src);
    this.ios.loadFileURLAllowingReadAccessToURL(url, url);
  } else {
    url = NSURL.URLWithString(src);
    this.ios.loadRequest(NSURLRequest.requestWithURL(url));
  }
}

You can see the method in its entirety below.

loadPDF(src: string) {
  if (!src) {
    return;
  }

  let url: NSURL;

  this.notify({ eventName: PDFViewCommon.loadStartEvent, object: this });

  // detect base64 stream
  const base64prefix = 'data:application/pdf;base64,';
  if (src.indexOf(base64prefix) === 0) {
    const base64data = NSData.alloc().initWithBase64EncodedStringOptions(
      src.substr(base64prefix.length),
      0
    );
    this.createTempFile(base64data);
    return;
  }

  if (src.indexOf('://') === -1) {
    url = NSURL.fileURLWithPath(src);
    this.ios.loadFileURLAllowingReadAccessToURL(url, url);
  } else {
    url = NSURL.URLWithString(src);
    const urlRequest = NSURLRequest.requestWithURL(url);
    this.ios.loadRequest(urlRequest);
  }
}

Each NativeScript custom view component can implement it's own createNativeView which can return any platform view you'd like. In this case we can create our own WKWebView configured with a delegate for good event handling with a few configuration options set to load our PDF into:

createNativeView() {
  const webView = new WKWebView({
    configuration: WKWebViewConfiguration.new(),
    frame: UIScreen.mainScreen.bounds,
  });

  this.delegate = WKWebViewDelegate.initWithOwner(new WeakRef(this));
  webView.navigationDelegate = this.delegate;

  webView.opaque = false;
  webView.autoresizingMask =
    UIViewAutoresizing.FlexibleWidth | UIViewAutoresizing.FlexibleHeight;
  return webView;
}

The delegate implementation uses a common pattern of statically initializing an instance with a WeakRef to it's owner which in this case would be our PDFView component. We implement the WKNavigationDelegate protocol which provides us the ability to strongly type various methods that will give us callbacks for how the WKWebView behaves. Pretty neat.

@NativeClass()
class WKWebViewDelegate extends NSObject implements WKNavigationDelegate {
  static ObjCProtocols = [WKNavigationDelegate];
  private owner: WeakRef<PDFView>;

  static initWithOwner(owner: WeakRef<PDFView>): WKWebViewDelegate {
    const delegate = WKWebViewDelegate.new() as WKWebViewDelegate;
    delegate.owner = owner;
    return delegate;
  }

  webViewDidFinishNavigation(webView: WKWebView) {
    const owner = this.owner?.deref();
    if (owner) {
      owner.notify({
        eventName: PDFView.loadEndEvent,
        object: owner,
      });
    }
  }
}

Angular

With the majority of the functionality being handled by our very own custom PDFView component, there is very little for Angular to actually do. We will chalk this up as wonderful encapsulation!

As with any NativeScript component, we need to register the element so that Angular knows it exists and can use it like any other component. From there, we can improve our user experience by using a nice page blocking loading indicator while loading in a PDF URL of our choosing.

import { Component } from '@angular/core';
import { LoadingIndicator } from '@nstudio/nativescript-loading-indicator';
import { registerElement } from '@nativescript/angular';
import { PDFView } from '../native-pdfview';
registerElement('PDFView', () => PDFView);

@Component({
  selector: 'ns-pdf-webview',
  templateUrl: './pdf-webview.component.html',
})
export class PDFWebViewComponent {
  loadingIndicator = new LoadingIndicator();
  pdfUrl =
    'https://websitesetup.org/wp-content/uploads/2020/09/Javascript-Cheat-Sheet.pdf';

  onLoadStart() {
    console.log('pdf loading started...');
    this.loadingIndicator.show({});
  }

  onLoadEnd() {
    console.log('pdf loaded!');
    this.loadingIndicator.hide();
  }
}

The template markup is pretty straight forward.

If we had not spoiled the surprise with the long writeup, you may not even know that PDFView is not technically an Angular component 🤯

<ActionBar title="PDF Viewer"></ActionBar>

<GridLayout>
  <PDFView
    [src]="pdfUrl"
    (loadStart)="onLoadStart()"
    (loadEnd)="onLoadEnd()"
  ></PDFView>
</GridLayout>

In Conclusion

It's always a neat moment when developers experience realtime updates from StackBlitz to their phone and realized how empowering native APIs can be. The goal is for it to feel like every JavaScript application you've already written.

We hope you enjoyed the PDF Viewer demo.