안녕하세요 이번에 작성하게 될 내용은 제가 react-native에서 Picker(가명)을 어떤 계기로 구현하게 되었고 어떤 식으로 구현했는지에 대해 전달을 하고자 작성해 봅니다
💻 개발 환경
React-native(expo) + TypeScript로 진행되었습니다.
🧐 구현하게 된 계기
진행 중인 토이 프로젝트에서 이러한 UI 디자인이 존재했습니다.
IOS 유저 분들이시라면 어? 어디서 봤던 기억이 있는데 하고 생각나실 수 있는 UI입니다.
실제로 IOS에서 UiPickerView 이렇게 제공하고 있기 때문입니다.
React Native에서 저 Picker를 간단하게 구현을 한다면
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"라고 검색을 하게 되었고 그 덕에 좋은 블로그 글을 발견하게 되었습니다.
딱 제가 원했던 글이었습니다. 해당 글을 읽고 막혔던 상황이 풀리는 기분이었고 신난 마음에 제 상황에 맞춰 커스텀하여 코드를 작성하게 되었습니다.
제 글도 저런 심정을 가지시고 찾아오시게 된 분에게 제가 저 글을 발견했을 때의 기분을 주게 된다면 감사할 것 같습니다.
읽어주셔서 감사합니다.
'개발' 카테고리의 다른 글
채팅방 목록에 새로운 기능을 추가해보자 (1) | 2023.10.19 |
---|---|
React Native 채팅방 만들기 (2) | 2023.10.12 |
useAsyncWithLoading custom hook (0) | 2023.09.12 |
React Native RadioButton 구현하기 (2) | 2023.09.01 |
React Native useOverlay를 만들어 리팩토링 하기 이 글은 진짜 떠야 해 (0) | 2023.08.22 |