Back button flow

-

Android devices hebben een terug-knop. Dit heeft het voordeel voor de gebruiker om gemakkelijk terug te kunnen waar je vandaan kwam. Voor Android apps is het aan de developer op de juiste manier met de back button om te gaan. Bij het maken van native apps voor Android gaat dat min of meer vanzelf, omdat daar gewerkt wordt vanuit de gedachte van child views, waarbij je met de back button naar de parent navigeert. Bij het maken van apps met bijvoorbeeld Ionic en Cordova moet hier echter bewust over worden nagedacht en logica voor worden geschreven.

De terug-knop werkt niet altijd zoals verwacht

Om het probleem duidelijk te maken ga ik uit van een eenvoudige app met een menu om naar de verschillende views binnen de app te kunnen navigeren. Het menu heeft een aantal items, bijvoorbeeld ‘dashboard’, ‘aanbiedingen’, ‘instellingen’ en ‘profiel’.

  1. De gebruiker bevindt zich op het dashboard en klikt in het menu op ‘instellingen’
  2. De app navigeert naar de instellingen view
  3. De gebruiker klikt nu op de terug-knop en verwacht terug te komen op het dashboard

Een Angular applicatie navigeert m.b.v. de url. Klikken op de back button brengt de gebruiker naar de vorige url. Maar wat gebeurt er als de gebruiker een paar keer tussen twee pagina’s in het menu klikt;

  1. De gebruiker bevindt zich op de instellingen view en klikt in het menu op ‘profiel’
  2. De gebruiker vindt daar niet wat hij zoekt en klikt in het menu op ‘instellingen’
  3. Ook bij ‘instellingen’ vindt hij niet wat hij zoekt en gaat via het menu toch weer terug naar ‘profiel’
  4. Bij ‘profiel’ herinnert hij zich opeens dat hij iets zag bij ‘instellingen’ wat hij zoekt en gaat via het menu opnieuw naar ‘instellingen’.
  5. Als de gebruiker uiteindelijk heeft gevonden wat hij zoekt en klaar is, klikt ie op de terug-knop van het device en verwacht terug te komen op het dashboard

Omdat een Angular applicatie navigeert m.b.v. de url zal de gebruiker bij het klikken op de back button niet naar het dashboard gaan, maar eerst een aantal keer heen en weer navigeren tussen ‘instellingen’ en ‘profiel’ voordat hij uiteindelijk op het dashboard belandt. Dat is ongewenst/onverwacht gedrag van de applicatie!

Hoe gaan we om met navigeren tussen views?

In een Angular Cordova applicatie kan een eventListener op de terug-knop worden gezet.

 

document.addEventListener("backbutton", (ev) => {
    ev.preventDefault();
}, false);

Met deze code snippet wordt voorkomen dat heen en weer genavigeerd wordt. Maar dit is niet genoeg, want nu doet de back button helemaal niks meer. Hiermee wordt een hardware knop van het device helemaal uitgeschakeld en dat is nogal een rigoreuze beslissing. Er zijn verschillende manier om dit aan te pakken; We zouden voor elke view kunnen definiëren wat de parent view is. Klikken op de terug-knop moet de gebruiker dan niet naar de vorige view brengen maar altijd naar de parent. Dit werkt niet als een pagina vanaf 2 pagina’s bereikbaar is; wat is dan de parent? Zolang de applicatie een eenvoudige navigatie structuur heeft, waarbij er een dashboard bestaat, overzicht (level 1) pagina’s en detail (level 2) pagina’s, kunnen we voor elke view definiëren welk level het heeft. In dat geval zijn er 3 mogelijkheden;

  • Als de huidige pagina een detail (level 2) pagina is, gebruik dan de history.back() om uit te zoeken waar de gebruiker vandaan kwam.
  • Als de huidige pagina een level 1 pagina is, ga dan naar het dashboard.
  • Als de huidige pagina het dashboard is, vraag de gebruiker dan of hij de applicatie wil afsluiten.

Hiermee kan dan de volgende route logica worden opgesteld;

 

document.addEventListener("backbutton", (ev) => {
  ev.preventDefault();
  historyService.onBack();
}, false);

 

export class HistoryService {

  onBack(): void {
    // if page is level 2? then go back to previous page
    // else if current page is dashboard? then open exit app confirm modal
    // else go to dashboard
    const currentRoute = getCurrentRoute()
    if (currentRoute.level > 1) {// current view is a level 2 view
      history.back();
    } else if (currentRoute.default) {// current view is the dashboard
      this.confirmExitApp();
    } else {
      this.navigateToDefaultView();
    }
  }

  this.getCurrentRoute(): Route {
    // this function should return the current route as een object with it's level
    // this logic goes beyond the scope of this article
  }

  confirmExitApp(): void {
    // this function should open a confirm modal with the question to exit the app
    // on confirm close the app with
    navigator.app.exitApp();
    // on dismiss close the modal
    // this logic goes beyond the scope of this article
  }

  navigateToDefaultView(): void {
    // this function should navigate to the dashboard
    // this logic goes beyond the scope of this article
  }

}

Deze logica werkt alleen als

  • het menu alleen level 1 pagina’s en eventueel het dashboard bevat
  • level 2 pagina’s alleen bereikbaar zijn vanaf level 1 pagina’s

Een level 2 pagina kan vanaf 2 verschillende level 1 pagina’s bereikbaar zijn, zolang de level 2 pagina maar niet vanaf een andere level 2 pagina bereikbaar is. Deze eenvoudige navigatie structuur heeft ook als voordeel dat een gebruiker nog begrijpt waar hij zich bevindt. Als de applicatie level 2, level 3 en misschien zelfs level 4 pagina’s zou hebben, of als een level 3 pagina bereikbaar is vanaf een level 2 pagina die bereikbaar is vanaf een level 4 pagina, dan wordt het moeilijker voor de gebruiker voor te stellen waar in de applicatie hij zich bevindt. Natuurlijk zullen er situaties zijn waar toch een level 3 pagina bereikbaar is. Al is het voor de gebruikersbeleving af te raden, zolang de level 2 pagina alleen vanaf de level 3 pagina bereikbaar is met de terug-knop of met een button die van history.back() gebruik maakt, blijft de route logica werken.

State changes en navigeren binnen een modal

Voorbeeld 1: De gebruiker gaat zijn wachtwoord aanpassen bij ‘profiel’;

  • De gebruiker klikt in het menu op ‘profiel’
  • Op de profiel view klikt de gebruiker op een knop ‘wachtwoord aanpassen’
  • Dan opent een modal met 2 invoervelden om een nieuw wachtwoord in te voeren
  • De gebruiker vult een nieuw wachtwoord in en klikt op een knop ‘verder gaan’
  • De modal toont dan een scherm waarin hij zijn oude wachtwoord moet invullen ter bevestiging
  • Op dat moment wil de gebruiker toch een ander nieuw wachtwoord kiezen en klikt op de back button van het device
  • De gebruiker verwacht dan dat de modal geopend blijft en de 2 invoervelden om een nieuw wachtwoord in te voeren worden getoond

Voorbeeld 2: De gebruiker wil een lijst met aanbiedingen als bulk aanpassen;

  • De gebruiker klikt in het menu op ‘aanbiedingen’
  • Op de aanbiedingen overzichtsview klikt de gebruiker op een knop ‘aanbiedingen aanpassen’
  • Dan verandert de state van de view in een ‘edit’ state, waarbij verwijder- en sorteerknopjes verschijnen
  • Op dat moment bedenkt de gebruiker zich en klikt op de terug-knop
  • De gebruiker verwacht dan dat de ‘edit’ state verandert in de ‘view’ state waarmee hij op de pagina terecht kwam

Helaas zullen de verwachtingen in beide voorbeelden niet uitkomen en zal de applicatie netjes, zoals bedacht naar het dashboard gaan. Om dit soort gevallen te kunnen afvangen willen we een lijst bijhouden waarin staat wat er op welk moment moet gebeuren als op de back button wordt geklikt. Soms zal genavigeerd moeten worden naar een andere view, in andere gevallen moet een bepaalde state veranderen naar de vorige state of moet een modal sluiten. En ook hier geldt weer dat als je een paar keer tussen 2 states wisselt, dat klikken op de terug-knop niet opnieuw in omgekeerde volgorde tussen de states gaat wisselen. En nog specifieker; als je op een ‘bewerken’-knop klikt en dan op ‘opslaan’ dan moet de terug-knop niet naar de ‘bewerken’ state gaan, maar de terug functionaliteit uitvoeren die uitgevoerd zou worden voordat je op de ‘bewerken’-knop klikte.

Een ‘history stack’

Door een ‘history stack’ bij te houden kunnen we definiëren dat bijvoorbeeld bij het openen van een modal de stack wordt opgehoogd met een functionaliteit die de modal sluit. En bij het sluiten van de modal zouden we alles van de stack kunnen verwijderen tot en met de functionaliteit die de modal sluit. De logica voor de back button wordt uitgebreid met een check of er een history stack bestaat. Als die bestaat, dan wordt de laatste functionaliteit in de history stack uitgevoerd en dit item wordt vervolgens verwijderd van de stack.

 

export class HistoryService {

  private historyStack: HistoryStackItem[] = [];

  onBack(): void {
    // if history stack exist, run last callBack and remove this item from stack
    // else if page is level 2? then go back to previous page
    // else if current page is dashboard? then open exit app confirm modal
    // else go to dashboard
    const currentRoute = getCurrentRoute();
    if (this.historyStack && this.historyStack.length) {
      const lastItemOnStack = this.historyStack[this.historyStack.length - 1];
      if (lastItemOnStack.popByBackButton) {
        this.historyStack.pop();
      }
      lastItemOnStack.callback();
    } else if (currentRoute.level > 1) {// current view is a level 2 view
      history.back();
    } else if (currentRoute.default) {// current view is the dashboard
      this.confirmExitApp();
    } else {
      this.navigateToDefaultView();
    }
  }

  setHistoryStack(callBack): void {
    this.historyStack.push(this.createHistoryStackItem(callBack, true);
  }

  removeHistoryStack(): void {
    this.historyStack = [];
  }

  private createHistoryStackItem(callBack, popByBackButton): HistoryStackItem {
    return {
      callBack: callBack,
      popByBackButton: popByBackButton
    };
  }

...

}

De setHistoryStack kan worden aangeroepen bij het openen van een modal of het veranderen van een state. Ook kan de ‘history stack’ worden geleegd om ervoor te zorgen dat daarna weer genavigeerd wordt op basis van level n views.

Terug-knop afhandelen is eenvoudig

De history service is redelijk eenvoudig en effectief! Hiermee vang je de meeste situaties af, waarin de verwachting bij het klikken op de terug-knop afwijkt van history.back();