Skip to content

Apply a custom animation to ui-view during resolve #456

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
jonrimmer opened this issue Sep 23, 2013 · 57 comments
Closed

Apply a custom animation to ui-view during resolve #456

jonrimmer opened this issue Sep 23, 2013 · 57 comments

Comments

@jonrimmer
Copy link

It would be very useful if ui-view ran a custom "resolve" animation between the beginning and end of a state change that affects it. This could be used to display a spinner or other loading animation, which is vital when the resolve includes fetching things from the server.

@nateabele
Copy link
Contributor

The API provides you with two event hooks: $stateChangeStart and $stateChangeSuccess. Use them wisely.

@jonrimmer
Copy link
Author

But how can I tell if a state change applies to a particular ui-view instance without relying on/reinventing private implementation details of the view directive? Since the directive already has access to these, wouldn't it be easier if it exposed this capability?

@nateabele
Copy link
Contributor

Okay, could you post some example code of how you would envision using this? Because implementing it as-described would mean all kinds of abstraction boundary violations.

@jonrimmer
Copy link
Author

I envisage that, when a relevant state change starts, the ui-view would apply a new ui-resolve CSS class to the element (Perhaps using the new $animate service? It has addClass() and removeClass() methods). The idea is that it should work very similarly to how enter, leave and move already work.

If I have following element:

<div class="main" ui-view="main">

Then during the resolve state of a relevant state change, the following CSS class would be applied:

<div class="main ui-resolve" ui-view="main">

Which I could then use to apply a transparent overlay and loading spinner:

.main.ui-resolve::after {
  position: absolute;
  content: '';
  width: 100%;
  height: 100%;
  background: rgba(255,255,255,0.5) url('spinner.gif') no-repeat center center;
}

When the resolve was finished, and the state change ended, the ui-resolve class would be removed, and the ui-view would animate the transition between the old view and the new as normal.

@stryju
Copy link

stryju commented Apr 8, 2014

@jonrimmer you could do it, but "globally" - adding class to the body element
on $stateChangeStart and $stateChangeSuccess events

no way to get the currently resolving state's element, afaik

@jonrimmer
Copy link
Author

@stryju - That approach results in a horrible UI experience. The user clicks on a button to refresh a list or something, and the entire screen gets blanketed in a generic spinner because the library has no way of showing a spinner just for the view(s) that are actually affected by the state change. No thanks.

In any case, after I filed this issue, I realised that the "resolve" feature is an anti-pattern. It waits for all the promises to resolve then animates the state change. This is completely wrong - you want your transition animations between states to run parallel to your data loads, so that the latter can be covered up by the former.

For example, imagine your have a list of items, and clicking on one of them hides the list and shows the item's details in a different view. If we have an async load for the item details that takes, on average, 400ms, then we can cover up the load almost entirely in most cases by having a 300ms "leave" animation on the list view, and a 300ms "enter" animation on the item details view. That way we provide a slicker feel to the UI and can avoid showing a spinner at all in most cases.

However, this requires that we initiate the async load and the state change animation at the same moment. If we use "resolve", then the entire async animation happens before the animation starts. The user clicks, sees a spinner, then sees the transition animation. The whole state change will take ~1000ms, which is too slow.

"Resolve" could be a useful way to cache dependencies between different views if it had the option not to wait on promises, but the current behaviour, of always resolving them before the state change starts makes it almost useless, IMO. It should be avoided for any dependencies that involve async loads.

@stryju
Copy link

stryju commented Apr 8, 2014

@jonrimmer i totally agree here.

there's no silver bullet here - the "resolve" pattern is powerful, but only if you know how to use it - otherwise you end up with what seems to be an "unresponsive" application :-)

@roberkules
Copy link

@stryju I still have a hard time to understand the point of resolve promises if they make the UI seem unresponsive. I would love to use them, but if there's no way to show some loading indicator (for the affected ui-view) it makes no sense.

Are the any plans to add this feature? Is there any workaround (besides moving the resolve logic to the controller)?
I checked SO but only found this question http://stackoverflow.com/q/18961332/45948

@stryju
Copy link

stryju commented May 8, 2014

@roberkules well, you COULD go with something like http://chieffancypants.github.io/angular-loading-bar/ - but it's global

you potentially could use a pub/sub pattern and broadcast which view is about to get loaded, check if it has resolve set and then use something there

@johnnyoshika
Copy link

I agree with @jonrimmer and @roberkules. Showing a loader animation only on the affected view while the resolve is happening is an important behavior that many developers would need to support (especially if they work with the type of designers that I've been working with). The solution that @jonrimmer proposes (i.e. adding a ui-resolve class to the affected view) would be an ideal solution.

@nateabele
Copy link
Contributor

In any case, after I filed this issue, I realised that the "resolve" feature is an anti-pattern. It waits for all the promises to resolve then animates the state change.

It's not an anti-pattern in itself, although the way people use it certainly can be. Perhaps some guidelines can be added to the documentation.

This is completely wrong - you want your transition animations between states to run parallel to your data loads, so that the latter can be covered up by the former.

This is kind of a hard problem, since state transitions are intended to be atomic, and a failing resolve should fail the transition, in which case the state machine's job is to ensure that the UI is left in a consistent state. I'm open to opinions on how we can achieve both objectives.

Regarding the specific problem of determining what views are affected at the outset of a state transition, I'm retooling ui-router's internals in a way that might make instrumenting this easier, but the fundamental design dictates one-way communication: $state calls $view, which pushes updates to uiView elements (which may or may not exist on the page at the time $view attempts to message them).

What we might be able to do is have $state make an extra call to $view (i.e. after $stateChangeStart fires) that allows $view to ping any affected uiViews on the page that would be subject to an update or removal on completion of a successful transition, and those uiViews can fire off events that either say 'I'm about to get updated, maybe!' or 'I might be going away in a second'. Those events can then be intercepted by the controllers responsible for said uiViews, and push UI updates accordingly.

Would that solve it?

@johnnyoshika
Copy link

@nateabele, your last suggestion (uiViews receiving and emitting notifications that state is about to change) would be a very welcome addition. It will allow us to offer feedback to the user that something is about to change on that view. I still think @jonrimmer's suggestion of adding a class (something like ui-resolve) to the impacted view is more desirable, because it will allow us to show a spinner at the right place at the right time without any code. Is it possible to do both? Even with just the solution you propose would be very valuable, as there currently isn't a way to do any of this at all.

@Gwash3189
Copy link

I'm going to second both @nateabele's and @johnnyoshika's suggestions. Both great ideas.

Alternately, this may be a dirty word around these parts but, Ember's router forces you to load remote content by using the resolve pattern. The way it accomplishes this is that each Router (or state for ui-view) can have a loading function. When the router is resolving the data it will then execute this loading function, if it exists. If the loading function does not exist on the current route, it will then look at the parent route (if there is one) and execute it's loading function.

Maybe ui.router can take a similar approach? A loading function can be executed, then when the state is ready to be shown (controller instantiated etc.) the content within the ui-view is blown away and replaced with the correct content?

Additionally, i don't know how this will affect ng-animate integration. i'll leave that to someone with more knowledge than i.

@johnnyoshika
Copy link

@Gwash3189, that's a really interesting suggestion and would work quite well.

@nateabele
Copy link
Contributor

I still think @jonrimmer's suggestion of adding a class (something like ui-resolve) to the impacted view is more desirable, because it will allow us to show a spinner at the right place at the right time without any code. Is it possible to do both?

Yeah, I don't see why not.

When the router is resolving the data it will then execute this loading function, if it exists.

Can you post some examples of how this works?

@nateabele nateabele reopened this May 13, 2014
@johnnyoshika
Copy link

The loading function that @Gwash3189 mentioned is described here:

http://emberjs.com/guides/routing/loading-and-error-substates/

@Gwash3189
Copy link

@johnnyoshika Thanks for that.

@nateabele I'll have a go at it during lunch today.

@Gwash3189
Copy link

@nateabele
here is a link to a plnkr with my attempt.
I tried implementing this functionality outside of the ui router source code. Reason being is that i don't know it well enough to make these changes during my lunch break. I hope you can get a general idea from my example though.

Just to touch on a couple of things with the example.

  1. Each state has a resolve of 1 second. There are three states, one with no child and another state with a child.
  2. There is a state constructor function i use. I no longer need this but i was to lazy to change all the code!
  3. The states 'another' and 'another.more' have loading functions. Something i didn't know about ui router (but i'm sure you do) is that the data property of a state is copied from a parent to a child. This means that if a parent has a loading function, each child will also have the same loading function. The function is only overwritten when the loading function is provided for a child router. This makes using the loading function from a parent super easy.
  4. I have to assign an id to my child view as that is the only way i can reference it from outside ui router.

I hope i touched on everything. If i missed anything, let me know.

Edit
Cleaned up the code in the example.

@Gwash3189
Copy link

@nateabele

I had another go at this problem. Again i didn't edit any ui router source.
This time i had an idea i tried separating the loading function into two parts, enter and leave.

Loading.enter would run when $stateChangeStart is fired. This function displays a loading spinner. Implementing this was easy and is identical to the other plnkr i posted.

The idea behind Loading.leave is that it would execute when the resolve is complete and the content (controller etc.) is ready to be instantiated. This gave me trouble and is still unimplemented as $stateChangeSuccess is broadcasted to late. Also, any other event, such as $viewContentLoaded does not send along the state it is transferring to, which makes finding any loading function impossible.

I think the a valid solution to this problem is doing the following.

  • Allow users to add loading functions as my per my last comment, although, instead of having a single function, have the ability to specify a "enter" and "leave" function.
    • The loading.enter function would run when $stateChangeStart is fired. Or it can run as soon as ui router starts trying to resolve the new content. During this stage the element with ui-view directive should be given a class of ui-resolve-enter
    • the loading.leave function would run when ui router has finish resolving the content and is ready to place the new content into the dom. The order execution would be loading.leave, then it would place the new content into the dom. During this stage the element with the ui-view directive should be given a class of ui-resolve-leave
  • Once the loading.leave stage is complete and the new content is being placed into the dom, any other animations should be used. For example, any old functionality with enter and leave when content is being placed into the dom should still work. This will allow users to display different animations for when content is loading (resolving) and when content is being put into the dom (inserting the now resolved content)

Please let me know your thoughts. I think this is a pretty solid solution as it gives the user hooks they can use (loading.enter & leave) and perform any thing during these phases and also provides ui-resolve-enter and ui-resolve-leave classes for css animations.

@pdeva
Copy link

pdeva commented Jun 4, 2014

@Gwash3189 what purpose does loading.leave serve? I mean after the loading is done, you just want the control flow to be what it is currently, which is to init the controller and display the template.
Even in your examples, loading.leave is not really used.

@danjohnso
Copy link

I think the existing API can work for this. By checking for a resolve property on the toState, and in deeper locations depending on your setup, you can show the loading only if there is a pending resolve.

    $rootScope.$on("$stateChangeStart", function (event, toState, toStateParams, fromState, fromStateParams) {
        var isLoading = toState.resolve;

        if(!isLoading) {
            for (var prop in toState.views) {
                if (toState.views.hasOwnProperty(prop)) {
                    if(toState.views[prop].resolve) {
                        isLoading = true;
                        break;
                    }
                }
            }
        } 

        if (isLoading) {
            //show loading code
        }
    });

    $rootScope.$on("$stateChangeSuccess", function (event, toState, toParams, fromState, fromParams) {
        //hide loading
    });

    $rootScope.$on("$stateChangeError", function (event, toState, toParams, fromState, fromParams, error) {
        //hide loading
    });

@FlorianTopf
Copy link

I had the same issue discussed here. My app seemed to "unresponsive" due to a pending resolve. In my resolve I even call a remote resource, so there is always some loading time. I played around with some scenarios described here, but my case was even more severe: The resolving state is abstract, so there is never a stateChange event fired when it is loaded implicitly with its children. I came up with putting a full width loading animation within the root ui-view (not the ui-view of the abstract state) like this:

<div ui-view>
    <div class="loading text-center">
        <img src="/public/img/layout/loading.gif" class="loadingImg" alt="loading..." />
    </div>
</div>

I find it a bit dirty, but it does its job. The div gets replaced when the abstract state is loaded. I hope this is useful for others.

@mr-moon
Copy link

mr-moon commented Jul 22, 2014

Or we could have something like templateUnresolved property (possibly along with templateUnresolvedUrl) and use that unless async data request has been resolved or failed. Or since, this whole problem sounds like "view itself has states", we could end up with something geeky like:

views: {
    'left.search.results': {
        resolve: function() { ... },
        controller: 'SearchResultsController',
        template: {
            init: 'Loading...',
            ready: 'url:views/search/results.html',
            failed: 'oooops...'
        }
    }
}

@robclancy
Copy link

Here is a directive I am using for this. I just made it so hasn't been tested much.
Basically you add resolving to your main ui-view and then add resolving="parentStateHere" to sub views which is then detects changes from and to that sub view and adds the resolving class and a mask plus loading image to the view (this is the part you would obviously edit for what you want to show when loading or just use css etc.

angular.module('myapp').directive('resolving', ['$rootScope', function($rootScope) {
  return {
    restrict: 'A',
    link: function(scope, element, attributes) {
      var mask = angular.element('<div class="resolving-mask"><img src="/assets/img/ajax-loader.gif" /></div>');
      var showResolving = function() {
        element.addClass('resolving');
        element.append(mask);
      };
      var hideResolving = function() {
        element.removeClass('resolving');
        mask.remove();
      };

      $rootScope.$on('$stateChangeStart', function(event, toState, toParams, fromState, fromParams) {
        if ( ! toState.resolve) {
          return;
        }

        // should be a resolve for a sub view
        if (fromState.name.indexOf(attributes.resolving+'.') === 0 && toState.name.indexOf(attributes.resolving+'.') === 0) {
          showResolving();
          return;
        }

        // main resolving directive (no attribute name) 
        if ( ! attributes.resolving) {
          // no fromState.name here means it should be first load, and no toState means redirecte
          if ( ! fromState.name || ! toState) {
            showResolving();
            return;
          }

          // each root state is different
          var fromStates = fromState.name.split('.');
          var toStates = toState.name.split('.');
          if ( ! fromStates[0] || ! toStates[0] || (fromStates[0] != toStates [0])) {
            showResolving();
          }
        }
      });

      $rootScope.$on('$stateChangeSuccess', hideResolving);
      $rootScope.$on('$stateChangeError', hideResolving);
    }
  };
}]);

EDIT: someone (or I if I find time) could make this into a directive on ui-view instead and do what is requested in this issue.

@barboaz
Copy link

barboaz commented Sep 8, 2014

Why was this issue closed? Was it made possible to show a loader animation only on the affected view while the resolve is happening?

@nateabele
Copy link
Contributor

@barboaz You may notice that this issue was reopened almost 4 months ago.

@barboaz
Copy link

barboaz commented Sep 9, 2014

@nateabele You're right, I misread the page.

@richardvanbergen
Copy link

@timlind +1. Hit the nail on the head. Though I can see an argument for both behaviors, there is a strong case for making this possible. I tried having a crack at it myself but the ui-view directive is quite complex because of all of the cloning and removing of elements that's going on.

@richardvanbergen
Copy link

I should also mention for the benefit of others that the only way I found around this was to manually handle animations and remove all of the resolvers and put them into the controller. That way the controllers resolve immediately and I just hide the new view until I'm ready.

For more details see here: http://stackoverflow.com/questions/26510621/changing-how-the-ui-view-directive-handles-page-transitions

@nateabele
Copy link
Contributor

@enrichit Thanks a lot for the input. We're in the process of a new version that will allow a lot more flexibility to handle this stuff. Could you just put together the different possible scenarios you want in a simple list of operations? Thanks.

@kwladyka
Copy link

I will be glad if i can do scenario like this:

  1. user click on some link and as an action some ui-view (can be more then one!) is loading/reloading.
  2. during loading/reloading on DOM element where ui-view is added class "ui-resolve" is added. It will be helpfull to change background to darker and put spinner in the center.

During this operation i see options:
a) actual ui-view is cleaning (empty content inside DOM element) but the size of this element (height and width) is not changing. It is important beause in other case user will suffer because page size change and jumping on site.
b) content of DOM element whre is ui-view is not changing. Just darker layer with spinner is added and user can't click anymore on this view but can see what was there before.
c) DOM element with ui-view is clearing (empty content, like <div ui-view="bla"></div> and inside some custom text is added like "Loading...". But i think it can be also done with CSS. But the difference is in that case size of ui-view element is changing.

@willread
Copy link

+1 for any sort of solution to this.

Even the addition of a reference to the ui-view element being passed in view/state events would let us easily roll our own loading indicator solutions. In my mind the main problem now is not being able to differentiate between view containers in a complex layout, forcing us to resort to top-level loading indicators that provide a less than ideal experience.

Integration with ng-animate would be a nice bonus, but I don't think it should be the only means provided as I know many developers prefer to use their own animation solutions.

@viktordavidovich
Copy link

Hello guys! So what about this issue? Can anybody help me? Can i use this method for resolving our problem.

angular.module('myapp').directive('resolving', ['$rootScope', function($rootScope) {
  return {
    restrict: 'A',
    link: function(scope, element, attributes) {
      var mask = angular.element('<div class="resolving-mask"><img src="/assets/img/ajax-loader.gif" /></div>');
      var showResolving = function() {
        element.addClass('resolving');
        element.append(mask);
      };
      var hideResolving = function() {
        element.removeClass('resolving');
        mask.remove();
      };

      $rootScope.$on('$stateChangeStart', function(event, toState, toParams, fromState, fromParams) {
        if ( ! toState.resolve) {
          return;
        }

        // should be a resolve for a sub view
        if (fromState.name.indexOf(attributes.resolving+'.') === 0 && toState.name.indexOf(attributes.resolving+'.') === 0) {
          showResolving();
          return;
        }

        // main resolving directive (no attribute name) 
        if ( ! attributes.resolving) {
          // no fromState.name here means it should be first load, and no toState means redirecte
          if ( ! fromState.name || ! toState) {
            showResolving();
            return;
          }

          // each root state is different
          var fromStates = fromState.name.split('.');
          var toStates = toState.name.split('.');
          if ( ! fromStates[0] || ! toStates[0] || (fromStates[0] != toStates [0])) {
            showResolving();
          }
        }
      });

      $rootScope.$on('$stateChangeSuccess', hideResolving);
      $rootScope.$on('$stateChangeError', hideResolving);
    }
  };
}]);

i have 2 buttons in my menu header item:

  1. first button Home - state: 'home', url: '/'
  2. second button Options - state: 'options', url: '/options'
    In the first state i have static content in the template home.html, but in the second state i have $http request to my server.
    I want, when i will click to second button , at first i will show animating (css or image no sense), then during resolve state animation will show, then state resolve, animation have to hide. Cause right now, when i click to second button, i should wait response time to resolve state....

@maciej-gurban
Copy link

Another mention of the same problem: https://www.bountysource.com/issues/1388153-don-t-wait-till-final-child-state-is-done-being-configured-in-order-to-load-parent-templates-and-controllers

+1

At this point, resolves seem unusable with nested states and multiple views as they block the whole application.

@antonpodolsky
Copy link

A fairly simple and generic solution would be determining currently resolved ui-view by element hierarchy.

  1. Create a directive and assign it to your ui-view elements. Example:
<div ui-view state-loader>
    <div ui-view state-loader></div>
</div>
  1. The directive will use the $stateChangeStart events to decide whether current ui-view is the one being resolved and add relevant classes. Example:
 angular.module('myApp')
  .directive('stateLoader', function stateLoader() {
    return {
      restrict: 'A',
      scope: {},

      link: function (scope, element) {
        scope.$on('$stateChangeStart', function (e, toState) {
          if (element.parents('[ui-view]').length === toState.name.split('.').length - 1) {
            element.addClass('loading-state');
          }
        });

        scope.$on('$viewContentLoaded', function () {
          element.removeClass('loading-state');
        });
      }
    };
  });

This works quite well for simple state configurations with nested views.
It probably isn't enough for more complicated configurations or states with multiple views.

@viktordavidovich
Copy link

thank's! last example looks like good.

@aikoven
Copy link

aikoven commented Jan 20, 2015

My SO answer:

My idea is to walk the path on state graph between transitioning states on $stateChangeStart and collect all involved views. Then every ui-view directive watches if corresponding view is involved in transition and adds 'ui-resolving' class on it's element.

The plunker demo introduces two root states: first and second, the latter has two substates second.sub1 and second.sub2. The state second.sub2 also targets footer view that belongs to its grandparent.

@ClearCloud8
Copy link

+1

@jumpwake
Copy link

@aikoven +1

Kudos, this helped tremendously. Tried everything I could find and nothing helped. I used a slight variation of this, but it solved the processing lag issue. The lag was not really evident until I bought an old Android phone to test with. If I can get the UX to work well on this old device, it'll work great on anything.

@javoire
Copy link

javoire commented Mar 2, 2015

+1

@AntonPOD your example worked great for simple ui view setups, as you said!

@PaddyMann
Copy link

Bleugh - just walked into this unfortunate resolve limitation.

The behaviour I want is similar to @aikoven solution, but if I click on 'second.sub1' from 'first', I want the 'second' wrapper view to load immediately (as it only contains the section header), and the loader to only apply to 'second.sub1' (which happens to be a grid loading some data).

As it is, I'm ditching the use of resolves in favour of loading data on a service on onEnter, and watching the service to see if it is loading / loaded (- my service explicitly provides a 'loading' variable that can be watched).

@oravecz
Copy link

oravecz commented Mar 5, 2015

@aikoven It seems like your solution is just a few steps away from using ng-enter/ng-leave semantics to enable ngAnimation capabilities.

@Maultasche
Copy link

I solved the problem in my application by setting a flag in my controller when the state was changing and then hiding or showing a loading animation and my ui-view. If I was interested in which state was being transitioned from and to, that information is also available.

Code from the controller:

        //Flag that indicates if the state is changing
        vm.stateChanging = false;

        //Call some code when a state change starts
        $scope.$on("$stateChangeStart", function (event, toState, toParams, fromState, fromParams) {
            vm.stateChanging = true;
        });

        //Call some code when a state change finishes
        $scope.$on("$stateChangeSuccess", function (event, toState, toParams, fromState, fromParams) {
            vm.stateChanging = false;
        });

The web page:

    <div ng-app="exampleApp" ng-controller="exampleController as example">
        <div ng-hide="example.stateChanging" style="margin-top: 10px;">
            <ui-view />
        </div>
        <div ng-show="example.stateChanging" style="margin-left: 10px;margin-top:10px;">
            <img src="/Content/Images/Shared/spinner-large.gif" alt="Loading..." />
            <span>Loading Page...</span>
        </div>
    </div>

The loading animation displays whenever I change states and it gets replaced by the view after the resolve has finished and the new state is ready.

@javoire
Copy link

javoire commented May 4, 2015

@Maultasche, I've done the same thing in some places, the problem is just that it doesn't solve the case @PaddyMann is describing

@doodirock
Copy link

I know work was being done with this, but are we any closer to a solution for more complex routes?

@joshuahiggins
Copy link
Contributor

Is this something that is currently being implemented in a future version or would you welcome pull requests?

@gaui
Copy link

gaui commented Jul 24, 2015

👍

@nateabele
Copy link
Contributor

@joshuahiggins We haven't really come back to it yet. I think a PR might be a little premature, but if you want to take a look at our work on the feature-1.0 branch and propose a solution, we'd certainly be open to that.

At this point, the 1.0 code is still pretty experimental, so I'd hesitate to put a lot of time into a solution before validating that it conforms to the current design vision. Hope that makes sense. Thanks for the interest.

@eddiemonge
Copy link
Contributor

Closing this as it has a lot of stuff in it and a few workarounds. If someone wants to open a new issue for a feature request with a specific proposal, that would be greatly helpful. Like nate said, you can/should/please also try the new feature-1.0 branch that is in development to see if its any better.

@gaui
Copy link

gaui commented Oct 14, 2015

How would I show an animation before state is loaded (promise resolve)?

@eddiemonge
Copy link
Contributor

you could attach it to the events: stateChangeStart and stateChangeSuccess

@gaui
Copy link

gaui commented Oct 14, 2015

Thank you @eddiemonge

@bill-bishop
Copy link

Here is a directive that allows simple control over showing loading states in the UI without abandoning pre-transition resolves

usage in html:

Home Page <a ui-sref="app.detail">Go to Detail</a>

<div loading-state="app.detail">Loading... (spinners, etc. can go here)</div>

implementation:

myModule.directive('loadingState', function ($rootScope) {
    var loadingStates = {};

    $rootScope.$on('$stateChangeStart', function (event, toState) {
        loadingStates[toState.name] = true;
    });

    ['$stateChangeSuccess', '$stateChangeError', '$stateNotFound'].forEach(function (eventType) {
        $rootScope.$on(eventType, function (event, toState) {
            delete loadingStates[toState.name];
        });
    });

    return {
        template: '<div ng-show="loading[state]" ng-transclude></div>',
        transclude: true,
        scope: {
            state: '@loadingState'
        },
        controller: function ($scope) {
            $scope.loading = loadingStates;
        }
    };
});

@christopherthielen
Copy link
Contributor

nice @william-mcmillian

@jjenzz
Copy link

jjenzz commented Jun 2, 2016

I've created a directive that will emit uiViewLoaderStart or uiViewLoaderStop on the $rootScope when a ui-view is loading a new state. The events will get passed the ui-view element that is about to transition so you can do what you want with it (e.g. add/remove a class, append/remove content, hide/show your own custom loading directive).

import angular from 'angular';
import 'angular-ui-router';

/**
 * @ngdoc module
 * @name uiViewLoader
 *
 * @description
 * Emits the appropriate `uiViewLoaderReady`, `uiViewLoaderStart` and `uiViewLoaderStop` events on
 * the `$rootScope`, passing them the respective `ui-view` element. This is to aid control over
 * a specific `ui-view` when it is transitioning/resolving.
 */
const UiViewLoaderModule = angular.module('uiViewLoader', [
  'ui.router'
])
  .directive('uiViewLoader', () => ({
    controller: UiViewLoaderController,
    controllerAs: '$ctrl',
    restrict: 'A'
  }))
  .constant('UI_VIEW_LOADER_EVENTS', {
    UI_VIEW_LOADER_READY: 'uiViewLoaderReady',
    UI_VIEW_LOADER_START: 'uiViewLoaderStart',
    UI_VIEW_LOADER_STOP: 'uiViewLoaderStop',
    STATE_CHANGE_START: '$stateChangeStart',
    STATE_CHANGE_SUCCESS: '$stateChangeSuccess',
    STATE_CHANGE_ERROR: '$stateChangeError',
    STATE_NOT_FOUND: '$stateNotFound'
  });

/*
 * @private
 * @ngdoc controller
 * @name uiViewLoader.controller:UiViewLoaderController
 */
class UiViewLoaderController {
  /**
   * @param {JQLite} $element
   * @param {Scope} $rootScope
   * @param {Scope} $scope
   * @param {Object} $attrs
   * @param {function} $interpolate
   * @param {Object.<string>} UI_VIEW_LOADER_EVENTS The events that this controller listens to
   * or publishes
   */
  constructor(
    $element,
    $rootScope,
    $scope,
    $attrs,
    $interpolate,
    UI_VIEW_LOADER_EVENTS
  ) {
    'ngInject';

    this._$element = $element;
    this._$rootScope = $rootScope;
    this._$scope = $scope;
    this._$attrs = $attrs;
    this._$interpolate = $interpolate;
    this._events = UI_VIEW_LOADER_EVENTS;
  }

  $onInit() {
    this._view = this._$interpolate(this._$attrs.uiView)(this._$scope);
    this._state = this._$interpolate(this._$attrs.uiViewLoader)(this._$scope).split('.').pop();
    this._viewRegex = new RegExp(`${this._view}@?`);
    this.addUiRouterListeners();

    this._$rootScope.$emit(this._events.UI_VIEW_LOADER_READY, this._$element);
  }

  $onDestroy() {
    // make sure destroy happens after 'uiViewLoaderStop' has fired
    const stopDestroy = this._$rootScope.$on(this._events.UI_VIEW_LOADER_STOP, () => {
      this.removeUiRouterListeners();
      stopDestroy();
    });
  }

  /**
   * Sets up listeners for uiRouter events
   */
  addUiRouterListeners() {
    this._listeners = [
      this._events.STATE_CHANGE_START,
      this._events.STATE_CHANGE_SUCCESS,
      this._events.STATE_CHANGE_ERROR,
      this._events.STATE_NOT_FOUND
    ].map(event => this._$rootScope.$on(event, this._onStateChange.bind(this)));
  }

  /**
   * Removes listeners from uiRouter events
   */
  removeUiRouterListeners() {
    this._listeners.forEach(destroy => destroy());
  }

  /**
   * Called when a uiRouter $stateChange* event is fired and emits the appropriate
   * `uiViewLoaderStart` or `uiViewLoaderStop` events on the `$rootScope`.
   *
   * @param {Object} event The $statChange* event object.
   * @param {Object} toState The state that the user is attempting to transition to.
   */
  _onStateChange(event, toState, toParams, fromState) {
    const { UI_VIEW_LOADER_START, UI_VIEW_LOADER_STOP } = this._events;
    const segments = ['', ...toState.name.split('.')];
    const index = segments.indexOf(this._state);
    const hasView = toState.views && Object.keys(toState.views).some(view => view.match(this._viewRegex));

    // isLoading if:
    // - It's a page load/refresh so all states are loading at this point (`!fromState.name`)
    // Or:
    // - We're navigating to a state with a views object containing this view (hasView)
    // Or:
    // - We're navigating to a state that contains this state in it's name (`index > 1`)
    // -- and it's a direct descendant of this state (`!segments[index + 2]`)
    if (!fromState.name || hasView || (index > -1 && !segments[index + 2])) {
      const isChangeStart = event.name === this._events.STATE_CHANGE_START;
      const emit = isChangeStart ? UI_VIEW_LOADER_START : UI_VIEW_LOADER_STOP;

      this._$rootScope.$emit(emit, this._$element, toState);
    }
  }
}

To use it you need to add a ui-view-loader attribute to your ui-view element and pass it the name of the state that the ui-view lives on. For example:

$stateProvider
  .state('root', {
    abstract: true,
    resolve: {
      data: $timeout => $timeout(angular.noop, 1000);
    }
  })
  .state('root.about', {
    url: '/about',
    resolve: {
      data: $timeout => $timeout(angular.noop, 1000);
    },
    views: {
      'main@': {
        template: `
          <h2>About</h2> 
          <div ui-view ui-view-loader="root.about"></div>
        `
      }
    }
  });

Doing it this way means that any state that loads as a direct descendant of the ui-view will emit the necessary events.

If you do not pass a state name it will assume $default state.

I have created a codepen demonstrating how this can be used with your own custom load-spinner directive to show custom spinners when a particular ui-view is loading: http://codepen.io/jjenzz/pen/RRNXKQ?editors=1010

If anyone can think of ways to improve this, please do share... 🙂

I should point out that you need Angular 1.5.5 for the above code to work (and an ES6 transpiler).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests