I built an infinite scroll for a mobile web app built with AngularJS with the following extras features:
I built it to be bidirectional
This is for a mobile web app so I wanted it to unload out-of-view contents to avoid memory issues
Here is the jsfiddle link.
Now, I have a few questions and I also needs a small code review:
I am not familiar with promises, but then() seems to be executed before $digest. Thus, I need to delay my codes with $timeout. For me, it's a sign that something is wrong. I would like to remove the $timeout on lines 85 and 98. The $timeout on line 85 is a bit "hacky", I need to make sure it is executed ms after then() otherwise, it won't work and I don't know why.
I would like to know if it's considered a "good practice" to call a $scope method from a directive. In my code, I am calling $scope.init(value) from my directive.
Including jQuery for a position() is quite funny. Should I be using a services with a function that does what $.position() does?
I know those could be seperate questions but they are really related to my piece of code.
For those who do not want to click on the jsfiddle link, here is the code:
HTML:
<div id="fixed" scroll-watch="4" scroll-up="loadTop()" scroll-down="loadBottom()">
<ul>
<li data-id="{{i.id}}" ng-repeat="i in items" ng-class="calculateType(i.id)">{{i.id}}</li>
</ul>
</div>
JS:
function Main($scope, $timeout, $q) {
var cleanup = 5;
$scope.items = [];
//This is called from the scrollWatch directive. IMO, this shouldn't be a good idea
$scope.init = function(value) {
var deferred = $q.defer();
//This $timeout is used to simulate an Ajax call so I will keep it there
$timeout(function() {
$scope.items = [{id: +value}];
$scope.loadTop();
$scope.loadBottom();
deferred.resolve();
}, 200);
return deferred.promise;
};
//This is only used to simulate different content's heights
$scope.calculateType = function(type) {
return 'type-' + Math.abs(type) % 4;
};
$scope.loadBottom = function() {
var deferred = $q.defer(),
counter;
if ($scope.items.length > 1) {
$scope.items.splice(0, cleanup);
}
//This $timeout is used to simulate an Ajax call so I will keep it there
$timeout(function() {
counter = (($scope.items[$scope.items.length - 1]) || {id: 0}).id;
for (var i = 1; i < 6; i++) {
$scope.items.push({id: counter + i});
}
deferred.resolve();
}, 200);
return deferred.promise;
};
$scope.loadTop = function() {
var deferred = $q.defer(),
counter;
//Why can't I use this here?
//$scope.items.splice($scope.items.length-cleanup, $scope.items.length);
//This $timeout is used to simulate an Ajax call so I will keep it there
$timeout(function() {
counter = (($scope.items[0]) || {id: 0}).id;
for (var i = 1; i < 6; i++) {
$scope.items.unshift({id: counter - i});
}
deferred.resolve();
}, 200);
return deferred.promise;
};
//Why is this method needs to be delayed inside the directive? I would like to call it in loadTop()
$scope.removeBottom = function() {
$scope.items.splice($scope.items.length-cleanup, $scope.items.length);
};
}
angular.module('scroll', []).directive('scrollWatch', ['$timeout', function($timeout) {
var lastScrollTop = 0;
return function($scope, elm, attr) {
var raw = elm[0];
$scope.init(attr.scrollWatch).then(function() {
//Why do I need this? It looks like the resolve is called before the $digest cycle
$timeout(function() {
raw.scrollTop = $('li[data-id="' + attr.scrollWatch + '"]').position().top;
}, 300); //This value needs to be great enough so it is executed after the $scope.loadTop()'s resolve, for now, I know that I can set it to 300 but in real life app?
});
elm.bind('scroll', function() {
if (raw.scrollTop > lastScrollTop && raw.scrollTop + raw.offsetHeight >= raw.scrollHeight) {
$scope.$apply(attr.scrollDown);
} else if (raw.scrollTop < lastScrollTop && raw.scrollTop === 0) {
var scrollHeight = raw.scrollHeight;
$scope.$apply(attr.scrollUp).then(function() {
//Why do I need this? It looks like the resolve is called before the $digest cycle
$timeout(function() {
raw.scrollTop = raw.scrollHeight - scrollHeight;
//I would like to move this in the $scope.loadTop()
$scope.removeBottom();
});
});
}
lastScrollTop = raw.scrollTop;
});
};
}]);
Thank you
http://www.youtube.com/watch?v=o84ryzNp36Q
Is a great video on Promises, how to write them and how they work.
https://github.com/stackfull/angular-virtual-scroll
Is a directive replacement for ng-repeat that doesn't load anything not on screen It does from what I can tell exactly what your looking for.
I would have put this as a comment but you need 50 cred or reputation or whatever they call it.
Related
I have a complex page (lots of ng-repeats nested) so the digest takes a while to finish. I want to give the user some feedback, so they don't think the browser is hung.
Below is a sample fiddle, when you click HIT ME the $watch hangs for 2 seconds. I want the "Working" message to show up, but it does not.
https://jsfiddle.net/jdhenckel/c7edvdt1/
var app = angular.module('myApp', []);
app.controller('myCtrl', function($scope) {
$scope.n = 0;
$scope.test = function() {
$scope.msg = 'Working...';
$scope.n += 1;
};
$scope.$watch(function(scope) {
return $scope.n;
}, function() {
var x = Date.now() + 2000;
while (x > Date.now()) {}
$scope.msg = 'Done.';
});
});
I also tried to use JQuery to directly change the DOM before the digest, but that also didn't work. Seems like my only option is to move all the long running stuff a future digest using a $timeout, but that seems like a hack!
Is there an elegant way to notify the user that the digest is running?
EDIT: Here is a possibly more realistic example.
https://jsfiddle.net/jdhenckel/9vcLq0k3/
$scope.n = 0;
$scope.msg = 'Ready';
$scope.test = function() {
$scope.msg = 'Working...';
$timeout(function() {
doStuff();
$scope.msg = 'Done';
}, 100);
}
This works because I moved all the expensive changes into doStuff.
I was hoping that Angular would provide a simpler way to do this (such as ng-cloak for initialization.) If not, then I'll keep using $timeout.
I need to pass some local parameter to the $q.all callback
var actions = [];
var jsonFiles = ["a.json","b.json","c.json"];
for(var index=0; index<3; index++){
actions.push($http.get('content/' + jsonFiles[index]);
}
$q.all(actions).then(function (values) {
console.log(index) // Need to print current request index
}
current output is of course 3,3,3
I need to print 0,1,2 according to the response order (it can be 1,0,2 or other combination)
I've created a jsfiddle with my issue - http://jsfiddle.net/dorcohen/n30er4ap/
If I understand you correctly , you should use params :
for (var index = 0; index < 3; index++)
{
actions.push($http.get( jsonFiles[index], {params:{"idx": index }}));
}
Then :
$q.all(actions).then(function(values) {
for (var i=0;i<values.length;i++)
$scope.indexes.push(values[i].config.params.idx);
})
Fiddle
OK, so it is rather overkill, but it will work I think
for(var index=0; index<3; index++){
actions.push($q.all([
$q.resolve(index),
$http.get('content/' + jsonFiles[index]);
]);
}
While #Royi's answer is correct, if will not work for "non-http" promises.
A nice workaround which will work for kind of promises is by using the anti-pattern of creating a defered object as a wrapper and resolving your own custom object.
While this is a anti-pattern, there are some cases you want to use it like here.
HTML:
<div ng-app="app">
<div ng-controller="ctrl">
<button ng-click="do()">
Click
</button>
</div>
</div>
JS:
angular.module('app', []).
controller('ctrl', function($scope, $http, $q) {
$scope.do = function() {
var acts = [];
for (var i = 0; i < 3; i++) {
var defer = $q.defer(); // create your own deferred object
// worked with a dummy promise - you can use what ever promise you want
var promise = $q.when({
obj: i
});
promise.then(
// closure for the current deferred obj + index since we are in a loop
function(d,idx) {
return function(item) {
d.resolve({res: item, index:idx}); // resolve my deferred object when I want and create a complex object with the index and the "real response"
}
}(defer,i));
acts.push(defer.promise);
}
$q.all(acts).then(function(res) {
console.log(res);
});
}
});
JSFIDDLE.
I have a large list of items which I navigate to with a button. If I load the data for the list controller immediately, there's a lag while angular builds the list before you see any change to the page. If I instead do a setTimeout in the list controller that waits say, 50ms, the screen changes instantly and the button feels more responsive.
My question is, what is the "proper" way to do this, because I'm pretty sure a setTimeout is a huge hack here.
Edited with Plunkr: http://plnkr.co/edit/VqEiiAFZfV1hoXadw0vf?p=preview
Also, some code from plunkr because stack overflow requires it for some reason:
Test.controller('ListController', function($scope) {
var arr = [];
for (var i=0; i<5000; i++) {
arr[i] = i;
}
$scope.list = arr
});
Test.controller('ListThatWaitsController', function($scope) {
var arr = [];
for (var i=0; i<5000; i++) {
arr[i] = i;
}
setTimeout(function() {
$scope.list = arr;
$scope.$apply()
}, 10);
});
Use the resolve property on your routes (assuming you are using $routeProvider)
$routeProvider.when('/myroute', {
templateUrl: 'view/partial.html',
resolve: {
listData: ['service', function (service) {
return service.getList(); // Returns a promise that resolves with list
}]
}
});
This way in whatever controller manages your $route, you can inject listData and it will be the array you want, but the $route won't resolve until the data is ready to go.
Short version: please reply to the title. Thanks for your help.
Longer version: I started out, as I suppose many n00bs do, with a little code, added some, bit by bit, and found myself with everything in one huge controller.
So, I split my functionality and had a bunch of smaller controllers.
Then I wanted them to communicate with each other & I discovered services.
Then I read that controllers should be lean & mean and I started to move lots of logic from controllers to services.
Now I find that some old code, which read
$scope.internetConnectionRetryTimer = $interval($scope.attemptInternetConnection, RECONNECT_ATTEMPT_FREQUENCY);
when moved into a service as
this.internetConnectionRetryTimer = $interval(this.attemptInternetConnection, RECONNECT_ATTEMPT_FREQUENCY);
doesn't seem to be running the timer; either that or it is not calling the function upon expiry.
Same question as the short version: can I actually use $interval in a service?
[Update] here's the code:
global vars SERVER is a URL and var RECONNECT_ATTEMPT_FREQUENCY = 5 * 1000; // 5 seconds
this.attemptInternetConnection = function()
{
$interval.cancel(this.internetConnectionRetryTimer);
var params = '?action=test_connection&user=dummy';
$http.get(SERVER + params).
success(function()
{
$interval.cancel(this.internetConnectionRetryTimer);
$rootScope.$broadcast('internetIsAvailable');
})
.error(function(status)
{
this.internetConnectionRetryTimer = $interval(this.attemptInternetConnection, RECONNECT_ATTEMPT_FREQUENCY);
$rootScope.$broadcast('internetIsUnavailable');
});
};// attemptInternetConnection()
No problem with that.
Here's an example:
<div ng-app="myApp" ng-controller="myCtrl">{{Data.Test}}</div>
angular.module('myApp', []).
controller('myCtrl', function ($scope, myService) {
$scope.Data = {Test: 'Test'};
myService.ChangeTest($scope.Data);
}).
service('myService', function ($interval) {
this.ChangeTest = function (data) {
$interval(function () {
if (data.Test == 'Test') data.Test = 'Changed Test';
else data.Test = 'Test';
},500);
}
});
Here's a Fiddle.
That should work fine. Though it depends how the method attemptInternetConnection has been written as the code is not posted. If you are referrencing any variables specific to the service inside attemptInternetConnection, it should be accessed by a referrence to the service object like the sample given below.
Demo: http://plnkr.co/edit/1J0qzw044WRHSFGvZyOD?p=preview
app.service('intervalTest', function($interval) {
var me = this;
me.comments = [{
total: 3,
comment: 'some comment 1'
}, {
total: 10,
comment: 'some other comment'
}];
this.getComments = function() {
return me.comments;
};
$interval(function() {
console.log('interval executed');
me.comments[0].total++;
}, 1000);
});
I have a situation where i am getting data on scroll from a service. Now i need to filter data using popular data and latest post
Here is my service:
App.factory('Serviec', function ($http, $rootScope) {
var Hututoo = function () {
this.items = [];
this.busy = false;
this.after = 'Serviec_0';
};
Serviec.prototype.nextPage = function () {
if (this.busy) return;
this.busy = true;
// return undefined
console.log($rootScope.listtype);
$http.get(baseurl + 'ajax/gethome?after=' + this.after).success(function (data) {
var items = data;
for (var i = 0; i < 5; i++) {
this.items.push(items[i]);
// debugger;
}
this.after = "Hututoo_" + this.items.length;
this.busy = false;
}.bind(this));
};
return Serviec;
});
In controller:
$scope.data= new Serviec();
$scope.listtype= 'latest';
$scope.changelist = function(str){
$rootScope.listtype = str;
$scope.data.items=[];
$scope.data.after = 'Serviec_0';
$http.post(baseurl+"ajax/gethome","after="+$scope.hututoo.after+"&list="+str).success(function(data){
$scope.data.items = data;
});
}
Html
<li ng-click="expression = 'latest';changelist('latest');" ng-class="{latest_icon:expression == 'latest'}">Latest Hoot</li>
<li ng-click="expression = 'popular';changelist('popular');" ng-class="{popular_icon:expression == 'popular'}">Popular Hoots</li>
So with these click i need to order data .I have to make http call to get data according to user click.
I was thinking that i can make a scope data that define listing type and get it in factory.
How can i inject this scope in Serviec Factory. I have tried it using rootscope. initally list type is set to latest , but it shows undefined. So what would be the best method achieve this?
Update:
Now i can access scope data in angular service, but small issue comes here is on list click previous item's in scope doesn't get empty and new items get pushed into the scope.
So demand is on list click previous data become zero and new get pushed into the scope.
Pass the $rootScope to controller and set the listtype as required.
App.controller('MainCtrl', function($scope, $rootScope, Hututoo) {
$scope.hututoo = new Hututoo();
$scope.listtype= 'latest';
$scope.changelist = function(str){
$rootScope.listtype= str;
$scope.hututoo = new Hututoo();
$scope.hututoo.nextPage();
}
});
Plunker
Avoid using $rootScope -- it's bad practice, much like using the head object in pure JS. You're already able to share data between the factory and controller, so why not just make listtype a property of the factory:
var Hututoo = function () {
...
this.listtype = 'latest';
};
and use it in your controller as you are other properties:
$scope.changelist = function(str){
$scope.hututoo.listtype = str;
...
};
Demo <-- ajax requests don't work for obvious reasons
$scope is not available to inject in services however you can pass it using parameters like so.
app.factory('Hututoo', function ($resource) {
var somePrivateVar = [];
return {
set: function(scopeVar){
somePrivateVar.push(scopeVar);
},
get: function(){
return somePrivateVar;
}
}
});
then in controller
Hututoo.set($scope.anyVar);