Android Back Button on a Progressive Web Application closes de App

20,362

Solution 1

While the android back button cannot be directly hooked into from within a progressive web app context, there exists a history api which we can use to achieve your desired result.

First up, when there's no browser history for the page that the user is on, pressing the back button immediately closes the app.
We can prevent this by adding a previous history state when the app is first opens:

window.addEventListener('load', function() {
  window.history.pushState({}, '')
})

The documentation for this function can be found on mdn:

pushState() takes three parameters: a state object, a title (which is currently ignored), and (optionally) a URL[...] if it isn't specified, it's set to the document's current URL.

So now the user has to press the back button twice. One press brings us back to the original history state, the next press closes the app.


Part two is we hook into the window's popstate event which is fired whenever the browser navigates backwards or forwards in history via a user action (so not when we call history.pushState).

A popstate event is dispatched to the window each time the active history entry changes between two history entries for the same document.

So now we have:

window.addEventListener('load', function() {
  window.history.pushState({}, '')
})

window.addEventListener('popstate', function() {
  window.history.pushState({}, '')
})

When the page is loaded, we immediately create a new history entry, and each time the user pressed 'back' to go to the first entry, we add the new entry back again!


Of course this solution is only so simple for single-page apps with no routing. It will have to be adapted for applications that already use the history api to keep the current url in sync with where the user navigates.

To do this, we will add an identifier to the history's state object. This will allow us to take advantage of the following aspect of the popstate event:

If the activated history entry was created by a call to history.pushState(), [...] the popstate event's state property contains a copy of the history entry's state object.

So now during our popstate handler we can distinguish between the history entry we are using to prevent the back-button-closes-app behaviour versus history entries used for routing within the app, and only re-push our preventative history entry when it specifically has been popped:

window.addEventListener('load', function() {
  window.history.pushState({ noBackExitsApp: true }, '')
})

window.addEventListener('popstate', function(event) {
  if (event.state && event.state.noBackExitsApp) {
    window.history.pushState({ noBackExitsApp: true }, '')
  }
})

The final observed behaviour is that when the back button is pressed, we either go back in the history of our progressive web app's router, or we remain on the first page seen when the app was opened.

Solution 2

@alecdwm, that is pure genius!

Not only does it work on Android (in Chrome and the Samsung browser), it also works in desktop web browsers. I tested it on Chrome, Firefox and Edge on Windows, and it's likely the results would be the same on Mac. I didn't test IE because eew. Even if you're mostly designing for iOS devices that have no back button, it's still a good idea to ensure that Android (and Windows Mobile... awww... poor Windows Mobile) back buttons are handled so that the PWA feels much more like a native app.

Attaching an event listener to the load event didn't work for me, so I just cheated and added it to an existing window.onload init function I already had anyhow.

Keep in mind that it might frustrate users who would actually want to really Go Back to whatever web page they were looking at before navigating to your PWA while browsing it as a standard web page. In that case, you can add a counter and if the user hits back twice, you can actually allow the "normal" back event to happen (or allow the app to close).

Chrome on Android also (for some reason) added an extra empty history state, so it took one additional Back to actually go back. If anyone has any insight on that, I'd be curious to know the reason.

Here's my anti-frustration code:

var backPresses = 0;
var isAndroid = navigator.userAgent.toLowerCase().indexOf("android") > -1;
var maxBackPresses = 2;
function handleBackButton(init) {
    if (init !== true)
        backPresses++;
    if ((!isAndroid && backPresses >= maxBackPresses) ||
    (isAndroid && backPresses >= maxBackPresses - 1)) {
        window.history.back();
    else
        window.history.pushState({}, '');
}
function setupWindowHistoryTricks() {
    handleBackButton(true);
    window.addEventListener('popstate', handleBackButton);
}

Solution 3

This approach has a couple of improvements over existing answers:

Allows the user to exit if they press back twice within 2 seconds: The best duration is debatable but the idea of allowing an override option is common in Android apps so it's often the correct approach.

Only enables this behaviour when in standalone (PWA) mode: This ensures the website keeps behaving as the user would expect when within an Android web browser and only applies this workaround when the user sees the website presented as a "real app".

function isStandalone () {
    return !!navigator.standalone || window.matchMedia('(display-mode: standalone)').matches;
}

// Depends on bowser but wouldn't be hard to use a
// different approach to identifying that we're running on Android
function exitsOnBack () {
    return isStandalone() && browserInfo.os.name === 'Android';
}

// Everything below has to run at page start, probably onLoad

if (exitsOnBack()) handleBackEvents();

function handleBackEvents() {
    window.history.pushState({}, '');

    window.addEventListener('popstate', () => {
        //TODO: Optionally show a "Press back again to exit" tooltip
        setTimeout(() => {
            window.history.pushState({}, '');
            //TODO: Optionally hide tooltip
        }, 2000);
    });
}

Solution 4

In my case, I had a SPA with different drawers on that page and I want them to close when User hits back button.. you can see different drawers in the image below: SPA with drawers

I was managing states(eg open or close) of all drawers at a central location (Global state),

I added the followin code to a useEffect hook that runs only once on loading of web app

// pusing initial state on loading
window.history.pushState(
        {                   // Initial states of drawers
          bottomDrawer,
          todoDetailDrawer,
          rightDrawer,
        },
        ""
      );

      window.addEventListener("popstate", function () {
        //dispatch to previous drawer states
        // dispatch will run when window.history.back() is executed
        dispatch({
          type: "POP_STATE",
        });
      });

and here is what my dispatch "POP_STATE" was doing,

if (window.history.state !== null) {
        const {
          bottomDrawer,
          rightDrawer,
          todoDetailDrawer,
        } = window.history.state; // <- retriving state from window.history
        return { // <- setting the states
          ...state,
          bottomDrawer,
          rightDrawer,
          todoDetailDrawer,
        };

It was retriving the last state of drawers from window.history and setting it to current state,

Now the last part, when I was calling window.history.pushState({//object with current state}, "title", "url eg /RightDrawer") and window.history.back()

very simple, window.history.pushState({//object with current state}, "title", "url eg /RightDrawer") on every onClick that opens the drawer

& window.history.back() on every action that closes the drawer.

Share:
20,362

Related videos on Youtube

rmpestano
Author by

rmpestano

Updated on July 09, 2022

Comments

  • rmpestano
    rmpestano almost 2 years

    Can a "pure" HTML5/Javascript (progressive) web application intercept the mobile device back button in order to avoid the App to exit?

    This question is similar to this one but I want to know if it is possible to achieve such behavior without depending on PhoneGap/Ionic or Cordova.

  • Fatih Coşkun
    Fatih Coşkun over 5 years
    I was also thinking that Chrome on Android adds an extra empty history state for fullscreen PWAs. But in fact that is not the case. Instead it is impossible to close the app programmatically via history.back() or history.go(-x). Try it. Invoke as many history.back() as you want, it will never exit the app. What will exit the app: a physical press on the back button, when there is no back-history left.
  • Axel Stone
    Axel Stone over 5 years
    Excellent solution, however in my experience there is no need for maxBackPresses - 1 for android.
  • Tom Wyllie
    Tom Wyllie over 4 years
    I like the idea here and am implementing something similar myself, but isn't there a race condition where this'll break if the user navigates to a new page during the 2000ms timeout, as the empty state will be pushed on top of the new page's state?
  • Luckyrat
    Luckyrat over 4 years
    Yeah. That can't happen in the environment in which I've used the code but in the general case you would need to cancel the timer at some appropriate point in your routing / page navigation handling code. The implementation will vary based on what mechanisms your code already has to handle page navigation and what state management solution you're using but in the hackiest possible way, one could understand the principle by assigning the setTimeout return value to a global variable and then call clearTimeout with that value when navigation occurs.
  • Angry Cub
    Angry Cub over 4 years
    The popstate solution works for my PWA, however I just want to point out that, from my experience, Chrome WILL close the PWA if it doesn't detect ANY user interaction, before the click on Android's back button (the interaction can be a link click, zoom-in, scroll ...). I was testing this code snippet on a minimal sample app, and couldn't understand why it wasn't working. I assume it is a safety measure against spam pop-ups, but hadn't read anything about it in the guides fetched by Google.
  • JJS
    JJS about 2 years
    where does browserInfo api come from?
  • Luckyrat
    Luckyrat about 2 years
    The "browser" npm package. Would be easy enough to use an alternative if you prefer.