My Boundary As Much As I Experienced

RN) 좌우 스와이핑이 되는 탭바 구현하기 (useAnimatedReaction, useAnimatedStyle) 본문

FrontEnd/React Native

RN) 좌우 스와이핑이 되는 탭바 구현하기 (useAnimatedReaction, useAnimatedStyle)

Bumang 2024. 11. 4. 17:12

만들게 된 배경

앤트타임 내 스킨 종류가 4가지에서 6가지가 되면서

equipment페이지의 탭바가 너무 협소해지게 되었고, 고정영역으로 있기 힘들어졌다.

그래서 좌우스와이핑이 되는 탭바를 만들게 되었다.

 

내가 기본적으로 사용하던 탭바 컴포넌트는 이것이다.

react-native-collapsible-tab-view

(https://github.com/PedroBern/react-native-collapsible-tab-view)

줄여서 RNCTV라고 하겠다.

 

보통의 경우에는 react-native-tab-view(https://reactnavigation.org/docs/tab-view/)만 써도 가능한데,

앤트타임엔 상단 영역이 collapsible 될 수 있는 구조의 view들이 많다보니 RNCTV를 메인으로 쓰게되었다.

react-native-collapsible-tab-view와 react-native-tab-view는 props구성에 조금 차이가 있지만,

큰 범주에는 차이 없으니 내꺼 봐도 참고가 될 것이다. RNTV의 구조도 도긴개긴일테니 비슷한 값들 찾아서 하셈.

 

1. TabView 세팅 후 renderTabbar에 커스텀 탭바 주입

(탭뷰를 구성하는 방법은 생략하겠다.)

탭뷰를 구성할 때 보통 기본적으로 renderTabBar로 materialTabBar를 사용할텐데

이것을 좌우 스와이프가 가능한 커스텀 탭바를 만들어서 교체하면 끝이다.

        <SafeAreaView hasNavigationHeader excludeBottom style={styles.conatiner}>
            <Tabs.Container
                ref={tabRef}
                renderHeader={renderHeader}
                headerContainerStyle={styles.headerContainer}
                renderTabBar={RenderTabBar} // 렌더 탭바에 커스텀 탭바를 제공
                cancelTranslation
            >
                <Tabs.Tab name={SkinType.PICKAXE}>
                    <EquipList // FlatList를 품은 컴포넌트
                        category={SkinType.PICKAXE}
                        inventory={data.pickaxe.inventory}
                        data={data.pickaxe.list}
                        contentContainerStyle={{ paddingBottom: insets.bottom }}
                        onPress={handlePressItem}
                        equiped={data.pickaxe.equip}
                    />
                </Tabs.Tab>
                <Tabs.Tab name={SkinType.MINERAL}>
                    <EquipList
                    //...
                    >

 

내가 만든 커스텀 렌더 탭바의 JSX리턴부 형태는 아래와 같다.

가로 ScrollView로 만들기 위해 ScrollView에 horizontal 속성만 넣으면 됐다. 매우 단순하다.

showHorizontalScrollIndicator는 말 그대로 스크롤할 시 스크롤바가 보이느냐 여부인데 false로 주었다.

contentContainerStyle은 그냥 style속성을 제공해주면 된다.

그리고 전개연산자로{...props} TabBarProps가 가지고 있는 속성들을 모두 제공해주었다.

const RenderTabBar = (props: TabBarProps<string>) => { // 라이브러리 속성
    // ...
    return (
        <ScrollView
            {...props}
            horizontal // 이게 핵심
            showsHorizontalScrollIndicator={false}
            contentContainerStyle={styles.tabBarContainer}
        >
        // ...
        </ScrollView>
    );
};

 

그리고 이제 스크롤뷰 안에 넣어줄 탭전환 버튼들을 생성해야한다.

탭전환 버튼은 어떤 기능이 있는가?

1. 탭의 이름이 표기되어 있다.

2. 누르면 그 탭으로 전환된다.

이것만 충족하면 된다.

 

이걸 구현하기 위해 RenderTabBar가 기본 제공받는 props들을 뜯어봤다.

RenderTabBar는 props로 TabBarProps<String>를 받는다. 

탭바 컴포넌트를 정의한 타입이니 분명 각 tab의 속성이나 이름들을 가지고 있겠지?

 

그리고 실제 TabBarProps를 뜯어보니 제네릭 T를 받는데 TabName을 상속하는걸 알 수 있다.

그리고 tabNames 속성으로 TabName들의 배열을 받는거 같다.

onTabPress가 각각의 탭이 클릭됐을 때 작동할 handler인거 같다.

그리고 아마 tabProps를 까보면 각각의 tab에 대한 상세한 정보가 나올거 같은데

export declare type RenderTabBar: (props: TabBarProps<string>) => React.JSX.Element

export declare type TabBarProps<T extends TabName = TabName> = {
    indexDecimal: Animated.SharedValue<number>;
    focusedTab: Animated.SharedValue<T>;
    tabNames: T[]; // 탭바의 이름들의 배열
    index: Animated.SharedValue<number>;
    containerRef: React.RefObject<ContainerRef>;
    onTabPress: (name: T) => void; // 탭 클릭
    tabProps: TabsWithProps<T>;
    /**
     * Custom width of the tabbar. Defaults to the window width.
     */
    width?: number;
};

 

tab에서 내용물'children'을 빼고, index만 가진 형태였기에,

export declare type TabsWithProps<T extends TabName = TabName> = Map<T, Omit<TabProps<T>, 'children'> & {
    index: number;
}>;

 

그냥 tabNamesonTabPress만 있으면 되겠다 싶었다.

그래서 아래와 같이 클릭 시 onTabPress를 자기 tabNames로 바꿔주는 Button을 만들었다.

const RenderTabBar = (props: TabBarProps<string>) => {

    return (
        <ScrollView
            {...props}
            horizontal
            showsHorizontalScrollIndicator={false}
            contentContainerStyle={styles.tabBarContainer}
        >
            {props.tabNames.map((route, tabIndex) => { // tabNames로 map순회하며
                return (
                    <Button //
                        name={route}
                        key={route}
                        onPress={() => props.onTabPress(route)} // onTabPress에 tabName을 패러미터로 제공
                        style={[styles.tabItem]}
                        onLayout={(event) => handleTabLayout(event, tabIndex)}
                    >
                        <Text
                            style={[
                                styles.tabLabel,
                                textStyles.bold
                            ]}
                        >
                            {route} // tabName임
                        </Text>
                    </Button>
                );
            })}
        </ScrollView>
    );
};

 

 

이렇게 하면 반절은 한거다.

그런데 여기까지만 하면 focused된 탭을 강조하지 못한다.

 

 

그래서 onTabPress를 할 때마다 focusedTab이 바뀔테니,

tabBarProps에 있는 focusedTab: Animated.SharedValue<T>을 가져오면 되겠다 싶었다.

focusedTab은 포커스된 탭의 인덱스를 가져오는게 아니라 이름을 가져오는 것이므로,

StyledSheet에 `props.focusedTab === route ? styles.focused : styles.default`을 제공해주면 되겠지?

라고 생각했는데 전혀 바뀌지 않았다.

 

알고보니 focusedTab은 Animated의 SharedValue라는 값으로, 단순 string이 아니었다.

 

sharedValue는 값이 변해도 컴포넌트가 재렌더가 일어나지 않고, 불변성을 유지하는 값이다.

useRef랑 비슷하다고? 맞다. 거의 useRef와 개념이 비슷하다.

RN의 animation용으로 개량된 ref라고 생각하면 된다.

부드러운 애니메이션이 재생될때마다 재렌더가 일어나서 재앙적인 프레임드랍을 내지 않기 위해 만들어졌다..

 

하여튼 sharedValue는 자동적으로 재렌더를 일으키지 않기 때문에! 재렌더를 원하는 타이밍에 시켜줘야 했다.

그래서 state로 만들었다.

    const [focusedTabName, setFocusedTabName] = useState(props.focusedTab.value); // .value안에 실제 string 값이 있음

 

이 focusedTabName으로 스타일 분기를 만들고,

                        <Text
                            style={[
                                focusedTabName === route ? styles.focused : styles.default,
                                styles.tabLabel,
                                textStyles.bold
                            ]}
                        >
                            {route} // tabName임
                        </Text>

 

useAnimatedReaction이라는 react-native-reanimated의 훅으로 타이밍을 조작했다. (useEffect대용)

useAnimatedReaction의 첫 번째 패러미터는 값의 변화를 탐지한다.
두 번째 패러미터는 첫 번째 패러미터의 값의 변화가 일어날 시 실행된다. (주로 애니메이션 로직을 담당)
 

그리고 여기서 runOnJS란?

react-native-reanimated는 성능을 위해 애니메이션 로직을 네이티브 스레드에서 실행시킨다.

그래서 기본적으로 자바스크립트 thread에서 상태 업데이트를 발생시킬 수가 없다..

그래서 애니메이션 끝에 state 변화를 이끌어내야하는 것은 runOnJS를 사용하여 자바스크립트 스레드로 작업을 넘겨야 한다.

    // focusedTab.value가 변경될 때마다 React 상태 업데이트
    useAnimatedReaction(
        () => props.focusedTab.value,
        (value) => {
            runOnJS(setFocusedTabName)(value); // UI 스레드에서 React 상태로 업데이트
        }
    );

 

그러면 아래와 같이 focused Tab 스타일도 구현도 완료되었다.

 

그런데 허전하지 않나?

원래 포커스된 탭의 아래엔 보더 같은 것이 있는 법이다.

 

 

이걸 구현하기 위해 각각의 탭의 width를 담을 tabWidths를 생성하고

useSharedValue훅으로(sharedValue 개념은 위에서 설명함) 초기값 20짜리 value를 만들었다. (좌측 패딩 20을 반영)

    const [tabWidths, setTabWidths] = useState<number[]>([]);
    const translateX = useSharedValue(20); // bottomLine의 위치

 

그리고 컴포넌트가 마운트될 때 tabWidths에 각각의 width들을 넣을 handleTabLayout이란 함수를 작성했다.

불변성을 유지하기 위해 새로운 배열을 생성해서 각 탭의 인덱스에 맞는 width를 넣어준다.

    // 탭의 너비를 저장하는 함수
    const handleTabLayout = (event: LayoutChangeEvent, index: number) => {
        const { width } = event.nativeEvent.layout;
        setTabWidths((prevWidths) => {
            const newWidths = [...prevWidths];
            newWidths[index] = width;
            return newWidths;
        });
    };

 

이 handleTabLayout을 아까 탭 iterataion하는 코드의 onLayout이벤트로 넣어준다.

            {props.tabNames.map((route, tabIndex) => {
                return (
                    <Button //
                        name={route}
                        key={route}
                        onPress={() => props.onTabPress(route)}
                        style={[styles.tabItem]}
                        onLayout={(event) => handleTabLayout(event, tabIndex)} // 각 탭의 width 감지
                    >
                        <Text
                            style={[
                                focusedTabName === route ? styles.focused : styles.default,
                                styles.tabLabel,
                                textStyles.bold
                            ]}
                        >
                            {route}
                        </Text>
                    </Button>
                );
            })}

 

그러면 막 [100, 80, 76, 96, ...] 이런식으로 저장되겠지.

그리고 탭의 아래에 absolute로 되어있는 Animated.View 컴포넌트(애니메이션용 보더임)을 생성해주고

            // ...
            {/* bottomLine을 Animated.View로 구현 */}
            <Animated.View style={[styles.bottomLine]} />
        </ScrollView>
    );
};

 

 

animatedBottomLineStyle을 사용하여 width와 translateX가 가변하는 애니메이션 스타일을 생성한다.

각각의 width를 모아놓은 배열이 [100, 80, 76, 96, ...]와 같다고 하면,

첫 번째 탭이 포커스되면 x축의 시작점은 0,

두 번째 탭이 포커스되면 '첫 번째 탭의 끝점'인 100,

세 번째 탭이 포커스되면 '두 번째 탭의 끝점'인 100 + 80,

이런 식일 것이다.

 

그렇다면 5번째 탭이 포커스되면 4번째까지의 width를 다 더하면 된다는 말 아닌가?

그래서 나는 slice로 포커스된 탭의 전까지를 추출해서, reduce로 합계를 구했다.

    // 포커스된 탭이 변경될 때 애니메이션으로 translateX 업데이트
    const animatedBottomLineStyle = useAnimatedStyle(() => {
        translateX.value = withTiming(
            // 내가 혼자 구현했는데 AI와 같은 알고리즘으로 짬ㅎ.
            tabWidths.slice(0, props.index.value).reduce((a, b) => a + b, 0),
            { duration: 100 }
        );
        return { // 객체를 반환
            transform: [{ translateX: translateX.value + 20 }],
            width: tabWidths[props.index.value] || 0
        };
    });

 

그리고 animatedBottomLineStyle을 하단 보더에 제공하면 끝!

            // ...
            {/* bottomLine을 Animated.View로 구현 */}
            <Animated.View style={[styles.bottomLine, animatedBottomLineStyle]} />
        </ScrollView>
    );
};

 

 

 

그런데 디자이너분한테 물어보니 tab들이 고정 width면 좋겠다고 해서

가장 width가 넓은 단어인 pickaxe에 맞춰서 77로 줬다.

아래가 그 결과이다.

 

 

 

전체 코드

import Text, { textStyles } from 'components/common/text';
import { LayoutChangeEvent, Platform, StyleSheet } from 'react-native';
import { TabBarProps } from 'react-native-collapsible-tab-view';
import { ScrollView } from 'react-native-gesture-handler';
import { theme } from 'theme';
import Button from 'components/common/button';
import { useState } from 'react';
import Animated, {
    runOnJS,
    useAnimatedReaction,
    useAnimatedStyle,
    useSharedValue,
    withTiming
} from 'react-native-reanimated';

const RenderTabBar = (props: TabBarProps<string>) => {
    const [focusedTabName, setFocusedTabName] = useState(props.focusedTab.value);

    // focusedTab.value가 변경될 때마다 React 상태 업데이트
    // 첫 번째 패러미터는 값의 변화를 탐지한다.
    // 두 번째 패러미터는 첫 번째 패러미터의 값의 변화가 일어날 시 실행된다. 주로 애니메이션을 담당.
    // runOnJS란? useAnimatedReaction 내부에서 직접 React의 상태를 업데이트하면 오류가 발생할 수 있습니다.
    // 애니메이션 로직은 네이티브 스레드에서 실행되기 때문에, 자바스크립트 상태 업데이트를 위해 runOnJS를 사용하여 자바스크립트 스레드로 작업을 넘깁니다.
    useAnimatedReaction(
        () => props.focusedTab.value,
        (value) => {
            runOnJS(setFocusedTabName)(value); // UI 스레드에서 React 상태로 업데이트
        }
    );

    const [tabWidths, setTabWidths] = useState<number[]>([]);
    const translateX = useSharedValue(20); // bottomLine의 위치

    // 포커스된 탭이 변경될 때 애니메이션으로 translateX 업데이트
    const animatedBottomLineStyle = useAnimatedStyle(() => {
        translateX.value = withTiming(
            // 내가 혼자 구현했는데 AI와 같은 알고리즘으로 짬ㅎ.
            tabWidths.slice(0, props.index.value).reduce((a, b) => a + b, 0),
            { duration: 100 }
        );
        return {
            transform: [{ translateX: translateX.value + 20 }], // 객체를 반환
            width: tabWidths[props.index.value] || 0
        };
    });

    // 탭의 너비를 저장하는 함수
    const handleTabLayout = (event: LayoutChangeEvent, index: number) => {
        const { width } = event.nativeEvent.layout;
        setTabWidths((prevWidths) => {
            const newWidths = [...prevWidths];
            console.log(width, 'this Tab Width is...');
            newWidths[index] = width;
            return newWidths;
        });
    };

    return (
        <ScrollView
            {...props}
            horizontal
            showsHorizontalScrollIndicator={false}
            contentContainerStyle={styles.tabBarContainer}
        >
            {props.tabNames.map((route, tabIndex) => {
                return (
                    <Button //
                        name={route}
                        key={route}
                        onPress={() => props.onTabPress(route)}
                        style={[styles.tabItem]}
                        onLayout={(event) => handleTabLayout(event, tabIndex)} // 각 탭의 width 감지
                    >
                        <Text
                            style={[
                                focusedTabName === route ? styles.focused : styles.default,
                                styles.tabLabel,
                                textStyles.bold
                            ]}
                        >
                            {route}
                        </Text>
                    </Button>
                );
            })}
            {/* bottomLine을 Animated.View로 구현 */}
            <Animated.View style={[styles.bottomLine, animatedBottomLineStyle]} />
        </ScrollView>
    );
};

export default RenderTabBar;

...

 

 

react-native-reanimated의 여러 훅을 실제 사용해보고

의도한 바를 정확히 구현해내 매우 기분 좋은 작업이었다.

useSharedValue, useAnimatedReaction, useAnimatedStyle들의 사용에 감을 익혀야겠다.