Skip to content

Commit e75e20d

Browse files
committed
refactor: $animate - add $animateClassToggler to stop race conditions
Addresses #1100. Fixes race conditions where $animate.{removeClass,hideClass} are called simultaneously, in addition fixes any blinking that coulld be caused by showing an element just attached to the dom.
1 parent f7849cb commit e75e20d

File tree

8 files changed

+166
-50
lines changed

8 files changed

+166
-50
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Stop race conditions with toggling an ngAnimate on an element rapidly,
3+
* due to the asynchronous nature of addClass and removeClass
4+
*
5+
* Additionally, only toggle classes on requestAnimationFrame, to stop flickers on
6+
* iOS and older android for elements that were just attached to the DOM.
7+
*/
8+
angular.module('ionic')
9+
.factory('$animateClassToggler', [
10+
'$animate',
11+
'$rootScope',
12+
function($animate, $rootScope) {
13+
14+
return function(element, className) {
15+
16+
var self = {
17+
_nextOperation: null,
18+
_animating: false,
19+
_toggle: toggle,
20+
_animate: animate,
21+
_animationDone: animationDone,
22+
addClass: function() { self._toggle(true); },
23+
removeClass: function() { self._toggle(false); }
24+
};
25+
26+
return self;
27+
28+
function toggle(hasClass) {
29+
var operation = hasClass ? 'addClass' : 'removeClass';
30+
if (self._animating) {
31+
self._nextOperation = operation;
32+
} else {
33+
self._animate(operation);
34+
}
35+
}
36+
37+
function animate(operation) {
38+
self._animating = true;
39+
ionic.requestAnimationFrame(function() {
40+
$rootScope.$evalAsync(function() {
41+
$animate[operation](element, className, self._animationDone);
42+
});
43+
});
44+
}
45+
46+
function animationDone() {
47+
self._animating = false;
48+
if (self._nextOperation) {
49+
self._animate(self._nextOperation);
50+
self._nextOperation = null;
51+
}
52+
}
53+
};
54+
}]);
55+

js/ext/angular/src/service/ionicBackdrop.js

+10-12
Original file line numberDiff line numberDiff line change
@@ -6,32 +6,30 @@ angular.module('ionic')
66
.factory('$ionicBackdrop', [
77
'$animate',
88
'$document',
9-
function($animate, $document) {
9+
'$animateClassToggler',
10+
function($animate, $document, $animateClassToggler) {
1011

11-
var el;
12+
var el = angular.element('<div class="backdrop ng-hide">');
1213
var backdropHolds = 0;
14+
var toggler = $animateClassToggler(el, 'ng-hide');
15+
16+
$document[0].body.appendChild(el[0]);
1317

1418
return {
1519
retain: retain,
1620
release: release,
17-
_getElement: getElement
21+
// exposed for testing
22+
_element: el
1823
};
1924

20-
function getElement() {
21-
if (!el) {
22-
el = angular.element('<div class="backdrop ng-hide">');
23-
$document[0].body.appendChild(el[0]);
24-
}
25-
return el;
26-
}
2725
function retain() {
2826
if ( (++backdropHolds) === 1 ) {
29-
$animate.removeClass(getElement(), 'ng-hide');
27+
toggler.removeClass();
3028
}
3129
}
3230
function release() {
3331
if ( (--backdropHolds) === 0 ) {
34-
$animate.addClass(getElement(), 'ng-hide');
32+
toggler.addClass();
3533
}
3634
}
3735
}]);

js/ext/angular/src/service/ionicLoading.js

+9-14
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ angular.module('ionic.service.loading', [])
4141
'$q',
4242
'$log',
4343
'$compile',
44-
function($animate, $document, $ionicTemplateLoader, $ionicBackdrop, $timeout, $q, $log, $compile) {
44+
'$animateClassToggler',
45+
function($animate, $document, $ionicTemplateLoader, $ionicBackdrop, $timeout, $q, $log, $compile, $animateClassToggler) {
4546

4647
var loaderInstance;
4748
//default value
@@ -81,13 +82,17 @@ function($animate, $document, $ionicTemplateLoader, $ionicBackdrop, $timeout, $q
8182
appendTo: $document[0].body
8283
})
8384
.then(function(loader) {
85+
86+
var toggler = $animateClassToggler(loader.element, 'ng-hide');
87+
8488
loader.show = function(options) {
8589
var self = this;
8690
var templatePromise = options.templateUrl ?
8791
$ionicTemplateLoader.load(options.templateUrl) :
8892
//options.content: deprecated
8993
$q.when(options.template || options.content || '');
9094

95+
9196
if (!this.isShown) {
9297
//options.showBackdrop: deprecated
9398
this.hasBackdrop = !options.noBackdrop && options.showBackdrop !== false;
@@ -111,26 +116,16 @@ function($animate, $document, $ionicTemplateLoader, $ionicBackdrop, $timeout, $q
111116
}
112117
});
113118

119+
toggler.removeClass();
120+
ionic.DomUtil.centerElementByMarginTwice(this.element[0]);
114121
this.isShown = true;
115-
ionic.requestAnimationFrame(function() {
116-
if (self.isShown) {
117-
$animate.removeClass(self.element, 'ng-hide');
118-
//Fix for ios: if we center the element twice, it always gets
119-
//position right. Otherwise, it doesn't
120-
ionic.DomUtil.centerElementByMargin(self.element[0]);
121-
//One frame after it's visible, position it
122-
ionic.requestAnimationFrame(function() {
123-
ionic.DomUtil.centerElementByMargin(self.element[0]);
124-
});
125-
}
126-
});
127122
};
128123
loader.hide = function() {
129124
if (this.isShown) {
130125
if (this.hasBackdrop) {
131126
$ionicBackdrop.release();
132127
}
133-
$animate.addClass(this.element, 'ng-hide');
128+
toggler.addClass();
134129
}
135130
$timeout.cancel(this.durationTimeout);
136131
this.isShown = false;

js/ext/angular/src/service/ionicPopup.js

+1-7
Original file line numberDiff line numberDiff line change
@@ -327,14 +327,8 @@ function($animate, $ionicTemplateLoader, $ionicBackdrop, $log, $q, $timeout, $ro
327327

328328
self.element.removeClass('popup-hidden');
329329
self.element.addClass('popup-showing active');
330+
ionic.DomUtil.centerElementByMarginTwice(self.element[0]);
330331
focusLastButton(self.element);
331-
//Fix for ios: if we center the element twice, it always gets
332-
//position right. Otherwise, it doesn't
333-
ionic.DomUtil.centerElementByMargin(self.element[0]);
334-
//One frame after it's visible, position it
335-
ionic.requestAnimationFrame(function() {
336-
ionic.DomUtil.centerElementByMargin(self.element[0]);
337-
});
338332
});
339333
};
340334
self.hide = function(callback) {
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,34 @@
11
describe('$ionicBackdrop service', function() {
22
beforeEach(module('ionic'));
33

4-
beforeEach(function() {
4+
beforeEach(inject(function($animate) {
55
ionic.requestAnimationFrame = function(cb) { cb(); };
6-
});
7-
8-
it('should create backdrop first time then reuse', inject(function($ionicBackdrop) {
9-
var el = $ionicBackdrop._getElement();
10-
var el2 = $ionicBackdrop._getElement();
11-
expect(el.hasClass('backdrop')).toBe(true);
12-
expect(el.hasClass('ng-hide')).toBe(true);
13-
expect(el[0]).toBe(el2[0]);
146
}));
157

16-
it('should remove ngHide on retain', inject(function($ionicBackdrop) {
17-
var el = $ionicBackdrop._getElement();
8+
it('should remove ngHide on retain', inject(function($ionicBackdrop, $timeout) {
9+
var el = $ionicBackdrop._element;
1810
expect(el.hasClass('ng-hide')).toBe(true);
1911
$ionicBackdrop.retain();
12+
$timeout.flush();
2013
expect(el.hasClass('ng-hide')).toBe(false);
2114
}));
2215

23-
it('should add ngHide on retain', inject(function($ionicBackdrop) {
24-
var el = $ionicBackdrop._getElement();
16+
it('should add ngHide on retain', inject(function($ionicBackdrop, $timeout) {
17+
var el = $ionicBackdrop._element;
2518
expect(el.hasClass('ng-hide')).toBe(true);
2619
$ionicBackdrop.retain();
20+
$timeout.flush();
2721
expect(el.hasClass('ng-hide')).toBe(false);
2822
$ionicBackdrop.release();
23+
$timeout.flush();
2924
expect(el.hasClass('ng-hide')).toBe(true);
3025
}));
3126

32-
it('should require equal releases and retains', inject(function($ionicBackdrop) {
33-
var el = $ionicBackdrop._getElement();
27+
it('should require equal releases and retains', inject(function($ionicBackdrop, $timeout) {
28+
var el = $ionicBackdrop._element;
3429
expect(el.hasClass('ng-hide')).toBe(true);
3530
$ionicBackdrop.retain();
31+
$timeout.flush();
3632
expect(el.hasClass('ng-hide')).toBe(false);
3733
$ionicBackdrop.retain();
3834
expect(el.hasClass('ng-hide')).toBe(false);
@@ -43,6 +39,7 @@ describe('$ionicBackdrop service', function() {
4339
$ionicBackdrop.release();
4440
expect(el.hasClass('ng-hide')).toBe(false);
4541
$ionicBackdrop.release();
42+
$timeout.flush();
4643
expect(el.hasClass('ng-hide')).toBe(true);
4744
}));
4845
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
describe('$animateClassToggler service', function() {
2+
beforeEach(module('ionic'));
3+
4+
var toggler, el;
5+
beforeEach(inject(function($animateClassToggler) {
6+
el = angular.element('<div>');
7+
toggler = $animateClassToggler(el, 'foo');
8+
ionic.requestAnimationFrame = function(cb) { cb(); };
9+
}));
10+
11+
function flush() {
12+
inject(function($timeout) {
13+
$timeout.flush();
14+
});
15+
}
16+
17+
it('addClass should go to toggle', function() {
18+
spyOn(toggler, '_toggle');
19+
toggler.addClass();
20+
expect(toggler._toggle).toHaveBeenCalledWith(true);
21+
});
22+
it('removeClass should go to toggle', function() {
23+
spyOn(toggler, '_toggle');
24+
toggler.removeClass();
25+
expect(toggler._toggle).toHaveBeenCalledWith(false);
26+
});
27+
it('toggle should add class and remove class', function() {
28+
toggler._toggle(true);
29+
flush();
30+
expect(el.hasClass('foo')).toBe(true);
31+
toggler._toggle(false);
32+
flush();
33+
expect(el.hasClass('foo')).toBe(false);
34+
});
35+
36+
it('toggle should set nextOperation if animating', function() {
37+
toggler._animating = true;
38+
toggler._toggle(true);
39+
expect(toggler._nextOperation).toBe('addClass');
40+
toggler._toggle(false);
41+
expect(toggler._nextOperation).toBe('removeClass');
42+
});
43+
44+
it('animate should set animating to true and animationDone when done', inject(function($rootScope, $animate) {
45+
spyOn($rootScope, '$evalAsync').andCallThrough();
46+
spyOn($animate, 'addClass');
47+
toggler._animate('addClass');
48+
expect(toggler._animating).toBe(true);
49+
expect($rootScope.$evalAsync).toHaveBeenCalled();
50+
flush();
51+
expect($animate.addClass).toHaveBeenCalledWith(el, 'foo', toggler._animationDone);
52+
}));
53+
54+
it('animationDone should use nextOperation and unset animating if no more', function() {
55+
spyOn(toggler, '_animate').andCallThrough();
56+
toggler._animating = true;
57+
toggler._nextOperation = 'addClass';
58+
toggler._animationDone();
59+
expect(toggler._nextOperation).toBeFalsy();
60+
expect(toggler._animate).toHaveBeenCalled();
61+
flush();
62+
expect(el.hasClass('foo')).toBe(true);
63+
expect(toggler._animating).toBe(false);
64+
});
65+
66+
});

js/ext/angular/test/service/ionicLoading.unit.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,12 @@ describe('$ionicLoading service', function() {
4141
expect(loader.durationTimeout).toBeTruthy();
4242
expect(loader.durationTimeout.$$timeoutId).toBeTruthy();
4343
}));
44-
it('should remove ng-hide after raf', inject(function($ionicLoading) {
44+
it('should remove ng-hide', inject(function($ionicLoading, $timeout) {
4545
var loader = TestUtil.unwrapPromise($ionicLoading._getLoader());
4646
ionic.requestAnimationFrame = function(cb) { cb(); };
4747
expect(loader.element.hasClass('ng-hide')).toBe(true);
4848
loader.show({});
49+
$timeout.flush();
4950
expect(loader.element.hasClass('ng-hide')).toBe(false);
5051
}));
5152
it('should isShown = true', inject(function($ionicLoading) {
@@ -137,7 +138,7 @@ describe('$ionicLoading service', function() {
137138
it('show should only removeClass after raf is still isShown', inject(function($ionicLoading) {
138139
var loader = TestUtil.unwrapPromise($ionicLoading._getLoader());
139140
var rafCallback;
140-
ionic.requestAnimationFrame = function(cb) {
141+
ionic.requestAnimationFrame = function(cb) {
141142
rafCallback = cb;
142143
};
143144
loader.show({});

js/utils/dom.js

+10
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,16 @@
187187
el.style.marginLeft = (-el.offsetWidth) / 2 + 'px';
188188
el.style.marginTop = (-el.offsetHeight) / 2 + 'px';
189189
},
190+
//Center twice, after raf, to fix a bug with ios and showing elements
191+
//that have just been attached to the DOM.
192+
centerElementByMarginTwice: function(el) {
193+
ionic.requestAnimationFrame(function() {
194+
ionic.DomUtil.centerElementByMargin(el);
195+
ionic.requestAnimationFrame(function() {
196+
ionic.DomUtil.centerElementByMargin(el);
197+
});
198+
});
199+
},
190200

191201
/**
192202
* @ngdoc method

0 commit comments

Comments
 (0)