/
collectionRepeat.js
990 lines (866 loc) · 38.7 KB
/
collectionRepeat.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
/**
* @ngdoc directive
* @restrict A
* @name collectionRepeat
* @module ionic
* @codepen 7ec1ec58f2489ab8f359fa1a0fe89c15
* @description
* `collection-repeat` allows an app to show huge lists of items much more performantly than
* `ng-repeat`.
*
* It renders into the DOM only as many items as are currently visible.
*
* This means that on a phone screen that can fit eight items, only the eight items matching
* the current scroll position will be rendered.
*
* **The Basics**:
*
* - The data given to collection-repeat must be an array.
* - If the `item-height` and `item-width` attributes are not supplied, it will be assumed that
* every item in the list has the same dimensions as the first item.
* - Don't use angular one-time binding (`::`) with collection-repeat. The scope of each item is
* assigned new data and re-digested as you scroll. Bindings need to update, and one-time bindings
* won't.
*
* **Performance Tips**:
*
* - The iOS webview has a performance bottleneck when switching out `<img src>` attributes.
* To increase performance of images on iOS, cache your images in advance and,
* if possible, lower the number of unique images. We're working on [a solution](https://github.com/driftyco/ionic/issues/3194).
*
* @usage
* #### Basic Item List ([codepen](http://codepen.io/ionic/pen/0c2c35a34a8b18ad4d793fef0b081693))
* ```html
* <ion-content>
* <ion-item collection-repeat="item in items">
* {% raw %}{{item}}{% endraw %}
* </ion-item>
* </ion-content>
* ```
*
* #### Grid of Images ([codepen](http://codepen.io/ionic/pen/5515d4efd9d66f780e96787387f41664))
* ```html
* <ion-content>
* <img collection-repeat="photo in photos"
* item-width="33%"
* item-height="200px"
* ng-src="{% raw %}{{photo.url}}{% endraw %}">
* </ion-content>
* ```
*
* #### Horizontal Scroller, Dynamic Item Width ([codepen](http://codepen.io/ionic/pen/67cc56b349124a349acb57a0740e030e))
* ```html
* <ion-content>
* <h2>Available Kittens:</h2>
* <ion-scroll direction="x" class="available-scroller">
* <div class="photo" collection-repeat="photo in main.photos"
* item-height="250" item-width="photo.width + 30">
* <img ng-src="{{photo.src}}">
* </div>
* </ion-scroll>
* </ion-content>
* ```
*
* @param {expression} collection-repeat The expression indicating how to enumerate a collection,
* of the format `variable in expression` – where variable is the user defined loop variable
* and `expression` is a scope expression giving the collection to enumerate.
* For example: `album in artist.albums` or `album in artist.albums | orderBy:'name'`.
* @param {expression=} item-width The width of the repeated element. The expression must return
* a number (pixels) or a percentage. Defaults to the width of the first item in the list.
* (previously named collection-item-width)
* @param {expression=} item-height The height of the repeated element. The expression must return
* a number (pixels) or a percentage. Defaults to the height of the first item in the list.
* (previously named collection-item-height)
* @param {number=} item-render-buffer The number of items to load before and after the visible
* items in the list. Default 3. Tip: set this higher if you have lots of images to preload, but
* don't set it too high or you'll see performance loss.
* @param {boolean=} force-refresh-images Force images to refresh as you scroll. This fixes a problem
* where, when an element is interchanged as scrolling, its image will still have the old src
* while the new src loads. Setting this to true comes with a small performance loss.
*/
IonicModule
.directive('collectionRepeat', CollectionRepeatDirective)
.factory('$ionicCollectionManager', RepeatManagerFactory);
var ONE_PX_TRANSPARENT_IMG_SRC = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
var WIDTH_HEIGHT_REGEX = /height:.*?px;\s*width:.*?px/;
var DEFAULT_RENDER_BUFFER = 3;
CollectionRepeatDirective.$inject = ['$ionicCollectionManager', '$parse', '$window', '$$rAF', '$rootScope', '$timeout'];
function CollectionRepeatDirective($ionicCollectionManager, $parse, $window, $$rAF, $rootScope, $timeout) {
return {
restrict: 'A',
priority: 1000,
transclude: 'element',
$$tlb: true,
require: '^^$ionicScroll',
link: postLink
};
function postLink(scope, element, attr, scrollCtrl, transclude) {
var scrollView = scrollCtrl.scrollView;
var node = element[0];
var containerNode = angular.element('<div class="collection-repeat-container">')[0];
node.parentNode.replaceChild(containerNode, node);
if (scrollView.options.scrollingX && scrollView.options.scrollingY) {
throw new Error("collection-repeat expected a parent x or y scrollView, not " +
"an xy scrollView.");
}
var repeatExpr = attr.collectionRepeat;
var match = repeatExpr.match(/^\s*([\s\S]+?)\s+in\s+([\s\S]+?)(?:\s+track\s+by\s+([\s\S]+?))?\s*$/);
if (!match) {
throw new Error("collection-repeat expected expression in form of '_item_ in " +
"_collection_[ track by _id_]' but got '" + attr.collectionRepeat + "'.");
}
var keyExpr = match[1];
var listExpr = match[2];
var listGetter = $parse(listExpr);
var heightData = {};
var widthData = {};
var computedStyleDimensions = {};
var data = [];
var repeatManager;
// attr.collectionBufferSize is deprecated
var renderBufferExpr = attr.itemRenderBuffer || attr.collectionBufferSize;
var renderBuffer = angular.isDefined(renderBufferExpr) ?
parseInt(renderBufferExpr) :
DEFAULT_RENDER_BUFFER;
// attr.collectionItemHeight is deprecated
var heightExpr = attr.itemHeight || attr.collectionItemHeight;
// attr.collectionItemWidth is deprecated
var widthExpr = attr.itemWidth || attr.collectionItemWidth;
var afterItemsContainer = initAfterItemsContainer();
var changeValidator = makeChangeValidator();
initDimensions();
// Dimensions are refreshed on resize or data change.
scrollCtrl.$element.on('scroll.resize', refreshDimensions);
angular.element($window).on('resize', onResize);
var unlistenToExposeAside = $rootScope.$on('$ionicExposeAside', onResize);
$timeout(refreshDimensions, 0, false);
function onResize() {
if (changeValidator.resizeRequiresRefresh(scrollView.__clientWidth, scrollView.__clientHeight)) {
refreshDimensions();
}
}
scope.$watchCollection(listGetter, function(newValue) {
data = newValue || (newValue = []);
if (!angular.isArray(newValue)) {
throw new Error("collection-repeat expected an array for '" + listExpr + "', " +
"but got a " + typeof value);
}
// Wait for this digest to end before refreshing everything.
scope.$$postDigest(function() {
getRepeatManager().setData(data);
if (changeValidator.dataChangeRequiresRefresh(data)) refreshDimensions();
});
});
scope.$on('$destroy', function() {
angular.element($window).off('resize', onResize);
unlistenToExposeAside();
scrollCtrl.$element && scrollCtrl.$element.off('scroll.resize', refreshDimensions);
computedStyleNode && computedStyleNode.parentNode &&
computedStyleNode.parentNode.removeChild(computedStyleNode);
computedStyleScope && computedStyleScope.$destroy();
computedStyleScope = computedStyleNode = null;
repeatManager && repeatManager.destroy();
repeatManager = null;
});
function makeChangeValidator() {
var self;
return (self = {
dataLength: 0,
width: 0,
height: 0,
// A resize triggers a refresh only if we have data, the scrollView has size,
// and the size has changed.
resizeRequiresRefresh: function(newWidth, newHeight) {
var requiresRefresh = self.dataLength && newWidth && newHeight &&
(newWidth !== self.width || newHeight !== self.height);
self.width = newWidth;
self.height = newHeight;
return !!requiresRefresh;
},
// A change in data only triggers a refresh if the data has length, or if the data's
// length is less than before.
dataChangeRequiresRefresh: function(newData) {
var requiresRefresh = newData.length > 0 || newData.length < self.dataLength;
self.dataLength = newData.length;
return !!requiresRefresh;
}
});
}
function getRepeatManager() {
return repeatManager || (repeatManager = new $ionicCollectionManager({
afterItemsNode: afterItemsContainer[0],
containerNode: containerNode,
heightData: heightData,
widthData: widthData,
forceRefreshImages: !!(isDefined(attr.forceRefreshImages) && attr.forceRefreshImages !== 'false'),
keyExpression: keyExpr,
renderBuffer: renderBuffer,
scope: scope,
scrollView: scrollCtrl.scrollView,
transclude: transclude,
}));
}
function initAfterItemsContainer() {
var container = angular.element(
scrollView.__content.querySelector('.collection-repeat-after-container')
);
// Put everything in the view after the repeater into a container.
if (!container.length) {
var elementIsAfterRepeater = false;
var afterNodes = [].filter.call(scrollView.__content.childNodes, function(node) {
if (ionic.DomUtil.contains(node, containerNode)) {
elementIsAfterRepeater = true;
return false;
}
return elementIsAfterRepeater;
});
container = angular.element('<span class="collection-repeat-after-container">');
if (scrollView.options.scrollingX) {
container.addClass('horizontal');
}
container.append(afterNodes);
scrollView.__content.appendChild(container[0]);
}
return container;
}
function initDimensions() {
//Height and width have four 'modes':
//1) Computed Mode
// - Nothing is supplied, so we getComputedStyle() on one element in the list and use
// that width and height value for the width and height of every item. This is re-computed
// every resize.
//2) Constant Mode, Static Integer
// - The user provides a constant number for width or height, in pixels. We parse it,
// store it on the `value` field, and it never changes
//3) Constant Mode, Percent
// - The user provides a percent string for width or height. The getter for percent is
// stored on the `getValue()` field, and is re-evaluated once every resize. The result
// is stored on the `value` field.
//4) Dynamic Mode
// - The user provides a dynamic expression for the width or height. This is re-evaluated
// for every item, stored on the `.getValue()` field.
if (!heightExpr && !widthExpr) {
heightData.computed = widthData.computed = true;
} else {
if (heightExpr) {
parseDimensionAttr(heightExpr, heightData);
} else {
heightData.computed = true;
}
if (!widthExpr) widthExpr = '"100%"';
parseDimensionAttr(widthExpr, widthData);
}
}
function refreshDimensions() {
var hasData = data.length > 0;
if (hasData && (heightData.computed || widthData.computed)) {
computeStyleDimensions();
}
if (hasData && heightData.computed) {
heightData.value = computedStyleDimensions.height;
if (!heightData.value) {
throw new Error('collection-repeat tried to compute the height of repeated elements "' +
repeatExpr + '", but was unable to. Please provide the "item-height" attribute. ' +
'http://ionicframework.com/docs/api/directive/collectionRepeat/');
}
} else if (!heightData.dynamic && heightData.getValue) {
// If it's a constant with a getter (eg percent), we just refresh .value after resize
heightData.value = heightData.getValue();
}
if (hasData && widthData.computed) {
widthData.value = computedStyleDimensions.width;
if (!widthData.value) {
throw new Error('collection-repeat tried to compute the width of repeated elements "' +
repeatExpr + '", but was unable to. Please provide the "item-width" attribute. ' +
'http://ionicframework.com/docs/api/directive/collectionRepeat/');
}
} else if (!widthData.dynamic && widthData.getValue) {
// If it's a constant with a getter (eg percent), we just refresh .value after resize
widthData.value = widthData.getValue();
}
// Dynamic dimensions aren't updated on resize. Since they're already dynamic anyway,
// .getValue() will be used.
getRepeatManager().refreshLayout();
}
function parseDimensionAttr(attrValue, dimensionData) {
if (!attrValue) return;
var parsedValue;
// Try to just parse the plain attr value
try {
parsedValue = $parse(attrValue);
} catch(e) {
// If the parse fails and the value has `px` or `%` in it, surround the attr in
// quotes, to attempt to let the user provide a simple `attr="100%"` or `attr="100px"`
if (attrValue.trim().match(/\d+(px|%)$/)) {
attrValue = '"' + attrValue + '"';
}
parsedValue = $parse(attrValue);
}
var constantAttrValue = attrValue.replace(/(\'|\"|px|%)/g, '').trim();
var isConstant = constantAttrValue.length && !/([a-zA-Z]|\$|:|\?)/.test(constantAttrValue);
dimensionData.attrValue = attrValue;
// If it's a constant, it's either a percent or just a constant pixel number.
if (isConstant) {
var intValue = parseInt(parsedValue());
// For percents, store the percent getter on .getValue()
if (attrValue.indexOf('%') > -1) {
var decimalValue = intValue / 100;
dimensionData.getValue = dimensionData === heightData ?
function() { return Math.floor(decimalValue * scrollView.__clientHeight); } :
function() { return Math.floor(decimalValue * scrollView.__clientWidth); };
} else {
// For static constants, just store the static constant.
dimensionData.value = intValue;
}
} else {
dimensionData.dynamic = true;
dimensionData.getValue = dimensionData === heightData ?
function heightGetter(scope, locals) {
var result = parsedValue(scope, locals);
if (result.charAt && result.charAt(result.length - 1) === '%')
return Math.floor(parseInt(result) / 100 * scrollView.__clientHeight);
return parseInt(result);
} :
function widthGetter(scope, locals) {
var result = parsedValue(scope, locals);
if (result.charAt && result.charAt(result.length - 1) === '%')
return Math.floor(parseInt(result) / 100 * scrollView.__clientWidth);
return parseInt(result);
};
}
}
var computedStyleNode;
var computedStyleScope;
function computeStyleDimensions() {
if (!computedStyleNode) {
transclude(computedStyleScope = scope.$new(), function(clone) {
clone[0].removeAttribute('collection-repeat'); // remove absolute position styling
computedStyleNode = clone[0];
});
}
computedStyleScope[keyExpr] = (listGetter(scope) || [])[0];
if (!$rootScope.$$phase) computedStyleScope.$digest();
containerNode.appendChild(computedStyleNode);
var style = $window.getComputedStyle(computedStyleNode);
computedStyleDimensions.width = parseInt(style.width);
computedStyleDimensions.height = parseInt(style.height);
containerNode.removeChild(computedStyleNode);
}
}
}
RepeatManagerFactory.$inject = ['$rootScope', '$window', '$$rAF'];
function RepeatManagerFactory($rootScope, $window, $$rAF) {
var EMPTY_DIMENSION = { primaryPos: 0, secondaryPos: 0, primarySize: 0, secondarySize: 0, rowPrimarySize: 0 };
return function RepeatController(options) {
var afterItemsNode = options.afterItemsNode;
var containerNode = options.containerNode;
var forceRefreshImages = options.forceRefreshImages;
var heightData = options.heightData;
var widthData = options.widthData;
var keyExpression = options.keyExpression;
var renderBuffer = options.renderBuffer;
var scope = options.scope;
var scrollView = options.scrollView;
var transclude = options.transclude;
var data = [];
var getterLocals = {};
var heightFn = heightData.getValue || function() { return heightData.value; };
var heightGetter = function(index, value) {
getterLocals[keyExpression] = value;
getterLocals.$index = index;
return heightFn(scope, getterLocals);
};
var widthFn = widthData.getValue || function() { return widthData.value; };
var widthGetter = function(index, value) {
getterLocals[keyExpression] = value;
getterLocals.$index = index;
return widthFn(scope, getterLocals);
};
var isVertical = !!scrollView.options.scrollingY;
// We say it's a grid view if we're either dynamic or not 100% width
var isGridView = isVertical ?
(widthData.dynamic || widthData.value !== scrollView.__clientWidth) :
(heightData.dynamic || heightData.value !== scrollView.__clientHeight);
var isStaticView = !heightData.dynamic && !widthData.dynamic;
var PRIMARY = 'PRIMARY';
var SECONDARY = 'SECONDARY';
var TRANSLATE_TEMPLATE_STR = isVertical ?
'translate3d(SECONDARYpx,PRIMARYpx,0)' :
'translate3d(PRIMARYpx,SECONDARYpx,0)';
var WIDTH_HEIGHT_TEMPLATE_STR = isVertical ?
'height: PRIMARYpx; width: SECONDARYpx;' :
'height: SECONDARYpx; width: PRIMARYpx;';
var estimatedHeight;
var estimatedWidth;
var repeaterBeforeSize = 0;
var repeaterAfterSize = 0;
var renderStartIndex = -1;
var renderEndIndex = -1;
var renderAfterBoundary = -1;
var renderBeforeBoundary = -1;
var itemsPool = [];
var itemsLeaving = [];
var itemsEntering = [];
var itemsShownMap = {};
var nextItemId = 0;
var estimatedItemsAcross;
var scrollViewSetDimensions = isVertical ?
function() { scrollView.setDimensions(null, null, null, view.getContentSize(), true); } :
function() { scrollView.setDimensions(null, null, view.getContentSize(), null, true); };
// view is a mix of list/grid methods + static/dynamic methods.
// See bottom for implementations. Available methods:
//
// getEstimatedPrimaryPos(i), getEstimatedSecondaryPos(i), getEstimatedIndex(scrollTop),
// calculateDimensions(toIndex), getDimensions(index),
// updateRenderRange(scrollTop, scrollValueEnd), onRefreshLayout(), onRefreshData()
var view = isVertical ? new VerticalViewType() : new HorizontalViewType();
(isGridView ? GridViewType : ListViewType).call(view);
(isStaticView ? StaticViewType : DynamicViewType).call(view);
var contentSizeStr = isVertical ? 'getContentHeight' : 'getContentWidth';
var originalGetContentSize = scrollView.options[contentSizeStr];
scrollView.options[contentSizeStr] = angular.bind(view, view.getContentSize);
scrollView.__$callback = scrollView.__callback;
scrollView.__callback = function(transformLeft, transformTop, zoom, wasResize) {
var scrollValue = view.getScrollValue();
if(window.d)dump('_-callback render', scrollValue, view.scrollPrimarySize + renderAfterBoundary);
if (renderStartIndex === -1 ||
scrollValue + view.scrollPrimarySize > renderAfterBoundary ||
scrollValue < renderBeforeBoundary) {
render();
}
scrollView.__$callback(transformLeft, transformTop, zoom, wasResize);
};
var isLayoutReady = false;
var isDataReady = false;
this.refreshLayout = function(itemsAfterRepeater) {
estimatedHeight = heightGetter(0, data[0]);
estimatedWidth = widthGetter(0, data[0]);
// Get the size of every element AFTER the repeater. We have to get the margin before and
// after the first/last element to fix a browser bug with getComputedStyle() not counting
// the first/last child's margins into height.
var style = getComputedStyle(afterItemsNode) || {};
var firstStyle = afterItemsNode.firstElementChild && getComputedStyle(afterItemsNode.firstElementChild) || {};
var lastStyle = afterItemsNode.lastElementChild && getComputedStyle(afterItemsNode.lastElementChild) || {};
repeaterAfterSize = (parseInt(style[isVertical ? 'height' : 'width']) || 0) +
(firstStyle && parseInt(firstStyle[isVertical ? 'marginTop' : 'marginLeft']) || 0) +
(lastStyle && parseInt(lastStyle[isVertical ? 'marginBottom' : 'marginRight']) || 0);
// Get the offsetTop of the repeater.
repeaterBeforeSize = 0;
var current = containerNode;
do {
repeaterBeforeSize += current[isVertical ? 'offsetTop' : 'offsetLeft'];
} while( ionic.DomUtil.contains(scrollView.__content, current = current.offsetParent) );
var containerPrevNode = containerNode.previousElementSibling;
var beforeStyle = containerPrevNode ? $window.getComputedStyle(containerPrevNode) : {};
var beforeMargin = parseInt(beforeStyle[isVertical ? 'marginBottom' : 'marginRight'] || 0);
// Because we position the collection container with position: relative, it doesn't take
// into account where to position itself relative to the previous element's marginBottom.
// To compensate, we translate the container up by the previous element's margin.
containerNode.style[ionic.CSS.TRANSFORM] = TRANSLATE_TEMPLATE_STR
.replace(PRIMARY, -beforeMargin)
.replace(SECONDARY, 0);
repeaterBeforeSize -= beforeMargin;
if (!scrollView.__clientHeight || !scrollView.__clientWidth) {
scrollView.__clientWidth = scrollView.__container.clientWidth;
scrollView.__clientHeight = scrollView.__container.clientHeight;
}
(view.onRefreshLayout || angular.noop)();
view.refreshDirection();
scrollViewSetDimensions();
// Create the pool of items for reuse, setting the size to (estimatedItemsOnScreen) * 2,
// plus the size of the renderBuffer.
if (!isLayoutReady) {
var poolSize = Math.max(20, renderBuffer * 3);
for (var i = 0; i < poolSize; i++) {
itemsPool.push(new RepeatItem());
}
}
isLayoutReady = true;
if (isLayoutReady && isDataReady) {
// If the resize or latest data change caused the scrollValue to
// now be out of bounds, resize the scrollView.
if (scrollView.__scrollLeft > scrollView.__maxScrollLeft ||
scrollView.__scrollTop > scrollView.__maxScrollTop) {
scrollView.resize();
}
forceRerender(true);
}
};
this.setData = function(newData) {
data = newData;
(view.onRefreshData || angular.noop)();
isDataReady = true;
};
this.destroy = function() {
render.destroyed = true;
itemsPool.forEach(function(item) {
item.scope.$destroy();
item.scope = item.element = item.node = item.images = null;
});
itemsPool.length = itemsEntering.length = itemsLeaving.length = 0;
itemsShownMap = {};
//Restore the scrollView's normal behavior and resize it to normal size.
scrollView.options[contentSizeStr] = originalGetContentSize;
scrollView.__callback = scrollView.__$callback;
scrollView.resize();
(view.onDestroy || angular.noop)();
};
function forceRerender() {
return render(true);
}
function render(forceRerender) {
if (render.destroyed) return;
var i;
var item;
var dim;
var scope;
var scrollValue = view.getScrollValue();
var scrollValueEnd = scrollValue + view.scrollPrimarySize;
view.updateRenderRange(scrollValue, scrollValueEnd);
renderStartIndex = Math.max(0, renderStartIndex - renderBuffer);
renderEndIndex = Math.min(data.length - 1, renderEndIndex + renderBuffer);
for (i in itemsShownMap) {
if (i < renderStartIndex || i > renderEndIndex) {
item = itemsShownMap[i];
delete itemsShownMap[i];
itemsLeaving.push(item);
item.isShown = false;
}
}
// Render indicies that aren't shown yet
//
// NOTE(ajoslin): this may sound crazy, but calling any other functions during this render
// loop will often push the render time over the edge from less than one frame to over
// one frame, causing visible jank.
// DON'T call any other functions inside this loop unless it's vital.
for (i = renderStartIndex; i <= renderEndIndex; i++) {
// We only go forward with render if the index is in data, the item isn't already shown,
// or forceRerender is on.
if (i >= data.length || (itemsShownMap[i] && !forceRerender)) continue;
item = itemsShownMap[i] || (itemsShownMap[i] = itemsLeaving.length ? itemsLeaving.pop() :
itemsPool.length ? itemsPool.shift() :
new RepeatItem());
itemsEntering.push(item);
item.isShown = true;
scope = item.scope;
scope.$index = i;
scope[keyExpression] = data[i];
scope.$first = (i === 0);
scope.$last = (i === (data.length - 1));
scope.$middle = !(scope.$first || scope.$last);
scope.$odd = !(scope.$even = (i&1) === 0);
if (scope.$$disconnected) ionic.Utils.reconnectScope(item.scope);
dim = view.getDimensions(i);
if (item.secondaryPos !== dim.secondaryPos || item.primaryPos !== dim.primaryPos) {
item.node.style[ionic.CSS.TRANSFORM] = TRANSLATE_TEMPLATE_STR
.replace(PRIMARY, (item.primaryPos = dim.primaryPos))
.replace(SECONDARY, (item.secondaryPos = dim.secondaryPos));
}
if (item.secondarySize !== dim.secondarySize || item.primarySize !== dim.primarySize) {
item.node.style.cssText = item.node.style.cssText
.replace(WIDTH_HEIGHT_REGEX, WIDTH_HEIGHT_TEMPLATE_STR
//TODO fix item.primarySize + 1 hack
.replace(PRIMARY, (item.primarySize = dim.primarySize) + 1)
.replace(SECONDARY, (item.secondarySize = dim.secondarySize))
);
}
}
// If we reach the end of the list, render the afterItemsNode - this contains all the
// elements the developer placed after the collection-repeat
if (renderEndIndex === data.length - 1) {
dim = view.getDimensions(data.length - 1) || EMPTY_DIMENSION;
afterItemsNode.style[ionic.CSS.TRANSFORM] = TRANSLATE_TEMPLATE_STR
.replace(PRIMARY, dim.primaryPos + dim.primarySize)
.replace(SECONDARY, 0);
}
while (itemsLeaving.length) {
item = itemsLeaving.pop();
item.scope.$broadcast('$collectionRepeatLeave');
ionic.Utils.disconnectScope(item.scope);
itemsPool.push(item);
item.node.style[ionic.CSS.TRANSFORM] = 'translate3d(-9999px,-9999px,0)';
item.primaryPos = item.secondaryPos = null;
}
if (forceRefreshImages) {
for (i = 0, ii = itemsEntering.length; i < ii && (item = itemsEntering[i]); i++) {
if (!item.images) continue;
for (var j = 0, jj = item.images.length, img; j < jj && (img = item.images[j]); j++) {
var src = img.src;
img.src = ONE_PX_TRANSPARENT_IMG_SRC;
img.src = src;
}
}
}
if (forceRerender) {
var rootScopePhase = $rootScope.$$phase;
while (itemsEntering.length) {
item = itemsEntering.pop();
if (!rootScopePhase) item.scope.$digest();
}
} else {
digestEnteringItems();
}
}
function getNextItem() {
if (itemsLeaving.length)
return itemsLeaving.pop();
else if (itemsPool.length)
return itemsPool.shift();
return new RepeatItem();
}
function digestEnteringItems() {
var item;
if (digestEnteringItems.running) return;
digestEnteringItems.running = true;
$$rAF(function process() {
var rootScopePhase = $rootScope.$$phase;
while (itemsEntering.length) {
item = itemsEntering.pop();
if (item.isShown) {
if (!rootScopePhase) item.scope.$digest();
}
}
digestEnteringItems.running = false;
});
}
function RepeatItem() {
var self = this;
this.scope = scope.$new();
this.id = 'item'+ (nextItemId++);
transclude(this.scope, function(clone) {
self.element = clone;
self.element.data('$$collectionRepeatItem', self);
// TODO destroy
self.node = clone[0];
// Batch style setting to lower repaints
self.node.style[ionic.CSS.TRANSFORM] = 'translate3d(-9999px,-9999px,0)';
self.node.style.cssText += ' height: 0px; width: 0px;';
ionic.Utils.disconnectScope(self.scope);
containerNode.appendChild(self.node);
self.images = clone[0].getElementsByTagName('img');
});
}
function VerticalViewType() {
this.getItemPrimarySize = heightGetter;
this.getItemSecondarySize = widthGetter;
this.getScrollValue = function() {
return Math.max(0, Math.min(scrollView.__scrollTop - repeaterBeforeSize,
scrollView.__maxScrollTop - repeaterBeforeSize - repeaterAfterSize));
};
this.refreshDirection = function() {
this.scrollPrimarySize = scrollView.__clientHeight;
this.scrollSecondarySize = scrollView.__clientWidth;
this.estimatedPrimarySize = estimatedHeight;
this.estimatedSecondarySize = estimatedWidth;
this.estimatedItemsAcross = isGridView &&
Math.floor(scrollView.__clientWidth / estimatedWidth) ||
1;
};
}
function HorizontalViewType() {
this.getItemPrimarySize = widthGetter;
this.getItemSecondarySize = heightGetter;
this.getScrollValue = function() {
return Math.max(0, Math.min(scrollView.__scrollLeft - repeaterBeforeSize,
scrollView.__maxScrollLeft - repeaterBeforeSize - repeaterAfterSize));
};
this.refreshDirection = function() {
this.scrollPrimarySize = scrollView.__clientWidth;
this.scrollSecondarySize = scrollView.__clientHeight;
this.estimatedPrimarySize = estimatedWidth;
this.estimatedSecondarySize = estimatedHeight;
this.estimatedItemsAcross = isGridView &&
Math.floor(scrollView.__clientHeight / estimatedHeight) ||
1;
};
}
function GridViewType() {
this.getEstimatedSecondaryPos = function(index) {
return (index % this.estimatedItemsAcross) * this.estimatedSecondarySize;
};
this.getEstimatedPrimaryPos = function(index) {
return Math.floor(index / this.estimatedItemsAcross) * this.estimatedPrimarySize;
};
this.getEstimatedIndex = function(scrollValue) {
return Math.floor(scrollValue / this.estimatedPrimarySize) *
this.estimatedItemsAcross;
};
}
function ListViewType() {
this.getEstimatedSecondaryPos = function() {
return 0;
};
this.getEstimatedPrimaryPos = function(index) {
return index * this.estimatedPrimarySize;
};
this.getEstimatedIndex = function(scrollValue) {
return Math.floor((scrollValue) / this.estimatedPrimarySize);
};
}
function StaticViewType() {
this.getContentSize = function() {
return this.getEstimatedPrimaryPos(data.length - 1) + this.estimatedPrimarySize +
repeaterBeforeSize + repeaterAfterSize;
};
// static view always returns the same object for getDimensions, to avoid memory allocation
// while scrolling. This could be dangerous if this was a public function, but it's not.
// Only we use it.
var dim = {};
this.getDimensions = function(index) {
dim.primaryPos = this.getEstimatedPrimaryPos(index);
dim.secondaryPos = this.getEstimatedSecondaryPos(index);
dim.primarySize = this.estimatedPrimarySize;
dim.secondarySize = this.estimatedSecondarySize;
return dim;
};
this.updateRenderRange = function(scrollValue, scrollValueEnd) {
renderStartIndex = Math.max(0, this.getEstimatedIndex(scrollValue));
// Make sure the renderEndIndex takes into account all the items on the row
renderEndIndex = Math.min(data.length - 1,
this.getEstimatedIndex(scrollValueEnd) + this.estimatedItemsAcross - 1);
renderBeforeBoundary = Math.max(0,
this.getEstimatedPrimaryPos(renderStartIndex));
renderAfterBoundary = this.getEstimatedPrimaryPos(renderEndIndex) +
this.estimatedPrimarySize;
};
}
function DynamicViewType() {
var self = this;
var debouncedScrollViewSetDimensions = ionic.debounce(scrollViewSetDimensions, 25, true);
var calculateDimensions = isGridView ? calculateDimensionsGrid : calculateDimensionsList;
var dimensionsIndex;
var dimensions = [];
// Get the dimensions at index. {width, height, left, top}.
// We start with no dimensions calculated, then any time dimensions are asked for at an
// index we calculate dimensions up to there.
function calculateDimensionsList(toIndex) {
var i, prevDimension, dim;
for (i = Math.max(0, dimensionsIndex); i <= toIndex && (dim = dimensions[i]); i++) {
prevDimension = dimensions[i - 1] || EMPTY_DIMENSION;
dim.primarySize = self.getItemPrimarySize(i, data[i]);
dim.secondarySize = self.scrollSecondarySize;
dim.primaryPos = prevDimension.primaryPos + prevDimension.primarySize;
dim.secondaryPos = 0;
}
}
function calculateDimensionsGrid(toIndex) {
var i, prevDimension, dim;
for (i = Math.max(dimensionsIndex, 0); i <= toIndex && (dim = dimensions[i]); i++) {
prevDimension = dimensions[i - 1] || EMPTY_DIMENSION;
dim.secondarySize = Math.min(
self.getItemSecondarySize(i, data[i]),
self.scrollSecondarySize
);
dim.secondaryPos = prevDimension.secondaryPos + prevDimension.secondarySize;
if (i === 0 || dim.secondaryPos + dim.secondarySize > self.scrollSecondarySize) {
dim.secondaryPos = 0;
dim.primarySize = self.getItemPrimarySize(i, data[i]);
dim.primaryPos = prevDimension.primaryPos + prevDimension.rowPrimarySize;
dim.rowStartIndex = i;
dim.rowPrimarySize = dim.primarySize;
} else {
dim.primarySize = self.getItemPrimarySize(i, data[i]);
dim.primaryPos = prevDimension.primaryPos;
dim.rowStartIndex = prevDimension.rowStartIndex;
dimensions[dim.rowStartIndex].rowPrimarySize = dim.rowPrimarySize = Math.max(
dimensions[dim.rowStartIndex].rowPrimarySize,
dim.primarySize
);
dim.rowPrimarySize = Math.max(dim.primarySize, dim.rowPrimarySize);
}
}
}
this.getContentSize = function() {
var dim = dimensions[dimensionsIndex] || EMPTY_DIMENSION;
return ((dim.primaryPos + dim.primarySize) || 0) +
this.getEstimatedPrimaryPos(data.length - dimensionsIndex - 1) +
repeaterBeforeSize + repeaterAfterSize;
};
this.onDestroy = function() {
dimensions.length = 0;
};
this.onRefreshData = function() {
// Make sure dimensions has as many items as data.length.
// This is to be sure we don't have to allocate objects while scrolling.
for (i = dimensions.length, len = data.length; i < len; i++) {
dimensions.push({});
}
dimensionsIndex = -1;
};
this.onRefreshLayout = function() {
dimensionsIndex = -1;
};
this.getDimensions = function(index) {
index = Math.min(index, data.length - 1);
if (dimensionsIndex < index) {
// Once we start asking for dimensions near the end of the list, go ahead and calculate
// everything. This is to make sure when the user gets to the end of the list, the
// scroll height of the list is 100% accurate (not estimated anymore).
if (index > data.length * 0.9) {
calculateDimensions(data.length - 1);
dimensionsIndex = data.length - 1;
scrollViewSetDimensions();
} else {
calculateDimensions(index);
dimensionsIndex = index;
debouncedScrollViewSetDimensions();
}
}
return dimensions[index];
};
var oldRenderStartIndex = -1;
var oldScrollValue = -1;
this.updateRenderRange = function(scrollValue, scrollValueEnd) {
var i;
var len;
var dim;
// Calculate more dimensions than we estimate we'll need, to be sure.
this.getDimensions( this.getEstimatedIndex(scrollValueEnd) * 2 );
// -- Calculate renderStartIndex
// base case: start at 0
if (oldRenderStartIndex === -1 || scrollValue === 0) {
i = 0;
// scrolling down
} else if (scrollValue >= oldScrollValue) {
for (i = oldRenderStartIndex, len = data.length; i < len; i++) {
if ((dim = this.getDimensions(i)) && dim.primaryPos + dim.rowPrimarySize >= scrollValue) {
break;
}
}
// scrolling up
} else {
for (i = oldRenderStartIndex; i >= 0; i--) {
if ((dim = this.getDimensions(i)) && dim.primaryPos <= scrollValue) {
// when grid view, make sure the render starts at the beginning of a row.
i = isGridView ? dim.rowStartIndex : i;
break;
}
}
}
renderStartIndex = Math.min(Math.max(0, i), data.length - 1);
renderBeforeBoundary = renderStartIndex !== -1 ? this.getDimensions(renderStartIndex).primaryPos : -1;
// -- Calculate renderEndIndex
var lastRowDim;
for (i = renderStartIndex + 1, len = data.length; i < len; i++) {
if ((dim = this.getDimensions(i)) && dim.primaryPos + dim.rowPrimarySize > scrollValueEnd) {
// Go all the way to the end of the row if we're in a grid
if (isGridView) {
lastRowDim = dim;
while (i < len - 1 &&
(dim = this.getDimensions(i + 1)).primaryPos === lastRowDim.primaryPos) {
i++;
}
}
break;
}
}
renderEndIndex = Math.min(i, data.length - 1);
renderAfterBoundary = renderEndIndex !== -1 ?
((dim = this.getDimensions(renderEndIndex)).primaryPos + (dim.rowPrimarySize || dim.primarySize)) :
-1;
oldScrollValue = scrollValue;
oldRenderStartIndex = renderStartIndex;
};
}
};
}