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.
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!
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);
}
});
});
}
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.
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,
});
}
}
}
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>
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.