How to embed a PWA into a (existing) native iOS / Android App
Manuel Sommerhalder
Posted on January 5, 2021
In this article I will show you, how to embed a progressive web app (PWA) or any website into a (existing) native app from a frontend perspective. How they can communicate with each other and navigation handling.
The PWA was built with Nuxt, so the example code will be Nuxt specific, but the principles are applicable to every PWA.
Table of contents 📖
Why? 🤔
A good question. You might consider following points:
- 🏗 You may want to replace an existing iOS/Android app with a PWA step by step or build some new feature that should also work on the web
- 🤑 It reduces developer effort, you only need to develop once on one platform
- 🤖 The new feature should be indexable by search engines
- 🏃♂️ New features / fixes can be shipped faster, because you don't have to go through the publishing process
- 🏠 App will still be available at the App/Play Store, so users can find and install it over different channels
- 🦄 Maybe you want to do something that a PWA can't do (yet) like accessing the calendar, contacts or intercepting SMS/calls. Or do something iOS Safari can't do, because the API still is missing (e.g. background sync).
Storage & Login Session 🍪
The PWA will be displayed inside a WKWebView (>= iOS 8). You can think of it as an iframe for native apps. Every WKWebView has its own storage data (cookies, localStorage, IndexedDB, etc.) and it will be restored when closed and reopened again. But the native App doesn't share its own cookies with the WebView.
So if you need a logged in user, you should manually reuse the login session, to prevent the user from having to login in a second time, after opening the PWA in the WebView.
To achieve this, the app developer can set a cookie with the already established session/token that he got e.g. from the initial app login screen or from a fingerprint. To set a cookie for the WebView, he can use the WKHTTPCookieStore:
Communication 📨
You might want your PWA and native app to be able to talk to each other and tell them to execute actions. So we are going to build a bridge, where they can talk with each other.
For this we will add a global object (window.bridge
) with two methods. One for invoking actions on the native app from inside the PWA (invokeNative
) and a second one for receiving messages and commands (invokedByNative
), which will be executed from within the native app. Inside this method, we can put the messages into our Vuex store, so that we can observe them.
The method names, the data structure you pass to the iOS/Android developer and data you receive are up to you and the app developer.
The app developer can inject JS Code into the WebView. For our example, he would have to define a global method window.invokeCSharpAction
, which receives a JSON string. We can check for that function to detect whether we are inside the app or just in a normal browser.
Below, the code for the bridge, which was put into a Nuxt plugin:
// bridge.client.js nuxt plugin
export default (context, inject) => {
// force app mode with ?app param to be able to test
const { app } = context.query;
// determine whether the app is opened inside native app
const inApp = !!window.invokeCSharpAction
|| typeof app !== 'undefined';
// inject global $inApp variable and
inject('inApp', inApp);
context.store.commit('setInApp', inApp);
// the bridge object in the global namespace
window.bridge = {
// invoke native function via PWA
invokeNative(data) {
if (!window.invokeCSharpAction) {
return;
}
window.invokeCSharpAction(
JSON.stringify(data)
);
},
// called by native app
invokedByNative(data) {
// write passed data to the store
context.store.commit(
'addAppMessage',
JSON.parse(data)
);
}
}
inject('bridge', window.bridge);
}
After setting up the bridge, we are able to invoke native actions inside our PWA like this:
// callable in stores, components & plugins
this.$bridge.invokeNative({
function: 'Close'|'SaveSomething'
payload: {
lang, title, ...
}
});
And the native app developer can call PWA actions by executing JS code like this:
// callable in native app
this.$bridge.invokedByNative({
function: 'GoBack'|'HasSavedSomething'
response: {
success, ...
}
});
A save action inside a Vuex store could look like this:
async saveSomething({ state, commit, rootGetters }) {
// prevent saving while it's already saving
if (state.isSaving) {
return;
}
commit('setIsSaving', true);
// data we want to pass to the app or to our API
const payload = { ... };
// call the bridge method inside app
if (this.$inApp) {
this.$bridge.invokeNative({
function: 'SaveSomething',
payload
});
// otherwise we will call the API like we're used to
} else {
// await POST or PUT request response ...
// finish saving and set response id
if (response.success) {
commit('setId', response.id);
} else {
// Failed, inform user 😢
}
commit('setIsSaving', false);
}
}
You might noticed that we don't get a direct response from the bridge method like we would get from an ordinary API call. To be able to know when the app has finished the action and whether is was successful, the native app has to inform us by calling the invokedByNative
method. In the PWA, we can listen to the received message like this:
// inside app / layout component
import { mapState, mapMutations } from 'vuex';
export default {
computed: {
// map messages and isSaving to show a loader e.g.
...mapState(['appMessages', 'isSaving'])
},
methods: {
// map necessary mutations
...mapMutations(['setId', 'setIsSaving'])
},
watch: {
// watch the messages from the store for any changes
appMessages(mgs) {
// get last received message
const lastMsg = mgs[mgs.length - 1];
const appFunction = lastMsg.function;
const response = lastMsg.response || {};
// check if last message belongs to function we expect a response from
if (appFunction === 'HasSavedSomething') {
if (response.success) {
this.setId(response.id);
} else {
// Failed, inform user 😢
}
this.setIsSaving(false);
}
}
}
};
Now we're done setting up the bridge and can send each other commands!
Navigation 🧭
When your PWA is running inside a WebView just as part of a native app, make sure that the user is always able to get back to the app, without having to close the entire app.
You can utilize the global $inApp
variable we have set before in the Nuxt plugin to change your templates and components. You may want to display a close button in a menubar when the PWA is opened in the WebView or make other design adjustments:
Furthermore, the app developer should make sure to catch HTTP errors like 404 or 500, close the WebView and possibly show a message to inform the user.
Another challenge is implementing the back button/navigation. On Android we usually have a back button at the bottom. On iOS we don't have one, but could use a swipe gesture instead.
When the user navigates back, history.back
should be called inside the PWA as long as there are previous visited sites, otherwise the WebView should be closed.
Unfortunately, the window.history
API doesn't offer a possibility to detect on which position you are currently in your history entries or accessing them. Also the canGoBack property doesn't seem to work, when pushState
is used inside the PWA, to update URLs.
We can solve this inside the PWA, by implementing our own history / back-forward list:
// inside app / layout component
export default {
data() {
return {
history: []
}
},
watch: {
// watch route which updates when URL has changed
'$route'(to, from) {
// find if target page has been visited
const entry = this.appRouteHistory
.findIndex(
entry => to.path === entry.from.path
);
if (entry > -1) {
// back navigation:
// remove every entry that is
// after target page in history
this.appRouteHistory.splice(entry);
} else {
// forward navigation
this.appRouteHistory.push({ to, from });
}
}
},
methods: {
goBack() {
const lastAppHistoryEntry = this.appRouteHistory.length > 0
? this.appRouteHistory[this.appRouteHistory.length-1]
: null;
if (lastAppHistoryEntry) {
// go back to last entry
this.$router.push(lastAppHistoryEntry.from);
} else {
// tell the app it should close the WebView
this.$bridge.invokeNative({
function: 'Close'
})
}
}
}
}
Inside the app, the developer can overwrite the back button functionality to call this JS code:
// callable in native app to go back in PWA or close WebView
this.$bridge.invokedByNative({ function: 'GoBack' });
Finally, make also sure listen to the GoBack
message inside the watch: { appMessages() }
method (see implementation in the communication section above) and call the goBack
method.
if (appFunction === 'GoBack') {
this.goBack();
}
The End 🔚
I hope this article gave you an quick overview of establishing a connection between your PWA and (existing) native app. Leave a comment, if you have any questions!
Posted on January 5, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.