Frontend/React-Native

[React Native] Collapsible Tab View 만들기

w00se 2021. 9. 18. 01:23

https://pixabay.com/ko/photos/놀이터-회전-신장-매력-5188459/

Collapsible Tab View는 아래의 예시처럼 Tab Screen의 scroll gesture에 따라 Header가 위치 및 크기가 조절되는 view입니다.

 

collapsible tab view 예시

 

React Native에서 해당 기능을 아래 라이브러리로 구현 가능합니다.

- react-native-collapsible-tab-view

 

또한 라이브러리는 아니지만 JungHsuan 님께서 멋진 예시 코드를 공개해주셨습니다.

- https://github.com/JungHsuan/react-native-collapsible-tabview

 

이번 게시글에서는 Collapsible TabView를 구현하기 위해 위 두 가지 코드를 보면서 공부한 내용을 정리하려 합니다.

 

필요 라이브러리

- react-native-tab-view

 

진행 순서

step 0. View 생성

step 1. scroll에 따라 collapsible 되는 header 원리 파악하기

step 2. tab view의 header에 collapsible 적용 하기

step 3. tab screen 간 scroll sync 맞추기 

 

step 0.  View 생성

기능 구현을 위해 생성한 파일은 총 세 개입니다.

- CollapsibleTabViewTestScreen.js: collapsible tab view를 적용시킬 screen 파일

- CollapsibleHeader.js: collapsible tab view의 header 파일

- CollapsibleFlatList.js: scroll event를 적용할 FlatList가 있으며 tab view의 각 screen으로 사용될 파일

 

CollapsibleTabViewTestScreen.js

import React, { useState, useCallback, useRef } from 'react';
import {
    View,
    StyleSheet,
    Animated,
} from 'react-native';
import CollapsibleHeader from '../components/CollapsibleHeader';
import CollapsibleFlatList from '../components/CollapsibleFlatList';


function CollapsibleTabViewTestScreen (props) {
    const [ headerHeight, setHeaderHeight ] = useState(0);

    const scrollY = useRef(new Animated.Value(0)).current;
    const headerTranslateY = scrollY.interpolate({
        inputRange: [0, headerHeight],
        outputRange: [0, -headerHeight],
        extrapolate: "clamp"
    })

    const headerOnLayout = useCallback((event)=>{
        const { height } = event.nativeEvent.layout;
        setHeaderHeight(height);
    }, []);

    return (
        <View style={styles.rootContainer}>
            {
                headerHeight > 0?
                <CollapsibleFlatList headerHeight={headerHeight} scrollY={scrollY}/>:
                null
            }
            <Animated.View style={{ ...styles.headerContainer, transform: [ { translateY: headerTranslateY } ] }} onLayout={headerOnLayout} pointerEvents="box-none">
                <CollapsibleHeader />
            </Animated.View>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        flex: 1,
    },
    headerContainer: {
        position: "absolute",
        width: "100%",
    }
});

export default CollapsibleTabViewTestScreen;

 

CollapsibleHeader.js

import React from 'react';
import {
    View,
    StyleSheet,
    Text
} from 'react-native';

function CollapsibleHeader (props) {
    return (
        <View style={styles.rootContainer} pointerEvents="box-none">
            <Text style={styles.headerText}>Header</Text>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        width: "100%",
        height: 302,
        justifyContent: "center",
        alignItems: "center",
        backgroundColor: "#587058",
    },
    headerText: {
        fontSize: 25,
        color: "#FFD800"
    }
});

export default CollapsibleHeader;

 

CollapsibleFlatList.js

import React, { useCallback } from 'react';
import {
    View,
    StyleSheet,
    Text,
    Animated,
    Dimensions
} from 'react-native';

const sampleData = new Array(100).fill(0);
const window = Dimensions.get("window");

function CollapsibleFlatList (props) {
    const { headerHeight } = props;

    const renderItem = useCallback(({item, index})=>{
        return (
            <View style={{ ...styles.itemContainer, backgroundColor: index % 2 === 0? "#587498": "#E86850" }}>
                <Text style={styles.itemText}>
                    {index}
                </Text>
            </View>
        );
    }, []);

    const keyExtractor = useCallback((item, index) => index.toString(), []);

    return (
        <View style={styles.rootContainer}>
            <Animated.FlatList
                data={sampleData}
                renderItem={renderItem}
                keyExtractor={keyExtractor}
                contentContainerStyle={{
                    paddingTop: headerHeight,
                    minHeight: window.height + headerHeight
                }}
                scrollEventThrottle={16}
                onScroll={Animated.event(
                    [{ nativeEvent: { contentOffset: { y: props.scrollY } } }],
                    { useNativeDriver: true }
                )}
                bounces={false}
            />
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        flex: 1,
    },
    itemContainer: {
        width: "100%",
        height: 100,
        justifyContent: "center",
        alignItems: "center",
    },
    itemText: {
        fontSize: 25,
        color: "#FFD800"
    },
});

export default CollapsibleFlatList;

 

step 1. scroll에 따라 collapsible 되는 header 원리 파악하기

collapsible 한 header를 구현하기 위해서는 scroll의 y position에 따라 header의 영역이 줄어드는 효과를 구현해야 합니다.

 

저는 처음에 위 효과를 위해서 scroll의 위치에 반응하여 header의 높이를 조절하는 방향으로 구현을 했지만, 해당 방법으로 구현하면 android에서 떨림 현상이 발생하는 것을 확인했습니다.

 

위 방법 대신 다른 분들의 코드를 공부해서 발견한 접근 방식은 scroll의 위치에 따라 header의 y 위치를 조절하는 방법입니다.

 

접근 방식을 정리하면 아래와 같습니다.

- onScroll event의 contentOffset.y에 따라 header style의 translateY 값이 변하도록 구현합니다.

- header 영역에서도 scroll 기능을 적용시키기 위해 scrollView(또는 FlatList)의 contentContainerStyle에 header의 높이만큼 paddingTop 속성을 적용시킵니다.

 

onScroll event의 contentOffset.y에 따라 header style의 translateY 값이 변하도록 구현

- CollapsibleTabViewTestScreen.js

function CollapsibleTabViewTestScreen (props) {
    const [ headerHeight, setHeaderHeight ] = useState(0);

    /**
     * scrollY
     * - onScroll 이벤트의 contentOffset.y를 저장한다.
     * 
     * headerTranslateY
     * - header 영역의 translateY style에 적용될 변수로 scrollY가 커질수록 header 영역을 위로 이동시킨다.
     *  (즉, scroll를 내릴수록 header 영역이 위로 올라간다.)
     * - 최대 header의 높이 만큼 위로 이동 시킨다.
     * 
     */
    const scrollY = useRef(new Animated.Value(0)).current; // onScroll 이벤트의 contentOffset.y를 저장할 변수
    const headerTranslateY = scrollY.interpolate({
        inputRange: [0, headerHeight],
        outputRange: [0, -headerHeight],
        extrapolate: "clamp"
    })

    ...

    return (
        <View style={styles.rootContainer}>
            ...
            <Animated.View style={{ ...styles.headerContainer, transform: [ { translateY: headerTranslateY } ] }} onLayout={headerOnLayout} pointerEvents="box-none">
                <CollapsibleHeader />
            </Animated.View>
        </View>
    );
}

 

- CollapsibleFlatList.js

function CollapsibleFlatList (props) {
    const { headerHeight } = props;

    ...

    return (
        <View style={styles.rootContainer}>
            <Animated.FlatList
                ...
                // scrollY에 contentOffset.y 값을 저장
                onScroll={Animated.event(
                    [{ nativeEvent: { contentOffset: { y: props.scrollY } } }],
                    { useNativeDriver: true }
                )}
                ...
            />
        </View>
    );
}

 

header 영역에서도 scroll 기능을 적용시키기 위해 scrollView(또는 FlatList)의 contentContainerStyle에 header의 높이만큼 paddingTop 속성을 적용

 

- CollapsibleTabViewTestScreen.js

function CollapsibleTabViewTestScreen (props) {
    ...

    return (
        <View style={styles.rootContainer}>
            {
                headerHeight > 0?
                <CollapsibleFlatList headerHeight={headerHeight} scrollY={scrollY}/>:
                null
            }
            {/* 
                pointerEvents="box-none"
                - header 안에 touchable한 view의 영역을 제외한 나머지 영역은 터치 이벤트를 수신하지 않도록 설정
                => 이 설정을 통해 겹쳐있는 scroll view 영역이 터치 이벤트를 수신하게 됩니다.

                position: "absolute"
                - 이 style 값을 통해 header 영역과 scroll view 영역이 겹쳐지게 됩니다.
            */}
            <Animated.View style={{ ...styles.headerContainer, transform: [ { translateY: headerTranslateY } ] }} onLayout={headerOnLayout} pointerEvents="box-none">
                <CollapsibleHeader />
            </Animated.View>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        flex: 1,
    },
    headerContainer: {
        position: "absolute",
        width: "100%",
    }
});

export default CollapsibleTabViewTestScreen;

 

- CollapsibleFlatList.js

function CollapsibleFlatList (props) {
    ...

    return (
        <View style={styles.rootContainer}>
            <Animated.FlatList
                ...
                // paddingTop
                // - 해당 설정을 통해 data가 headerHeight 위치부터 보이도록 구현할 수 있습니다.
                // - padding 값이 적용된 영역도 스크롤이 가능합니다.
                // => 이 덕분에 header 영역에서의 scroll geture로 scrollView(또는 FlatList)를 scroll할 수 있습니다.
                //
                // minHeight
                // - 최소 높이를 적용시켜 항상 headerHeight 이상으로 scroll이 가능하게 합니다. 
                contentContainerStyle={{
                    paddingTop: headerHeight,
                    minHeight: window.height + headerHeight
                }}
                // scrollEventThrottle
                // - 해당 설정은 ios에서 scroll 할때 떨림을 방지해줍니다.
                scrollEventThrottle={16}
                ...
            />
        </View>
    );
}

 

scrollView(또는 FlatList)의 contentContainerStyle에 paddingTop을 적용시킨 사진과 설명은 아래와 같습니다.

contentContainerStyle에 paddignTop을 적용시킨 모습과 설명

 

여기까지 코드를 구현하면 아래처럼 구현이 됩니다.

 

step 1. scroll에 따라 collapsible되는 header 원리 파악하기 구현 예시

 

step 2. tab view의 header에 collapsible 적용 하기

이제 react-native-tab-view로 만든 tabView의 각 screen에 CollapsibleFlatList.js를 적용시킵니다.

 

이번 단계에서 구현할 내용은 아래와 같습니다.

- react-native-tab-view의 각 tab screen에 CollapsibleFlatList 연결하기

- scrollY의 변화에 따라 tabBar의 translateY 조절하기

 

- CollapsibleTabViewTestScreen.js

import React, { useState, useCallback, useRef } from 'react';
import {
    View,
    StyleSheet,
    Animated,
    TouchableOpacity,
    Text
} from 'react-native';
import { TabView } from 'react-native-tab-view';

import CollapsibleHeader from '../components/CollapsibleHeader';
import CollapsibleFlatList from '../components/CollapsibleFlatList';

const TABBAR_HEIGHT = 60;

function CollapsibleTabViewTestScreen (props) {
    const [ headerHeight, setHeaderHeight ] = useState(0);
    const [ tabRoutes, setTabRoutes ] = useState([
        { key: "screen1", title: "screen1" },
        { key: "screen2", title: "screen2" },
    ])
    const [ tabIndex, setTabIndex ] = useState(0);
    const tabIndexRef = useRef(0);
    /**
     * isListGlidingRef
     * - scroll이 움직일 때는 true, 멈춰있을 때는 false가 저장된다.
     * - scroll이 움직일 때 tab 전환을 방지할 때 사용된다.
     */
    const isListGlidingRef = useRef(false);

    const scrollY = useRef(new Animated.Value(0)).current;
    const headerTranslateY = scrollY.interpolate({
        inputRange: [0, headerHeight],
        outputRange: [0, -headerHeight],
        extrapolate: "clamp"
    })

    /**
     * tabBarTranslateY
     * - scrollY에 따라 변하는 값
     * - 해당 값을 tabBar의 translateY에 지정하면 scroll이 내려가면 tabBar의 위치가 위로 올라간다.
     */
    const tabBarTranslateY = scrollY.interpolate({
        inputRange: [0, headerHeight],
        outputRange: [headerHeight, 0],
        extrapolateRight: "clamp"
    });

    const headerOnLayout = useCallback((event)=>{
        const { height } = event.nativeEvent.layout;
        setHeaderHeight(height);
    }, []);

    const onTabIndexChange = useCallback((id)=>{
        setTabIndex(id);
        tabIndexRef.current = id;
    }, []);

    const onTabPress = useCallback((idx)=>{
        if(!isListGlidingRef.current) {
            setTabIndex(idx);
            tabIndexRef.current = idx;
        }
    }, []);

    const onMomentumScrollBegin = useCallback(()=>{
        isListGlidingRef.current = true;
    }, []);
    const onMomentumScrollEnd = useCallback(()=>{
        isListGlidingRef.current = false;
    }, [ headerHeight ]);
    const onScrollEndDrag = useCallback(()=>{

    }, [ headerHeight ]);

    /**
     * renderTabBar
     * - 탭바를 렌더링하는 함수
     * - tabBarTranslateY에 따라 translateY가 변하도록 style 값 지정
     * => scroll의 위치에 따라 tabBar의 위치가 변하는 효과 구현
     */
    const renderTabBar = useCallback((props)=>{
        return (
            <Animated.View style={[ styles.collapsibleTabBar, { transform: [ { translateY: tabBarTranslateY } ] } ]}>
                {
                    props.navigationState.routes.map((route, idx) => {
                        return (
                            <TouchableOpacity
                                style={styles.collapsibleTabBarButton}
                                key={idx}
                                onPress={()=>{ onTabPress(idx); }}
                            >
                                <View style={styles.collapsibleTabBarLabelContainer}>
                                    <Text style={styles.collapsibleTabBarLabelText}>{route.title}</Text>
                                </View>
                            </TouchableOpacity>
                        );
                    })
                }        
            </Animated.View>
        )
    }, [ headerHeight ]);

    const renderScene = useCallback(()=>{
        return (<CollapsibleFlatList headerHeight={headerHeight} tabBarHeight={TABBAR_HEIGHT} scrollY={scrollY} 
                onMomentumScrollBegin={onMomentumScrollBegin} onMomentumScrollEnd={onMomentumScrollEnd} onScrollEndDrag={onScrollEndDrag}/>)
    }, [ headerHeight ]);

    return (
        <View style={styles.rootContainer}>
            {
                headerHeight > 0?
                <TabView
                    navigationState={{ index: tabIndex, routes: tabRoutes }}
                    renderScene={renderScene}
                    renderTabBar={renderTabBar}
                    onIndexChange={onTabIndexChange}
                />:
                null
            }
            <Animated.View style={{ ...styles.headerContainer, transform: [ { translateY: headerTranslateY } ] }} onLayout={headerOnLayout} pointerEvents="box-none">
                <CollapsibleHeader />
            </Animated.View>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        flex: 1,
    },
    headerContainer: {
        position: "absolute",
        width: "100%",
    },
    collapsibleTabBar: {
        flexDirection: "row",
        alignItems: "center",
        height: TABBAR_HEIGHT,
        backgroundColor: "#FFFFFF",
        zIndex: 1,
    },
    collapsibleTabBarButton: {
        flex: 1,
    },
    collapsibleTabBarLabelContainer: {
        justifyContent: "center",
        alignItems: "center",
        height: '100%',
    },
    collapsibleTabBarLabelText: {
        fontSize: 15,
        color: "#587058"
    },
});

export default CollapsibleTabViewTestScreen;

 

- CollapsibleFlatList.js

import React, { useCallback } from 'react';
import {
    View,
    StyleSheet,
    Text,
    Animated,
    Dimensions
} from 'react-native';

const sampleData = new Array(100).fill(0);
const window = Dimensions.get("window");

function CollapsibleFlatList (props) {
    ...

    return (
        <View style={styles.rootContainer}>
            <Animated.FlatList
                data={sampleData}
                renderItem={renderItem}
                keyExtractor={keyExtractor}
                // minHeight
                // - 기존 minHeight 값에서 tabBarHeight 값을 빼준다.
                // => scroll 가능한 길이가 너무 커지는 현상을 방지
                contentContainerStyle={{
                    paddingTop: headerHeight,
                    minHeight: window.height + headerHeight - tabBarHeight
                }}
                scrollEventThrottle={16}
                onScroll={Animated.event(
                    [{ nativeEvent: { contentOffset: { y: props.scrollY } } }],
                    { useNativeDriver: true }
                )}
                onMomentumScrollBegin={props.onMomentumScrollBegin}
                onMomentumScrollEnd={props.onMomentumScrollEnd}
                onScrollEndDrag={props.onScrollEndDrag}
                bounces={false}
            />
        </View>
    );
}

const styles = StyleSheet.create({
    ...
});

export default CollapsibleFlatList;

 

이번 단계까지 수행하면 아래처럼 구현이 됩니다.

 

step 2. tab view의 header에 collapsible 적용 하기 구현 예시

 

scroll에 따라 tabView의 header의 영역이 잘 조절이 되지만, 아직 탭을 전환할 때 부자연스러운 동작을 확인할 수 있습니다.

이는 각 탭의 scroll 위치가 다르기 때문에 발생하는 문제로, 다음 단계에서는 해당 문제를 해결하는 방법을 정리하겠습니다.

 

step 3. tab screen 간 scroll sync 맞추기

이번 단계에서는 각 screen의 scroll 위치를 동기화시키는 방법을 정리하려 합니다.

 

1. 동기화 시점

- scroll의 움직임이 멈췄을 때(= onMomentumScrollEnd)

- 사용자가 drag를 멈췄을 때(= onScrollEnd)

 

2. 동기화 기준

- (focuse 된 탭의 scroll 위치) < (header 높이): 아직 header가 완전히 collapse 되지 않은 상태로, 현재 focus 된 탭의 scroll 위치와 다른 탭들의 scroll 위치를 일치시킨다.

- (focus 된 탭의 scroll 위치) >= (header 높이): header가 완전히 collapse 된 상태로, 각 탭의 scroll 위치를 유지한다.(단, 모든 탭의 scroll 위치는 header의 높이보다 커야 하며, 그렇지 않은 탭은 scroll 위치를 header의 높이 위치로 이동시킨다.)

 

이와 관련된 함수는 아래와 같이 구현할 수 있습니다.

 

- CollapsibleTabViewTestScreen.js

import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
    View,
    StyleSheet,
    Animated,
    TouchableOpacity,
    Text
} from 'react-native';
import { TabView } from 'react-native-tab-view';

import CollapsibleHeader from '../components/CollapsibleHeader';
import CollapsibleFlatList from '../components/CollapsibleFlatList';

const TABBAR_HEIGHT = 60;

function CollapsibleTabViewTestScreen (props) {
    const [ headerHeight, setHeaderHeight ] = useState(0);
    const [ tabRoutes, setTabRoutes ] = useState([
        { key: "screen1", title: "screen1" },
        { key: "screen2", title: "screen2" },
    ])
    const [ tabIndex, setTabIndex ] = useState(0);
    const tabIndexRef = useRef(0);
    const isListGlidingRef = useRef(false);
    /**
     * listArrRef: 각 스크린 속 FlatList의 ref를 저장하는 변수
     * listOffsetRef: 각 스크린의 scorll 위치를 저장하는 변수
     */
    const listArrRef = useRef([]);
    const listOffsetRef = useRef({});

    const scrollY = useRef(new Animated.Value(0)).current;
    
    ...

    /**
     * animation 값의 업데이트를 관찰하기 위해 사용되는 listener
     * 자세한 설명은 아래의 공식 문서와 링크에서 확인 가능합니다.
     * https://reactnative.dev/docs/0.63/animatedvalue#addlistener
     * https://stackoverflow.com/questions/56177270/how-can-i-get-the-value-from-an-animated-or-interpolated-value-in-react-native
     */
    useEffect(()=>{
        scrollY.addListener(({ value })=>{});

        return () => {
            scrollY.removeListener();
        }
    }, []);

    ...
    
    /**
     * 스크롤 위치를 동기화 시키는 함수
     */
    const syncScrollOffset = ()=>{
        const focusedTabKey = tabRoutes[tabIndexRef.current].key;

        listArrRef.current.forEach((item)=>{
            if (item.key !== focusedTabKey) {
                if (scrollY._value < headerHeight && scrollY._value >= 0) {
                    if (item.value) {
                        item.value.scrollToOffset({
                            offset: scrollY._value,
                            animated: false,
                        });
                        listOffsetRef.current[item.key] = scrollY._value;
                    }
                } else if (scrollY._value >= headerHeight) {
                    if ( listOffsetRef.current[item.key] < headerHeight ||
                         listOffsetRef.current[item.key] === null) {
                        if (item.value) {
                            item.value.scrollToOffset({
                                offset: headerHeight,
                                animated: false,
                            });
                            listOffsetRef.current[item.key] = headerHeight;
                        }
                    }
                }
            } else{
                if (item.value) {
                    listOffsetRef.current[item.key] = scrollY._value;
                }
            }
        })
    }

    const onMomentumScrollBegin = useCallback(()=>{
        isListGlidingRef.current = true;
    }, []);
    const onMomentumScrollEnd = useCallback(()=>{
        isListGlidingRef.current = false;
        syncScrollOffset();
    }, [ headerHeight ]);
    const onScrollEndDrag = useCallback(()=>{
        syncScrollOffset();
    }, [ headerHeight ]);
    
    ...

    const renderScene = useCallback(({ route })=>{
        const isFocused = route.key === tabRoutes[tabIndex].key;
    
        return (<CollapsibleFlatList headerHeight={headerHeight} tabBarHeight={TABBAR_HEIGHT} scrollY={scrollY}
                onMomentumScrollBegin={onMomentumScrollBegin} onMomentumScrollEnd={onMomentumScrollEnd} onScrollEndDrag={onScrollEndDrag}
                tabRoute={route} listArrRef={listArrRef} isTabFocused={isFocused}
                />)
    }, [ headerHeight, tabIndex ]);

    return (
        <View style={styles.rootContainer}>
            {
                headerHeight > 0?
                <TabView
                    navigationState={{ index: tabIndex, routes: tabRoutes }}
                    renderScene={renderScene}
                    renderTabBar={renderTabBar}
                    onIndexChange={onTabIndexChange}
                />:
                null
            }
            <Animated.View style={{ ...styles.headerContainer, transform: [ { translateY: headerTranslateY } ] }} onLayout={headerOnLayout} pointerEvents="box-none">
                <CollapsibleHeader />
            </Animated.View>
        </View>
    );
}

const styles = StyleSheet.create({
    ...
});

export default CollapsibleTabViewTestScreen;

 

- CollapsibleFlatList.js

...

function CollapsibleFlatList (props) {
    const { headerHeight, tabBarHeight, tabRoute, listArrRef, isTabFocused } = props;

    ...
    
    return (
        <View style={styles.rootContainer}>
            <Animated.FlatList
                /**
                 * FlatList의 ref를 listArrRef에 저장
                 */
                ref={(ref)=>{
                    let foundIndex = listArrRef.current.findIndex((e) => e.key === tabRoute.key);

                    if (foundIndex === -1) {
                        listArrRef.current.push({
                            key: tabRoute.key,
                            value: ref
                        });
                    } else {
                        listArrRef.current[foundIndex] = {
                            key: tabRoute.key,
                            value: ref
                        }
                    }
                }}
                data={sampleData}
                ...
            />
        </View>
    );
}

const styles = StyleSheet.create({
    ...
});

export default CollapsibleFlatList;

 

여기까지 구현하면 아래와 같이 구현이 됩니다.

 

step 3. tab screen 간 scroll sync 맞추기

 

전체 코드

CollapsibleTabViewTestScreen.js

import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
    View,
    StyleSheet,
    Animated,
    TouchableOpacity,
    Text
} from 'react-native';
import { TabView } from 'react-native-tab-view';

import CollapsibleHeader from '../components/CollapsibleHeader';
import CollapsibleFlatList from '../components/CollapsibleFlatList';

const TABBAR_HEIGHT = 60;

function CollapsibleTabViewTestScreen (props) {
    const [ headerHeight, setHeaderHeight ] = useState(0);
    const [ tabRoutes, setTabRoutes ] = useState([
        { key: "screen1", title: "screen1" },
        { key: "screen2", title: "screen2" },
    ])
    const [ tabIndex, setTabIndex ] = useState(0);
    const tabIndexRef = useRef(0);
    const isListGlidingRef = useRef(false);
    const listArrRef = useRef([]);
    const listOffsetRef = useRef({});

    const scrollY = useRef(new Animated.Value(0)).current;
    const headerTranslateY = scrollY.interpolate({
        inputRange: [0, headerHeight],
        outputRange: [0, -headerHeight],
        extrapolate: "clamp"
    })

    const tabBarTranslateY = scrollY.interpolate({
        inputRange: [0, headerHeight],
        outputRange: [headerHeight, 0],
        extrapolateRight: "clamp"
    });

    useEffect(()=>{
        scrollY.addListener(({ value })=>{});

        return () => {
            scrollY.removeListener();
        }
    }, []);

    const headerOnLayout = useCallback((event)=>{
        const { height } = event.nativeEvent.layout;
        setHeaderHeight(height);
    }, []);

    const onTabIndexChange = useCallback((id)=>{
        setTabIndex(id);
        tabIndexRef.current = id;
    }, []);

    const onTabPress = useCallback((idx)=>{
        if(!isListGlidingRef.current) {
            setTabIndex(idx);
            tabIndexRef.current = idx;
        }
    }, []);

    const syncScrollOffset = ()=>{
        const focusedTabKey = tabRoutes[tabIndexRef.current].key;

        listArrRef.current.forEach((item)=>{
            if (item.key !== focusedTabKey) {
                if (scrollY._value < headerHeight && scrollY._value >= 0) {
                    if (item.value) {
                        item.value.scrollToOffset({
                            offset: scrollY._value,
                            animated: false,
                        });
                        listOffsetRef.current[item.key] = scrollY._value;
                    }
                } else if (scrollY._value >= headerHeight) {
                    if ( listOffsetRef.current[item.key] < headerHeight ||
                         listOffsetRef.current[item.key] === null) {
                        if (item.value) {
                            item.value.scrollToOffset({
                                offset: headerHeight,
                                animated: false,
                            });
                            listOffsetRef.current[item.key] = headerHeight;
                        }
                    }
                }
            } else{
                if (item.value) {
                    listOffsetRef.current[item.key] = scrollY._value;
                }
            }
        })
    }

    const onMomentumScrollBegin = useCallback(()=>{
        isListGlidingRef.current = true;
    }, []);
    const onMomentumScrollEnd = useCallback(()=>{
        isListGlidingRef.current = false;
        syncScrollOffset();
    }, [ headerHeight ]);
    const onScrollEndDrag = useCallback(()=>{
        syncScrollOffset();
    }, [ headerHeight ]);
    
    const renderTabBar = useCallback((props)=>{
        return (
            <Animated.View style={[ styles.collapsibleTabBar, { transform: [ { translateY: tabBarTranslateY } ] } ]}>
                {
                    props.navigationState.routes.map((route, idx) => {
                        return (
                            <TouchableOpacity
                                style={styles.collapsibleTabBarButton}
                                key={idx}
                                onPress={()=>{ onTabPress(idx); }}
                            >
                                <View style={styles.collapsibleTabBarLabelContainer}>
                                    <Text style={styles.collapsibleTabBarLabelText}>{route.title}</Text>
                                </View>
                            </TouchableOpacity>
                        );
                    })
                }        
            </Animated.View>
        )
    }, [ headerHeight ]);

    const renderScene = useCallback(({ route })=>{
        const isFocused = route.key === tabRoutes[tabIndex].key;
    
        return (<CollapsibleFlatList headerHeight={headerHeight} tabBarHeight={TABBAR_HEIGHT} scrollY={scrollY}
                onMomentumScrollBegin={onMomentumScrollBegin} onMomentumScrollEnd={onMomentumScrollEnd} onScrollEndDrag={onScrollEndDrag}
                tabRoute={route} listArrRef={listArrRef} isTabFocused={isFocused}
                />)
    }, [ headerHeight, tabIndex ]);

    return (
        <View style={styles.rootContainer}>
            {
                headerHeight > 0?
                <TabView
                    navigationState={{ index: tabIndex, routes: tabRoutes }}
                    renderScene={renderScene}
                    renderTabBar={renderTabBar}
                    onIndexChange={onTabIndexChange}
                />:
                null
            }
            <Animated.View style={{ ...styles.headerContainer, transform: [ { translateY: headerTranslateY } ] }} onLayout={headerOnLayout} pointerEvents="box-none">
                <CollapsibleHeader />
            </Animated.View>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        flex: 1,
    },
    headerContainer: {
        position: "absolute",
        width: "100%",
    },
    collapsibleTabBar: {
        flexDirection: "row",
        alignItems: "center",
        height: TABBAR_HEIGHT,
        backgroundColor: "#FFFFFF",
        zIndex: 1,
    },
    collapsibleTabBarButton: {
        flex: 1,
    },
    collapsibleTabBarLabelContainer: {
        justifyContent: "center",
        alignItems: "center",
        height: '100%',
    },
    collapsibleTabBarLabelText: {
        fontSize: 15,
        color: "#587058"
    },
});

export default CollapsibleTabViewTestScreen;

 

 

CollapsibleHeader.js

import React from 'react';
import {
    View,
    StyleSheet,
    Text
} from 'react-native';

function CollapsibleHeader (props) {
    return (
        <View style={styles.rootContainer} pointerEvents="box-none">
            <Text style={styles.headerText}>Header</Text>
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        width: "100%",
        height: 302,
        justifyContent: "center",
        alignItems: "center",
        backgroundColor: "#587058",
    },
    headerText: {
        fontSize: 25,
        color: "#FFD800"
    }
});

export default CollapsibleHeader;

 

CollapsibleFlatList.js

import React, { useCallback } from 'react';
import {
    View,
    StyleSheet,
    Text,
    Animated,
    Dimensions
} from 'react-native';

const sampleData = new Array(100).fill(0);
const window = Dimensions.get("window");

function CollapsibleFlatList (props) {
    const { headerHeight, tabBarHeight, tabRoute, listArrRef, isTabFocused } = props;

    const renderItem = useCallback(({item, index})=>{
        return (
            <View style={{ ...styles.itemContainer, backgroundColor: index % 2 === 0? "#587498": "#E86850" }}>
                <Text style={styles.itemText}>
                    {index}
                </Text>
            </View>
        );
    }, []);

    const keyExtractor = useCallback((item, index) => index.toString(), []);

    return (
        <View style={styles.rootContainer}>
            <Animated.FlatList
                ref={(ref)=>{
                    let foundIndex = listArrRef.current.findIndex((e) => e.key === tabRoute.key);

                    if (foundIndex === -1) {
                        listArrRef.current.push({
                            key: tabRoute.key,
                            value: ref
                        });
                    } else {
                        listArrRef.current[foundIndex] = {
                            key: tabRoute.key,
                            value: ref
                        }
                    }
                }}
                data={sampleData}
                renderItem={renderItem}
                keyExtractor={keyExtractor}
                contentContainerStyle={{
                    paddingTop: headerHeight,
                    minHeight: window.height + headerHeight - tabBarHeight
                }}
                scrollEventThrottle={16}
                onScroll={
                    isTabFocused?
                    Animated.event(
                    [{ nativeEvent: { contentOffset: { y: props.scrollY } } }],
                    { useNativeDriver: true }
                    ):
                    null
                }
                onMomentumScrollBegin={props.onMomentumScrollBegin}
                onMomentumScrollEnd={props.onMomentumScrollEnd}
                onScrollEndDrag={props.onScrollEndDrag}
                bounces={false}
            />
        </View>
    );
}

const styles = StyleSheet.create({
    rootContainer: {
        flex: 1,
    },
    itemContainer: {
        width: "100%",
        height: 100,
        justifyContent: "center",
        alignItems: "center",
    },
    itemText: {
        fontSize: 25,
        color: "#FFD800"
    },
});

export default CollapsibleFlatList;

참고 자료

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

https://github.com/JungHsuan/react-native-collapsible-tabview - react-native-collapsible-tabview

 

읽어 주셔서 감사합니다 :)

잘못된 부분이 있다면 댓글로 편히 알려주세요😊