React Native) 캐러셀에 Indicator와 controller를 추가해보자

2ast·2022년 10월 6일
0
post-custom-banner

[캐러셀 만들기 (feat. ScrollView)][캐러셀 만들기 (feat. FlatList)]에 이어 이번에는 캐러셀에 indicator와 controller를 추가해보려고 한다.

캐러셀을 보면 종종 좌우 화살표를 눌러 컨텐츠를 넘길 수 있도록 하거나 하단에 있는 인디케이터를 통해 현재 보고 있는 것이 전체 중에 몇번째 아이템인지 알려주는 것들을 볼 수 있을 것이다. 이처럼 스크롤이 아니라 외부의 버튼을 통해 리스트를 제어하고 현재 보고 있는 아이템이 몇번째 아이템인지 알아내기 위해서는 이전에 만들어놓은 캐러셀 ScrollView에 두가지 속성을 추가해야 한다.(FlatList도 완전히 동일하므로 별도의 설명 생략)

const onScrollEnd = (e) => {
    const xOffset = e.nativeEvent.contentOffset.x;
};

 return <ScrollView
 	...
    onMomentumScrollEnd={onScrollEnd}
    contentOffset={{x: 0, y: 0}}
   >
  {...}     
  </ScrollView>

본격적으로 코드를 작성하기 전에 새로 삽입한 두개의 속성에 대해서 설명을 하자면 onMomentumScrollEnd는 스크롤이 끝났을 때 해당 이벤트와 관련된 무수히 많은 정보를 전달해준다. 그중에서 우리가 사용할 데이터는 nativeEvent.contentOffset이다. contentOffset은 해당 이벤트가 끝났을 때 list의 content가 리스트의 시작점으로부터 얼마나 떨어져 있는가를 보여준다. 우리는 가로 스크롤을 사용하고, pagingEnabled 속성을 주었기 때문에 onScrollEnd이벤트가 발생되었을 때 x offset을 확인하면 리스트 시작점으로 얼마나 떨어져 있는지 알 수 있게 되고, 그 떨어진 거리를 각 아이템의 너비로 나누면 현재 index를 알 수 있게 된다. 그리고 두번 째 속성인 contentOffset은 {x: number, y: number} 꼴의 object를 받으며, 렌더링될 때 입력한 좌표를 초기값으로 보여주는 역할을 한다.

코드를 작성해보자

이제 이 두 가지 속성을 이용해 코드를 작성해보자 (전체 코드 중에 바뀐 부분 위주로만 작성될 예정이니 전체 코드는 이 글의 마지막 부분 또는 [캐러셀 만들기 (feat. ScrollView)] 참조)

const [currentIndex, setCurrentIndex] = useState(0);

 const onScrollEnd = (e: {nativeEvent: {contentOffset: {x: number}}}) => {
    const xOffset = e.nativeEvent.contentOffset.x;
    const index = xOffset / itemWidth;
    setCurrentIndex(index);
  };

const indexToOffset = () => {
  return {x: currentIndex * itemWidth, y: 0};
};

 return <ScrollView
 	...
	onMomentumScrollEnd={onScrollEnd}
    contentOffset={indexToOffset()}
   >
  {...}     
  </ScrollView>

onScrollEnd로부터 x축으로의 offset을 구하고 그것을 각 아이템의 너비로 나눠 현재 보여지고 있는 아이템이 몇번 째 인덱스인지 가져왔다. 그리고 그 index로 부터 현재 offset 좌표를 리턴하는 indexToOffset 함수도 구현하였다. 해당 함수의 결과값을 ScrollView contentOffset의 값으로 주어서, currentIndex가 변경될 때마다 해당 오프셋 좌표로 ScrollView를 초기화하도록 만든 것이다. 이제 currentIndex를 통해 ScrollView의 아이템도 마음대로 제어할 수 있게 되었다.

UI로 구현

const onControllerPress = (type: 'prev' | 'next') => {
    let nowIndex = currentIndex;
    if (type === 'prev') {
      nowIndex = currentIndex === 0 ? currentIndex : currentIndex - 1;
    } else if (type === 'next') {
      nowIndex =
        currentIndex === data.length - 1 ? currentIndex : currentIndex + 1;
    }
    setCurrentIndex(nowIndex);
  };

---------------------------------------------------------
<DotContainer>
    {data.map((_, index) => {
      const isFoused = currentIndex === index;
      return <Dot key={index} isFocused={isFoused} />;
    })}
</DotContainer>
<ChevronBtn
  onPress={() => {
    onControllerPress('prev');
  }}>
  <Image
    style={{width: 20, height: 20}}
    source={require('../back.png')}
    />
</ChevronBtn>
<ChevronBtn
  onPress={() => {
    onControllerPress('next');
  }}
  style={{right: 0}}>
  <Image
    style={{width: 20, height: 20}}
    source={require('../forward.png')}
    />
</ChevronBtn>

indicator를 나타내는 DotContainer 컴포넌트와 Dot컴포넌트, controller를 나타내는 ChevronBtn 컴포넌트들이다.(스타일 코드는 하단에 첨부할 예정) 원리를 설명하자면 DotContainer 안쪽에 현재 data의 개수만큼 Dot 컴포넌트들을 배치해놓고, 각 Dot의 index와 currentIndex가 같다면 현재 포커싱 된 상태라고 판단, 스타일에 변화를 주는 코드이다.
ChevronBtn은 controller 역할을 하는 컴포넌트로, 터치할 경우 onControllerPress 함수가 실행된다. 해당 함수는 type이 'prev'인지 'next'인지 판단해서 다음/이전 index를 구한 뒤, currentIndex 값을 변경해준다. 아까 ScrollView에 contentOffset을 currentIndex 값을 참조하도록 설정해주었기 때문에 변화된 currentIndex를 따라 ScrollView에 보여지는 아이템이 변경되므로, 외부 버튼으로 캐러셀 아이템을 컨트롤할 수 있게 되었다.

잘 작동한다.


import styled from '@emotion/native';
import React, {useState} from 'react';
import {Image, ScrollView} from 'react-native';

const Row = styled.View`
  flex-direction: row;
`;
const CarouselContainer = styled.View`
  flex: 1;
`;
const CarouselItemContainer = styled.View`
  width: ${(props: {width: number}) => props.width}px;
  height: 100%;
  padding: 20px;
`;
const CarouselItem = styled.View`
  flex: 1;
  background-color: ${(props: {color: string}) => props.color};
`;
const DotContainer = styled.View`
  width: 100%;
  height: 50px;
  justify-content: center;
  align-items: center;
  flex-direction: row;
`;
const Dot = styled.View`
  width: 7px;
  height: 7px;
  margin: 0px 5px;
  border-width: 1px;
  border-color: black;
  background-color: ${(props: {isFocused: boolean}) =>
    props.isFocused ? 'black' : 'white'};
  border-radius: 10px;
`;
const ChevronBtn = styled.TouchableOpacity`
  /* z-index: 1; */
  position: absolute;
  justify-content: center;
  height: 80%;
`;

const ScrollViewCarousel = () => {
  const data = ['tomato', 'skyblue', 'green', 'beige', 'yellow'];
  const [itemWidth, setItemWidth] = useState(0);
  const [currentIndex, setCurrentIndex] = useState(0);

  const onScrollEnd = (e: {nativeEvent: {contentOffset: {x: number}}}) => {
    const xOffset = e.nativeEvent.contentOffset.x;
    const index = xOffset / itemWidth;
    setCurrentIndex(index);
  };

  const indexToOffset = () => {
    return {x: currentIndex * itemWidth, y: 0};
  };

  const onControllerPress = (type: 'prev' | 'next') => {
    let nowIndex = currentIndex;
    if (type === 'prev') {
      nowIndex = currentIndex === 0 ? currentIndex : currentIndex - 1;
    } else if (type === 'next') {
      nowIndex =
        currentIndex === data.length - 1 ? currentIndex : currentIndex + 1;
    }
    setCurrentIndex(nowIndex);
  };

  return (
    <CarouselContainer style={{flex: 1}}>
      <ScrollView
        style={{flex: 1}}
        onMomentumScrollEnd={onScrollEnd}
        horizontal
        pagingEnabled
        contentContainerStyle={{width: `${100 * data.length}%`}}
        scrollEventThrottle={200}
        decelerationRate="fast"
        onContentSizeChange={w => setItemWidth(w / data.length)}
        contentOffset={indexToOffset()}
        showsHorizontalScrollIndicator={false}>
        <Row>
          {data.map((item: string) => {
            return (
              <CarouselItemContainer key={item} width={itemWidth}>
                <CarouselItem color={item} />
              </CarouselItemContainer>
            );
          })}
        </Row>
      </ScrollView>
      <DotContainer>
        {data.map((_, index) => {
          const isFoused = currentIndex === index;
          return <Dot key={index} isFocused={isFoused} />;
        })}
      </DotContainer>
      <ChevronBtn
        onPress={() => {
          onControllerPress('prev');
        }}>
        <Image
          style={{width: 20, height: 20}}
          source={require('../back.png')}
        />
      </ChevronBtn>
      <ChevronBtn
        onPress={() => {
          onControllerPress('next');
        }}
        style={{right: 0}}>
        <Image
          style={{width: 20, height: 20}}
          source={require('../forward.png')}
        />
      </ChevronBtn>
    </CarouselContainer>
  );
};

export default ScrollViewCarousel;
profile
React-Native 개발블로그
post-custom-banner

0개의 댓글