Home React 이미지 슬라이드(infinite loop) 구현
Post
Cancel

React 이미지 슬라이드(infinite loop) 구현

To do

라이브러리 없이 아래 나열된 기능을 기준으로 이미지 슬라이드 구현을 해보았다.

  • < (이전), >(다음) 버튼 클릭으로 슬라이드 이미지 전환
  • 모바일에서 터치로 슬라이드 이미지 전환
  • 무한 루프로 마지막 이미지에서 다음 이미지로 터치 또는 클릭 시 자연스럽게 첫 번째 이미지로 이미지 전환, 첫 번째 이미지에서 이전 이미지로 터치 또는 클릭 시 마지막 이미지로 자연스럽게 이미지 전환

완성된 화면

버튼 클릭 시 이미지 슬라이드 전환 기능

아래와 같이 이미지 링크(string)를 배열로 받을 수 있는 컴포넌트로 구현하였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React, { FC } from 'react';
import { SwiperImage } from 'components';

const Sample: FC = () => {
  /* ... 생략 ...*/
  const data = ['이미지 url 링크 1', '이미지 url 링크 2'];

  return (
    <div>
      <SwiperImage data={data} />
    </div>
  );
};

export default Sample;

슬라이드 기능

  • 위와 같이 보일 주황색 네모칸처럼 화면 크기를 정해둔 뒤 overflow: hidden 속성으로 주황색 네모칸을 벗어나는 이미지들은 숨김 처리한다.
  • 이미지를 감싸고 있는 div에 display: flex 속성으로 가로로 이미지를 나열한다.
  • 슬라이드 기능이 오른쪽으로 넘어가야 되기 때문에 x축이 마이너스 방향으로 이동되기 때문에 transform: translateX(-${보일 이미지 인덱스}00%)로 이동된다.
  • transition: all 0.4s ease-in-out 속성과 주어 자연스러운 모션을 준다.

아래는 위의 설명을 구현한 소스이다.

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
import classNames from 'classnames';
import React, { FC, useEffect, useRef, useState } from 'react';
import { IoIosArrowBack, IoIosArrowForward } from 'react-icons/io';

interface Props {
  data: string[];
}

const SwiperImage: FC<Props> = ({ data }) => {
  const ref = useRef<HTMLDivElement>(null);

  const [imageList, setImageList] = useState([
    data[data?.length - 1],
    ...data,
    data[0],
  ]);

  const [currentImgIndex, setCurrentImgIndex] = useState(1);

  const [style, setStyle] = useState({
    transform: `translateX(-${currentImgIndex}00%)`,
    transition: `all 0.4s ease-in-out`,
  });

  const nextSlide = () => {
    setCurrentImgIndex(currentImgIndex + 1);
    setStyle({
      transform: `translateX(-${currentImgIndex + 1}00%)`,
      transition: `all 0.4s ease-in-out`,
    });
  };

  const prevSlide = () => {
    setCurrentImgIndex(currentImgIndex - 1);
    setStyle({
      transform: `translateX(-${currentImgIndex - 1}00%)`,
      transition: `all 0.4s ease-in-out`,
    });
  };

  return (
    <div className="relative">
      <div
        className="overflow-hidden max-w-[480px] min-w-[280px] w-full bg-black"
      >
        <div ref={ref} style={style} className={`flex`}>
          {imageList?.map((el, i) => {
            return (
              <img
                key={i}
                src={el}
                className={'w-auto h-auto object-contain'}
              />
            );
          })}
        </div>
      </div>
      <div className="absolute w-full flex justify-between top-[50%]">
        <button className="text-white text-xl" onClick={prevSlide}>
          <IoIosArrowBack />
        </button>
        <button className="text-white text-xl" onClick={nextSlide}>
          <IoIosArrowForward />
        </button>
      </div>
      {/* ... 생략 ... */}
    </div>
  );
};

export default SwiperImage;

여기서 문제점!

여기서 첫 번째 이미지에서 사용자가 이전 버튼을 또 클릭하게 되면 마지막 이미지로 전환되지만 자연스럽지 못하게 휘리릭 마지막으로 넘어간다.

자연럽게 무한 루프 기능 추가하기

  • 위 화면과 같이 첫 번째 이미지(이미지 1) 왼쪽에 트릭을 주기 위한 가짜 마지막 이미지(이미지 4)를 두고 마지막 이미지(이미지 4) 오른쪽에도 트릭을 주기 위한 가짜 첫 번째 이미지(이미지 1)를 둔다.
  • 이미지 4에서 다음 버튼을 클릭할 경우 오른쪽에 있는 trick 이미지 1으로 넘어가는데, 여기서 settimeout으로 빠르게 transition: 0ms으로 효과 없이 사용자가 눈치채지 못하도록 진짜 이미지 1으로 이동한다.
  • 이미지 1에서 다음 버튼을 클릭할 경우 오른쪽에 있는 trick 이미지 4으로 넘어가는데, 여기서 settimeout으로 빠르게 transition: 0ms으로 효과 없이 사용자가 눈치채지 못하도록 진짜 이미지 4으로 이동한다.

  • 인덱스 0과 인덱스 imageList[imageList.length - 1]는 trick 이미지이기 때문에 가장 첫 번째 인덱스와 마지막 인덱스를 제외한 이미지들을 보여준다.

아래는 위 내용을 구현한 소스이다.

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
import classNames from 'classnames';
import React, { FC, useEffect, useRef, useState } from 'react';
import { IoIosArrowBack, IoIosArrowForward } from 'react-icons/io';

interface Props {
  data: string[];
}

const SwiperImage: FC<Props> = ({ data }) => {
  const ref = useRef<HTMLDivElement>(null);

  const [imageList, setImageList] = useState([
    data[data?.length - 1],
    ...data,
    data[0],
  ]);

  const [currentImgIndex, setCurrentImgIndex] = useState(1);

  const [touch, setTouch] = useState({
    start: 0,
    end: 0,
  });

  const [style, setStyle] = useState({
    transform: `translateX(-${currentImgIndex}00%)`,
    transition: `all 0.4s ease-in-out`,
  });

  const nextSlide = () => {
    setCurrentImgIndex(currentImgIndex + 1);
    setStyle({
      transform: `translateX(-${currentImgIndex + 1}00%)`,
      transition: `all 0.4s ease-in-out`,
    });
  };

  const prevSlide = () => {
    setCurrentImgIndex(currentImgIndex - 1);
    setStyle({
      transform: `translateX(-${currentImgIndex - 1}00%)`,
      transition: `all 0.4s ease-in-out`,
    });
  };

  useEffect(() => {
    if (currentImgIndex === 0) {
      setCurrentImgIndex(imageList.length - 2);
      setTimeout(function () {
        setStyle({
          transform: `translateX(-${imageList.length - 2}00%)`,
          transition: '0ms',
        });
      }, 500);
    }

    if (currentImgIndex >= imageList?.length - 1) {
      setCurrentImgIndex(1);
      setTimeout(() => {
        setStyle({
          transform: `translateX(-${1}00%)`,
          transition: '0ms',
        });
      }, 500);
    }
  }, [currentImgIndex, imageList.length]);

  useEffect(() => {
    setStyle({
      transform: `translateX(-${1}00%)`,
      transition: '0ms',
    });
  }, [imageList]);

  return (
    <div className="relative">
      <div
        className="overflow-hidden max-w-[480px] min-w-[280px] w-full bg-black"
   
      >
        <div ref={ref} style={style} className={`flex`}>
          {imageList?.map((el, i) => {
            return (
              <img
                key={i}
                src={el}
                className={'w-auto h-auto object-contain'}
              />
            );
          })}
        </div>
      </div>
      <div className="absolute w-full flex justify-between top-[50%]">
        <button className="text-white text-xl" onClick={prevSlide}>
          <IoIosArrowBack />
        </button>
        <button className="text-white text-xl" onClick={nextSlide}>
          <IoIosArrowForward />
        </button>
      </div>
      {/* ... 생략 ... */}
    </div>
  );
};

export default SwiperImage;

모바일 터치 슬라이드 기능 구현

  • onTouchStart
    • e.touches[0].pageX 처음 터치한 위치를 기억해 둔다.
  • onTouchMove
    • 터치하면서 넘길 때 해당 위치까지 끌려오기 위해 transition: 0ms으로 이동할 위치로 이동한다
    1
    2
    3
    4
    5
    6
    7
    8
    
      const current = ref.current.clientWidth * currentImgIndex;
      const result = -current + (e.targetTouches[0].pageX - touch.start);
      // 터치이동 시 이동되는 위치
    
      setStyle({
        transform: `translate3d(${result}px, 0px, 0px)`,
        transition: '0ms',
      });
    
  • onTouchEnd
    • 처음 터치했을 때 위치보다 마지막 이동된 위치가 더 작을 경우 이전 슬라이드로, 반대인 경우 다음 슬라이드로 이동한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
      const end = e.changedTouches[0].pageX;
      if (touch.start > end) {
        nextSlide();
      } else {
        prevSlide();
      }
      setTouch({
        ...touch,
        end,
      });
    

완성된 구현 소스

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
import classNames from 'classnames';
import React, { FC, useEffect, useRef, useState } from 'react';
import { IoIosArrowBack, IoIosArrowForward } from 'react-icons/io';

interface Props {
  data: string[];
}

const SwiperImage: FC<Props> = ({ data }) => {
  const ref = useRef<HTMLDivElement>(null);

  const [imageList] = useState([data[data?.length - 1], ...data, data[0]]);

  const [currentImgIndex, setCurrentImgIndex] = useState(1);

  const [touch, setTouch] = useState({
    start: 0,
    end: 0,
  });

  const [style, setStyle] = useState({
    transform: `translateX(-${currentImgIndex}00%)`,
    transition: `all 0.4s ease-in-out`,
  });

  const nextSlide = () => {
    setCurrentImgIndex(currentImgIndex + 1);
    setStyle({
      transform: `translateX(-${currentImgIndex + 1}00%)`,
      transition: `all 0.4s ease-in-out`,
    });
  };

  const prevSlide = () => {
    setCurrentImgIndex(currentImgIndex - 1);
    setStyle({
      transform: `translateX(-${currentImgIndex - 1}00%)`,
      transition: `all 0.4s ease-in-out`,
    });
  };

  useEffect(() => {
    if (currentImgIndex === 0) {
      setCurrentImgIndex(imageList.length - 2);
      setTimeout(function () {
        setStyle({
          transform: `translateX(-${imageList.length - 2}00%)`,
          transition: '0ms',
        });
      }, 500);
    }

    if (currentImgIndex >= imageList?.length - 1) {
      setCurrentImgIndex(1);
      setTimeout(() => {
        setStyle({
          transform: `translateX(-${1}00%)`,
          transition: '0ms',
        });
      }, 500);
    }
  }, [currentImgIndex, imageList.length]);

  return (
    <div className="relative">
      <div
        className="overflow-hidden max-w-[480px] min-w-[280px] w-full bg-black"
        onTouchStart={(e) => {
          setTouch({
            ...touch,
            start: e.touches[0].pageX,
          });
        }}
        onTouchMove={(e) => {
          if (ref?.current) {
            const current = ref.current.clientWidth * currentImgIndex;
            const result = -current + (e.targetTouches[0].pageX - touch.start);
            setStyle({
              transform: `translate3d(${result}px, 0px, 0px)`,
              transition: '0ms',
            });
          }
        }}
        onTouchEnd={(e) => {
          const end = e.changedTouches[0].pageX;
          if (touch.start > end) {
            nextSlide();
          } else {
            prevSlide();
          }
          setTouch({
            ...touch,
            end,
          });
        }}
      >
        <div ref={ref} style={style} className={`flex`}>
          {imageList?.map((el, i) => {
            return (
              <img
                key={i}
                src={el}
                className={'w-auto h-auto object-contain'}
              />
            );
          })}
        </div>
      </div>
      <div className="absolute w-full flex justify-between top-[50%]">
        <button className="text-white text-xl" onClick={prevSlide}>
          <IoIosArrowBack />
        </button>
        <button className="text-white text-xl" onClick={nextSlide}>
          <IoIosArrowForward />
        </button>
      </div>
      <div className="text-gray-500 mt-4 text-center flex justify-center">
        {data.map((el, i) => {
          return (
            <div
              key={i}
              className={classNames(
                'bg-gray-200 h-[6px] w-[6px] mr-1 rounded',
                {
                  'bg-rose-200': i + 1 === currentImgIndex,
                }
              )}
            />
          );
        })}
      </div>
    </div>
  );
};

export default SwiperImage;
This post is licensed under CC BY 4.0 by the author.