How to embed a PWA into a (existing) native iOS / Android App

oncode

Manuel Sommerhalder

Posted on January 5, 2021

How to embed a PWA into a (existing) native iOS / Android App

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:

Alt Text

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.

Alt Text

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);
}


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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);
    }
}


Enter fullscreen mode Exit fullscreen mode

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);
            }
        }
    }
};


Enter fullscreen mode Exit fullscreen mode

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:

Alt Text

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'
                })
            }
        }
    }
}


Enter fullscreen mode Exit fullscreen mode

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' });


Enter fullscreen mode Exit fullscreen mode

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();
}

Enter fullscreen mode Exit fullscreen mode




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!

💖 💪 🙅 🚩
oncode
Manuel Sommerhalder

Posted on January 5, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related