Comment faire fonctionner le bouton Retour avec une machine à états AngularJS ui-router?

87

J'ai implémenté une application de page unique angularjs en utilisant ui-router .

À l'origine, j'ai identifié chaque état en utilisant une URL distincte, mais cela rendait les URL compactées GUID peu conviviales.

J'ai donc défini mon site comme une machine à états beaucoup plus simple. Les états ne sont pas identifiés par des URL mais sont simplement transférés selon les besoins, comme ceci:

Définir des états imbriqués

angular
.module 'app', ['ui.router']
.config ($stateProvider) ->
    $stateProvider
    .state 'main', 
        templateUrl: 'main.html'
        controller: 'mainCtrl'
        params: ['locationId']

    .state 'folder', 
        templateUrl: 'folder.html'
        parent: 'main'
        controller: 'folderCtrl'
        resolve:
            folder:(apiService) -> apiService.get '#base/folder/#locationId'

Transition vers un état défini

#The ui-sref attrib transitions to the 'folder' state

a(ui-sref="folder({locationId:'{{folder.Id}}'})")
    | {{ folder.Name }}

Ce système fonctionne très bien et j'aime sa syntaxe propre. Cependant, comme je n'utilise pas d'URL, le bouton de retour ne fonctionne pas.

Comment puis-je conserver ma machine d'état soignée du routeur ui tout en activant la fonctionnalité du bouton de retour?

biofractale
la source
1
"les états ne sont pas identifiés par des URL" - et je soupçonne votre problème. Le bouton de retour est assez protégé du code (sinon les gens le remplaceraient, causant des problèmes). Pourquoi ne pas simplement laisser angular créer de meilleures URL, comme le fait SO (OK, ils n'utilisent peut-être pas angular, mais leur schéma d'URL est illustratif)?
jcollum
En outre, cette question peut aider: stackoverflow.com/questions/13499040/…
jcollum
De plus, comme vous n'utilisez pas d'URL, cela ne signifie-t-il pas que pour accéder à l'état Z, les gens devront cliquer sur les états X et Y pour y accéder? Cela pourrait devenir ennuyeux.
jcollum
ira-t-il avec l'état avec différents paramètres? @jcollum
VijayVishnu
Je n'en ai aucune idée, c'était il y a trop longtemps
jcollum

Réponses:

78

Remarque

Les réponses qui suggèrent d'utiliser des variantes de $window.history.back()ont toutes manqué une partie cruciale de la question: Comment restaurer l'état de l'application à l'emplacement d'état correct à mesure que l'historique saute (arrière / avant / actualisation). Dans cet esprit; s'il vous plaît, lisez la suite.


Oui, il est possible d'avoir le navigateur en arrière / en avant (historique) et actualiser tout en exécutant une ui-routermachine à états pure , mais cela prend un peu de travail.

Vous avez besoin de plusieurs composants:

  • URL uniques . Le navigateur n'active les boutons Précédent / Suivant que lorsque vous modifiez les URL, vous devez donc générer une URL unique par état visité. Cependant, ces URL n'ont pas besoin de contenir d'informations d'état.

  • Un service de session . Chaque URL générée est corrélée à un état particulier, vous avez donc besoin d'un moyen de stocker vos paires url-état afin de pouvoir récupérer les informations d'état après le redémarrage de votre application angulaire par des clics arrière / avant ou d'actualisation.

  • Une histoire d'État . Un dictionnaire simple des états de ui-router avec une URL unique. Si vous pouvez compter sur HTML5, vous pouvez utiliser l' API d'Historique HTML5 , mais si, comme moi, vous ne pouvez pas, vous pouvez l'implémenter vous-même en quelques lignes de code (voir ci-dessous).

  • Un service de localisation . Enfin, vous devez être en mesure de gérer à la fois les changements d'état de l'interface utilisateur, déclenchés en interne par votre code, et les changements d'URL normaux du navigateur généralement déclenchés par l'utilisateur cliquant sur les boutons du navigateur ou en tapant des éléments dans la barre du navigateur. Tout cela peut devenir un peu délicat car il est facile de se demander ce qui a déclenché quoi.

Voici ma mise en œuvre de chacune de ces exigences. J'ai tout regroupé en trois services:

Le service de session

class SessionService

    setStorage:(key, value) ->
        json =  if value is undefined then null else JSON.stringify value
        sessionStorage.setItem key, json

    getStorage:(key)->
        JSON.parse sessionStorage.getItem key

    clear: ->
        @setStorage(key, null) for key of sessionStorage

    stateHistory:(value=null) ->
        @accessor 'stateHistory', value

    # other properties goes here

    accessor:(name, value)->
        return @getStorage name unless value?
        @setStorage name, value

angular
.module 'app.Services'
.service 'sessionService', SessionService

Ceci est un wrapper pour l' sessionStorageobjet javascript . Je l'ai réduit pour plus de clarté ici. Pour une explication complète de cela, veuillez consulter: Comment gérer l'actualisation de la page avec une application à une seule page AngularJS

Le service d'histoire de l'État

class StateHistoryService
    @$inject:['sessionService']
    constructor:(@sessionService) ->

    set:(key, state)->
        history = @sessionService.stateHistory() ? {}
        history[key] = state
        @sessionService.stateHistory history

    get:(key)->
        @sessionService.stateHistory()?[key]

angular
.module 'app.Services'
.service 'stateHistoryService', StateHistoryService

Il StateHistoryServices'occupe du stockage et de la récupération des états historiques définis par des URL uniques générées. C'est vraiment juste un wrapper pratique pour un objet de style dictionnaire.

Le service de localisation d'État

class StateLocationService
    preventCall:[]
    @$inject:['$location','$state', 'stateHistoryService']
    constructor:(@location, @state, @stateHistoryService) ->

    locationChange: ->
        return if @preventCall.pop('locationChange')?
        entry = @stateHistoryService.get @location.url()
        return unless entry?
        @preventCall.push 'stateChange'
        @state.go entry.name, entry.params, {location:false}

    stateChange: ->
        return if @preventCall.pop('stateChange')?
        entry = {name: @state.current.name, params: @state.params}
        #generate your site specific, unique url here
        url = "/#{@state.params.subscriptionUrl}/#{Math.guid().substr(0,8)}"
        @stateHistoryService.set url, entry
        @preventCall.push 'locationChange'
        @location.url url

angular
.module 'app.Services'
.service 'stateLocationService', StateLocationService

Le StateLocationServicegère deux événements:

  • locationChange . Ceci est appelé lorsque l'emplacement du navigateur est modifié, généralement lorsque vous appuyez sur le bouton Précédent / Suivant / Actualiser ou lorsque l'application démarre pour la première fois ou lorsque l'utilisateur tape une URL. Si un état pour l'emplacement actuel.url existe dans le, StateHistoryServiceil est utilisé pour restaurer l'état via ui-routeur $state.go.

  • stateChange . Ceci est appelé lorsque vous déplacez l'état en interne. Le nom et les paramètres de l'état actuel sont stockés dans la StateHistoryServiceclé par une URL générée. Cette URL générée peut être tout ce que vous voulez, elle peut ou non identifier l'état d'une manière lisible par l'homme. Dans mon cas, j'utilise un paramètre d'état plus une séquence de chiffres générée aléatoirement dérivée d'un guid (voir le pied de page pour l'extrait du générateur de guid). L'URL générée est affichée dans la barre du navigateur et, surtout, ajoutée à la pile d'historique interne du navigateur à l'aide de @location.url url. Il ajoute l'URL à la pile d'historique du navigateur qui active les boutons avant / arrière.

Le gros problème avec cette technique est que l'appel @location.url urlde la stateChangeméthode déclenchera l' $locationChangeSuccessévénement et appellera donc la locationChangeméthode. De même, l'appel de @state.gofrom locationChangedéclenchera l' $stateChangeSuccessévénement et donc la stateChangeméthode. Cela devient très déroutant et gâche l'historique du navigateur sans fin.

La solution est très simple. Vous pouvez voir le preventCalltableau utilisé comme pile ( popet push). Chaque fois qu'une des méthodes est appelée, elle empêche que l'autre méthode ne soit appelée une seule fois. Cette technique n'interfère pas avec le déclenchement correct des événements $ et maintient tout droit.

Il ne nous reste plus qu'à appeler les HistoryServiceméthodes au moment opportun dans le cycle de vie de la transition d'état. Cela se fait dans la .runméthode AngularJS Apps , comme ceci:

App.run angulaire

angular
.module 'app', ['ui.router']
.run ($rootScope, stateLocationService) ->

    $rootScope.$on '$stateChangeSuccess', (event, toState, toParams) ->
        stateLocationService.stateChange()

    $rootScope.$on '$locationChangeSuccess', ->
        stateLocationService.locationChange()

Générer un Guid

Math.guid = ->
    s4 = -> Math.floor((1 + Math.random()) * 0x10000).toString(16).substring(1)
    "#{s4()}#{s4()}-#{s4()}-#{s4()}-#{s4()}-#{s4()}#{s4()}#{s4()}"

Avec tout cela en place, les boutons avant / arrière et le bouton d'actualisation fonctionnent tous comme prévu.

biofractale
la source
1
dans l'exemple du SessionService ci-dessus, je pense que l'accesseur: méthode devrait utiliser @setStorageet @getStorageplutôt que `@ get / setSession '.
Peter Whitfield
3
Quelle langue est utilisée pour cet exemple. Ne semble pas anguleux que je connaisse.
deepak
Le langage était javascript, la syntaxe était coffeescript.
biofractal
1
@jlguenego Vous avez un historique de navigateur fonctionnel / des boutons de va-et-vient de Bowser et des URL que vous pouvez mettre en signet.
Torsten Barthel
3
@jlguenego - les réponses qui suggèrent d'utiliser des variantes de $window.history.back()ont manqué une partie cruciale de la question. Le but était de restaurer l' état de l'application à l'emplacement d'état correct au fur et à mesure que l'historique saute (arrière / avant / actualisation). Ceci serait normalement réalisé en fournissant les données d'état via l'URI. La question demandait comment sauter entre les emplacements d'état sans données d'état URI (explicites). Compte tenu de cette contrainte, il n'est pas suffisant de simplement répliquer le bouton de retour car cela repose sur les données d'état de l'URI pour rétablir l'emplacement de l'état.
biofractale
46
app.run(['$window', '$rootScope', 
function ($window ,  $rootScope) {
  $rootScope.goBack = function(){
    $window.history.back();
  }
}]);

<a href="#" ng-click="goBack()">Back</a>
Guillaume Massé
la source
2
aime ça! ... lol ... pour plus de clarté, il ne $window.history.backfaut pas faire de magie $rootScope... donc revenir en arrière pourrait être lié à votre champ d'application de la directive de la barre de navigation si vous le souhaitez.
Benjamin Conant
@BenjaminConant Pour les personnes qui ne savent pas comment implémenter cela, il vous suffit de mettre le $window.history.back();dans une fonction à appeler ng-click.
chakeda
correct rootScope est uniquement de rendre la fonction accessible dans n'importe quel modèle
Guillaume Massé
Dîner de poulet.
Cody
23

Après avoir testé différentes propositions, j'ai trouvé que le moyen le plus simple est souvent le meilleur.

Si vous utilisez un ui-router angulaire et que vous avez besoin d'un bouton pour revenir en arrière, voici:

<button onclick="history.back()">Back</button>

ou

<a onclick="history.back()>Back</a>

// Attention, ne définissez pas le href ou le chemin sera cassé.

Explication: Supposons une application de gestion standard. Rechercher un objet -> Afficher l'objet -> Modifier l'objet

Utilisation des solutions angulaires À partir de cet état:

Rechercher -> Afficher -> Modifier

À :

Rechercher -> Afficher

Eh bien, c'est ce que nous voulions sauf si maintenant vous cliquez sur le bouton de retour du navigateur, vous y serez à nouveau:

Rechercher -> Afficher -> Modifier

Et ce n'est pas logique

Cependant en utilisant la solution simple

<a onclick="history.back()"> Back </a>

de :

Rechercher -> Afficher -> Modifier

après avoir cliqué sur le bouton:

Rechercher -> Afficher

après avoir cliqué sur le bouton de retour du navigateur:

Chercher

La cohérence est respectée. :-)

bArraxas
la source
7

Si vous recherchez le bouton "retour" le plus simple, vous pouvez configurer une directive comme celle-ci:

    .directive('back', function factory($window) {
      return {
        restrict   : 'E',
        replace    : true,
        transclude : true,
        templateUrl: 'wherever your template is located',
        link: function (scope, element, attrs) {
          scope.navBack = function() {
            $window.history.back();
          };
        }
      };
    });

Gardez à l'esprit qu'il s'agit d'un bouton "retour" assez peu intelligent car il utilise l'historique du navigateur. Si vous l'incluez sur votre page de destination, il renverra un utilisateur à n'importe quelle URL d'où il provient avant d'atterrir sur la vôtre.

dgrant069
la source
3

solution du bouton Précédent / Suivant du navigateur
J'ai rencontré le même problème et je l'ai résolu en utilisant l'objet popstate eventde $ window et ui-router's $state object. Un événement popstate est envoyé à la fenêtre chaque fois que l'entrée d'historique active change.
Les événements $stateChangeSuccesset $locationChangeSuccessne sont pas déclenchés lors du clic du bouton du navigateur, même si la barre d'adresse indique le nouvel emplacement.
Donc, en supposant que vous avez navigué des Etats mainà folderla mainfois, lorsque vous appuyez backsur le navigateur, vous devez être de retour à l' folderitinéraire. Le chemin est mis à jour mais la vue ne l'est pas et affiche toujours tout ce que vous avez main. essaye ça:

angular
.module 'app', ['ui.router']
.run($state, $window) {

     $window.onpopstate = function(event) {

        var stateName = $state.current.name,
            pathname = $window.location.pathname.split('/')[1],
            routeParams = {};  // i.e.- $state.params

        console.log($state.current.name, pathname); // 'main', 'folder'

        if ($state.current.name.indexOf(pathname) === -1) {
            // Optionally set option.notify to false if you don't want 
            // to retrigger another $stateChangeStart event
            $state.go(
              $state.current.name, 
              routeParams,
              {reload:true, notify: false}
            );
        }
    };
}

les boutons Précédent / Suivant devraient fonctionner correctement après cela.

Remarque: vérifiez la compatibilité du navigateur pour window.onpopstate () pour être sûr

jfab fab
la source
3

Peut être résolu en utilisant une simple directive "go-back-history", celle-ci ferme également la fenêtre en cas d'absence d'historique précédent.

Utilisation de la directive

<a data-go-back-history>Previous State</a>

Déclaration de directive angulaire

.directive('goBackHistory', ['$window', function ($window) {
    return {
        restrict: 'A',
        link: function (scope, elm, attrs) {
            elm.on('click', function ($event) {
                $event.stopPropagation();
                if ($window.history.length) {
                    $window.history.back();
                } else {
                    $window.close();  
                }
            });
        }
    };
}])

Remarque: Travailler avec ui-router ou non.

hthetiot
la source
0

Le bouton Retour ne fonctionnait pas non plus pour moi, mais j'ai compris que le problème était que j'avais du htmlcontenu dans ma page principale, dans l' ui-viewélément.

c'est à dire

<div ui-view>
     <h1> Hey Kids! </h1>
     <!-- More content -->
</div>

J'ai donc déplacé le contenu dans un nouveau .htmlfichier et l' ai marqué comme modèle dans le .jsfichier avec les itinéraires.

c'est à dire

   .state("parent.mystuff", {
        url: "/mystuff",
        controller: 'myStuffCtrl',
        templateUrl: "myStuff.html"
    })
CodyBugstein
la source
-1

history.back()et passer à l'état précédent donnent souvent l'effet que vous ne voulez pas. Par exemple, si vous avez un formulaire avec des onglets et que chaque onglet a son propre état, cet onglet précédent vient de basculer sélectionné, pas de retour du formulaire. Dans le cas d'états imbriqués, vous devez généralement penser à la sorcière des états parents que vous souhaitez restaurer.

Cette directive résout le problème

angular.module('app', ['ui-router-back'])

<span ui-back='defaultState'> Go back </span>

Il revient à l'état, qui était actif avant que le bouton ne s'affiche. Facultatif defaultStateest le nom d'état utilisé lorsqu'il n'y a pas d'état précédent en mémoire. Il restaure également la position de défilement

Code

class UiBackData {
    fromStateName: string;
    fromParams: any;
    fromStateScroll: number;
}

interface IRootScope1 extends ng.IScope {
    uiBackData: UiBackData;
}

class UiBackDirective implements ng.IDirective {
    uiBackDataSave: UiBackData;

    constructor(private $state: angular.ui.IStateService,
        private $rootScope: IRootScope1,
        private $timeout: ng.ITimeoutService) {
    }

    link: ng.IDirectiveLinkFn = (scope, element, attrs) => {
        this.uiBackDataSave = angular.copy(this.$rootScope.uiBackData);

        function parseStateRef(ref, current) {
            var preparsed = ref.match(/^\s*({[^}]*})\s*$/), parsed;
            if (preparsed) ref = current + '(' + preparsed[1] + ')';
            parsed = ref.replace(/\n/g, " ").match(/^([^(]+?)\s*(\((.*)\))?$/);
            if (!parsed || parsed.length !== 4)
                throw new Error("Invalid state ref '" + ref + "'");
            let paramExpr = parsed[3] || null;
            let copy = angular.copy(scope.$eval(paramExpr));
            return { state: parsed[1], paramExpr: copy };
        }

        element.on('click', (e) => {
            e.preventDefault();

            if (this.uiBackDataSave.fromStateName)
                this.$state.go(this.uiBackDataSave.fromStateName, this.uiBackDataSave.fromParams)
                    .then(state => {
                        // Override ui-router autoscroll 
                        this.$timeout(() => {
                            $(window).scrollTop(this.uiBackDataSave.fromStateScroll);
                        }, 500, false);
                    });
            else {
                var r = parseStateRef((<any>attrs).uiBack, this.$state.current);
                this.$state.go(r.state, r.paramExpr);
            }
        });
    };

    public static factory(): ng.IDirectiveFactory {
        const directive = ($state, $rootScope, $timeout) =>
            new UiBackDirective($state, $rootScope, $timeout);
        directive.$inject = ['$state', '$rootScope', '$timeout'];
        return directive;
    }
}

angular.module('ui-router-back')
    .directive('uiBack', UiBackDirective.factory())
    .run(['$rootScope',
        ($rootScope: IRootScope1) => {

            $rootScope.$on('$stateChangeSuccess',
                (event, toState, toParams, fromState, fromParams) => {
                    if ($rootScope.uiBackData == null)
                        $rootScope.uiBackData = new UiBackData();
                    $rootScope.uiBackData.fromStateName = fromState.name;
                    $rootScope.uiBackData.fromStateScroll = $(window).scrollTop();
                    $rootScope.uiBackData.fromParams = fromParams;
                });
        }]);
Sel
la source