

회색 View (swiper_area) 영역 위에서 마우스를 움직이면 마우스의 x위치 값 (locationX)을 얻을 수 있는데, 이를 이용해 연두색 View의 left를 조절한다.
어느정도 위치가 변경되면 자동으로 이미지 사이즈에 맞게 left를 조절해주어야 한다.
핵심 함수는 slide() 함수와 swipe() 함수이다.
import {useState} from 'react';
import {GestureResponderEvent, Image, ImageStyle, View} from 'react-native';
import {useStyle} from '../hooks/style';
import {SAMPLE_ISSUES} from '../sample/data';
const IMAGE_LENGTH = SAMPLE_ISSUES.length;
const SIZE = 300;
본격적으로 시작하기 전에 sample image는 sample data를 모아둔 파일에서 가져왔고, 이미지의 사이즈는 width 300, height 300으로 고정해두었다.
ImageSwiper 컴포넌트의 상태는 다음과 같다. (여기서 마우스는 휴대폰을 사용하는 사용자의 손가락이라고 생각하자.)
const ImageSwiper = () => {
const {styles, css, colors} = useStyle();
const [touchedPosition, setTouchedPosition] = useState(0);
const [fixedLeft, setFixedLeft] = useState(0);
const [left, setLeft] = useState(0);
const [currIdx, setCurrIdx] = useState(0);
...
};
touchedPosition: swiper_area 영역을 클릭했을 때 마우스의 locationX 값을 저장해둔다.
fixedLeft: swiper_area 영역을 클릭했을 때 현재 left의 값을 저장해둔다. 사용자가 클릭 후 마우스를 움직일 때마다 left값이 계속 변경되므로 임시로 저장해두는 용도이다. 나중에 left를 설정하기 위한 계산식에 필요하다.
left: 연두색 View left값이다. 0이면 첫 번째 이미지, -300이면 두 번째 이미지.
currIdx: 이 포스트에서 다루진 않지만 이미지 하단 현재 이미지가 몇 번째인지 나타내는 dot을 그릴 때 사용된다.
<>
<View style={[styles.swiper_container, css[`w${SIZE}`], css[`h${SIZE}`]]}>
<View
style={[
css.fd_row,
css[`h${SIZE}`],
{
width: IMAGE_LENGTH * SIZE,
left,
},
]}>
{SAMPLE_ISSUES.map(({id, image}) => (
<Image
key={id}
source={image}
style={[css[`w${SIZE}`], css[`h${SIZE}`]] as ImageStyle}
/>
))}
</View>
<View
style={styles.swiper_area}
onTouchStart={({nativeEvent: {locationX}}) => {
setTouchedPosition(Math.floor(locationX));
setFixedLeft(left);
}}
onTouchMove={slide}
onTouchEnd={swipe}
/>
</View>
{/* dots */}
</>;
onTouchStart에 걸려있는 함수에서 클릭된 위치를 touchedPosition에 저장하고,
현재 left 값을 fixedLeft에 저장해둔다.
예를 들어, 영역의 오른쪽 부분을 클릭했다면 touchedPosition엔 약 240정도가 저장될 것이다.
또, 현재가 두 번째 이미지라면 fixedLeft에는 -300이 저장될 것이다.
const slide = ({nativeEvent: {locationX}}: GestureResponderEvent) => {
const diff = touchedPosition - Math.floor(locationX); // 1
const isNext = diff > 0; // 2
const firstOrLast = isNext ? SIZE * (IMAGE_LENGTH - 1) : 0; // 3
setLeft(prevLeft => {
const tmpLeft = isNext ? Math.abs(prevLeft) : prevLeft; // 4
const newLeft =
tmpLeft < firstOrLast ? fixedLeft - diff : firstOrLast * -1; // 5
return newLeft;
});
};
1, 2: 처음 클릭한 위치와 현재 마우스 포인터의 위치로 두 위치 간 차이를 구한다. diff가 양수면 다음 이미지로 넘기려는 동작으로 볼 수 있고, 음수면 이전 이미지로 넘기려는 동작으로 볼 수 있다.
3: 첫 번째 혹은 마지막 이미지의 left 값을 미리 구해놓는다.
4: 그 후, 사용자가 움직인 만큼 좌측이나 우측으로 이동시킨다. 다음 이미지로 넘기려는 동작을 한다면 left값은 음수가 될 테니 절댓값을 사용한다. (조건식을 더 사용하지 않고 newLeft를 하나의 식으로 구하기 위함.)
5-1: 첫 번째 이미지에서 이전 이미지로 넘어가려는 동작이나 마지막 이미지에서 다음 이미지로 넘어가려는 동작이 아닐 경우(조건이 참인 경우) fixedLeft - diff 로 새로운 left를 설정한다.
(diff가 양수면 다음 이미지로 넘기는 동작=>left값은 감소, 음수면 이전 이미지로 넘기는 동작=>left값은 증가)
5-2: 조건이 거짓인 경우 현재 left값을 유지한다.
slice() 함수에서 콘솔 로그로 간단히 테스트해 본 결과 RN 자체적으로 onTouchMove에 걸린 함수를 throttle로 감싸서 실행하는 듯 하다. 그리고 사용자가 움직이는대로 자연스럽게 이미지가 움직여야 하기 때문에 한번 더 throttle을 사용하지 않았다.
마우스 클릭이 끝났을 때 실행되는 함수이다. 이미지 사이즈에 맞게 남은 left값을 계산하여 자동으로 넘겨준다.
const swipe = () => {
const diff = fixedLeft - left;
const isNext = diff > 0;
const hasToMove = Math.abs(diff) > 50;
const moveIntl = setInterval(() => {
setLeft(prevLeft => {
const tmpLeft =
(isNext ? prevLeft * -1 : prevLeft) * (hasToMove ? 1 : -1);
const target = hasToMove
? fixedLeft + (isNext ? SIZE * -1 : SIZE)
: fixedLeft * -1;
const c = hasToMove ? -20 : 10;
if (tmpLeft < target * (isNext ? -1 : 1)) {
return prevLeft + (isNext ? c : c * -1);
} else {
clearInterval(moveIntl);
setCurrIdx(Math.floor(Math.abs(target) / SIZE));
return hasToMove ? target : target * -1;
}
});
});
};
우선 사용자가 아주 조금만 움직였다면 이전, 혹은 다음 이미지로 넘기지 않고 다시 원래 이미지로 돌아가도록 하기로 했다. 어느 정도 넘기려는 의지를 가지고 동작을 취했을 때 이미지를 넘겨준다.
따라서 총 4가지의 경우의 수가 나온다.
1: 이전 이미지로 아주 조금 넘긴 경우 => left를 감소시켜 다시 원래 이미지로 이동
2: 다음 이미지로 아주 조금 넘긴 경우 => left를 증가시켜 다시 원래 이미지로 이동
3: 이전 이미지로 많이 넘긴 경우 => left를 증가시켜 이전 이미지로 이동
4: 다음 이미지로 많이 넘긴 경우 => left를 감소시켜 이전 이미지로 이동
현재 코드는 if문과 중복코드를 남발하여 작성한 후 refactoring한 결과이다. 조금 복잡해 보이지만 거의 1/4가량으로 줄인 것에 만족해본다... 자세한 설명은 생략한다.
결국 최종 target (left의 값)을 구하고 setInterval을 이용해 그 값이 될 때까지 left를 변경하며 조금씩 이동시킨다. target의 값이 되면 clear해준다.
onTouchMove에 throttle이 걸려있는 것 처럼 onTouchEnd에는 debounce가 걸려있는 듯 싶다. 클릭이 종료되고(터치가 끝나고) 함수가 바로 실행되는 것이 아니라 일정 시간 이후에 실행되었다.
그래서 이미지가 자연스럽게 넘어가지 않고 중간에서 뚝 멈췄다가 다시 움직였다.

이 swipe() 함수를 onTouchEnd에 걸지 않고, onTouchMove에 걸려있는 slide() 함수 안에서 실행 시켰다.
slide() 함수는 마우스가 움직이는 동안 계속 실행되므로 debounce를 사용했다. debounce()로 swipe() 함수를 감싼 후 slide() 안에서 실행 시켰다.
const debounce = (fn: (currLeft: number) => void) => {
let timer: ReturnType<typeof setTimeout>;
return (currLeft: number) => {
if (timer) clearTimeout(timer);
timer = setTimeout(fn, 30, currLeft);
};
};
좀 더 개발을 진행하다가 debounce 함수가 이곳저곳에서 사용되게 된다면 그 때 util로 빼기로 했다.
// eslint-disable-next-line react-hooks/exhaustive-deps
const swipe = useCallback(
debounce(currLeft => {
const diff = fixedLeft - currLeft;
const isNext = diff > 0;
const hasToMove = Math.abs(diff) > 50;
const moveIntl = setInterval(() => {
setLeft(prevLeft => {
const tmpLeft =
(isNext ? prevLeft * -1 : prevLeft) * (hasToMove ? 1 : -1);
const target = hasToMove
? fixedLeft + (isNext ? SIZE * -1 : SIZE)
: fixedLeft * -1;
const c = hasToMove ? -20 : 10;
if (tmpLeft < target * (isNext ? -1 : 1)) {
return prevLeft + (isNext ? c : c * -1);
} else {
clearInterval(moveIntl);
setCurrIdx(Math.floor(Math.abs(target) / SIZE));
return hasToMove ? target : target * -1;
}
});
});
}),
[fixedLeft],
);
slide() 함수에서도 left값을 변경하고 swipe() 함수에서도 left를 사용 및 변경하는데 swipe() 함수가 slide() 함수 안에서 실행될 경우 sync가 맞지 않는다. 따라서 swipe() 를 호출할 때 변경될 left 값을 넘겨주어야 한다.
또 useCallback을 사용할 때 인라인으로 함수를 넘겨주지 않으면 dependencies를 알 수 없어 eslint 에러가 발생한다. 어쩔 수 없이 debounce로 꼭 감싸주어야 하기 때문에 저 라인에서만 disable 시켜주기로 했다. 대신 dependency array에 필요한 값을 꼭 넣어주자!
const slide = ({nativeEvent: {locationX}}: GestureResponderEvent) => {
const diff = touchedPosition - Math.floor(locationX);
const isNext = diff > 0;
const firstOrLast = isNext ? SIZE * (IMAGE_LENGTH - 1) : 0;
setLeft(prevLeft => {
const tmpLeft = isNext ? Math.abs(prevLeft) : prevLeft;
const newLeft = tmpLeft < firstOrLast ? fixedLeft - diff : firstOrLast * -1;
swipe(newLeft);
return newLeft;
});
};
setLeft 함수 안에서 새로운 left값을 구한 후 swipe() 함수를 호출할 때 넘겨준다.
<View
style={styles.swiper_area}
onTouchStart={({nativeEvent: {locationX}}) => {
setTouchedPosition(Math.floor(locationX));
setFixedLeft(left);
}}
onTouchMove={slide}
// onTouchEnd={swipe}
/>;
onTouchEnd에서 swipe를 빼주었다.
이렇게 해서 자연스럽게 이미지가 넘어가는 ImageSwiper 컴포넌트를 완성했다!