UNPKG

13.4 kBJavaScriptView Raw
1import util from '../util.js';
2import platform from '../platform.js';
3import animit from '../animit.js';
4import GestureDetector from '../gesture-detector.js';
5
6const directionMap = {
7 vertical: {
8 axis: 'Y',
9 size: 'Height',
10 dir: ['up', 'down'],
11 t3d: ['0px, ', 'px, 0px']
12 },
13 horizontal: {
14 axis: 'X',
15 size: 'Width',
16 dir: ['left', 'right'],
17 t3d: ['', 'px, 0px, 0px']
18 }
19};
20
21export default class Swiper {
22 constructor(params) {
23 // Parameters
24 const FALSE = (() => false);
25 `getInitialIndex getBubbleWidth isVertical isOverScrollable isCentered
26 isAutoScrollable refreshHook preChangeHook postChangeHook overScrollHook`
27 .split(/\s+/)
28 .forEach(key => this[key] = params[key] || FALSE);
29
30 this.getElement = params.getElement; // Required
31 this.scrollHook = params.scrollHook; // Optional
32 this.itemSize = params.itemSize || '100%';
33
34 this.getAutoScrollRatio = (...args) => {
35 let ratio = params.getAutoScrollRatio && params.getAutoScrollRatio(...args);
36 ratio = typeof ratio === 'number' && ratio === ratio ? ratio : .5;
37 if (ratio < 0.0 || ratio > 1.0) {
38 util.throw('Invalid auto-scroll-ratio ' + ratio + '. Must be between 0 and 1');
39 }
40 return ratio;
41 };
42
43 // Prevent clicks only on desktop
44 this.shouldBlock = util.globals.actualMobileOS === 'other';
45
46 // Bind handlers
47 this.onDragStart = this.onDragStart.bind(this);
48 this.onDrag = this.onDrag.bind(this);
49 this.onDragEnd = this.onDragEnd.bind(this);
50 this.onResize = this.onResize.bind(this);
51
52 this._shouldFixScroll = util.globals.actualMobileOS === 'ios';
53 }
54
55 init({ swipeable, autoRefresh } = {}) {
56 this.initialized = true;
57 this.target = this.getElement().children[0];
58 this.blocker = this.getElement().children[1];
59 if (!this.target || !this.blocker) {
60 util.throw('Expected "target" and "blocker" elements to exist before initializing Swiper');
61 }
62
63 if (!this.shouldBlock) {
64 this.blocker.style.display = 'none';
65 }
66
67 // Add classes
68 this.getElement().classList.add('ons-swiper');
69 this.target.classList.add('ons-swiper-target');
70 this.blocker.classList.add('ons-swiper-blocker');
71
72 // Setup listeners
73 this._gestureDetector = new GestureDetector(this.getElement(),
74 { dragMinDistance: 1, dragLockToAxis: true, passive: !this._shouldFixScroll }
75 );
76 this._mutationObserver = new MutationObserver(() => this.refresh());
77 this.updateSwipeable(swipeable);
78 this.updateAutoRefresh(autoRefresh);
79
80 // Setup initial layout
81 this._scroll = this._offset = this._lastActiveIndex = 0;
82 this._updateLayout();
83 this._setupInitialIndex();
84 setImmediate(() => this.initialized && this._setupInitialIndex());
85
86 // Fix rendering glitch on Android 4.1
87 // Fix for iframes where the width is inconsistent at the beginning
88 if (window !== window.parent || this.offsetHeight === 0) {
89 window.requestAnimationFrame(() => this.initialized && this.onResize());
90 }
91 }
92
93 dispose() {
94 this.initialized = false;
95 this.updateSwipeable(false);
96 this.updateAutoRefresh(false);
97
98 this._gestureDetector && this._gestureDetector.dispose();
99 this.target = this.blocker = this._gestureDetector = this._mutationObserver = null;
100
101 this.setupResize(false);
102 }
103
104 onResize() {
105 const i = this._scroll / this.itemNumSize;
106 this._reset();
107 this.setActiveIndex(i);
108 this.refresh();
109 }
110
111 get itemCount() {
112 return this.target.children.length;
113 }
114
115 get itemNumSize() {
116 if (typeof this._itemNumSize !== 'number' || this._itemNumSize !== this._itemNumSize) {
117 this._itemNumSize = this._calculateItemSize();
118 }
119 return this._itemNumSize;
120 }
121
122 get maxScroll() {
123 const max = this.itemCount * this.itemNumSize - this.targetSize;
124 return Math.ceil(max < 0 ? 0 : max); // Need to return an integer value.
125 }
126
127 _calculateItemSize() {
128 const matches = this.itemSize.match(/^(\d+)(px|%)/);
129
130 if (!matches) {
131 util.throw(`Invalid state: swiper's size unit must be '%' or 'px'`);
132 }
133
134 const value = parseInt(matches[1], 10);
135 return matches[2] === '%' ? Math.round(value / 100 * this.targetSize) : value;
136 }
137
138 _setupInitialIndex() {
139 this._reset();
140 this._lastActiveIndex = Math.max(Math.min(Number(this.getInitialIndex()), this.itemCount), 0);
141 this._scroll = this._offset + this.itemNumSize * this._lastActiveIndex;
142 this._scrollTo(this._scroll);
143 }
144
145 _setSwiping(toggle) {
146 this.target.classList.toggle('swiping', toggle); // Hides everything except shown pages
147 }
148
149 setActiveIndex(index, options = {}) {
150 this._setSwiping(true);
151 index = Math.max(0, Math.min(index, this.itemCount - 1));
152 const scroll = Math.max(0, Math.min(this.maxScroll, this._offset + this.itemNumSize * index));
153
154 return this._changeTo(scroll, options);
155 }
156
157 getActiveIndex(scroll = this._scroll) {
158 scroll -= this._offset;
159 const count = this.itemCount,
160 size = this.itemNumSize;
161
162 if (this.itemNumSize === 0 || !util.isInteger(scroll)) {
163 return this._lastActiveIndex;
164 }
165
166 if (scroll <= 0) {
167 return 0;
168 }
169
170 for (let i = 0; i < count; i++) {
171 if (size * i <= scroll && size * (i + 1) > scroll) {
172 return i;
173 }
174 }
175
176 return count - 1;
177 }
178
179 setupResize(add) {
180 window[(add ? 'add' : 'remove') + 'EventListener']('resize', this.onResize, true);
181 }
182
183 show() {
184 this.setupResize(true);
185 this.onResize();
186 setTimeout(() => this.target && this.target.classList.add('active'), 1000/60); // Hide elements after animations
187 }
188
189 hide() {
190 this.setupResize(false);
191 this.target.classList.remove('active'); // Show elements before animations
192 }
193
194 updateSwipeable(shouldUpdate) {
195 if (this._gestureDetector) {
196 const action = shouldUpdate ? 'on' : 'off';
197 this._gestureDetector[action]('drag', this.onDrag);
198 this._gestureDetector[action]('dragstart', this.onDragStart);
199 this._gestureDetector[action]('dragend', this.onDragEnd);
200 }
201 }
202
203 updateAutoRefresh(shouldWatch) {
204 if (this._mutationObserver) {
205 shouldWatch
206 ? this._mutationObserver.observe(this.target, { childList: true })
207 : this._mutationObserver.disconnect();
208 }
209 }
210
211 updateItemSize(newSize) {
212 this.itemSize = newSize || '100%';
213 this.refresh();
214 }
215
216 toggleBlocker(block) {
217 this.blocker.style.pointerEvents = block ? 'auto' : 'none';
218 }
219
220 _canConsumeGesture(gesture) {
221 const d = gesture.direction;
222 const isFirst = this._scroll === 0 && !this.isOverScrollable();
223 const isLast = this._scroll === this.maxScroll && !this.isOverScrollable();
224
225 return this.isVertical()
226 ? ((d === 'down' && !isFirst) || (d === 'up' && !isLast))
227 : ((d === 'right' && !isFirst) || (d === 'left' && !isLast));
228 }
229
230 onDragStart(event) {
231 this._ignoreDrag = event.consumed || !util.isValidGesture(event);
232
233 if (!this._ignoreDrag) {
234 const consume = event.consume;
235 event.consume = () => { consume && consume(); this._ignoreDrag = true; };
236
237 if (this._canConsumeGesture(event.gesture)) {
238 const startX = event.gesture.center && event.gesture.center.clientX || 0,
239 distFromEdge = this.getBubbleWidth() || 0,
240 start = () => {
241 consume && consume();
242 event.consumed = true;
243 this._started = true; // Avoid starting drag from outside
244 this.shouldBlock && this.toggleBlocker(true);
245 this._setSwiping(true);
246 util.iosPreventScroll(this._gestureDetector);
247 };
248
249 // Let parent elements consume the gesture or consume it right away
250 startX < distFromEdge || startX > (this.targetSize - distFromEdge)
251 ? setImmediate(() => !this._ignoreDrag && start())
252 : start();
253 }
254 }
255 }
256
257 onDrag(event) {
258 if (!event.gesture || this._ignoreDrag || !this._started) {
259 return;
260 }
261
262 this._continued = true; // Fix for random 'dragend' without 'drag'
263 event.stopPropagation();
264
265 this._scrollTo(this._scroll - this._getDelta(event), { throttle: true });
266 }
267
268 onDragEnd(event) {
269 this._started = false;
270 if (!event.gesture || this._ignoreDrag || !this._continued) {
271 this._ignoreDrag = true; // onDragEnd might fire before onDragStart's setImmediate
272 return;
273 }
274
275 this._continued = false;
276 event.stopPropagation();
277
278 const scroll = this._scroll - this._getDelta(event);
279 const normalizedScroll = this._normalizeScroll(scroll);
280 scroll === normalizedScroll ? this._startMomentumScroll(scroll, event) : this._killOverScroll(normalizedScroll);
281 this.shouldBlock && this.toggleBlocker(false);
282 }
283
284 _startMomentumScroll(scroll, event) {
285 const velocity = this._getVelocity(event),
286 matchesDirection = event.gesture.interimDirection === this.dM.dir[this._getDelta(event) < 0 ? 0 : 1];
287
288 const nextScroll = this._getAutoScroll(scroll, velocity, matchesDirection);
289 let duration = Math.abs(nextScroll - scroll) / (velocity + 0.01) / 1000;
290 duration = Math.min(.25, Math.max(.1, duration));
291
292 this._changeTo(nextScroll, { swipe: true, animationOptions: { duration, timing: 'cubic-bezier(.4, .7, .5, 1)' } });
293 }
294
295 _killOverScroll(scroll) {
296 this._scroll = scroll;
297 const direction = this.dM.dir[Number(scroll > 0)];
298 const killOverScroll = () => this._changeTo(scroll, { animationOptions: { duration: .4, timing: 'cubic-bezier(.1, .4, .1, 1)' } });
299 this.overScrollHook({ direction, killOverScroll }) || killOverScroll();
300 }
301
302 _changeTo(scroll, options = {}) {
303 const e = { activeIndex: this.getActiveIndex(scroll), lastActiveIndex: this._lastActiveIndex, swipe: options.swipe || false };
304 const change = e.activeIndex !== e.lastActiveIndex;
305 const canceled = change ? this.preChangeHook(e) : false;
306
307 this._scroll = canceled ? this._offset + e.lastActiveIndex * this.itemNumSize : scroll;
308 this._lastActiveIndex = canceled ? e.lastActiveIndex : e.activeIndex;
309
310 return this._scrollTo(this._scroll, options).then(() => {
311 if (scroll === this._scroll && !canceled) {
312 this._setSwiping(false);
313 change && this.postChangeHook(e);
314 } else if (options.reject) {
315 this._setSwiping(false);
316 return Promise.reject('Canceled');
317 }
318 });
319 }
320
321 _scrollTo(scroll, options = {}) {
322 if (options.throttle) {
323 const ratio = 0.35;
324 if (scroll < 0) {
325 scroll = this.isOverScrollable() ? Math.round(scroll * ratio) : 0;
326 } else {
327 const maxScroll = this.maxScroll;
328 if (maxScroll < scroll) {
329 scroll = this.isOverScrollable() ? maxScroll + Math.round((scroll - maxScroll) * ratio) : maxScroll;
330 }
331 }
332 }
333
334 const opt = options.animation === 'none' ? {} : options.animationOptions;
335 this.scrollHook && this.itemNumSize > 0 && this.scrollHook((scroll / this.itemNumSize).toFixed(2), options.animationOptions || {});
336
337 return new Promise(resolve =>
338 animit(this.target)
339 .queue({ transform: this._getTransform(scroll) }, opt)
340 .play(resolve)
341 );
342 }
343
344 _getAutoScroll(scroll, velocity, matchesDirection) {
345 const max = this.maxScroll,
346 offset = this._offset,
347 size = this.itemNumSize;
348
349 if (!this.isAutoScrollable()) {
350 return Math.max(0, Math.min(max, scroll));
351 }
352
353 let arr = [];
354 for (let s = offset; s < max; s += size) {
355 arr.push(s);
356 }
357 arr.push(max);
358
359 arr = arr
360 .sort((left, right) => Math.abs(left - scroll) - Math.abs(right - scroll))
361 .filter((item, pos) => !pos || item !== arr[pos - 1]);
362
363 let result = arr[0];
364 const lastScroll = this._lastActiveIndex * size + offset;
365 const scrollRatio = Math.abs(scroll - lastScroll) / size;
366
367 if (scrollRatio <= this.getAutoScrollRatio(matchesDirection, velocity, size)) {
368 result = lastScroll;
369 } else {
370 if (scrollRatio < 1.0 && arr[0] === lastScroll && arr.length > 1) {
371 result = arr[1];
372 }
373 }
374 return Math.max(0, Math.min(max, result));
375 }
376
377 _reset() {
378 this._targetSize = this._itemNumSize = undefined;
379 }
380
381 _normalizeScroll(scroll) {
382 return Math.max( Math.min(scroll, this.maxScroll), 0);
383 }
384
385 refresh() {
386 this._reset();
387 this._updateLayout();
388
389 if (util.isInteger(this._scroll)) {
390 const scroll = this._normalizeScroll(this._scroll);
391 scroll !== this._scroll ? this._killOverScroll(scroll) : this._changeTo(scroll);
392 } else {
393 this._setupInitialIndex();
394 }
395
396 this.refreshHook();
397 }
398
399 get targetSize() {
400 if (!this._targetSize) {
401 this._targetSize = this.target[`offset${this.dM.size}`];
402 }
403 return this._targetSize;
404 }
405
406 _getDelta(event) {
407 return event.gesture[`delta${this.dM.axis}`];
408 }
409
410 _getVelocity(event) {
411 return event.gesture[`velocity${this.dM.axis}`];
412 }
413
414 _getTransform(scroll) {
415 return `translate3d(${this.dM.t3d[0]}${-scroll}${this.dM.t3d[1]})`;
416 }
417
418 _updateLayout() {
419 this.dM = directionMap[this.isVertical() ? 'vertical' : 'horizontal'];
420 this.target.classList.toggle('ons-swiper-target--vertical', this.isVertical());
421
422 for (let c = this.target.children[0]; c; c = c.nextElementSibling) {
423 c.style[this.dM.size.toLowerCase()] = this.itemSize;
424 }
425
426 if (this.isCentered()) {
427 this._offset = (this.targetSize - this.itemNumSize) / -2 || 0;
428 }
429 }
430}
431