UNPKG

13.4 kBJavaScriptView Raw
1/*
2Copyright 2013-2015 ASIAL CORPORATION
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8 http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15
16*/
17
18import util from '../util.js';
19import platform from '../platform.js';
20
21export class LazyRepeatDelegate {
22
23 constructor(userDelegate, templateElement = null) {
24 if (typeof userDelegate !== 'object' || userDelegate === null) {
25 util.throw('"delegate" parameter must be an object');
26 }
27 this._userDelegate = userDelegate;
28
29 if (!(templateElement instanceof Element) && templateElement !== null) {
30 util.throw('"templateElement" parameter must be an instance of Element or null');
31 }
32 this._templateElement = templateElement;
33 }
34
35 get itemHeight() {
36 return this._userDelegate.itemHeight;
37 }
38
39 /**
40 * @return {Boolean}
41 */
42 hasRenderFunction() {
43 return this._userDelegate._render instanceof Function;
44 }
45
46 /**
47 * @return {void}
48 */
49 _render() {
50 this._userDelegate._render.apply(this._userDelegate, arguments);
51 }
52
53 /**
54 * @param {Number} index
55 * @param {Function} done A function that take item object as parameter.
56 */
57 loadItemElement(index, done) {
58 if (this._userDelegate.loadItemElement instanceof Function) {
59 this._userDelegate.loadItemElement(index, done);
60 } else {
61 const element = this._userDelegate.createItemContent(index, this._templateElement);
62 if (!(element instanceof Element)) {
63 util.throw('"createItemContent" must return an instance of Element');
64 }
65
66 done({element});
67 }
68 }
69
70 /**
71 * @return {Number}
72 */
73 countItems() {
74 const count = this._userDelegate.countItems();
75 if (typeof count !== 'number') {
76 util.throw('"countItems" must return a number');
77 }
78 return count;
79 }
80
81 /**
82 * @param {Number} index
83 * @param {Object} item
84 * @param {Element} item.element
85 */
86 updateItem(index, item) {
87 if (this._userDelegate.updateItemContent instanceof Function) {
88 this._userDelegate.updateItemContent(index, item);
89 }
90 }
91
92 /**
93 * @return {Number}
94 */
95 calculateItemHeight(index) {
96 if (this._userDelegate.calculateItemHeight instanceof Function) {
97 const height = this._userDelegate.calculateItemHeight(index);
98
99 if (typeof height !== 'number') {
100 util.throw('"calculateItemHeight" must return a number');
101 }
102
103 return height;
104 }
105
106 return 0;
107 }
108
109 /**
110 * @param {Number} index
111 * @param {Object} item
112 */
113 destroyItem(index, item) {
114 if (this._userDelegate.destroyItem instanceof Function) {
115 this._userDelegate.destroyItem(index, item);
116 }
117 }
118
119 /**
120 * @return {void}
121 */
122 destroy() {
123 if (this._userDelegate.destroy instanceof Function) {
124 this._userDelegate.destroy();
125 }
126
127 this._userDelegate = this._templateElement = null;
128 }
129}
130
131/**
132 * This class provide core functions for ons-lazy-repeat.
133 */
134export class LazyRepeatProvider {
135
136 /**
137 * @param {Element} wrapperElement
138 * @param {LazyRepeatDelegate} delegate
139 */
140 constructor(wrapperElement, delegate) {
141 if (!(delegate instanceof LazyRepeatDelegate)) {
142 util.throw('"delegate" parameter must be an instance of LazyRepeatDelegate');
143 }
144
145 this._wrapperElement = wrapperElement;
146 this._delegate = delegate;
147 this._insertIndex = (this._wrapperElement.children[0] && this._wrapperElement.children[0].tagName === 'ONS-LAZY-REPEAT') ? 1 : 0;
148
149 if (wrapperElement.tagName.toLowerCase() === 'ons-list') {
150 wrapperElement.classList.add('lazy-list');
151 }
152
153 this._pageContent = this._findPageContentElement(wrapperElement);
154
155 if (!this._pageContent) {
156 util.throw('LazyRepeat must be descendant of a Page element');
157 }
158
159 this.lastScrollTop = this._pageContent.scrollTop;
160 this.padding = 0;
161 this._topPositions = [0];
162 this._renderedItems = {};
163
164 if (!this._delegate.itemHeight && !this._delegate.calculateItemHeight(0)) {
165 this._unknownItemHeight = true;
166 }
167
168 this._addEventListeners();
169 this._onChange();
170 }
171
172 get padding() {
173 return parseInt(this._wrapperElement.style.paddingTop, 10);
174 }
175
176 set padding(newValue) {
177 this._wrapperElement.style.paddingTop = newValue + 'px';
178 }
179
180 _findPageContentElement(wrapperElement) {
181 const pageContent = util.findParent(wrapperElement, '.page__content');
182
183 if (pageContent) {
184 return pageContent;
185 }
186
187 const page = util.findParent(wrapperElement, 'ons-page');
188 if (page) {
189 const content = util.findChild(page, '.content');
190 if (content) {
191 return content;
192 }
193 }
194
195 return null;
196 }
197
198 _checkItemHeight(callback) {
199 this._delegate.loadItemElement(0, item => {
200 if (!this._unknownItemHeight) {
201 util.throw('Invalid state');
202 }
203
204 this._wrapperElement.appendChild(item.element);
205
206 const done = () => {
207 this._delegate.destroyItem(0, item);
208 item.element && item.element.remove();
209 delete this._unknownItemHeight;
210 callback();
211 };
212
213 this._itemHeight = item.element.offsetHeight;
214
215 if (this._itemHeight > 0) {
216 done();
217 return;
218 }
219
220 // retry to measure offset height
221 // dirty fix for angular2 directive
222 this._wrapperElement.style.visibility = 'hidden';
223 item.element.style.visibility = 'hidden';
224
225 setImmediate(() => {
226 this._itemHeight = item.element.offsetHeight;
227 if (this._itemHeight == 0) {
228 util.throw('Invalid state: "itemHeight" must be greater than zero');
229 }
230 this._wrapperElement.style.visibility = '';
231 done();
232 });
233 });
234 }
235
236 get staticItemHeight() {
237 return this._delegate.itemHeight || this._itemHeight;
238 }
239 _countItems() {
240 return this._delegate.countItems();
241 }
242
243 _getItemHeight(i) {
244 // Item is rendered
245 if (Object.prototype.hasOwnProperty.call(this._renderedItems, i)) {
246 if (!Object.prototype.hasOwnProperty.call(this._renderedItems[i], 'height')) {
247 this._renderedItems[i].height = this._renderedItems[i].element.offsetHeight;
248 }
249 return this._renderedItems[i].height;
250 }
251
252 // Item is not rendered, scroll up
253 if (this._topPositions[i + 1] && this._topPositions[i]) {
254 return this._topPositions[i + 1] - this._topPositions[i];
255 }
256 // Item is not rendered, scroll down
257 return this.staticItemHeight || this._delegate.calculateItemHeight(i);
258 }
259
260 _calculateRenderedHeight() {
261 return Object.keys(this._renderedItems).reduce((a, b) => a + this._getItemHeight(+(b)), 0);
262 }
263
264 _onChange() {
265 this._render();
266 }
267
268 _lastItemRendered() {
269 return Math.max(...Object.keys(this._renderedItems));
270 }
271
272 _firstItemRendered() {
273 return Math.min(...Object.keys(this._renderedItems));
274 }
275
276 refresh() {
277 const forceRender = { forceScrollDown: true };
278 const firstItemIndex = this._firstItemRendered();
279
280 if (util.isInteger(firstItemIndex)) {
281 this._wrapperElement.style.height = this._topPositions[firstItemIndex] + this._calculateRenderedHeight() + 'px';
282 this.padding = this._topPositions[firstItemIndex];
283 forceRender.forceFirstIndex = firstItemIndex;
284 }
285
286 this._removeAllElements();
287 this._render(forceRender);
288 this._wrapperElement.style.height = 'inherit';
289 }
290
291 _render({forceScrollDown = false, forceFirstIndex, forceLastIndex} = {}) {
292 if (this._unknownItemHeight) {
293 return this._checkItemHeight(this._render.bind(this, arguments[0]));
294 }
295
296 const isScrollUp = !forceScrollDown && this.lastScrollTop > this._pageContent.scrollTop;
297 this.lastScrollTop = this._pageContent.scrollTop;
298 const keep = {};
299
300 const offset = this._wrapperElement.getBoundingClientRect().top;
301 const limit = 4 * window.innerHeight - offset;
302 const count = this._countItems();
303
304 const items = [];
305 const start = forceFirstIndex || Math.max(0, this._calculateStartIndex(offset) - 30); // Recalculate for 0 or undefined
306 let i = start;
307
308 for (let top = this._topPositions[i]; i < count && top < limit; i++) {
309 if (i >= this._topPositions.length) { // perf optimization
310 this._topPositions.length += 100;
311 }
312
313 this._topPositions[i] = top;
314 top += this._getItemHeight(i);
315 }
316
317 if (this._delegate.hasRenderFunction && this._delegate.hasRenderFunction()) {
318 return this._delegate._render(start, i, () => {
319 this.padding = this._topPositions[start];
320 });
321 }
322
323 if (isScrollUp) {
324 for (let j = i - 1; j >= start; j--) {
325 keep[j] = true;
326 this._renderElement(j, isScrollUp);
327 }
328 } else {
329 const lastIndex = forceLastIndex || Math.max(i - 1, ...Object.keys(this._renderedItems)); // Recalculate for 0 or undefined
330 for (let j = start; j <= lastIndex; j++) {
331 keep[j] = true;
332 this._renderElement(j, isScrollUp);
333 }
334 }
335
336 Object.keys(this._renderedItems).forEach(key => keep[key] || this._removeElement(key, isScrollUp));
337 }
338
339 /**
340 * @param {Number} index
341 * @param {Boolean} isScrollUp
342 */
343 _renderElement(index, isScrollUp) {
344 const item = this._renderedItems[index];
345 if (item) {
346 this._delegate.updateItem(index, item); // update if it exists
347 return;
348 }
349
350 this._delegate.loadItemElement(index, item => {
351 if (isScrollUp) {
352 this._wrapperElement.insertBefore(item.element, this._wrapperElement.children[this._insertIndex]);
353 this.padding = this._topPositions[index];
354 item.height = this._topPositions[index + 1] - this._topPositions[index];
355 } else {
356 this._wrapperElement.appendChild(item.element);
357 }
358
359 this._renderedItems[index] = item;
360 });
361 }
362
363 /**
364 * @param {Number} index
365 * @param {Boolean} isScrollUp
366 */
367 _removeElement(index, isScrollUp = true) {
368 index = +(index);
369 const item = this._renderedItems[index];
370 this._delegate.destroyItem(index, item);
371
372 if (isScrollUp) {
373 this._topPositions[index + 1] = undefined;
374 } else {
375 this.padding = this.padding + this._getItemHeight(index);
376 }
377
378 if (item.element.parentElement) {
379 item.element.parentElement.removeChild(item.element);
380 }
381
382 delete this._renderedItems[index];
383 }
384
385 _removeAllElements() {
386 Object.keys(this._renderedItems).forEach(key => this._removeElement(key));
387 }
388
389 _recalculateTopPositions(start, end) {
390 for (let i = start; i <= end; i++) {
391 this._topPositions[i + 1] = this._topPositions[i] + this._getItemHeight(i);
392 }
393 }
394
395 _calculateStartIndex(current) {
396 const firstItemIndex = this._firstItemRendered();
397 const lastItemIndex = this._lastItemRendered();
398
399 // Fix for Safari scroll and Angular 2
400 this._recalculateTopPositions(firstItemIndex, lastItemIndex);
401
402 let start = 0;
403 let end = this._countItems() - 1;
404
405 // Binary search for index at top of screen so we can speed up rendering.
406 for (;;) {
407 const middle = Math.floor((start + end) / 2);
408 const value = current + this._topPositions[middle];
409
410 if (end < start) {
411 return 0;
412 } else if (value <= 0 && value + this._getItemHeight(middle) > 0) {
413 return middle;
414 } else if (isNaN(value) || value >= 0) {
415 end = middle - 1;
416 } else {
417 start = middle + 1;
418 }
419 }
420 }
421
422 _debounce(func, wait, immediate) {
423 let timeout;
424 return function() {
425 const callNow = immediate && !timeout;
426 clearTimeout(timeout);
427 if (callNow) {
428 func.apply(this, arguments);
429 } else {
430 timeout = setTimeout(() => {
431 timeout = null;
432 func.apply(this, arguments);
433 }, wait);
434 }
435 };
436 }
437
438 _doubleFireOnTouchend() {
439 this._render();
440 this._debounce(this._render.bind(this), 100);
441 }
442
443 _addEventListeners() {
444 util.bindListeners(this, ['_onChange', '_doubleFireOnTouchend']);
445
446 if (platform.isIOS()) {
447 this._boundOnChange = this._debounce(this._boundOnChange, 30);
448 }
449
450 this._pageContent.addEventListener('scroll', this._boundOnChange, true);
451
452 if (platform.isIOS()) {
453 util.addEventListener(this._pageContent, 'touchmove', this._boundOnChange, { capture: true, passive: true });
454 this._pageContent.addEventListener('touchend', this._boundDoubleFireOnTouchend, true);
455 }
456
457 window.document.addEventListener('resize', this._boundOnChange, true);
458 }
459
460 _removeEventListeners() {
461 this._pageContent.removeEventListener('scroll', this._boundOnChange, true);
462
463 if (platform.isIOS()) {
464 util.removeEventListener(this._pageContent, 'touchmove', this._boundOnChange, { capture: true, passive: true });
465 this._pageContent.removeEventListener('touchend', this._boundDoubleFireOnTouchend, true);
466 }
467
468 window.document.removeEventListener('resize', this._boundOnChange, true);
469 }
470
471 destroy() {
472 this._removeAllElements();
473 this._delegate.destroy();
474 this._parentElement = this._delegate = this._renderedItems = null;
475 this._removeEventListeners();
476 }
477}
478