CSS&JS/⚡Thinkers

[vanilla JS] 바닐라 자바스크립트로 무한 슬라이더 만들기. v1.0

arancia_ 2022. 10. 11. 10:43

깃허브 주소 : https://github.com/OhIkmyeong/infinite-slider

 

GitHub - OhIkmyeong/infinite-slider: 무한 슬라이더

무한 슬라이더. Contribute to OhIkmyeong/infinite-slider development by creating an account on GitHub.

github.com

  • 이전,다음 버튼
  • 페이저
  • 마우스다운,마우스업 이벤트로 드래그시 이동
  • 터치스타트, 터치엔드 이벤트로 터치시 이동
  • 자동재생은 아직 구현 안 함

요즘 ott앱이나 많은 웹페이지 메인 슬라이드들은 신기하게 무한슬라이더 느낌을 제공하는데 (가장 마지막페이지 / 처음페이지의 구분이 느껴지지 않음) 대체 어떻게 만든걸까..생각하다가 약간 어설프지만 구현해보았음...
특히나 현재 보여지는 아이템 전후로 이전/다음 아이템이 살짝 보이기 때문에 수포자는 약간 머리가 아팠음

내가 생각한 원리는 다음과 같음, 원래 기본적인 슬라이더 아이템 3개가 있다고 해보자
(아이템은 2개부터 가능하지만 이해하기 쉽도록 3개로 설명하겠음.)
[A,B,C]
그럼 여기에 앞 뒤로 아이템을 2개씩 복사하여 추가해주는거다
[b,c,A,B,C,a,b]

처음에는 A 아이템을 먼저 보여줘야 한다. 그러면 사용자는 화면에서 보이는 모습은
[c,A,B]가 된다.

이후
[A,B,C]
[B,C,a]
의 과정을 거친다.

이제 여기서 한 번 더 다음 아이템을 보기로 하면 일단
[C,a,b] 로 이동을 시켜 준뒤 > transition을 일시적으로 제거 하고 > [c,A,B]로 위치시킨다. >
그런 다음 다시 transition을 추가해준다.

이전 페이지 보기만 누를 떄도 마찬가지.
시작은 [c,A,B]
이전 페이지를 누르면 일단은
[b,c,A]로 부드럽게 이동시키고, 트랜지션 제거, [B,C,a]로 이동

리팩토링은 피곤해서 나중에 2.0 만들 생각이 생기면 하겠다..
안그래도 dom 만드는 부분은 따로 분리하려고 했는데 클래스 분리하니까 에러~~~나서
귀찮아졌음...

 

HTML

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
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>무한 슬라이더</title>
    <link rel="stylesheet" type="text/css" href="./css/reset.css">
    <link rel="stylesheet" type="text/css" href="./css/infinite-slider.css">
    <script src="js/main.js" type="module"></script>
</head>
<body>
    <h1>Infinite Slider</h1>
    <ul class="infinite-slider" id="slider-test">
        <li style="--bg-color:salmon">0</li>
        <li style="--bg-color:orange">1</li>
        <li style="--bg-color:gold">2</li>
        <li style="--bg-color:royalblue">3</li>
        <li style="--bg-color:violet">4</li>
        <li style="--bg-color:tomato">5</li>
    </ul>
    <h6>Thank you!</h6>
 
    <template>
        위의 아이템이 다음과 같이 변합니다.
        <div class="infinite-slider-wrap">
            <ul class="infinite-slider">
                <li data-slider-item="0" style="--bg-color:salmon">1</li>
                <li data-slider-item="1" style="--bg-color:orange">2</li>
                <li data-slider-item="2" style="--bg-color:yellow">3</li>
                <li data-slider-item="3" style="--bg-color:green">4</li>
                <li data-slider-item="4" style="--bg-color:royalblue">5</li>
            </ul><!-- infinite-slider -->
    
            <div class="infinite-slider-pager">
                <button data-slider-item="0" title="1" class="on"></button>
                <button data-slider-item="1" title="2"></button>
                <button data-slider-item="2" title="3"></button>
                <button data-slider-item="3" title="4"></button>
                <button data-slider-item="4" title="5"></button>
            </div>
    
            <button class="infinite-slider-btn-prev" title="이전"></button>
            <button class="infinite-slider-btn-next" title="다음"></button>
        </div>
    </template>
</body>
</html>
cs

CSS

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
@charset "utf-8";
.infinite-slider-wrap{
    position:relative; overflow:hidden;
    width:100%; max-width:100vw; aspect-ratio:3/1;
    border:1px solid #fff;
}
 
.infinite-slider{
    --gap:5vmin;
    display:flex;flex-flow:row nowrap;
    gap:var(--gap);
    position:relative;
    height:100%;
    cursor:grab;
    transition:transform .3s .1s;
}
 
    .infinite-slider.off-transition{
        transition:none;
    }
 
/* 아이템 */
.infinite-slider > li{
    flex:none;
    display:flex;
    justify-content:center;align-items:center;
    position:relative;
    height:100%; aspect-ratio:2.5/1;
    background-color:var(--bg-color);
    font-size:10vmax;
    opacity:.3;
    transition:opacity .2s linear, boxShadow .2s .4s linear;
    user-select:none;
}
 
    /* li.on */
    .infinite-slider > li.on{
        opacity:1;
        box-shadow:0 0 5rem var(--bg-color);
    }
 
/* [페이저] */
.infinite-slider-pager{
    display:flex;
    gap:15px;
    position:absolute;
    left:50%; bottom:20px;
    transform:translateX(-50%);
}
 
.infinite-slider-pager button{
    height:16px; width:10vmin;
    background:rgba(255,255,255,.5);
    border:1px solid #00000055;
    border-radius:50px;
    cursor:pointer;
}
    /* on */
    .infinite-slider-pager button.on{
        background:rgba(255,255,255);
        border-color:#000;
        box-shadow:0 0 2rem rgba(255,255,255,1);
    }
 
/* 버튼 */
.infinite-slider-btn-prev,
.infinite-slider-btn-next{
    display:block;
    position:absolute;
    width:7vmin; aspect-ratio:1/1;
    top:50%;transform:translateY(-50%);
    background:#fff;
    border:1px solid #000; border-radius:50%;
    cursor:pointer;
}
.infinite-slider-btn-prev{left:20px;}
.infinite-slider-btn-next{right:20px;}
 
.infinite-slider-btn-prev::after,
.infinite-slider-btn-next::after{
    content:'';display:block;position:absolute;pointer-events:none; box-sizing:border-box;
    top:50%;
    transform:translate(-50%,-50%) rotate(45deg);
    width:30%;aspect-ratio:1/1;
    border:7px solid #000;
}
 
.infinite-slider-btn-prev::after{
    left:55%;
    border-width:0 0 7px 7px;
}
.infinite-slider-btn-next::after{
    left:47%;
    border-width:7px 7px 0 0;
}
 
cs

main.js

1
2
3
4
5
import { InfiniteSlider } from "./InfiniteSlider.js";
 
const $slider = document.getElementById('slider-test');
 
const ifnSlider = new InfiniteSlider($slider);
cs

InfiniteSlider.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
export class InfiniteSlider{
    constructor($slider){
        this.$slider = $slider;
        this.$$item = null;
        this.$wrap = null;
        this.$pager = null;
        this.$btnPrev = null;
        this.$btnNext = null;
        this.LEN = null;
        this.IDX_PREV = 0;
        this.IDX = 0;
        this.SIZE = {
            gap : null,
            wid : null,
            widWrap : null,
            sibHalf : null,
            per : null,
        };
        this.POS = {
            start : null,
            end : null
        }
        this.init();
    }//constructor
 
    /* [init] */
    init(){
        this.make_wrap();
        this.numbering_items();
        this.append_last_item_to_first();
        this.append_first_item_to_last()
        this.add_on_item_by_pager();
        this.make_pager();
        this.add_on_pager();
        this.make_btn_prev_next();
        this.cacul_size();
 
        this.move_slider_by_pager();
 
        //📌📌이벤트 추가
        /* 📌 resize */
        window.addEventListener('resize',this.cacul_size);
 
        /* 📌 pager */
        this.$pager.addEventListener('click',e=>{
            const $btn = e.target;
            if($btn.tagName != "BUTTON"return;
 
            //button.on 달기
            const idxBtn = Array.prototype.indexOf.call(this.$pager.children,$btn);
            this.IDX_PREV = this.IDX;
            this.IDX = idxBtn;
            this.add_on_pager();
 
            //slider 움직이기
            this.move_slider_by_pager();
 
            //item.on 달기
            this.add_on_item_by_pager();
        });
 
        /* 📌 prev, next 버튼에 이벤트 달기 */
        this.$btnPrev.addEventListener('click',()=>{
            this.move_general("prev");
        });
        
        this.$btnNext.addEventListener('click',()=>{
            this.move_general("next");
        });
 
        /* 📌 드래그에 이벤트 달기 */
        this.add_mouse_down();
 
        /* 📌 touch에 이벤트 달기 */
        this.$slider.addEventListener('touchstart',(e)=>{
            this.POS.start = e.changedTouches[0].clientX;
        });
 
        this.$slider.addEventListener('touchend',(e)=>{
            this.POS.end = e.changedTouches[0].clientX;
            const moveAmount = Math.abs(this.POS.end - this.POS.start);
            const winWidPer = window.innerWidth / 10;
 
            if(moveAmount > winWidPer){
                const direction = this.POS.start > this.POS.end ? "next" : "prev";
                this.move_general(direction);
            }
        });
    }//init
 
    /** 
     * prev,next,드래그,터치에 의한 변화 공통 
     * @param {String}direction "prev"|"next"
    */
    move_general(direction){
        this.IDX_PREV = this.IDX;
        if(direction == "prev"){
            this.IDX--;
        }else{
            this.IDX++;
        }
        // console.log("prev",this.IDX_PREV, "curr", this.IDX);
        this.add_on_pager();
        this.add_on_item_by_general();
        this.move_slider_by_general();
    }//move_general
 
    /** wrap을 추가하여, 그 안에 슬라이더를 넣습니다. */
    make_wrap(){
        this.$wrap = document.createElement('DIV');
        this.$wrap.classList.add('infinite-slider-wrap');
 
        const $sliderParent = this.$slider.parentElement;
        const $after = this.$slider.nextElementSibling;
        $sliderParent.appendChild(this.$wrap);
        if($after) $sliderParent.insertBefore(this.$wrap,$after);
    
        this.$wrap.appendChild(this.$slider);
    }//make_wrap
 
    /** 슬라이더의 아이템들에 넘버링을 추가합니다.. */
    numbering_items(){
        this.LEN = this.$slider.children.length;
        this.$$item = Array.from(this.$slider.children);
        this.$$item.forEach(($item,idx)=>{
            $item.dataset.sliderItem = idx;
        });
    }//numbering_items
 
    /** 해당 인덱스에 해당하는 아이템에 .on을 붙입니다. */
    add_on_item_by_pager(){
        const $on = this.$$item[this.IDX]; 
        this.$$item.forEach($item =>{
            $item.classList.toggle('on', $item == $on);
        });
        if(this.IDX == 0){
            this.$slider.children[1].classList.remove('on');
            this.$slider.children[this.LEN + 2].classList.remove('on');
        }
 
        if(this.IDX == this.LEN - 1){
            this.$slider.children[this.LEN + 2].classList.remove('on');
        }
 
        if(this.IDX == this.LEN){
            this.$slider.children[this.LEN + 2].classList.add('on');
        }
 
        if(this.IDX == 1 && this.IDX_PREV == this.LEN){
            this.$$item[0].classList.add('on');
        }
 
        if(this.IDX == - 1){
            this.$slider.children[1].classList.add('on');
        }
 
        if(this.IDX == this.LEN - 2 && this.IDX_PREV == - 1){
            this.$slider.children[1].classList.add('on');
            this.$$item[this.LEN - 1].classList.add('on');
        }
    }//add_on_item_by_pager
 
    /** item에 .on 붙이기(prev,next,드래그,터치) */
    add_on_item_by_general(){
        // console.log("prev",this.IDX_PREV, "curr", this.IDX);
        const $on = this.$$item[this.IDX];
        if(this.IDX >= this.LEN){
            const $fakeFirst = this.$slider.children[this.LEN + 2];
            const $first = this.$$item[0];
            $fakeFirst.classList.add('on');
            $first.classList.add('on');
        }else if(this.IDX < 0){
            const $fakeLast = this.$slider.children[1];
            const $last = this.$$item[this.LEN - 1];
            $fakeLast.classList.add('on');
            $last.classList.add('on');
        }else{
            Array.from(this.$slider.children).forEach($item =>{
                $item.classList.toggle('on', $item == $on);
            });
        }
    }//add_on_item_by_general
 
    /** 가장 마지막 아이템을 처음으로 붙입니다*/
    append_last_item_to_first(){
        const $first = this.$$item[0];
        const $last = this.$$item[this.LEN - 1];
        const $last2 = this.$$item[this.LEN - 2];
        const $cloneLast = $last.cloneNode(true);
        const $cloneLast2 = $last2.cloneNode(true);
        this.$slider.insertBefore($cloneLast,$first);
        this.$slider.insertBefore($cloneLast2,$cloneLast);
    }//append_last_item_to_first
 
    /** 가장 첫번째 아이템을 뒤로 붙인다*/
    append_first_item_to_last(){
        const $cloneFirst = this.$$item[0].cloneNode(true);
        const $cloneSecond = this.$$item[1].cloneNode(true);
        this.$slider.appendChild($cloneFirst);
        this.$slider.appendChild($cloneSecond);
    }//append_first_item_to_last
 
    /** pager를 만들고, 이벤트를 추가합니다 */
    make_pager(){
        this.$pager = document.createElement('DIV');
        this.$pager.classList.add('infinite-slider-pager');
 
        const $frag = document.createDocumentFragment();
        for(let i=0; i<this.LEN; i++){
            const $btn = document.createElement("BUTTON");
            $btn.dataset.sliderItem = i;
            $btn.title = i + 1;
            $frag.appendChild($btn);
        }   
        this.$pager.appendChild($frag);
        this.$wrap.appendChild(this.$pager);
    }//make_pager
 
    /**
     * pager에 클래스 on을 답니다.
     */
    add_on_pager(){
        let btnIdx;
        if(this.IDX > this.LEN - 1){
            btnIdx = 0
        }else if(this.IDX < 0){
            btnIdx = this.LEN - 1;
        }else{
            btnIdx = this.IDX;
        }
        const $btn = this.$pager.children[btnIdx]; 
        const $$sib = Array.prototype.filter.call(this.$pager.children, $sib => $sib !== $btn);
        $$sib.forEach($sib => $sib.classList.remove('on')); 
        $btn.classList.add('on');
    }
 
    /** btn-prev,next를 만들고 이벤트를 추가합니다. */
    make_btn_prev_next(){
        this.$btnPrev = document.createElement('BUTTON');
        this.$btnNext = document.createElement('BUTTON');
 
        this.$btnPrev.classList.add('infinite-slider-btn-prev');
        this.$btnNext.classList.add('infinite-slider-btn-next');
 
        this.$btnPrev.title = "이전";
        this.$btnNext.title = "다음";
 
        this.$wrap.appendChild(this.$btnPrev);
        this.$wrap.appendChild(this.$btnNext);
    }//make_btn_prev_next
 
    /** 
     * move Slider(페이저) 총괄
     * */
    move_slider_by_pager(){
        // console.log("prev",this.IDX_PREV, "now",this.IDX);
        if(this.IDX == 0 && this.IDX_PREV == this.LEN - 1){
            this.IDX = this.LEN;
            this.move_slider_only();
            this.IDX_PREV = this.IDX;
        }else if(this.IDX == 0 && this.IDX_PREV == this.LEN){
            this.$slider.classList.add('off-transition');
            this.move_slider_only();
            setTimeout(()=>{
                this.$slider.classList.remove('off-transition');
            },0);
        }else if(this.IDX == 1 && this.IDX_PREV == this.LEN){
            this.$slider.classList.add('off-transition');
            setTimeout(async()=>{
                await this.fake_move_to_first();
                this.$$item[0].classList.remove('on');
                this.move_slider_only();
            },250);
        }else if(this.IDX == this.LEN - 1 && this.IDX_PREV == 0){
            this.IDX = -1;
            this.move_slider_only();
            this.IDX_PREV = this.IDX;
        }else if(this.IDX == this.LEN - 2 && this.IDX_PREV == -1){
            this.$slider.classList.add('off-transition');
            setTimeout(async()=>{
                await this.fake_move_to_last();
                this.$$item[this.LEN - 1].classList.remove('on');
                this.move_slider_only();
            },250);
        }else{
            this.move_slider_only();
        }
    }//move_slider_by_pager
 
    /** prev,next,드래그,터치로 이동할 때 */
    move_slider_by_general(){
        this.move_slider_only();
        if(this.IDX >= this.LEN){
            this.$slider.addEventListener('transitionend',()=>{
                this.$slider.classList.add('off-transition');
                this.IDX = 0;
                this.move_slider_only();
                setTimeout(()=>{
                    this.$slider.classList.remove('off-transition');
                },10);
            },{once:true});
        }else if(this.IDX < 0){
            this.$slider.addEventListener('transitionend',()=>{
                this.$slider.classList.add('off-transition');
                this.IDX = this.LEN - 1;
                this.move_slider_only();
                setTimeout(()=>{
                    this.$slider.classList.remove('off-transition');
                },10);
            },{once:true});
        }
    }//move_slider_by_general
 
    move_slider_only(){
        this.cacul_size_per_only();
        this.$slider.style.transform = `translateX(${this.SIZE.per * -1}px)`;
    }//move_slider_only
 
    fake_move_to_first(){
        return new Promise(res=>{
            this.IDX = 0;
            this.move_slider_only();
            setTimeout(()=>{
                this.IDX = 1;
                this.$slider.classList.remove('off-transition');
                res(true);
            },200);
        });
    }
 
    fake_move_to_last(){
        return new Promise(res => {
            this.IDX = this.LEN - 1;
            this.move_slider_only();
            setTimeout(()=>{
                this.IDX = this.LEN - 2;
                this.$slider.classList.remove('off-transition');
                res();
            },200);
        });
    }
    
    cacul_size = () =>{
        this.SIZE.gap =  window.innerWidth < window.innerHeight ? window.innerWidth / 100 * 5 : window.innerHeight / 100 * 5;
        this.SIZE.wid = this.$slider.children[0].offsetWidth ;
        this.SIZE.widWrap = this.$wrap.offsetWidth;
        this.SIZE.sibHalf = (this.SIZE.widWrap - (this.SIZE.wid + this.SIZE.gap * 2)) / 2;
        this.cacul_size_per_only();
    }//cacul_size
 
    cacul_size_per_only(){
        this.SIZE.per = (this.SIZE.wid * (this.IDX + 2)) - this.SIZE.sibHalf + (this.SIZE.gap * (this.IDX + 1));
    }//cacul_size_per_only
 
    add_mouse_down(){
        this.$slider.addEventListener('mousedown',(e)=>{
            this.POS.start = e.clientX;
            this.$slider.style.cursor = 'grabbing';
            this.add_mouse_up();
        },{once:true});
    }//add_mouse_down
 
    add_mouse_up(){
        this.$slider.addEventListener('mouseup',(e)=>{
            this.POS.end = e.clientX;
            const moveAmount = Math.abs(this.POS.end - this.POS.start);
            const winWidPer = window.innerWidth / 10;
 
            if(moveAmount > winWidPer){
                console.log(moveAmount,winWidPer);
                const direction = this.POS.start > this.POS.end ? "next" : "prev";
                this.move_general(direction);
            }
            this.$slider.style.cursor = 'grab';
            this.add_mouse_down();
        },{once:true});
    }//add_mouse_up
}//class-InfiniteSlider
cs

 

수익창출을 하지 않는(할 수 없는) 블로그이기에
코멘트는 정말 큰 힘이 됩니다 ^_^)