Bitfolio앱을 개발할 때 구현했던 UI의 일부입니다.
해당 포스트에서는 react navigation의 material-top-tabs를 interection을 포함하여 customizing 하는 방법을 공유합니다.
서론 (일기장)
animation을 구현할 때 reanimated 라이브러리에 의존하시는 분이 계실 수 있으니 제가 겪었던 경험을 바탕으로 짧게 참고 사항 먼저 소개하겠습니다. :)
@react-navigation/material-top-tabs는 react-native-tab-view에 의존하고 있습니다.
react-native-tab-view의 Release 목록을 보니 react-native-gesture-handler 와 react-native-reanimated 제거로 인해 이전 버전과 호환되지 않는다는 문장이 명시되어 있습니다. 따라서 custom tab bar에 interection을 추가할 때 Reanimated api는 호환되지 않으니 주의하시길 바라며.. :)
Reanimated를 이용한 Animation 로직을 제외한 나머지 API의 대부분은 material top tabs의 version 5.x와 달라지지 않았으므로, 본론에서는 v6를 바탕으로 구현하지만 v5도 정상 동작될 선에서 설명합니다.
전제 조건 or Dependencies
- react-native >= 0.63.0
- expo >= 41 (if you use Expo)
- typescript >= 4.1.0 (if you use TypeScript)
- @react-navigation/native >= 6.x
- @react-navigation/material-top-tabs > 6.x
- react-native-pager-view >= 5.x
- react-native-tab-view > 3.x
용어 정리
먼저 시작하기에 앞서 본론에서 사용될 몇 가지 용어를 정의하겠습니다.
그리고, 쉬운 설명을 위해 product의 일부 핵심만 추출하여 예제로 재작성하였습니다. 이것을 결과물로 하는 설명이니 참고해주세요.
layout 측정 로직 또는 animation 구현을 제외한 기본적인 Navigation 동작은 문서에 등록된 예제를 바탕으로 구현되었습니다. 따라서 measure layout과 interactions 위주로 작성합니다.
1. Tab Navigation 역할을 해줄 "탭 바(Tab Bar)"입니다. 각각의 탭 요소들은 "탭(Tab)"이라고 부르겠습니다.
2. 현재 위치를 나타내는 "Indicator"입니다. Animated를 이용하여 부드러운 움직임을 구현할 것입니다.
3. "탭 뷰(Tab View)"입니다. scrollable 콘텐츠가 될 수도 있고, 사진 또는 다양한 콘텐츠가 자유롭게 구성될 수 있습니다.
구현할 interactions 소개
각각의 탭 넓이가 글자 수에 따라 동적인 스크롤 가능한 탭 바를 구현 할 때 Indicator 또한 활성화 탭에 따라 넓이가 달라질 것입니다.
material top tab을 감싸고 있는 react-native-tab-view는 현재 위치를 나타내는 Animated node를 "position" props로 제공합니다.
이것을 이용하여 구현할 Interaction 목록입니다.
- 탭이 활성화/비활성화되는 위치에 가까워 짐에 따라 opacity를 보간합니다.
- 현재 위치를 나타낼 Indicator의 위치를 보간합니다.
- 2번과 동시에 Indicator의 scale을 이전 활성화 탭과 다음 활성화 탭의 크기에 따라 보간합니다.
- 활성화 탭의 위치가 디바이스 스크린의 정 중앙에 위치할 수 있도록 탭 바의 스크롤 위치를 조정합니다.
구현
UI와 Navigation 구성은 최대한 간단히 하였습니다. 예제와 함께 참고해주세요.
// App.tsx
<NavigationContainer>
<Tab.Navigator tabBar={props => <TabBar {...props} />}>
<Tab.Screen name='overview' component={TabView} />
<Tab.Screen name='profile' component={TabView} />
<Tab.Screen name='news' component={TabView} />
<Tab.Screen name='transactions' component={TabView} />
<Tab.Screen name='notice' component={TabView} />
<Tab.Screen name='discussion' component={TabView} />
</Tab.Navigator>
</NavigationContainer>
Tab.Navigator 컴포넌트의 tabBar props로 custom 한 TabBar를 전달할 수 있습니다.
interection을 구현하기 위해서는 각 탭의 left, width 값과 탭 바의 전체 넓이 값이 필요합니다.
const tabRefs = useRef<RefObject<TouchableOpacity>[]>(
Array.from({ length: state.routes.length }, () => createRef())
).current;
우선 각각의 탭 위치와 넓이를 측정하기 위해 엘리멘트 구성 요소를 참조할 때 사용하는 "ref"를 연결해 줄 것입니다.
route의 개수만큼 React.createRef()로 refs를 생성하여 tabRefs 상수에 할당해 주었습니다.
각 탭의 left, width 값을 구하기 위해 각 탭을 감싸고 있는 ScrollView를 기준으로 하여 측정할 것입니다.
그러기 위해서는 ScrollView의 참조가 필요하기 때문에 다음과 같이 ref를 만들어 주겠습니다.
const scrollViewRef = useRef<ScrollView>(null);
이제 탭 바의 전체 넓이 값을 구하기 위해 onLayout 이벤트에 전달할 event 함수를 만들어 주겠습니다.
const [tabBarSize, setTabBarSize] = useState(0);
const handleTabWrapperLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout;
setTabBarSize(width);
};
각 탭의 상위 요소인 ScrollView에 ref를 전달하고, 각각의 탭은 tabRefs의 요소를 props로 받아 연결해 주겠습니다. 그리고 탭 바의 전체 넓이 값을 얻을 수 있는 onLayout 이벤트를 전달합니다.
아래 대부분의 코드들은 문서에 등록된 예제 이므로 설명을 제외하고, 추가로 구현한 부분 위주입니다.
// TabBar.tsx
<View style={styles.container}>
<ScrollView
horizontal
ref={scrollViewRef} <-- ref속성에 전달
showsHorizontalScrollIndicator={false}
>
<View
onLayout={handleTabWrapperLayout} // <-- 전달
style={styles.tabWrapper}
>
{state.routes.map(({ key, name }, index) => {
const ref = tabRefs[index]; <-- 각각의 탭에 연결될 요소
// ...공식 문서의 예제와 일치하는 코드는 제외하였습니다.
const opacity = position.interpolate({
inputRange,
outputRange: inputRange.map(i => (i === index ? 1 : 0.6)),
});
return (
<Tab
key={key}
ref={ref}
label={label as string}
opacity={opacity}
isFocused={isFocused}
onPress={onPress}
/>
);
})}
</View>
// skip
</ScrollView>
</View>
현재 위치(0 ~ 탭의 개수)가 animated interpolate 타입 props position에 입력되기 때문에 이 position을 이용하여 현재 활성화 탭(i === index)이라면 opacity를 선명하게 해 주고, 반대인 경우 0.6으로 불투명도를 주었습니다.
// Tab.tsx
const Tab = forwardRef<TouchableOpacity, ITab>(
({ label, isFocused, opacity, onPress }, ref) => {
return (
<TouchableOpacity
ref={ref} <-- 연결
accessible
accessibilityRole="button"
accessibilityState={isFocused ? { selected: true } : {}}
accessibilityLabel={label}
activeOpacity={0.6}
style={styles.container}
onPress={onPress}
>
<Animated.Text
style={[styles.textStyle, { opacity }]} <-- opacity(animated interpolation) 할당
>
{label}
</Animated.Text>
</TouchableOpacity>
);
});
export default Tab;
Tab 컴포넌트에서 forwardRef로 전달받아 연결해 주었습니다.
이제 interections를 구현하기 위해 필요한 값들을 얻을 수 있게 되었습니다.
const [measures, setMeasures] = useState<ITabMeasure[] | null>(
null
);
useEffect(() => {
if (scrollViewRef.current) {
const temp: ITabMeasure[] = [];
tabRefs.forEach((ref, _, array) => {
ref.current?.measureLayout(
scrollViewRef.current as any,
(left, top, width, height) => {
temp.push({ left, top, width, height });
if (temp.length === array.length) {
setMeasures(temp);
}
},
() => console.log('fail')
);
});
}
}, [tabRefs]);
다음과 같이 measureLayout 메서드를 이용하여 각 탭을 감싸고 있는 scrollView를 기준으로 한 각 탭의 left, top, width, height 값을 측정할 수 있습니다.
따라서 각 탭의 측정값을 순서대로 measures state에 배열로 담아줍니다.
이제 Indicator에 포함된 interections를 구현해 봅시다.
const standardSize = useMemo(() => {
if (!tabBarSize) return 0;
return tabBarSize / state.routes.length;
}, [tabBarSize]);
const inputRange = useMemo(() => {
return state.routes.map((_, i) => i);
}, [state]);
const indicatorScale = useMemo(() => {
if (!measures || !standardSize) return 0;
return position.interpolate({
inputRange,
outputRange: measures.map(
measure => measure.width / standardSize
),
});
}, [inputRange, measures, standardSize]);
const translateX = useMemo(() => {
if (!measures || !standardSize) return 0;
return position.interpolate({
inputRange,
outputRange: measures.map(
measure =>
measure.left - (standardSize - measure.width) / 2
),
});
}, [inputRange, measures, standardSize]);
// Indicator
<Animated.View
style={[
styles.indicator,
{
width: standardSize,
transform: [
{
translateX,
},
{
scaleX: indicatorScale,
},
],
},
]}
/>;
자주 사용될 inputRange와 standardSize를 재사용할 수 있도록 useMemo를 감싼 상수로 빼두겠습니다.
이제 각 탭의 넓이에 맞춰 Indicator의 scale이 보간되어야 하는데, 각 탭의 측정값이나, 탭 바의 전체 넓이가 준비되지 않았음을 대비하여 예외처리해주었습니다.
각 탭의 넓이에는 기본 값으로 standardSize(탭 바의 넓이 / 탭의 개수)로 설정되어 있습니다.
- 각 탭의 넓이와 standardSize의 비율을 계산하여 Indicator의 scaleX 값을 보간합니다.
- 현재 위치를 각 탭의 left위치로 계산하여 translateX 값을 보간합니다.
이제 마지막으로 활성화 탭의 위치가 디바이스의 중앙에 위치할 수 있도록 ScrollView의 스크롤 위치를 조정하는 일만 남았습니다.
useEffect(() => {
if (scrollViewRef.current && measures) {
const { index } = state;
const screenCenterXPos = DWidth / 2 - measures[index].width / 2;
scrollViewRef.current.scrollTo({
x: measures[index].left - screenCenterXPos,
y: 0,
animated: true,
});
}
}, [state, measures]);
다음과 같이 정 중앙값으로 계산하여 scroll 시켜 주었습니다.
useEffect의 두 번째 인자로 의존 값 배열에 state가 속해 있으므로 활성화 탭이 전환될 때마다 재 실행될 것입니다.
마무리
react navigation v5를 사용하다가 v6로 업그레이드하는 과정에서 기존에 문제없이 동작하던 Material top tabs가 문제를 일으켰습니다. Upgrading from 5.x 가이드에 친절히 설명해 주어서 쉽게 해결할 수 있었네요.
기존에 react-native-reanimated의 animated api를 사용하여 top tabs가 구현되어 있었고, migration 하면서 하는 김에 포스트로 구현 경험을 남기게 되었습니다.
부족한 부분이 있다면 댓글로 지적해주세요.
'React Native' 카테고리의 다른 글
React Native Animated로 스크롤 시 Header Title을 Fade In, Fade Out 구현하기 (0) | 2022.01.13 |
---|