고윤태의 개발 블로그

누구나 접근하기 쉽지만 얻어 가는 것이 있는 글을 쓰기 위해 노력 중입니다.

개발

React Native Picker

고윤태 2023. 9. 19. 16:08
안녕하세요 이번에 작성하게 될 내용은 제가 react-native에서 Picker(가명)을 어떤 계기로 구현하게 되었고 어떤 식으로 구현했는지에 대해 전달을 하고자 작성해 봅니다

💻 개발 환경

React-native(expo) + TypeScript로 진행되었습니다.

🧐 구현하게 된 계기

진행 중인 토이 프로젝트에서 이러한 UI 디자인이 존재했습니다.

IOS 유저 분들이시라면 어? 어디서 봤던 기억이 있는데 하고 생각나실 수 있는 UI입니다.

실제로 IOS에서 UiPickerView 이렇게 제공하고 있기 때문입니다.

React Native에서 저 Picker를 간단하게 구현을 한다면

https://github.com/react-native-picker/picker

 

GitHub - react-native-picker/picker: Picker is a cross-platform UI component for selecting an item from a list of options.

Picker is a cross-platform UI component for selecting an item from a list of options. - GitHub - react-native-picker/picker: Picker is a cross-platform UI component for selecting an item from a lis...

github.com

해당 라이브러리를 사용하시는 것도 좋은 방법입니다만.

라이브러리의 예제를 봤을 때

 

출처 (https://github.com/react-native-picker/picker)

각각 환경에서 다른 모습으로 사용자에게 보이고 있다는 것을 알 수 있었습니다.

제가 원하는 것은 모든 사용자에게 동일한 경험을 제공하는 것이기에 디자이너분께 제 의견을 전달드려 직접 구현을 진행하기로 하여

직접 구현하게 되었습니다.

🤔 기능 산출 및 개발 계획

제가 구현을 해야 하는 것은 "시간 선택"입니다.

위의 사진을 보시면 AM, PM | 시 | 분을 선택할 수 있어야 합니다. 즉 총 3개 각각의 스크롤이 가능하도록 구현이 되어야 합니다.

그 3개는 선택하는 항목만 다를 뿐 제공하는 기능은 같습니다.

일련의 값을 보여주고 사용자가 그중 하나를 선택하도록 하는 인터랙티브 한 리스트 UI를 제공합니다.

제가 생각한 구조는 아래 사진과 같습니다.

 

WheelPicker라는 리스트 UI를 제공하는 컴포넌트를 만든 후 WheelPicker 3개를 합쳐서 TimePicker를 만드는 것입니다.

하나 고민이 됐던 부분은 사진을 보시면 선택된 것을 사용자에게 직관성을 주기 위해 가운데에 하이라이팅 처리가 된 것을 WheelPicker에서 구현할지 TimePicker에서 구현할지 고민해 봤지만

당연히 margin을 사용하는 경우도 존재할 것이고 시와 분을 구분 지어주는 : 을 표기하기 위해선 TimePicker에서 표시해야 한다라고 생각을 확정 지었습니다.

👀 컴포넌트 개발(CODE)

WheelPicker

import React, { useEffect, useRef, useState } from 'react';
import {
  Animated,
  ListRenderItemInfo,
  NativeScrollEvent,
  NativeSyntheticEvent,
  Text,
  View,
  ViewStyle,
} from 'react-native';
import Color from '../../constants/color';
import TYPOS from './typo';

interface Props {
  items: string[];
  onItemChange: (item: string) => void;
  itemHeight: number;
  initValue?: string;
  containerStyle?: ViewStyle;
}

const WheelPicker: React.FC<Props> = (props) => {
  const { items, onItemChange, itemHeight, initValue } = props;
  const scrollY = useRef(new Animated.Value(0)).current;
  const initValueIndex = initValue ? items.indexOf(initValue) : -1;
  const [selectedIndex, setSelectedIndex] = useState(
    initValueIndex >= 0 ? items[initValueIndex] : items[0]
  );

  const renderItem = ({ item, index }: ListRenderItemInfo<string>) => {
    const inputRange = [
      (index - 2) * itemHeight,
      (index - 1) * itemHeight,
      index * itemHeight,
    ];
    const scale = scrollY.interpolate({
      inputRange,
      outputRange: [0.8, 1, 0.8],
    });

    return (
      <Animated.View
        style={[
          {
            height: itemHeight,
            transform: [{ scale }],
            alignItems: 'center',
            justifyContent: 'center',
          },
        ]}
      >
        <Text
          style={[
            TYPOS.headline4,
            {
              color: selectedIndex === item ? Color.neutral1 : Color.neutral2,
            },
          ]}
        >
          {item}
        </Text>
      </Animated.View>
    );
  };

  const modifiedItems = ['', ...items, ''];

  const momentumScrollEnd = (
    event: NativeSyntheticEvent<NativeScrollEvent>
  ) => {
    const y = event.nativeEvent.contentOffset.y;
    const index = Math.round(y / itemHeight);
    setSelectedIndex(items[index]);
  };

  useEffect(() => {
    onItemChange(selectedIndex);
  }, [selectedIndex]);

  return (
    <View style={[{ height: itemHeight * 3 }, props.containerStyle]}>
      <Animated.FlatList
        data={modifiedItems}
        renderItem={renderItem}
        showsVerticalScrollIndicator={false}
        snapToInterval={itemHeight}
        onMomentumScrollEnd={momentumScrollEnd}
        scrollEventThrottle={16}
        onScroll={Animated.event(
          [{ nativeEvent: { contentOffset: { y: scrollY } } }],
          { useNativeDriver: true }
        )}
        getItemLayout={(_, index) => ({
          length: itemHeight,
          offset: itemHeight * index,
          index,
        })}
        initialScrollIndex={initValueIndex}
      />
    </View>
  );
};

export default WheelPicker;

코드 설명

props

 

items 스크롤 가능한 리스트에 표시될 문자열 배열입니다.
onItemChange 선택된 아이템이 변경될 때 호출되는 콜백 함수입니다.
itemHeight 각 아이템의 높이를 지정합니다.
initValue 초기 선택값을 설정합니다. 이 값은 items 배열에 존재해야 합니다.
containerStyle 외부에서 추가로 스타일링할 수 있도록 하기 위한 prop입니다.

 

renderItem 함수에서 각 항목에 애니메이션을 적용합니다. 중앙에 위치한 항목은 다른 항목보다 크게 보이며(scale=1), 그 외의 항목들은 작게(scale=0.8) 보이도록 했습니다.

스크롤 애니메이션은 Animated.event를 통해 처리하며, 스크롤이 멈출 때(onMomentumScrollEnd) 현재 선택된 아이템을 업데이트합니다.


TimePicker

import { View, Text } from 'react-native';
import Color from '../../constants/color';
import WheelPicker from './WheelPicker';
import { useRef } from 'react';
import TYPOS from './typo';

interface Time {
  ampm: string;
  hour: string;
  minute: string;
}

interface Props {
  onTimeChange: (time: Time) => void;
  itemHeight: number;
  initValue?: Time;
}

const TimePicker = ({ onTimeChange, itemHeight, initValue }: Props) => {
  const ampmItems = ['AM', 'PM'];
  const hourItems = Array.from({ length: 13 }, (_, i) =>
    i.toString().padStart(2, '0')
  );
  const minuteItems = Array.from({ length: 60 }, (_, i) =>
    i.toString().padStart(2, '0')
  );
  const { ampm, hour, minute } = initValue || {};

  const selectedAMPM = useRef('');
  const selectedHour = useRef('');
  const selectedMinute = useRef('');

  const handleIndexChange = (category: string, item: string) => {
    switch (category) {
      case 'ampm':
        selectedAMPM.current = item;
        break;
      case 'hour':
        selectedHour.current = item;
        break;
      case 'minute':
        selectedMinute.current = item;
        break;
      default:
        throw new Error('Invalid time category');
    }

    onTimeChange({
      ampm: selectedAMPM.current,
      hour: selectedHour.current,
      minute: selectedMinute.current,
    });
  };

  return (
    <View
      style={{
        flexDirection: 'row',
        height: itemHeight * 3,
        justifyContent: 'center',
      }}
    >
      <WheelPicker
        items={ampmItems}
        onItemChange={(item) => handleIndexChange('ampm', item)}
        itemHeight={itemHeight}
        initValue={ampm}
        containerStyle={{ marginRight: 70 }}
      />
      <WheelPicker
        items={hourItems}
        onItemChange={(item) => handleIndexChange('hour', item)}
        itemHeight={itemHeight}
        initValue={hour}
        containerStyle={{ paddingHorizontal: 16 }}
      />
      <View
        style={{
          height: itemHeight * 3,
          alignItems: 'center',
          justifyContent: 'center',
        }}
      >
        <Text style={[TYPOS.headline4, { color: Color.black }]}>:</Text>
      </View>
      <WheelPicker
        items={minuteItems}
        onItemChange={(item) => handleIndexChange('minute', item)}
        itemHeight={itemHeight}
        initValue={minute}
        containerStyle={{ paddingHorizontal: 16 }}
      />
      <View
        style={{
          position: 'absolute',
          height: itemHeight,
          top: itemHeight,
          backgroundColor: Color.neutral5,
          left: 0,
          right: 0,
          zIndex: -1,
        }}
      ></View>
    </View>
  );
};

export default TimePicker;

코드 설명

위에 작성한 'WheelPicker' 컴포넌트를 기반으로 시간(시, 분)과 AM/PM을 선택하는 UI 요소를 제공합니다.

props

 

onTimeChange 선택된 시간이 변경될 때 호출되는 콜백 함수입니다.
itemHeight 각 아이템의 높이를 지정합니다.
initValue 초기 선택값을 설정합니다. 이 값은 {ampm, hour, minute} 형태의 객체여야 합니다.

 

handleIndexChange 함수에서 우리는 각 카테고리(ampm/hour/minute)에 따라 선택된 값을 업데이트하고 외부로 전달합니다.

 완성된 WheelPicker, TimePicker

WheelPicker

WheelPicker 예제 코드

import React from 'react';
import { View } from 'react-native';
import WheelPicker from '../components/ui/WheelPicker';

const Screen = () => {
  return (
    <View style={{ flex: 1 }}>
      <View style={{ height: 36 * 3 }}>
        <WheelPicker
          itemHeight={36}
          items={['바나나', '사과', '포도', '딸기', '오렌지']}
          onItemChange={(item) => {
            console.log('onItemChange');
            console.log(item);
          }}
        />
      </View>
    </View>
  );
};

export default Screen;

TimePicker

TimePicker 예제 코드

import React from 'react';
import { View } from 'react-native';
import TimePicker from '../components/ui/TimePicker';

const Screen = () => {
  return (
    <View style={{ flex: 1 }}>
      <TimePicker
        itemHeight={36}
        onTimeChange={(time) => {
          console.log(time);
        }}
      />
    </View>
  );
};

export default Screen;

😁 글 작성 후기

제가 최근에 만든 UI 컴포넌트 중 첫 시작이 가장 막막한 컴포넌트였습니다. 예제를 어떤 식으로 검색해야 할지 어떤 식으로 만들어야 할지 감이 하나도 안 잡혔었거든요

구글에 검색을 해봐도 제가 원하는 결과를 딱히 얻을 수 있진 않은 상황이었습니다. 

그러던 도중 제가 우연히 "react native ios picker for android"라고 검색을 하게 되었고 그 덕에 좋은 블로그 글을 발견하게 되었습니다.

https://medium.com/@gogulbharathisubbaraj/implementing-ios-style-picker-in-react-native-part-1-4e938e218b92

 

Implementing iOS style picker in React Native — Part 1

A tutorial on implementing an iOS style picker, purely in react native using FlatList.

medium.com

딱 제가 원했던 글이었습니다. 해당 글을 읽고 막혔던 상황이 풀리는 기분이었고 신난 마음에 제 상황에 맞춰 커스텀하여 코드를 작성하게 되었습니다.

 

제 글도 저런 심정을 가지시고 찾아오시게 된 분에게 제가 저 글을 발견했을 때의 기분을 주게 된다면 감사할 것 같습니다.

읽어주셔서 감사합니다.